- internal/gitea/config.go:tea 組態的行級編輯引擎(新增/更新/ 設預設/移除),未知欄位與 preferences 區段逐字保留;原子寫入 (暫存檔 + rename,0600)。 - internal/cli/login.go:teai login list/add/default/remove。add 先以 token 呼叫 GET /user 驗證,成功才寫入並記下帳號;未給 --token 時 自 stdin 讀一行;401 結束碼 3。未給 --name 時同站既有項目就地更新。 - 輸出欄位 name/url/user/default,永不輸出 token。 - 測試:編輯引擎 12 項、指令層 11 項(httptest 假 API),並以真 tea 驗證 teai 寫出的組態可互通(tea whoami 讀出同帳號)。 Closes #14
264 lines
7.5 KiB
Go
264 lines
7.5 KiB
Go
// config_test.go 驗證 tea 相容組態的行級編輯:新增、更新、設預設、
|
|
// 移除,以及「未知欄位與其他區段逐字保留」的關鍵性質。
|
|
package gitea
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// realWorldTeaConfig 是 tea 實際寫出的組態(含 ssh_* 等未知欄位與 preferences)。
|
|
const realWorldTeaConfig = `logins:
|
|
- name: alterminal
|
|
url: https://gitea.alterminal.com
|
|
token: tok-1
|
|
default: true
|
|
ssh_host: ""
|
|
ssh_key: ""
|
|
insecure: false
|
|
ssh_certificate_principal: ""
|
|
ssh_agent: false
|
|
ssh_key_agent_pub: ""
|
|
version_check: false
|
|
user: ceo
|
|
created: 0
|
|
preferences:
|
|
editor: false
|
|
flag_defaults:
|
|
remote: ""
|
|
`
|
|
|
|
func TestListLoginsParsesAllEntries(t *testing.T) {
|
|
logins := ListLogins([]byte(realWorldTeaConfig))
|
|
if len(logins) != 1 {
|
|
t.Fatalf("want 1 login, got %d", len(logins))
|
|
}
|
|
l := logins[0]
|
|
if l.Name != "alterminal" || l.URL != "https://gitea.alterminal.com" || l.Token != "tok-1" || !l.Default || l.User != "ceo" {
|
|
t.Fatalf("unexpected entry: %+v", l)
|
|
}
|
|
}
|
|
|
|
func TestUpsertLoginUpdatesExistingPreservesRest(t *testing.T) {
|
|
out, final, err := UpsertLogin([]byte(realWorldTeaConfig), LoginEntry{
|
|
Name: "alterminal", URL: "https://gitea.alterminal.com", Token: "tok-2", User: "ceo",
|
|
}, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := string(out)
|
|
// 更新 token、保留其他欄位。
|
|
if !strings.Contains(s, "token: tok-2") || strings.Contains(s, "tok-1") {
|
|
t.Fatalf("token not updated:\n%s", s)
|
|
}
|
|
for _, want := range []string{
|
|
"ssh_host: \"\"", "insecure: false", "ssh_key_agent_pub: \"\"",
|
|
"user: ceo", "created: 0", "default: true",
|
|
"preferences:", "editor: false", "flag_defaults:", `remote: ""`,
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Fatalf("missing %q after edit:\n%s", want, s)
|
|
}
|
|
}
|
|
if final.Default != true {
|
|
t.Fatalf("existing default should stay default, got %+v", final)
|
|
}
|
|
// 編輯後仍可解析出正確值。
|
|
logins := ListLogins(out)
|
|
if len(logins) != 1 || logins[0].Token != "tok-2" || !logins[0].Default {
|
|
t.Fatalf("reparse mismatch: %+v", logins)
|
|
}
|
|
}
|
|
|
|
func TestUpsertLoginAppendsNewAndMakesDefault(t *testing.T) {
|
|
out, final, err := UpsertLogin([]byte(realWorldTeaConfig), LoginEntry{
|
|
Name: "second", URL: "https://git.example.com", Token: "tok-b", User: "bob",
|
|
}, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
logins := ListLogins(out)
|
|
if len(logins) != 2 {
|
|
t.Fatalf("want 2 logins, got %d:\n%s", len(logins), out)
|
|
}
|
|
if logins[0].Default || !logins[1].Default {
|
|
t.Fatalf("only the new login should be default: %+v", logins)
|
|
}
|
|
if logins[1].Name != "second" || logins[1].Token != "tok-b" || logins[1].User != "bob" {
|
|
t.Fatalf("new entry mismatch: %+v", logins[1])
|
|
}
|
|
if !final.Default {
|
|
t.Fatalf("final should be default: %+v", final)
|
|
}
|
|
// tea 的欄位順序仍在。
|
|
s := string(out)
|
|
if !strings.Contains(s, " - name: second\n") {
|
|
t.Fatalf("new item not appended in tea style:\n%s", s)
|
|
}
|
|
}
|
|
|
|
func TestUpsertLoginFirstEverLoginCreatesSection(t *testing.T) {
|
|
out, final, err := UpsertLogin(nil, LoginEntry{
|
|
Name: "only", URL: "https://gitea.alterminal.com", Token: "tok", User: "ceo",
|
|
}, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
logins := ListLogins(out)
|
|
if len(logins) != 1 || !logins[0].Default || logins[0].Token != "tok" {
|
|
t.Fatalf("first login should auto-default: %s", out)
|
|
}
|
|
if !final.Default {
|
|
t.Fatalf("final should be default: %+v", final)
|
|
}
|
|
if !strings.HasPrefix(string(out), "logins:\n") {
|
|
t.Fatalf("section not created:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestUpsertLoginRejectsBadInput(t *testing.T) {
|
|
cases := []LoginEntry{
|
|
{Name: "", URL: "https://x", Token: "t"},
|
|
{Name: "n", URL: " ", Token: "t"},
|
|
{Name: "n", URL: "https://x", Token: ""},
|
|
}
|
|
for _, e := range cases {
|
|
if _, _, err := UpsertLogin(nil, e, true); err == nil {
|
|
t.Errorf("UpsertLogin(%+v) should fail", e)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetDefaultLoginSwitchesFlag(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
|
|
`
|
|
out, err := SetDefaultLogin([]byte(cfg), "b")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
logins := ListLogins(out)
|
|
if logins[0].Default || !logins[1].Default {
|
|
t.Fatalf("default flag not switched:\n%s", out)
|
|
}
|
|
// b 之後的內容(如有)不受影響。
|
|
if !strings.Contains(string(out), "token: ta") || !strings.Contains(string(out), "token: tb") {
|
|
t.Fatalf("tokens lost:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestSetDefaultLoginUnknownNameErrors(t *testing.T) {
|
|
if _, err := SetDefaultLogin([]byte(realWorldTeaConfig), "nope"); err == nil {
|
|
t.Fatal("unknown name should error")
|
|
}
|
|
}
|
|
|
|
func TestRemoveLoginPromotesNextDefault(t *testing.T) {
|
|
// 第二個登入放在 logins 區段內(preferences 之前)。
|
|
cfg := `logins:
|
|
- name: alterminal
|
|
url: https://gitea.alterminal.com
|
|
token: tok-1
|
|
default: true
|
|
user: ceo
|
|
- name: backup
|
|
url: https://backup.example.com
|
|
token: tb
|
|
default: false
|
|
preferences:
|
|
editor: false
|
|
`
|
|
out, promoted, err := RemoveLogin([]byte(cfg), "alterminal")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if promoted != "backup" {
|
|
t.Fatalf("promoted = %q, want backup:\n%s", promoted, out)
|
|
}
|
|
logins := ListLogins(out)
|
|
if len(logins) != 1 || logins[0].Name != "backup" || !logins[0].Default {
|
|
t.Fatalf("remaining login mismatch: %+v", logins)
|
|
}
|
|
if strings.Contains(string(out), "tok-1") || strings.Contains(string(out), "name: alterminal") {
|
|
t.Fatalf("login not fully removed:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestRemoveLoginLastOne(t *testing.T) {
|
|
out, promoted, err := RemoveLogin([]byte(realWorldTeaConfig), "alterminal")
|
|
if err != nil || promoted != "" {
|
|
t.Fatalf("want clean removal, promoted=%q err=%v", promoted, err)
|
|
}
|
|
if strings.Contains(string(out), "alterminal") {
|
|
t.Fatalf("login not removed:\n%s", out)
|
|
}
|
|
if !strings.Contains(string(out), "preferences:") {
|
|
t.Fatalf("preferences section lost:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestSaveTeaConfigFileAtomicAndMode(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "tea", "config.yml")
|
|
if err := SaveTeaConfigFile(path, []byte("logins: []\n")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != 0o600 {
|
|
t.Fatalf("perm = %v, want 0600", info.Mode().Perm())
|
|
}
|
|
// 再寫一次(覆蓋既有檔),不留暫存殘檔。
|
|
if err := SaveTeaConfigFile(path, []byte("logins: []\n# v2\n")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, _ := os.ReadDir(filepath.Dir(path))
|
|
if len(entries) != 1 {
|
|
for _, e := range entries {
|
|
t.Logf("leftover: %s", e.Name())
|
|
}
|
|
t.Fatalf("want exactly 1 file, got %d", len(entries))
|
|
}
|
|
data, _ := os.ReadFile(path)
|
|
if !bytes.Contains(data, []byte("# v2")) {
|
|
t.Fatalf("overwrite failed: %s", data)
|
|
}
|
|
}
|
|
|
|
func TestSplitJoinLinesRoundTrip(t *testing.T) {
|
|
original := "a\nb\n"
|
|
got := string(joinLines(splitLines([]byte(original))))
|
|
if got != original {
|
|
t.Fatalf("round trip: %q", got)
|
|
}
|
|
if joinLines(splitLines(nil)) != nil {
|
|
t.Fatal("nil input should stay nil")
|
|
}
|
|
}
|
|
|
|
func TestYamlScalarQuoting(t *testing.T) {
|
|
cases := map[string]string{
|
|
"https://gitea.alterminal.com": "https://gitea.alterminal.com", // 含 : 與 / 的純量在 YAML 合法,不必引號
|
|
"tok-abc_123": "tok-abc_123",
|
|
"has space": `"has space"`,
|
|
"ceo": "ceo",
|
|
}
|
|
for in, want := range cases {
|
|
if got := yamlScalar(in); got != want {
|
|
t.Errorf("yamlScalar(%q) = %s, want %s", in, got, want)
|
|
}
|
|
}
|
|
}
|