// config_test.go 驗證登入組態的行級編輯:新增、更新、設預設、 // 移除,以及「未知欄位與其他區段逐字保留」的關鍵性質。 package gitea import ( "bytes" "os" "path/filepath" "strings" "testing" ) // extraFieldsConfig 含未知欄位與額外區段,用來驗證行級編輯不會改壞不相干內容。 const extraFieldsConfig = `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(extraFieldsConfig)) 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(extraFieldsConfig), 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(extraFieldsConfig), 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) } // 新項目依 teai 欄位順序附加。 s := string(out) if !strings.Contains(s, " - name: second\n") { t.Fatalf("new item not appended:\n%s", s) } for _, ban := range []string{"ssh_", "insecure:", "version_check:", "created:"} { if strings.Contains(s[strings.Index(s, "- name: second"):], ban) { t.Fatalf("new item leaked tea field %q:\n%s", ban, 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) } s := string(out) for _, ban := range []string{"ssh_", "insecure:", "version_check:", "created:", "preferences:"} { if strings.Contains(s, ban) { t.Fatalf("new config leaked tea field %q:\n%s", ban, s) } } } 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(extraFieldsConfig), "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(extraFieldsConfig), "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 TestSaveConfigFileAtomicAndMode(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "teai", "config.yml") if err := SaveConfigFile(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 := SaveConfigFile(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) } } }