// login_test.go 驗證 login 指令:list/add/default/remove 的輸出、 // 驗證流程(先 GET /user 再寫入)、401 不寫檔、token 不外洩。 package cli import ( "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "gitea.alterminal.com/alterminal/teai/internal/gitea" ) // loginTestEnv 建立測試環境:臨時組態檔路徑 + 可替換的 stdin。 func loginTestEnv(t *testing.T, configContent string) (args []string, configPath string) { t.Helper() dir := t.TempDir() configPath = filepath.Join(dir, "config.yml") if configContent != "" { if err := os.WriteFile(configPath, []byte(configContent), 0o600); err != nil { t.Fatal(err) } } t.Setenv("TEA_CONFIG", configPath) return []string{"--config", configPath}, configPath } // newAPIServer 建立假的 Gitea API:/user 檢查 Authorization。 func newAPIServer(t *testing.T, wantToken string, status int) (*httptest.Server, *atomic.Value) { t.Helper() var gotAuth atomic.Value srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth.Store(r.Header.Get("Authorization")) if r.URL.Path != "/api/v1/user" { w.WriteHeader(http.StatusNotFound) return } if status != 0 { w.WriteHeader(status) return } fmt.Fprint(w, `{"login":"ceo"}`) })) t.Cleanup(srv.Close) return srv, &gotAuth } func TestLoginListJSONNoToken(t *testing.T) { prefix, _ := loginTestEnv(t, `logins: - name: alterminal url: https://gitea.alterminal.com token: super-secret default: true user: ceo `) stdout, _, code := run(append(prefix, "login", "list")...) if code != 0 { t.Fatalf("code = %d", code) } if !strings.Contains(stdout, `"name":"alterminal"`) || !strings.Contains(stdout, `"user":"ceo"`) { t.Fatalf("unexpected output: %s", stdout) } if strings.Contains(stdout, "super-secret") || strings.Contains(stdout, "token") { t.Fatalf("token leaked: %s", stdout) } } func TestLoginListEmpty(t *testing.T) { prefix, path := loginTestEnv(t, "") if _, err := os.Stat(path); err == nil { t.Fatal("precondition: config should not exist") } stdout, _, code := run(append(prefix, "login", "list")...) if code != 0 { t.Fatalf("code = %d", code) } if strings.TrimSpace(stdout) != "[]" { t.Fatalf("want [], got %q", stdout) } } func TestLoginAddVerifiesThenWrites(t *testing.T) { srv, gotAuth := newAPIServer(t, "tok-xyz", 0) prefix, configPath := loginTestEnv(t, "") stdout, _, code := run(append(prefix, "--url", srv.URL, "login", "add", "--name", "test-site", "--token", "tok-xyz")...) if code != 0 { t.Fatalf("code = %d, stdout = %s", code, stdout) } if got := gotAuth.Load(); got != "token tok-xyz" { t.Fatalf("server saw Authorization %v", got) } if !strings.Contains(stdout, `"name":"test-site"`) || !strings.Contains(stdout, `"user":"ceo"`) { t.Fatalf("unexpected output: %s", stdout) } if strings.Contains(stdout, "tok-xyz") { t.Fatalf("token leaked: %s", stdout) } // 寫入的組態可被解析,且欄位齊全。 data, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) } logins := gitea.ListLogins(data) if len(logins) != 1 { t.Fatalf("want 1 login, got %d:\n%s", len(logins), data) } l := logins[0] if l.Name != "test-site" || l.URL != srv.URL || l.Token != "tok-xyz" || !l.Default || l.User != "ceo" { t.Fatalf("entry mismatch: %+v", l) } info, _ := os.Stat(configPath) if info.Mode().Perm() != 0o600 { t.Fatalf("perm = %v", info.Mode().Perm()) } } func TestLoginAddTokenFromStdin(t *testing.T) { srv, _ := newAPIServer(t, "stdin-tok", 0) prefix, configPath := loginTestEnv(t, "") old := loginStdin loginStdin = strings.NewReader("stdin-tok\n") defer func() { loginStdin = old }() stdout, _, code := run(append(prefix, "--url", srv.URL, "login", "add")...) if code != 0 { t.Fatalf("code = %d, stdout = %s", code, stdout) } data, _ := os.ReadFile(configPath) if l := gitea.ListLogins(data); len(l) != 1 || l[0].Token != "stdin-tok" { t.Fatalf("stdin token not saved:\n%s", data) } } func TestLoginAddRejected401NoWrite(t *testing.T) { srv, _ := newAPIServer(t, "", http.StatusUnauthorized) prefix, configPath := loginTestEnv(t, `logins: - name: keep url: https://keep.example.com token: keep-tok default: true `) _, stderr, code := run(append(prefix, "--url", srv.URL, "login", "add", "--token", "bad-tok")...) // 401 是 API 錯誤;對照 README「輸出與結束碼」=3(#10)。 if code != 3 { t.Fatalf("401 should exit 3 (README api error), got %d (stderr %s)", code, stderr) } if !strings.Contains(stderr, "401") { t.Fatalf("stderr should mention 401: %s", stderr) } // 組態不受影響。 data, _ := os.ReadFile(configPath) if l := gitea.ListLogins(data); len(l) != 1 || l[0].Token != "keep-tok" { t.Fatalf("config must be untouched:\n%s", data) } } func TestLoginAddUpdatesExistingInPlace(t *testing.T) { srv, _ := newAPIServer(t, "new-tok", 0) prefix, configPath := loginTestEnv(t, `logins: - name: alterminal url: `+srv.URL+` token: old-tok default: true user: someone-else ssh_key: "keep-me" preferences: editor: false `) stdout, _, code := run(append(prefix, "--url", srv.URL, "login", "add", "--token", "new-tok")...) if code != 0 { t.Fatalf("code = %d, out = %s", code, stdout) } data, _ := os.ReadFile(configPath) s := string(data) // 未給 --name:同站既有項目(name: alterminal)就地更新,不新增重複項目。 if strings.Contains(stdout, `"name":"127.0.0.1:`) { t.Fatalf("should reuse existing entry name, got %s", stdout) } if !strings.Contains(s, "- name: alterminal") { t.Fatalf("existing entry name lost:\n%s", s) } if strings.Contains(s, "old-tok") || !strings.Contains(s, "token: new-tok") { t.Fatalf("token not replaced:\n%s", s) } if !strings.Contains(s, `ssh_key: "keep-me"`) || !strings.Contains(s, "editor: false") { t.Fatalf("unrelated content lost:\n%s", s) } if !strings.Contains(s, "user: ceo") { t.Fatalf("user not refreshed:\n%s", s) } } func TestLoginDefaultAndRemove(t *testing.T) { cfg := `logins: - name: a url: https://a.example.com token: ta default: true - name: b url: https://b.example.com token: tb default: false ` prefix, configPath := loginTestEnv(t, cfg) stdout, _, code := run(append(prefix, "login", "default", "b")...) if code != 0 || !strings.Contains(stdout, `"name":"b"`) { t.Fatalf("default failed: code=%d out=%s", code, stdout) } data, _ := os.ReadFile(configPath) if l := gitea.ListLogins(data); !l[1].Default || l[0].Default { t.Fatalf("default not switched:\n%s", data) } // remove --yes:移除預設者 b,a 遞補。 stdout, _, code = run(append(prefix, "login", "remove", "--yes", "b")...) if code != 0 || !strings.Contains(stdout, `"name":"b"`) { t.Fatalf("remove failed: code=%d out=%s", code, stdout) } data, _ = os.ReadFile(configPath) if l := gitea.ListLogins(data); len(l) != 1 || l[0].Name != "a" || !l[0].Default { t.Fatalf("promote failed:\n%s", data) } } func TestLoginRemoveAsksConfirmation(t *testing.T) { prefix, _ := loginTestEnv(t, `logins: - name: a url: https://a.example.com token: ta default: true `) old := loginStdin loginStdin = strings.NewReader("n\n") defer func() { loginStdin = old }() _, _, code := run(append(prefix, "login", "remove", "a")...) if code != 0 { t.Fatalf("cancel should still exit 0, got %d", code) } // 取消:檔案不變(token 仍在)。 data, _ := os.ReadFile(filepath.Dir(prefix[1]) + "/config.yml") if !strings.Contains(string(data), "token: ta") { t.Fatalf("cancelled remove must not write:\n%s", data) } } func TestLoginUnknownSubcommandUsage(t *testing.T) { prefix, _ := loginTestEnv(t, "") _, stderr, code := run(append(prefix, "login", "bogus")...) // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。 if code != 2 { t.Fatalf("want exit 2 (README usage), got %d", code) } if !strings.Contains(stderr, "unknown login subcommand") { t.Fatalf("stderr: %s", stderr) } } func TestLoginNoSubcommandUsage(t *testing.T) { prefix, _ := loginTestEnv(t, "") _, stderr, code := run(append(prefix, "login")...) // 對照 README「輸出與結束碼」:用法錯誤=2(#10)。 if code != 2 { t.Fatalf("want exit 2 (README usage), got %d", code) } if !strings.Contains(stderr, "subcommand") { t.Fatalf("stderr: %s", stderr) } } func TestLoginRemoveNotFound(t *testing.T) { prefix, _ := loginTestEnv(t, "logins: []\n") _, stderr, code := run(append(prefix, "login", "remove", "--yes", "ghost")...) // ExitInternal 不在 README 保證範圍;此處固定為 1(內部錯誤,#10)。 if code != 1 { t.Fatalf("want exit 1 (internal), got %d", code) } if !strings.Contains(stderr, `login "ghost" not found`) { t.Fatalf("stderr: %s", stderr) } } func TestLoginRegisteredInUsage(t *testing.T) { stdout, _, code := run() if code != 0 || !strings.Contains(stdout, "login") { t.Fatalf("usage should list login: %s", stdout) } }