Files
larksuite-cli/shortcuts/apps/apps_env_pull_test.go
2026-06-26 17:57:48 +08:00

1181 lines
38 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func assertValidationError(t *testing.T, err error, wantSubstr string) {
t.Helper()
if err == nil {
t.Fatal("expected a validation error, got nil")
}
if !errs.IsValidation(err) && output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if wantSubstr != "" && !strings.Contains(err.Error(), wantSubstr) {
t.Fatalf("expected validation message containing %q, got %v", wantSubstr, err)
}
}
func assertEnvPullBody(t *testing.T, req *http.Request) {
t.Helper()
assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"})
}
func TestResolveEnvPullTarget_DefaultProjectPathUsesCWD(t *testing.T) {
cwd := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() err=%v", err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
if err := os.Chdir(cwd); err != nil {
t.Fatalf("Chdir() err=%v", err)
}
wantProject, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() after Chdir err=%v", err)
}
gotProject, gotFile, err := resolveEnvPullTarget("")
if err != nil {
t.Fatalf("resolveEnvPullTarget() err=%v", err)
}
if gotProject != wantProject {
t.Fatalf("project path = %q, want %q", gotProject, wantProject)
}
wantFile := filepath.Join(wantProject, ".env.local")
if gotFile != wantFile {
t.Fatalf("env file = %q, want %q", gotFile, wantFile)
}
}
func TestResolveEnvPullTarget_CustomProjectPath(t *testing.T) {
root := t.TempDir()
gotProject, gotFile, err := resolveEnvPullTarget(root)
if err != nil {
t.Fatalf("resolveEnvPullTarget() err=%v", err)
}
if gotProject != root {
t.Fatalf("project path = %q, want %q", gotProject, root)
}
wantFile := filepath.Join(root, ".env.local")
if gotFile != wantFile {
t.Fatalf("env file = %q, want %q", gotFile, wantFile)
}
}
func TestCheckEnvPullTargetRejectsSymlink(t *testing.T) {
dir := t.TempDir()
realFile := filepath.Join(dir, "real.env")
if err := os.WriteFile(realFile, []byte("A = \"1\"\n"), 0o600); err != nil {
t.Fatalf("WriteFile() err=%v", err)
}
link := filepath.Join(dir, ".env.local")
if err := os.Symlink(realFile, link); err != nil {
t.Fatalf("Symlink() err=%v", err)
}
err := checkEnvPullTarget(link)
assertValidationError(t, err, "must be a regular file")
}
func TestCheckEnvPullTargetRejectsDirectory(t *testing.T) {
dir := filepath.Join(t.TempDir(), ".env.local")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll() err=%v", err)
}
err := checkEnvPullTarget(dir)
assertValidationError(t, err, "must be a regular file")
}
func TestParseEnvPullAssignmentLine(t *testing.T) {
tests := map[string]string{
`FOO = "bar"`: "FOO",
`FOO=bar`: "FOO",
`FOO = bar`: "FOO",
`FOO='bar'`: "FOO",
`export FOO=bar`: "FOO",
`FOO=`: "FOO",
`FOO=a=b=c`: "FOO",
}
for line, want := range tests {
key, ok := parseEnvPullAssignmentLine(line)
if !ok {
t.Fatalf("expected line to parse: %q", line)
}
if key != want {
t.Fatalf("key for %q = %q, want %q", line, key, want)
}
}
}
func TestParseEnvPullAssignmentLineRejectsComment(t *testing.T) {
if _, ok := parseEnvPullAssignmentLine("# FOO = \"bar\""); ok {
t.Fatalf("commented line should not be treated as active assignment")
}
}
func TestParseEnvPullAssignmentLineRejectsInvalidExport(t *testing.T) {
if _, ok := parseEnvPullAssignmentLine("export =bar"); ok {
t.Fatalf("invalid export line should not be treated as active assignment")
}
}
func TestParseEnvPullAssignmentLineTreatsExportPrefixWithoutDelimiterAsKey(t *testing.T) {
key, ok := parseEnvPullAssignmentLine("exportFOO=bar")
if !ok {
t.Fatalf("expected export-prefixed key to parse")
}
if key != "exportFOO" {
t.Fatalf("key = %q, want exportFOO", key)
}
}
func TestFormatEnvPullAssignmentEscapesQuotesAndBackslashes(t *testing.T) {
got := formatEnvPullAssignment("TOKEN", `a"b\c`)
want := `TOKEN="a\"b\\c"`
if got != want {
t.Fatalf("formatEnvPullAssignment() = %q, want %q", got, want)
}
}
func TestMergeEnvPullFileContentPreservesCommentsAndMalformedLines(t *testing.T) {
original := strings.Join([]string{
"# FOO = \"old\"",
"FOO=old",
"BROKEN LINE",
"KEEP = \"stay\"",
"",
}, "\n")
merged, updated, created := mergeEnvPullFileContent(original, map[string]string{
"FOO": "new",
"BAR": "added",
})
if !strings.Contains(merged, "# FOO = \"old\"") {
t.Fatalf("comment line must be preserved: %q", merged)
}
if !strings.Contains(merged, `FOO="new"`) {
t.Fatalf("active key must be updated: %q", merged)
}
if !strings.Contains(merged, "BROKEN LINE") {
t.Fatalf("malformed line must be preserved: %q", merged)
}
if !strings.Contains(merged, "KEEP = \"stay\"") {
t.Fatalf("unrelated key must be preserved: %q", merged)
}
if !strings.Contains(merged, `BAR="added"`) {
t.Fatalf("missing key must be appended: %q", merged)
}
if len(updated) != 1 || updated[0] != "FOO" {
t.Fatalf("updated = %v, want [FOO]", updated)
}
if len(created) != 1 || created[0] != "BAR" {
t.Fatalf("created = %v, want [BAR]", created)
}
}
func TestMergeEnvPullFileContentUpdatesCommonAssignmentStylesWithoutDuplicateKeys(t *testing.T) {
original := strings.Join([]string{
`FOO=old`,
`BAR = old`,
`export BAZ=old`,
`QUX='old'`,
"",
}, "\n")
merged, updated, created := mergeEnvPullFileContent(original, map[string]string{
"FOO": "new-foo",
"BAR": "new-bar",
"BAZ": "new-baz",
"QUX": "new-qux",
})
for _, want := range []string{
`FOO="new-foo"`,
`BAR="new-bar"`,
`BAZ="new-baz"`,
`QUX="new-qux"`,
} {
if strings.Count(merged, want) != 1 {
t.Fatalf("expected exactly one canonical assignment %q in %q", want, merged)
}
}
for _, legacy := range []string{`FOO=old`, `BAR = old`, `export BAZ=old`, `QUX='old'`} {
if strings.Contains(merged, legacy) {
t.Fatalf("legacy assignment should be replaced, still found %q in %q", legacy, merged)
}
}
if len(updated) != 4 {
t.Fatalf("updated = %v, want 4 items", updated)
}
if len(created) != 0 {
t.Fatalf("created = %v, want empty", created)
}
}
func TestBuildEnvPullSuccessDataSuppressesEnvKeysAndValues(t *testing.T) {
data := buildEnvPullSuccessData("app_x", "/repo/.env.local", envPullDatabaseInfo{Detected: true, ExpiresAtRaw: "1780389006", ExpiresAtText: "2026-06-02 16:30:06 CST"})
if _, ok := data["updated"]; ok {
t.Fatalf("success data must not expose updated key names: %v", data)
}
if _, ok := data["created"]; ok {
t.Fatalf("success data must not expose created key names: %v", data)
}
if _, ok := data["project_path"]; ok {
t.Fatalf("success data must not expose project_path: %v", data)
}
if _, ok := data["updated_count"]; ok {
t.Fatalf("success data must not expose updated_count: %v", data)
}
if _, ok := data["created_count"]; ok {
t.Fatalf("success data must not expose created_count: %v", data)
}
if got := data["app_id"]; got != "app_x" {
t.Fatalf("app_id = %v, want app_x", got)
}
if got := data["env_file"]; got != "/repo/.env.local" {
t.Fatalf("env_file = %v, want /repo/.env.local", got)
}
if got := data["database_url_expires_at"]; got != "1780389006" {
t.Fatalf("database_url_expires_at = %v, want 1780389006", got)
}
}
func TestAppsEnvPull_DryRunUsesPostBodyAndResolvedEnvFile(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
projectDir := t.TempDir()
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"method": "POST"`) {
t.Fatalf("dry-run must use POST: %s", got)
}
if !strings.Contains(got, `/open-apis/spark/v1/apps/app_x/env_vars`) {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, `"env": "dev"`) || strings.Contains(got, `"include_values"`) {
t.Fatalf("dry-run must include only env=dev in the request body: %s", got)
}
if !strings.Contains(got, filepath.Join(projectDir, ".env.local")) {
t.Fatalf("dry-run must include resolved env file path: %s", got)
}
}
func TestAppsEnvPull_PrettyOutput_WithDatabaseLine(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
OnMatch: func(req *http.Request) {
assertEnvPullBody(t, req)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": []interface{}{
map[string]interface{}{"key": "SUDA_DATABASE_URL", "value": "postgres://db", "extras": []interface{}{map[string]interface{}{"key": "expiresAt", "value": "1780389006"}}},
map[string]interface{}{"key": "APP_ID", "value": "app_x"},
},
},
},
})
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "App detected: app_x") {
t.Fatalf("missing app summary: %q", got)
}
if !strings.Contains(got, "Development database detected") {
t.Fatalf("missing database line: %q", got)
}
if !strings.Contains(got, "✓ Local environment written to "+filepath.Join(projectDir, ".env.local")) {
t.Fatalf("missing env file write line in pretty output: %q", got)
}
wantExpiry := time.Unix(1780389006, 0).Local().Format("2006-01-02 15:04:05 MST")
if !strings.Contains(got, "\n\nDATABASE_URL is valid until "+wantExpiry+".\n") {
t.Fatalf("missing blank-line separated expiry block: %q", got)
}
if !strings.Contains(got, "Run `lark-cli apps +env-pull --app-id <app_id>` again to refresh it.") {
t.Fatalf("missing refresh hint line: %q", got)
}
if strings.Contains(got, "postgres://db") {
t.Fatalf("pretty output must not print env values: %q", got)
}
}
func TestAppsEnvPull_JSONOutput_UsesSummaryFieldsOnly(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": []interface{}{
map[string]interface{}{"key": "AAA", "value": "value-a"},
map[string]interface{}{"key": "SUDA_DATABASE_URL", "value": "postgres://db", "extras": []interface{}{map[string]interface{}{"key": "expiresAt", "value": "1780389006"}}},
},
},
},
})
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"app_id": "app_x"`) {
t.Fatalf("json output must expose app_id: %s", got)
}
if !strings.Contains(got, `"env_file": "`+filepath.Join(projectDir, ".env.local")+`"`) {
t.Fatalf("json output must expose env_file: %s", got)
}
if !strings.Contains(got, `"database_url_expires_at": "1780389006"`) {
t.Fatalf("json output must expose raw database_url_expires_at: %s", got)
}
if strings.Contains(got, `"project_path"`) {
t.Fatalf("json output must not expose project_path: %s", got)
}
if strings.Contains(got, `"updated_count"`) || strings.Contains(got, `"created_count"`) {
t.Fatalf("json output must not expose count fields: %s", got)
}
if strings.Contains(got, `"AAA"`) || strings.Contains(got, `"value-a"`) || strings.Contains(got, `"postgres://db"`) {
t.Fatalf("json output must not expose env keys or env values: %s", got)
}
}
func TestAppsEnvPull_MalformedPayloadSkipsInvalidEntries(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": []interface{}{"bad"},
},
},
})
err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--format", "pretty", "--as", "user"},
factory, stdout)
if err != nil {
t.Fatalf("malformed entries should be skipped, not fail; err=%v", err)
}
}
func TestAppsEnvPull_TargetSymlinkIsRejectedBeforeAPI(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
projectDir := t.TempDir()
linkTarget := filepath.Join(projectDir, "real.env")
if err := os.WriteFile(linkTarget, []byte("KEEP = \"1\"\n"), 0o600); err != nil {
t.Fatalf("WriteFile() err=%v", err)
}
if err := os.Symlink(linkTarget, filepath.Join(projectDir, ".env.local")); err != nil {
t.Fatalf("Symlink() err=%v", err)
}
err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"},
factory, stdout)
assertValidationError(t, err, "must be a regular file")
}
func TestReadEnvPullFile_MissingFileReturnsEmpty(t *testing.T) {
got, err := readEnvPullFile(filepath.Join(t.TempDir(), "missing.env"))
if err != nil {
t.Fatalf("readEnvPullFile() err=%v", err)
}
if got != "" {
t.Fatalf("readEnvPullFile() = %q, want empty string", got)
}
}
func TestAppsEnvPull_WritesCanonicalEnvFile(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": map[string]interface{}{
"AAA": "new",
"BBB": `quote"and\\slash`,
},
},
},
})
if err := os.WriteFile(filepath.Join(projectDir, ".env.local"), []byte(strings.Join([]string{
"# AAA = \"commented\"",
"AAA=old",
"KEEP = \"stay\"",
"BROKEN LINE",
"",
}, "\n")), 0o600); err != nil {
t.Fatalf("WriteFile() err=%v", err)
}
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
data, err := os.ReadFile(filepath.Join(projectDir, ".env.local"))
if err != nil {
t.Fatalf("ReadFile() err=%v", err)
}
got := string(data)
if !strings.Contains(got, "# AAA = \"commented\"") {
t.Fatalf("comment must be preserved: %q", got)
}
if !strings.Contains(got, `AAA="new"`) {
t.Fatalf("active value must be updated: %q", got)
}
if !strings.Contains(got, `BBB="quote\"and\\\\slash"`) {
t.Fatalf("new key must be appended canonically: %q", got)
}
if !strings.Contains(got, "KEEP = \"stay\"") {
t.Fatalf("unrelated key must be preserved: %q", got)
}
if !strings.Contains(got, "BROKEN LINE") {
t.Fatalf("malformed line must be preserved: %q", got)
}
}
func TestAppsEnvPull_DryRunDoesNotWriteFile(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
projectDir := t.TempDir()
target := filepath.Join(projectDir, ".env.local")
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("dry-run must not create target file, stat err=%v", err)
}
}
func TestAppsEnvPull_JSONOutputOmitsDatabaseLineText(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": map[string]interface{}{
"SUDA_DATABASE_URL": "short-lived-db-token",
},
},
},
})
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if strings.Contains(stdout.String(), "Development database detected") {
t.Fatalf("json output must not include pretty text: %s", stdout.String())
}
}
func TestAppsEnvPull_ValidationRequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--project-path", t.TempDir(), "--as", "user"},
factory, stdout)
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected missing app-id error, got %v", err)
}
}
func TestAppsEnvPull_ExecuteUsesNestedDataEnvVars(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": map[string]interface{}{
"AAA": "value-a",
},
},
},
})
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
data, err := os.ReadFile(filepath.Join(projectDir, ".env.local"))
if err != nil {
t.Fatalf("ReadFile() err=%v", err)
}
if !strings.Contains(string(data), `AAA="value-a"`) {
t.Fatalf("expected nested data env vars to be written, got %q", string(data))
}
}
func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
RawBody: []byte("[]"),
OnMatch: func(req *http.Request) {
assertEnvPullBody(t, req)
},
})
err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
factory, stdout,
)
if err == nil {
t.Fatalf("expected non-object JSON failure, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype)
}
if strings.Contains(p.Hint, "apps +list") || strings.Contains(p.Hint, "--app-id") {
t.Fatalf("hint should not point to app-id/list recovery for malformed upstream JSON: %q", p.Hint)
}
}
func TestAppsEnvPull_DevDBNotInitializedHintPointsToDBEnvCreate(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": -1,
"msg": "Multi-environment database is not initialized for this app. Invalid DB Branchdev",
},
OnMatch: func(req *http.Request) {
assertEnvPullBody(t, req)
},
})
err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
factory, stdout,
)
p := requireAppsAPIProblem(t, err)
if p.Code != -1 {
t.Fatalf("code = %d, want -1", p.Code)
}
for _, want := range []string{"+db-env-create", "--app-id app_x", "--environment dev", "--dry-run", "--yes"} {
if !strings.Contains(p.Hint, want) {
t.Fatalf("hint missing %q: %q", want, p.Hint)
}
}
if strings.Contains(p.Hint, "apps +list") {
t.Fatalf("hint should not point to app-id/list recovery for missing dev database: %q", p.Hint)
}
}
func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": []interface{}{
map[string]interface{}{"key": "AAA", "value": "value-a"},
map[string]interface{}{"key": "SUDA_DATABASE_URL", "value": "postgres://db", "extras": []interface{}{map[string]interface{}{"key": "expiresAt", "value": "1780389006"}}},
},
},
},
})
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
data, err := os.ReadFile(filepath.Join(projectDir, ".env.local"))
if err != nil {
t.Fatalf("ReadFile() err=%v", err)
}
gotFile := string(data)
if !strings.Contains(gotFile, `AAA="value-a"`) {
t.Fatalf("expected array env_vars entry to be written, got %q", gotFile)
}
if !strings.Contains(gotFile, `SUDA_DATABASE_URL="postgres://db"`) {
t.Fatalf("expected SUDA_DATABASE_URL array entry to be written, got %q", gotFile)
}
gotOut := stdout.String()
if !strings.Contains(gotOut, "Development database detected") {
t.Fatalf("expected database line in pretty output, got %q", gotOut)
}
wantExpiry := time.Unix(1780389006, 0).Local().Format("2006-01-02 15:04:05 MST")
if !strings.Contains(gotOut, "DATABASE_URL is valid until "+wantExpiry+".") {
t.Fatalf("expected expiry line in pretty output, got %q", gotOut)
}
if strings.Contains(gotOut, "expiresAt") {
t.Fatalf("extras metadata must not leak to output, got %q", gotOut)
}
}
func TestAppsEnvPull_JSONOutputCanBeDecoded(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": map[string]interface{}{
"AAA": "value-a",
},
},
},
})
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
AppID string `json:"app_id"`
EnvFile string `json:"env_file"`
DatabaseURLExpiresAt string `json:"database_url_expires_at"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("json.Unmarshal() err=%v; stdout=%s", err, stdout.String())
}
if !envelope.OK {
t.Fatalf("expected ok=true envelope, got %+v", envelope)
}
if envelope.Data.AppID != "app_x" {
t.Fatalf("app_id = %q, want app_x", envelope.Data.AppID)
}
if envelope.Data.EnvFile != filepath.Join(projectDir, ".env.local") {
t.Fatalf("env_file = %q, want %q", envelope.Data.EnvFile, filepath.Join(projectDir, ".env.local"))
}
if envelope.Data.DatabaseURLExpiresAt != "" {
t.Fatalf("database_url_expires_at = %q, want empty for payload without SUDA_DATABASE_URL extras", envelope.Data.DatabaseURLExpiresAt)
}
}
func TestAppsEnvPull_PrettyOutputWithoutDatabaseLine(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": map[string]interface{}{
"AAA": "value-a",
},
},
},
})
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if strings.Contains(stdout.String(), "Development database detected") {
t.Fatalf("unexpected database line in pretty output: %q", stdout.String())
}
}
func TestMergeEnvPullFileContentEmptyEnvVarsPreservesOriginalNewline(t *testing.T) {
original := "KEEP = \"stay\""
merged, updated, created := mergeEnvPullFileContent(original, map[string]string{})
if merged != "KEEP = \"stay\"\n" {
t.Fatalf("merged = %q, want trailing newline preserved", merged)
}
if len(updated) != 0 || len(created) != 0 {
t.Fatalf("updated=%v created=%v, want both empty", updated, created)
}
}
func TestParseEnvPullAssignmentLineRejectsInvalidKey(t *testing.T) {
if _, ok := parseEnvPullAssignmentLine("FOO BAR=baz"); ok {
t.Fatalf("assignment with whitespace in key should not be treated as active assignment")
}
}
func TestResolveEnvPullTargetCleansCustomPath(t *testing.T) {
root := filepath.Join(t.TempDir(), "demo")
input := filepath.Join(root, ".", "sub", "..")
gotProject, gotFile, err := resolveEnvPullTarget(input)
if err != nil {
t.Fatalf("resolveEnvPullTarget() err=%v", err)
}
wantProject := filepath.Clean(input)
if gotProject != wantProject {
t.Fatalf("project path = %q, want %q", gotProject, wantProject)
}
if gotFile != filepath.Join(wantProject, ".env.local") {
t.Fatalf("env file = %q, want %q", gotFile, filepath.Join(wantProject, ".env.local"))
}
}
func TestAppsEnvPull_DatabaseExtrasWithoutExpiresAtDoesNotFail(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": []interface{}{
map[string]interface{}{"key": "SUDA_DATABASE_URL", "value": "postgres://db", "extras": []interface{}{map[string]interface{}{"key": "notExpiresAt", "value": "1780389006"}}},
},
},
},
})
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "Development database detected") {
t.Fatalf("expected database detection line, got %q", got)
}
if strings.Contains(got, "DATABASE_URL is valid until") {
t.Fatalf("did not expect expiry line when expiresAt is absent, got %q", got)
}
}
func TestWriteEnvPullPretty(t *testing.T) {
var buf bytes.Buffer
writeEnvPullPretty(&buf, "app_x", "/repo/.env.local", envPullDatabaseInfo{Detected: true, ExpiresAtText: "2026-06-02 16:30:06 CST"}, nil)
got := buf.String()
if !strings.Contains(got, "App detected: app_x") {
t.Fatalf("missing app line: %q", got)
}
if !strings.Contains(got, "Development database detected") {
t.Fatalf("missing database line: %q", got)
}
if !strings.Contains(got, "✓ Local environment written to /repo/.env.local") {
t.Fatalf("missing env file write line: %q", got)
}
if !strings.Contains(got, "\n\nDATABASE_URL is valid until 2026-06-02 16:30:06 CST.\n") {
t.Fatalf("missing blank-line separated expiry block: %q", got)
}
if strings.Contains(got, "Skipped") {
t.Fatalf("no skipped warning when skippedKeys is nil: %q", got)
}
if !strings.Contains(got, "Run `lark-cli apps +env-pull --app-id <app_id>` again to refresh it.") {
t.Fatalf("missing refresh hint line: %q", got)
}
}
func TestWriteEnvPullPretty_SkippedKeys(t *testing.T) {
var buf bytes.Buffer
writeEnvPullPretty(&buf, "app_x", "/repo/.env.local", envPullDatabaseInfo{}, []string{"bad key", "=eq"})
got := buf.String()
if !strings.Contains(got, "⚠ Skipped 2 invalid key(s): bad key, =eq") {
t.Fatalf("missing skipped keys warning: %q", got)
}
}
func TestExtractEnvPullVars_SkipsInvalidKeys(t *testing.T) {
data := map[string]interface{}{
"env_vars": map[string]interface{}{
"VALID_KEY": "ok",
"also_valid_123": "ok2",
"has space": "skip1",
"has\nnewline": "skip2",
"=starts-eq": "skip3",
"": "skip4",
"has=equals": "skip5",
},
}
got, _, skipped, err := extractEnvPullVars(data)
if err != nil {
t.Fatalf("extractEnvPullVars() err=%v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 valid keys, got %d: %v", len(got), got)
}
if len(skipped) != 5 {
t.Fatalf("expected 5 skipped keys, got %d: %v", len(skipped), skipped)
}
if got["VALID_KEY"] != "ok" {
t.Fatalf("VALID_KEY = %q, want ok", got["VALID_KEY"])
}
if got["also_valid_123"] != "ok2" {
t.Fatalf("also_valid_123 = %q, want ok2", got["also_valid_123"])
}
}
func TestExtractEnvPullVars_ArraySkipsInvalidKeys(t *testing.T) {
data := map[string]interface{}{
"env_vars": []interface{}{
map[string]interface{}{"key": "GOOD_KEY", "value": "val1"},
map[string]interface{}{"key": "bad key", "value": "val2"},
},
}
got, _, skipped, err := extractEnvPullVars(data)
if err != nil {
t.Fatalf("extractEnvPullVars() err=%v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 valid key, got %d: %v", len(got), got)
}
if len(skipped) != 1 || skipped[0] != "bad key" {
t.Fatalf("expected 1 skipped key 'bad key', got %v", skipped)
}
if got["GOOD_KEY"] != "val1" {
t.Fatalf("GOOD_KEY = %q, want val1", got["GOOD_KEY"])
}
}
func TestAppsEnvPull_InjectsForceDBBranchWhenAbsent(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": map[string]interface{}{
"AAA": "value-a",
},
},
},
})
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
data, err := os.ReadFile(filepath.Join(projectDir, ".env.local"))
if err != nil {
t.Fatalf("ReadFile() err=%v", err)
}
got := string(data)
if !strings.Contains(got, `FORCE_DB_BRANCH="dev"`) {
t.Fatalf("expected FORCE_DB_BRANCH to be injected, got %q", got)
}
if !strings.Contains(got, `AAA="value-a"`) {
t.Fatalf("expected upstream env vars to remain, got %q", got)
}
}
func TestAppsEnvPull_InjectsForceDBBranchAlongsideArrayEnvVars(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": []interface{}{
map[string]interface{}{"key": "AAA", "value": "value-a"},
},
},
},
})
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
data, err := os.ReadFile(filepath.Join(projectDir, ".env.local"))
if err != nil {
t.Fatalf("ReadFile() err=%v", err)
}
if !strings.Contains(string(data), `FORCE_DB_BRANCH="dev"`) {
t.Fatalf("expected FORCE_DB_BRANCH to be injected for array env_vars, got %q", string(data))
}
}
func TestAppsEnvPull_ForceDBBranchOverwritesExistingLocalValue(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": map[string]interface{}{
"AAA": "value-a",
},
},
},
})
if err := os.WriteFile(filepath.Join(projectDir, ".env.local"), []byte(strings.Join([]string{
`FORCE_DB_BRANCH="prod"`,
"",
}, "\n")), 0o600); err != nil {
t.Fatalf("WriteFile() err=%v", err)
}
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
data, err := os.ReadFile(filepath.Join(projectDir, ".env.local"))
if err != nil {
t.Fatalf("ReadFile() err=%v", err)
}
got := string(data)
if !strings.Contains(got, `FORCE_DB_BRANCH="dev"`) {
t.Fatalf("expected FORCE_DB_BRANCH to be overwritten with dev, got %q", got)
}
if strings.Contains(got, `FORCE_DB_BRANCH="prod"`) {
t.Fatalf("expected stale FORCE_DB_BRANCH=\"prod\" to be replaced, got %q", got)
}
if strings.Count(got, "FORCE_DB_BRANCH=") != 1 {
t.Fatalf("expected exactly one FORCE_DB_BRANCH assignment, got %q", got)
}
}
func TestAppsEnvPull_ForceDBBranchInjectedEvenWhenUpstreamReturnsEmptyMap(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"env_vars": map[string]interface{}{},
},
},
})
if err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
data, err := os.ReadFile(filepath.Join(projectDir, ".env.local"))
if err != nil {
t.Fatalf("ReadFile() err=%v", err)
}
if !strings.Contains(string(data), `FORCE_DB_BRANCH="dev"`) {
t.Fatalf("expected FORCE_DB_BRANCH to be injected even with empty upstream map, got %q", string(data))
}
}
func TestEnsureTrailingNewline_Cases(t *testing.T) {
cases := []struct {
in, want string
}{
{"", ""},
{"abc", "abc\n"},
{"abc\n", "abc\n"},
}
for _, c := range cases {
if got := ensureTrailingNewline(c.in); got != c.want {
t.Errorf("ensureTrailingNewline(%q)=%q want %q", c.in, got, c.want)
}
}
}
func TestExtractEnvPullDatabaseExpiry_Cases(t *testing.T) {
t.Run("not a slice", func(t *testing.T) {
raw, text := extractEnvPullDatabaseExpiry("nope")
if raw != "" || text != "" {
t.Errorf("got %q,%q want empty", raw, text)
}
})
t.Run("no expiresAt key", func(t *testing.T) {
raw, text := extractEnvPullDatabaseExpiry([]interface{}{
map[string]interface{}{"key": "other", "value": "1"},
})
if raw != "" || text != "" {
t.Errorf("got %q,%q want empty", raw, text)
}
})
t.Run("non-map element skipped", func(t *testing.T) {
raw, text := extractEnvPullDatabaseExpiry([]interface{}{"not-a-map"})
if raw != "" || text != "" {
t.Errorf("got %q,%q want empty", raw, text)
}
})
t.Run("string timestamp", func(t *testing.T) {
ts := int64(1700000000)
raw, text := extractEnvPullDatabaseExpiry([]interface{}{
map[string]interface{}{"key": "expiresAt", "value": "1700000000"},
})
want := time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
if raw != "1700000000" || text != want {
t.Errorf("got %q,%q want 1700000000,%q", raw, text, want)
}
})
t.Run("float timestamp", func(t *testing.T) {
ts := int64(1700000000)
raw, text := extractEnvPullDatabaseExpiry([]interface{}{
map[string]interface{}{"key": "expiresAt", "value": float64(ts)},
})
want := time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
if raw != "1700000000" || text != want {
t.Errorf("got %q,%q want 1700000000,%q", raw, text, want)
}
})
t.Run("invalid string timestamp", func(t *testing.T) {
raw, text := extractEnvPullDatabaseExpiry([]interface{}{
map[string]interface{}{"key": "expiresAt", "value": "notanumber"},
})
if raw != "" || text != "" {
t.Errorf("got %q,%q want empty", raw, text)
}
})
}
func TestExtractEnvPullVars_EdgeCases(t *testing.T) {
t.Run("missing env_vars", func(t *testing.T) {
_, _, _, err := extractEnvPullVars(map[string]interface{}{})
if err == nil {
t.Fatal("expected error for missing env_vars")
}
})
t.Run("nested under data", func(t *testing.T) {
vars, _, _, err := extractEnvPullVars(map[string]interface{}{
"data": map[string]interface{}{
"env_vars": map[string]interface{}{"FOO": "bar"},
},
})
if err != nil || vars["FOO"] != "bar" {
t.Fatalf("got vars=%v err=%v", vars, err)
}
})
t.Run("array form skips non-string value", func(t *testing.T) {
vars, _, _, err := extractEnvPullVars(map[string]interface{}{
"env_vars": []interface{}{
map[string]interface{}{"key": "K1", "value": "v1"},
map[string]interface{}{"key": "K2", "value": 5},
},
})
if err != nil {
t.Fatalf("err=%v", err)
}
if vars["K1"] != "v1" {
t.Errorf("K1 missing: %v", vars)
}
if _, ok := vars["K2"]; ok {
t.Errorf("K2 should be skipped (non-string value)")
}
})
}
func TestResolveEnvPullTarget_RejectsControlChars(t *testing.T) {
if _, _, err := resolveEnvPullTarget("bad\x01path"); err == nil {
t.Error("control char in --project-path must be rejected")
}
}
func TestReadEnvPullFile_ReadErrorOnDirectory(t *testing.T) {
// Reading a directory as a file is a non-ENOENT error path.
if _, err := readEnvPullFile(t.TempDir()); err == nil {
t.Error("reading a directory as env file must surface an error")
}
}
func TestEnsureEnvPullParentDir_MkdirError(t *testing.T) {
// A file occupying the would-be parent component makes MkdirAll fail.
base := t.TempDir()
blocker := filepath.Join(base, "blocker")
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := ensureEnvPullParentDir(filepath.Join(blocker, "child", ".env.local")); err == nil {
t.Error("MkdirAll over a file component must surface an error")
}
}
// TestExtractEnvPullVars_MissingEnvVarsIsInternalInvalidResponse pins that a
// response without a usable env_vars field classifies as
// internal/invalid_response — a broken upstream payload, not a flag problem
// the agent should retry with different arguments.
func TestExtractEnvPullVars_MissingEnvVarsIsInternalInvalidResponse(t *testing.T) {
for name, data := range map[string]map[string]interface{}{
"missing": {},
"wrong type": {"env_vars": "not-an-object"},
} {
t.Run(name, func(t *testing.T) {
_, _, _, err := extractEnvPullVars(data)
if err == nil {
t.Fatalf("expected error for %s env_vars", name)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype)
}
})
}
}