實現 login 指令(#14):tea 相容組態管理
- 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
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
// config.go — tea 相容登入組態的讀取與行級編輯。
|
||||
//
|
||||
// teai 與 tea 共用同一份組態檔(README 設計原則 5),因此寫入採「行級編輯」:
|
||||
// 只修改目標登入項目的所屬行,其餘內容(未知欄位、註解、preferences 區段、
|
||||
// 空行與縮排)逐字保留,避免改壞 tea 也在使用的檔案。新增項目依 tea 實際
|
||||
// 寫出的欄位順序輸出,讓檔案外觀與 tea 一致。
|
||||
//
|
||||
// token 值只會進出這個套件的資料結構與組態檔本身,永遠不出現在錯誤訊息。
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoginEntry 是組態檔中的一筆登入資料(編輯用,含 tea 的 user 欄位)。
|
||||
type LoginEntry struct {
|
||||
Name string
|
||||
URL string
|
||||
Token string
|
||||
Default bool
|
||||
User string
|
||||
}
|
||||
|
||||
// loginItemSpan 標記 logins 清單中一個項目在檔案中的行範圍 [start, end)。
|
||||
type loginItemSpan struct {
|
||||
name string
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
// configDoc 是解析後的組態檔:原始行與 logins 項目的位置資訊。
|
||||
type configDoc struct {
|
||||
lines []string
|
||||
items []loginItemSpan
|
||||
loginsHeader int // 「logins:」所在行;-1 表示沒有
|
||||
itemIndent string // 項目行的縮排(取第一個項目);預設四格空白
|
||||
}
|
||||
|
||||
// parseConfigDoc 掃描組態內容,定位 logins 清單與各項目的行範圍。
|
||||
// 只認識 tea 實際寫出的格式:頂層 `logins:` 加縮排的 `- key: value` 項目;
|
||||
// 其餘行原樣保留在 lines 中。
|
||||
func parseConfigDoc(data []byte) configDoc {
|
||||
doc := configDoc{lines: splitLines(data), loginsHeader: -1, itemIndent: " "}
|
||||
inLogins := false
|
||||
cur := -1
|
||||
for i, raw := range doc.lines {
|
||||
line := raw
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
indent := line[:len(line)-len(strings.TrimLeft(line, " "))]
|
||||
if indent == "" {
|
||||
if inLogins && doc.loginsHeader >= 0 {
|
||||
// 離開 logins 區段;listEnd 由最後一個項目的 end 決定,這裡不用記。
|
||||
}
|
||||
inLogins = strings.HasPrefix(trimmed, "logins:")
|
||||
if inLogins && doc.loginsHeader < 0 {
|
||||
doc.loginsHeader = i
|
||||
}
|
||||
cur = -1
|
||||
continue
|
||||
}
|
||||
if !inLogins {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||
if cur >= 0 {
|
||||
doc.items[cur].end = i
|
||||
}
|
||||
doc.items = append(doc.items, loginItemSpan{start: i, end: i + 1})
|
||||
cur = len(doc.items) - 1
|
||||
if len(doc.items) == 1 {
|
||||
doc.itemIndent = indent
|
||||
}
|
||||
if rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")); rest != "" {
|
||||
if key, value, ok := strings.Cut(rest, ":"); ok && strings.TrimSpace(key) == "name" {
|
||||
doc.items[cur].name = unquoteYAML(strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur >= 0 {
|
||||
doc.items[cur].end = i + 1
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
// findItem 依名稱找項目;找不到回 -1。
|
||||
func (d configDoc) findItem(name string) int {
|
||||
for i, it := range d.items {
|
||||
if it.name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// fieldIndent 回傳項目內屬性行的縮排(項目縮排 + 兩格,tea 的格式)。
|
||||
func (d configDoc) fieldIndent() string {
|
||||
return d.itemIndent + " "
|
||||
}
|
||||
|
||||
// entryAt 取某個項目的完整欄位值。
|
||||
func (d configDoc) entryAt(it loginItemSpan) LoginEntry {
|
||||
var e LoginEntry
|
||||
apply := func(kv string) {
|
||||
key, value, ok := strings.Cut(kv, ":")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = unquoteYAML(strings.TrimSpace(value))
|
||||
switch key {
|
||||
case "name":
|
||||
e.Name = value
|
||||
case "url":
|
||||
e.URL = value
|
||||
case "token":
|
||||
e.Token = value
|
||||
case "default":
|
||||
e.Default = value == "true"
|
||||
case "user":
|
||||
e.User = value
|
||||
}
|
||||
}
|
||||
first := strings.TrimSpace(d.lines[it.start])
|
||||
if rest := strings.TrimSpace(strings.TrimPrefix(first, "-")); rest != "" {
|
||||
apply(rest)
|
||||
}
|
||||
for i := it.start + 1; i < it.end; i++ {
|
||||
if kv := strings.TrimSpace(d.lines[i]); kv != "" && !strings.HasPrefix(kv, "#") {
|
||||
apply(kv)
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// ListLogins 回傳組態內容中的所有登入(依檔案順序)。
|
||||
func ListLogins(data []byte) []LoginEntry {
|
||||
doc := parseConfigDoc(data)
|
||||
out := make([]LoginEntry, 0, len(doc.items))
|
||||
for _, it := range doc.items {
|
||||
out = append(out, doc.entryAt(it))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListLoginsFile 讀取組態檔並列出登入;檔案不存在視為空清單(非錯誤)。
|
||||
func ListLoginsFile(path string) ([]LoginEntry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ListLogins(data), nil
|
||||
}
|
||||
|
||||
// UpsertLogin 新增或更新名為 e.Name 的登入。
|
||||
//
|
||||
// 已存在則就地更新 url/token/user/default(保留其他欄位與行);不存在則
|
||||
// 依 tea 的欄位順序插入新項目。makeDefault 為 true 時將其設為預設並取消
|
||||
// 其他登入的預設旗標;新增後若成為唯一登入也自動成為預設。
|
||||
// 回傳新內容與最終生效的項目值。
|
||||
func UpsertLogin(data []byte, e LoginEntry, makeDefault bool) ([]byte, LoginEntry, error) {
|
||||
if strings.TrimSpace(e.Name) == "" {
|
||||
return nil, LoginEntry{}, fmt.Errorf("gitea: login name must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(e.URL) == "" {
|
||||
return nil, LoginEntry{}, fmt.Errorf("gitea: login url must not be empty")
|
||||
}
|
||||
if e.Token == "" {
|
||||
return nil, LoginEntry{}, fmt.Errorf("gitea: login token must not be empty")
|
||||
}
|
||||
doc := parseConfigDoc(data)
|
||||
idx := doc.findItem(e.Name)
|
||||
if idx >= 0 {
|
||||
existing := doc.entryAt(doc.items[idx])
|
||||
wantDefault := existing.Default || makeDefault
|
||||
it := doc.items[idx]
|
||||
lines := doc.lines
|
||||
// 就地替換各欄位;缺少的欄位補在項目結尾。
|
||||
for _, kv := range []struct{ key, value string }{
|
||||
{"name", yamlScalar(e.Name)},
|
||||
{"url", yamlScalar(e.URL)},
|
||||
{"token", yamlScalar(e.Token)},
|
||||
{"user", yamlScalar(e.User)},
|
||||
{"default", strconv.FormatBool(wantDefault)},
|
||||
} {
|
||||
lines = setFieldInItem(lines, it, kv.key, kv.value, doc.fieldIndent())
|
||||
}
|
||||
out := joinLines(lines)
|
||||
if wantDefault {
|
||||
var err error
|
||||
out, err = SetDefaultLogin(out, e.Name)
|
||||
if err != nil {
|
||||
return nil, LoginEntry{}, err
|
||||
}
|
||||
}
|
||||
final := existing
|
||||
final.URL, final.Token, final.User = e.URL, e.Token, e.User
|
||||
final.Default = wantDefault
|
||||
return out, final, nil
|
||||
}
|
||||
|
||||
// 新增項目。
|
||||
wantDefault := makeDefault || len(doc.items) == 0
|
||||
block := newLoginBlock(doc.itemIndent, e.Name, e.URL, e.Token, wantDefault, e.User)
|
||||
var lines []string
|
||||
var insertAt int
|
||||
if doc.loginsHeader < 0 {
|
||||
if len(doc.lines) == 0 {
|
||||
lines = []string{"logins:"}
|
||||
} else {
|
||||
lines = append(slices.Clone(doc.lines), "", "logins:")
|
||||
}
|
||||
insertAt = len(lines)
|
||||
} else {
|
||||
lines = slices.Clone(doc.lines)
|
||||
// 「logins: []」是空清單的串流寫法,直接接項目會變無效 YAML;改回區段寫法。
|
||||
if len(doc.items) == 0 && strings.TrimSpace(lines[doc.loginsHeader]) == "logins: []" {
|
||||
lines[doc.loginsHeader] = "logins:"
|
||||
}
|
||||
if len(doc.items) > 0 {
|
||||
insertAt = doc.items[len(doc.items)-1].end
|
||||
} else {
|
||||
insertAt = doc.loginsHeader + 1
|
||||
}
|
||||
}
|
||||
out := append(slices.Clone(lines[:insertAt]), block...)
|
||||
out = append(out, lines[insertAt:]...)
|
||||
data2 := joinLines(out)
|
||||
if wantDefault {
|
||||
var err error
|
||||
data2, err = SetDefaultLogin(data2, e.Name)
|
||||
if err != nil {
|
||||
return nil, LoginEntry{}, err
|
||||
}
|
||||
}
|
||||
return data2, LoginEntry{
|
||||
Name: e.Name,
|
||||
URL: e.URL,
|
||||
Token: e.Token,
|
||||
Default: wantDefault,
|
||||
User: e.User,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetDefaultLogin 把名為 name 的登入設為預設,其餘一律取消預設。
|
||||
func SetDefaultLogin(data []byte, name string) ([]byte, error) {
|
||||
doc := parseConfigDoc(data)
|
||||
idx := doc.findItem(name)
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("gitea: login %q not found", name)
|
||||
}
|
||||
lines := doc.lines
|
||||
// 反序處理:前面的插入不影響後面已算好的行範圍,這裡的反序則保證
|
||||
// 後方項目的插入/修改不會推移前方項目的範圍。
|
||||
for i := len(doc.items) - 1; i >= 0; i-- {
|
||||
want := "false"
|
||||
if i == idx {
|
||||
want = "true"
|
||||
}
|
||||
lines = setFieldInItem(lines, doc.items[i], "default", want, doc.fieldIndent())
|
||||
}
|
||||
return joinLines(lines), nil
|
||||
}
|
||||
|
||||
// RemoveLogin 移除名為 name 的登入。若被移除者是預設且還有其他登入,
|
||||
// 剩下的第一筆自動遞補為預設。回傳新內容與遞補後的預設名稱(無則空字串)。
|
||||
func RemoveLogin(data []byte, name string) ([]byte, string, error) {
|
||||
doc := parseConfigDoc(data)
|
||||
idx := doc.findItem(name)
|
||||
if idx < 0 {
|
||||
return nil, "", fmt.Errorf("gitea: login %q not found", name)
|
||||
}
|
||||
removed := doc.entryAt(doc.items[idx])
|
||||
it := doc.items[idx]
|
||||
lines := slices.Delete(slices.Clone(doc.lines), it.start, it.end)
|
||||
out := joinLines(lines)
|
||||
promoted := ""
|
||||
if removed.Default {
|
||||
rest := ListLogins(out)
|
||||
if len(rest) > 0 {
|
||||
promoted = rest[0].Name
|
||||
var err error
|
||||
out, err = SetDefaultLogin(out, promoted)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, promoted, nil
|
||||
}
|
||||
|
||||
// setFieldInItem 把項目內 key 的值換成 value,保留該行的原始縮排;
|
||||
// key 不存在時以 fieldIndent 插在項目結尾。回傳新的行切片。
|
||||
func setFieldInItem(lines []string, it loginItemSpan, key, value, fieldIndent string) []string {
|
||||
first := lines[it.start]
|
||||
trimmed := strings.TrimSpace(first)
|
||||
if rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")); rest != "" {
|
||||
if k, _, ok := strings.Cut(rest, ":"); ok && strings.TrimSpace(k) == key {
|
||||
prefix := first[:strings.Index(first, "-")] + "- "
|
||||
lines[it.start] = prefix + key + ": " + value
|
||||
return lines
|
||||
}
|
||||
}
|
||||
for i := it.start + 1; i < it.end; i++ {
|
||||
t := strings.TrimSpace(lines[i])
|
||||
if k, _, ok := strings.Cut(t, ":"); ok && strings.TrimSpace(k) == key {
|
||||
indent := lines[i][:len(lines[i])-len(strings.TrimLeft(lines[i], " "))]
|
||||
lines[i] = indent + key + ": " + value
|
||||
return lines
|
||||
}
|
||||
}
|
||||
out := append(slices.Clone(lines[:it.end]), fieldIndent+key+": "+value)
|
||||
return append(out, lines[it.end:]...)
|
||||
}
|
||||
|
||||
// newLoginBlock 產生 tea 格式的新登入項目(欄位順序與 tea 實際寫出的一致)。
|
||||
func newLoginBlock(itemIndent, name, url, token string, isDefault bool, user string) []string {
|
||||
f := itemIndent + " "
|
||||
return []string{
|
||||
itemIndent + "- name: " + yamlScalar(name),
|
||||
f + "url: " + yamlScalar(url),
|
||||
f + "token: " + yamlScalar(token),
|
||||
f + "default: " + strconv.FormatBool(isDefault),
|
||||
f + `ssh_host: ""`,
|
||||
f + `ssh_key: ""`,
|
||||
f + "insecure: false",
|
||||
f + `ssh_certificate_principal: ""`,
|
||||
f + "ssh_agent: false",
|
||||
f + `ssh_key_agent_pub: ""`,
|
||||
f + "version_check: false",
|
||||
f + "user: " + yamlScalar(user),
|
||||
f + "created: 0",
|
||||
}
|
||||
}
|
||||
|
||||
// plainScalar 是可以不加引號直接寫出的 YAML 純量。
|
||||
var plainScalar = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/~+=@-]*$`)
|
||||
|
||||
// yamlScalar 把值寫成 YAML 純量:安全字元集直接輸出,其餘加雙引號。
|
||||
func yamlScalar(s string) string {
|
||||
if plainScalar.MatchString(s) {
|
||||
return s
|
||||
}
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
|
||||
// HostOf 取 URL 的主機部分(連埠,小寫);解析失敗回空字串。
|
||||
// 供比對「同一站點」的登入時使用。
|
||||
func HostOf(rawURL string) string { return hostOf(rawURL) }
|
||||
|
||||
// splitLines 把內容拆成行(去掉行尾換行與 CR)。
|
||||
func splitLines(data []byte) []string {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
s := strings.TrimSuffix(string(data), "\n")
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
// joinLines 把行組回內容,保證以單一換行結尾。
|
||||
func joinLines(lines []string) []byte {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
s := strings.Join(lines, "\n")
|
||||
if !strings.HasSuffix(s, "\n") {
|
||||
s += "\n"
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
// SaveTeaConfigFile 把組態內容原子寫入 path:先寫同目錄暫存檔(0600),
|
||||
// 再 rename 取代,避免半寫入狀態。必要時建立上層目錄(0700)。
|
||||
func SaveTeaConfigFile(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("gitea: create config dir: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".config-*.yml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea: create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() {
|
||||
if tmpName != "" {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("gitea: write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("gitea: chmod temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("gitea: close temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("gitea: rename config: %w", err)
|
||||
}
|
||||
tmpName = "" // 已改名成功,不必清理。
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user