Files
larksuite-cli/tests/cli_e2e/drive/drive_status_workflow_test.go
2026-05-14 20:37:39 +08:00

465 lines
16 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_StatusWorkflow exercises +status against a real Drive folder so
// the parts that dry-run can't reach — recursive listing pagination, the
// download+hash leg, scope handling, and the SHA-256 comparison itself —
// are covered against the real backend.
//
// Layout:
//
// folder/ (--folder-token target)
// ├── unchanged.txt "match" ↔ local: "match" → unchanged
// ├── modified.txt "remote" ↔ local: "local" → modified
// └── remote-only.txt "remote" ↔ (none) → new_remote
// local/ (--local-dir target)
// ├── unchanged.txt "match"
// ├── modified.txt "local"
// └── local-only.txt "anything" → new_local
//
// Expected output: each of the four buckets contains exactly the file we
// expect, with file_token set for the three buckets that have a Drive side.
func TestDrive_StatusWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-status-" + suffix
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
// Local working directory. +status's --local-dir must be relative to
// the binary's cwd, so each upload + the +status invocation share the
// same WorkDir.
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
// Helper: write a local file under workDir/<rel>.
writeLocal := func(rel, content string) {
t.Helper()
full := filepath.Join(workDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
// Helper: stage <content> into a sibling temp file then upload it as
// <name> under folderToken. +upload reads --file relative to its cwd.
uploadDriveFile := func(name, content string) string {
t.Helper()
// Stage outside `local/` so the local-side tree only sees what
// the test wants; +upload still reads relative to workDir.
stage := "_upload_" + name
writeLocal(stage, content)
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stage,
"--folder-token", folderToken,
"--name", name,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
// Seed both sides. Order doesn't matter functionally, but doing the
// uploads first lets the +status listing pick up everything in a
// single pass.
tokUnchanged := uploadDriveFile("unchanged.txt", "match")
tokModified := uploadDriveFile("modified.txt", "remote")
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote")
writeLocal("local/unchanged.txt", "match") // matches remote → unchanged
writeLocal("local/modified.txt", "local") // differs → modified
writeLocal("local/local-only.txt", "extra") // only here → new_local
// Run +status against the real folder.
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Assert each bucket contains exactly the file we expect, with the
// correct file_token for sides that have one.
out := result.Stdout
cases := []struct {
bucket string
path string
token string // empty when the bucket has no Drive side
}{
{"unchanged", "unchanged.txt", tokUnchanged},
{"modified", "modified.txt", tokModified},
{"new_local", "local-only.txt", ""},
{"new_remote", "remote-only.txt", tokRemoteOnly},
}
for _, c := range cases {
bucket := gjson.Get(out, "data."+c.bucket)
if !bucket.IsArray() {
t.Fatalf("data.%s must be an array, stdout:\n%s", c.bucket, out)
}
var found bool
bucket.ForEach(func(_, entry gjson.Result) bool {
if entry.Get("rel_path").String() != c.path {
return true // continue
}
found = true
if c.token != "" {
if got := entry.Get("file_token").String(); got != c.token {
t.Errorf("%s entry %q: file_token=%q want %q", c.bucket, c.path, got, c.token)
}
} else if entry.Get("file_token").String() != "" {
t.Errorf("%s entry %q must not carry file_token (local-only), stdout:\n%s", c.bucket, c.path, out)
}
return false // stop
})
if !found {
t.Errorf("%s bucket missing %q\nstdout:\n%s", c.bucket, c.path, out)
}
}
// Make sure each bucket is exactly the size we expect (4 files total,
// no double-bucketing). +upload may attach extra metadata (e.g. a
// folder type entry for `local/` itself) but the lister filters
// type=file so the buckets should be clean.
for _, b := range []struct {
bucket string
want int
}{
{"unchanged", 1},
{"modified", 1},
{"new_local", 1},
{"new_remote", 1},
} {
got := int(gjson.Get(out, "data."+b.bucket+".#").Int())
if got != b.want {
t.Errorf("data.%s length=%d want %d\nstdout:\n%s", b.bucket, got, b.want, out)
}
}
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
"--quick",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
quickResult.AssertExitCode(t, 0)
quickResult.AssertStdoutStatus(t, true)
quickOut := quickResult.Stdout
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
}
if got := int(gjson.Get(quickOut, "data.new_local.#").Int()); got != 1 {
t.Fatalf("quick new_local length=%d want 1\nstdout:\n%s", got, quickOut)
}
if got := int(gjson.Get(quickOut, "data.new_remote.#").Int()); got != 1 {
t.Fatalf("quick new_remote length=%d want 1\nstdout:\n%s", got, quickOut)
}
if got := gjson.Get(quickOut, "data.new_local.0.rel_path").String(); got != "local-only.txt" {
t.Fatalf("quick new_local path=%q want local-only.txt\nstdout:\n%s", got, quickOut)
}
if got := gjson.Get(quickOut, "data.new_remote.0.rel_path").String(); got != "remote-only.txt" {
t.Fatalf("quick new_remote path=%q want remote-only.txt\nstdout:\n%s", got, quickOut)
}
sharedCount := int(gjson.Get(quickOut, "data.modified.#").Int() + gjson.Get(quickOut, "data.unchanged.#").Int())
if sharedCount != 2 {
t.Fatalf("quick shared file count=%d want 2 across modified+unchanged\nstdout:\n%s", sharedCount, quickOut)
}
for _, path := range []string{"unchanged.txt", "modified.txt"} {
if !gjson.Get(quickOut, `data.modified.#(rel_path="`+path+`")`).Exists() && !gjson.Get(quickOut, `data.unchanged.#(rel_path="`+path+`")`).Exists() {
t.Fatalf("quick output missing shared path %q\nstdout:\n%s", path, quickOut)
}
}
}
// TestDrive_StatusQuickWorkflow proves that --quick really follows modified_time
// semantics on the live backend instead of silently behaving like the default
// exact hash mode.
//
// The fixture intentionally makes the two shared files diverge in opposite ways:
// - same-mtime.txt: bytes differ, mtime matches remote → quick=unchanged / exact=modified
// - remote-newer.txt: bytes match, local mtime is older → quick=modified / exact=unchanged
//
// This locks in the best-effort nature of quick mode with real Drive
// modified_time values fetched from the list API, plus the expected new_local /
// new_remote buckets.
func TestDrive_StatusQuickWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-status-quick-" + suffix
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
writeLocal := func(rel, content string) {
t.Helper()
full := filepath.Join(workDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
uploadDriveFile := func(name, content string) string {
t.Helper()
stage := "_upload_" + name
writeLocal(stage, content)
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stage,
"--folder-token", folderToken,
"--name", name,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
tokSameMtime := uploadDriveFile("same-mtime.txt", "remote bytes A")
tokRemoteNewer := uploadDriveFile("remote-newer.txt", "remote bytes B")
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote only")
remoteFiles := listDriveFolderFilesByName(t, ctx, folderToken)
sameMtimeRemote := remoteFiles["same-mtime.txt"]
remoteNewer := remoteFiles["remote-newer.txt"]
if sameMtimeRemote.ModifiedTime == "" || remoteNewer.ModifiedTime == "" {
t.Fatalf("expected modified_time for shared remote files, got: %#v", remoteFiles)
}
writeLocal("local/same-mtime.txt", "local bytes A") // bytes differ from remote
writeLocal("local/remote-newer.txt", "remote bytes B") // bytes match remote
writeLocal("local/local-only.txt", "local only") // local-only bucket
sameMtimePath := filepath.Join(workDir, "local", "same-mtime.txt")
remoteNewerPath := filepath.Join(workDir, "local", "remote-newer.txt")
sameMtimeAt := mustParseDriveEpochForE2E(t, sameMtimeRemote.ModifiedTime)
remoteNewerAt := mustParseDriveEpochForE2E(t, remoteNewer.ModifiedTime)
if err := os.Chtimes(sameMtimePath, sameMtimeAt, sameMtimeAt); err != nil {
t.Fatalf("chtimes same-mtime.txt: %v", err)
}
localOlder := remoteNewerAt.Add(-2 * time.Second)
if err := os.Chtimes(remoteNewerPath, localOlder, localOlder); err != nil {
t.Fatalf("chtimes remote-newer.txt: %v", err)
}
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
"--quick",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
quickResult.AssertExitCode(t, 0)
quickResult.AssertStdoutStatus(t, true)
quickOut := quickResult.Stdout
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
}
assertStatusBucketEntry(t, quickOut, "unchanged", "same-mtime.txt", tokSameMtime)
assertStatusBucketEntry(t, quickOut, "modified", "remote-newer.txt", tokRemoteNewer)
assertStatusBucketEntry(t, quickOut, "new_local", "local-only.txt", "")
assertStatusBucketEntry(t, quickOut, "new_remote", "remote-only.txt", tokRemoteOnly)
assertStatusBucketLen(t, quickOut, "unchanged", 1)
assertStatusBucketLen(t, quickOut, "modified", 1)
assertStatusBucketLen(t, quickOut, "new_local", 1)
assertStatusBucketLen(t, quickOut, "new_remote", 1)
exactResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, exactResult)
exactResult.AssertExitCode(t, 0)
exactResult.AssertStdoutStatus(t, true)
exactOut := exactResult.Stdout
if got := gjson.Get(exactOut, "data.detection").String(); got != "exact" {
t.Fatalf("exact detection=%q want exact\nstdout:\n%s", got, exactOut)
}
assertStatusBucketEntry(t, exactOut, "modified", "same-mtime.txt", tokSameMtime)
assertStatusBucketEntry(t, exactOut, "unchanged", "remote-newer.txt", tokRemoteNewer)
assertStatusBucketEntry(t, exactOut, "new_local", "local-only.txt", "")
assertStatusBucketEntry(t, exactOut, "new_remote", "remote-only.txt", tokRemoteOnly)
assertStatusBucketLen(t, exactOut, "unchanged", 1)
assertStatusBucketLen(t, exactOut, "modified", 1)
assertStatusBucketLen(t, exactOut, "new_local", 1)
assertStatusBucketLen(t, exactOut, "new_remote", 1)
}
type driveStatusListedFile struct {
Token string
ModifiedTime string
}
func listDriveFolderFilesByName(t *testing.T, ctx context.Context, folderToken string) map[string]driveStatusListedFile {
t.Helper()
params := fmt.Sprintf(`{"folder_token":"%s","page_size":200}`, folderToken)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "files", "list", "--params", params},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
files := make(map[string]driveStatusListedFile)
gjson.Get(result.Stdout, "data.files").ForEach(func(_, entry gjson.Result) bool {
name := entry.Get("name").String()
if name == "" {
return true
}
files[name] = driveStatusListedFile{
Token: entry.Get("token").String(),
ModifiedTime: entry.Get("modified_time").String(),
}
return true
})
return files
}
func mustParseDriveEpochForE2E(t *testing.T, raw string) time.Time {
t.Helper()
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
t.Fatalf("parse Drive epoch %q: %v", raw, err)
}
switch {
case v > 1e14 || v < -1e14:
return time.UnixMicro(v)
case v > 1e11 || v < -1e11:
return time.UnixMilli(v)
default:
return time.Unix(v, 0)
}
}
func assertStatusBucketEntry(t *testing.T, stdout, bucket, relPath, fileToken string) {
t.Helper()
entry := gjson.Get(stdout, `data.`+bucket+`.#(rel_path="`+relPath+`")`)
if !entry.Exists() {
t.Fatalf("bucket %s missing rel_path %q\nstdout:\n%s", bucket, relPath, stdout)
}
if fileToken == "" {
if got := entry.Get("file_token").String(); got != "" {
t.Fatalf("bucket %s rel_path %q unexpectedly carried file_token=%q\nstdout:\n%s", bucket, relPath, got, stdout)
}
return
}
if got := entry.Get("file_token").String(); got != fileToken {
t.Fatalf("bucket %s rel_path %q file_token=%q want %q\nstdout:\n%s", bucket, relPath, got, fileToken, stdout)
}
}
func assertStatusBucketLen(t *testing.T, stdout, bucket string, want int) {
t.Helper()
if got := int(gjson.Get(stdout, "data."+bucket+".#").Int()); got != want {
t.Fatalf("bucket %s length=%d want %d\nstdout:\n%s", bucket, got, want, stdout)
}
}
func skipDriveStatusExactIfMissingDownloadScope(t *testing.T, result *clie2e.Result) {
t.Helper()
if result == nil || result.ExitCode == 0 {
return
}
combinedLower := strings.ToLower(result.Stdout + "\n" + result.Stderr)
if strings.Contains(combinedLower, "missing_scope") && strings.Contains(combinedLower, "drive:file:download") {
t.Skipf("skip drive +status exact live workflow due to missing drive:file:download scope: stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}