mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 08:12:36 +08:00
1149 lines
36 KiB
Go
1149 lines
36 KiB
Go
// 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_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)
|
|
}
|
|
})
|
|
}
|
|
}
|