diff --git a/docs/reference.md b/docs/reference.md index 0905bd27..a089c052 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -249,6 +249,11 @@ The gateway issues **gateway keys** for clients, separate from a provider's upstream API keys. Turn on **Settings → Share on local network**, then open **Gateway → Gateway keys → Add gateway key**. This block appears only while sharing is on. Create a named key for each client and copy it from its row. +To move from another gateway without touching its clients, enter the key they +already send in **Your own key (optional)** (8–256 characters, no spaces, not +starting with `sk-magpie-`, unlike any other key); left empty, magpie makes +one. The list shows only its last four characters, and it is counted, +limited and restricted as any other key, from this computer too. Rename, disable, rotate or remove keys independently; rotation and removal ask for confirmation. Rotation keeps the name, enabled state and usage history; other keys are unchanged. While sharing is on, **Gateway → Connect** @@ -261,6 +266,7 @@ For a headless gateway, use the CLI before exposing the port: ```sh magpie gateway-key add "Remote laptop" # prints the new credential once +magpie gateway-key add Family --key - # keeps a key clients already send, read from stdin magpie gateway-key list # ids, names, enabled state and masked keys magpie gateway-key rotate # prints the replacement; identity stays the same magpie gateway-key remove # revokes remote access diff --git a/gateway_keys_cli.go b/gateway_keys_cli.go index 92e504cf..d98363f8 100644 --- a/gateway_keys_cli.go +++ b/gateway_keys_cli.go @@ -36,8 +36,26 @@ func gatewayKeysTo(out io.Writer, args []string) error { access.MigrateLegacyLANKeyBestEffort() return gatewayKeyAccounts(out, args[1:]) } + // add --key : the key keeps a value its clients already + // send (love1sbug on X); "--key -" reads it from stdin, out of the + // shell's history + var own string + if action == "add" && len(args) == 4 && args[2] == "--key" { + own = args[3] + if own == "-" { + b, err := io.ReadAll(io.LimitReader(os.Stdin, 4096)) + if err != nil { + return err + } + own = strings.TrimSpace(string(b)) + if own == "" { + return fmt.Errorf("no key came on stdin") + } + } + args = args[:2] + } if (action == "list" && len(args) != 1) || (action != "list" && len(args) != 2) { - return fmt.Errorf("usage: magpie gateway-key list | add | rotate | remove | limit [off | day|week|month [--tokens N] [--cost USD] [--cache-reads]] | models [all | /|/* ...] | accounts [all | /|/ ...]") + return fmt.Errorf("usage: magpie gateway-key list | add [--key | --key -] | rotate | remove | limit [off | day|week|month [--tokens N] [--cost USD] [--cache-reads]] | models [all | /|/* ...] | accounts [all | /|/ ...]") } switch action { case "list", "add", "rotate", "remove": @@ -83,7 +101,7 @@ func gatewayKeysTo(out io.Writer, args []string) error { } in := access.Change{Key: args[1]} if action == "add" { - in = access.Change{Name: args[1]} + in = access.Change{Name: args[1], Secret: own} } secret, err := access.Update(action+"-key", in) if err != nil { diff --git a/gateway_keys_cli_test.go b/gateway_keys_cli_test.go index 7b811db6..70fbc217 100644 --- a/gateway_keys_cli_test.go +++ b/gateway_keys_cli_test.go @@ -63,6 +63,44 @@ func TestGatewayKeyCLI(t *testing.T) { } } +// add --key keeps a value clients already send (love1sbug on X); "-" +// takes it from stdin. +func TestGatewayKeyCLIOwnValue(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", os.Getenv("HOME")) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var out bytes.Buffer + if err := gatewayKeysTo(&out, []string{"gateway-key", "add", "Family", "--key", "cpa-family-key-0042"}); err != nil || strings.TrimSpace(out.String()) != "cpa-family-key-0042" { + t.Fatal(out.String(), err) + } + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + w.WriteString("cpa-friend-key-0007\n") + w.Close() + stdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = stdin }() + if err := gatewayKeysTo(&bytes.Buffer{}, []string{"gateway-key", "add", "Friend", "--key", "-"}); err != nil { + t.Fatal(err) + } + for _, k := range []string{"cpa-family-key-0042", "cpa-friend-key-0007"} { + if _, ok := access.Authenticate(k); !ok { + t.Fatal("not a key:", k) + } + } + out.Reset() + if err := gatewayKeysTo(&out, []string{"gateway-key", "list"}); err != nil || strings.Contains(out.String(), "cpa-family") || !strings.Contains(out.String(), "…0042") { + t.Fatal("list must mask an own value", out.String(), err) + } + for _, args := range [][]string{{"add", "X", "--key"}, {"add", "X", "--key", "short"}, {"add", "X", "--key", "cpa-family-key-0042"}, {"rotate", "x", "--key", "cpa-other-key-0001"}} { + if err := gatewayKeysTo(&bytes.Buffer{}, append([]string{"gateway-key"}, args...)); err == nil { + t.Error("accepted", args) + } + } +} + func TestGatewayKeyListWithReadOnlyMigration(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) s := settings.Settings{LAN: true, LANKey: "sk-magpie-fixture-cli"} diff --git a/internal/access/access.go b/internal/access/access.go index 51c252b3..89e8d052 100644 --- a/internal/access/access.go +++ b/internal/access/access.go @@ -30,6 +30,82 @@ func Managed(secret string) bool { return strings.HasPrefix(secret, legacyLANPrefix) } +// Named: secret has a named gateway key's form (sk-magpie-…), or is the +// key's own value a user gave it (Change.Secret), so a request from this +// computer with one is counted and limited as that key, as a sk-magpie- +// key is. +func Named(secret string) bool { + if Managed(secret) { + return true + } + if secret == "" { + return false + } + own := ownSecrets() + _, ok := own[secret] + return ok +} + +// own caches the key store's own-value secrets by the file's size and +// time, so a request from this computer doesn't read it each time. +var own struct { + sync.Mutex + size int64 + mod int64 + secrets map[string]struct{} +} + +func ownSecrets() map[string]struct{} { + fi, err := os.Stat(Path()) + if err != nil { + return nil + } + own.Lock() + defer own.Unlock() + if own.secrets != nil && own.size == fi.Size() && own.mod == fi.ModTime().UnixNano() { + return own.secrets + } + mu.Lock() + keys, err := load() + mu.Unlock() + if err != nil { + return nil // unknown: tried again on the next request + } + m := map[string]struct{}{} + for _, k := range keys { + if k.Secret != "" && !Managed(k.Secret) { + m[k.Secret] = struct{}{} + } + } + own.size, own.mod, own.secrets = fi.Size(), fi.ModTime().UnixNano(), m + return m +} + +// ownSecret checks a key value a user brings (one their clients already +// send, from another gateway they move from, such as CLIProxyAPI's +// api-keys, which are any string): what an HTTP header carries, and no +// other key's. +func ownSecret(secret string, keys []Key) (string, error) { + secret = strings.TrimSpace(secret) + if n := len(secret); n < 8 || n > 256 { + return "", errors.New("Use a key between 8 and 256 characters") + } + for i := 0; i < len(secret); i++ { + if c := secret[i]; c <= ' ' || c > '~' { + return "", errors.New("A key can have only letters, digits and ASCII symbols, no spaces") + } + } + if strings.HasPrefix(secret, legacyLANPrefix) { + return "", errors.New("A key of your own can't start with sk-magpie-: leave the key empty and magpie makes one") + } + for _, k := range keys { + if subtle.ConstantTimeCompare([]byte(secret), []byte(k.Secret)) == 1 { + return "", errors.New("Another gateway key already has this key") + } + } + return secret, nil +} + type Key struct { ID string `json:"id"` Name string `json:"name"` @@ -97,6 +173,12 @@ func List() ([]Key, error) { } k.Masked = prefix + "…" + k.Secret[len(k.Secret)-6:] } + if k.Secret != "" && !Managed(k.Secret) { // a key of the user's own: only its end + k.Masked = "…" + if len(k.Secret) >= 12 { + k.Masked += k.Secret[len(k.Secret)-4:] + } + } k.Secret = "" } return keys, nil @@ -162,6 +244,10 @@ type Change struct { // Accounts is what "accounts-key" sets (#905); none lets the key use // every account and key. Accounts []string `json:"accounts,omitempty"` + // Secret is the value "add-key" gives the new key, one its clients + // already send (love1sbug on X: keys handed out from another gateway); + // "" for one magpie makes. + Secret string `json:"secret,omitempty"` } // Update writes the named key store atomically. @@ -185,11 +271,17 @@ func Update(action string, in Change) (string, error) { if err != nil { return "", err } - token, err := random(24) - if err != nil { - return "", err + if in.Secret != "" { + if secret, err = ownSecret(in.Secret, keys); err != nil { + return "", err + } + } else { + token, err := random(24) + if err != nil { + return "", err + } + secret = Prefix + token } - secret = Prefix + token keys = append(keys, Key{ID: id, Name: name, Secret: secret}) } else { i := slices.IndexFunc(keys, func(k Key) bool { return k.ID == in.Key }) diff --git a/internal/access/access_test.go b/internal/access/access_test.go index f7649fd1..1170841d 100644 --- a/internal/access/access_test.go +++ b/internal/access/access_test.go @@ -302,3 +302,51 @@ func TestLANRotationKeepsOlderMagpieWorking(t *testing.T) { t.Fatal("old key resurrected") } } + +// A key can be given a value of the user's own, one their clients already +// send (love1sbug on X), checked as a header can carry it and unlike any +// other key's. +func TestOwnKeyValue(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + const own = "cpa-family-key-0042" + if Named(own) { + t.Fatal("named before it is a key") + } + got, err := Update("add-key", Change{Name: "Family", Secret: " " + own + "\n"}) + if err != nil || got != own { + t.Fatal(got, err) + } + keys, err := List() + if err != nil || len(keys) != 1 || keys[0].Masked != "…0042" || keys[0].Secret != "" { + t.Fatal(keys, err) + } + if who, ok := Authenticate(own); !ok || who.KeyName != "Family" { + t.Fatal(who, ok) + } + if !Named(own) || Named(own+"x") || Named("") || !Named(Prefix+"abc") { + t.Fatal("Named") + } + for _, bad := range []string{"short", own, "has a space in it", "tab\there-and-more", "非ascii-key-value", "sk-magpie-key-mine-1234", strings.Repeat("k", 257)} { + if _, err := Update("add-key", Change{Name: "Other", Secret: bad}); err == nil { + t.Fatalf("accepted %q", bad) + } + } + if keys, _ := List(); len(keys) != 1 { + t.Fatal("a refused key was kept", keys) + } + // rotating it gives it one magpie makes; the old value no longer works + rotated, err := Update("rotate-key", Change{Key: keys[0].ID}) + if err != nil || !strings.HasPrefix(rotated, Prefix) { + t.Fatal(rotated, err) + } + if _, ok := Authenticate(own); ok || Named(own) { + t.Fatal("the old value still works") + } + short, err := Update("add-key", Change{Name: "Short", Secret: "abcd1234"}) + if err != nil { + t.Fatal(err) + } + if keys, _ := List(); keys[1].Masked != "…" { + t.Fatal("a short key shows its end", keys[1].Masked, short) + } +} diff --git a/internal/gateway/caller_own_key_test.go b/internal/gateway/caller_own_key_test.go new file mode 100644 index 00000000..f701c4bc --- /dev/null +++ b/internal/gateway/caller_own_key_test.go @@ -0,0 +1,79 @@ +package gateway + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/yetone/magpie/internal/access" + "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" + "github.com/yetone/magpie/internal/usage" +) + +// A key given its own value — one clients already send, from another +// gateway (love1sbug on X) — is that key from another computer and from +// this one, as a sk-magpie- key is: counted as it, and never sent on. +func TestOwnKeyValueIsTheKey(t *testing.T) { + fresh(t) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer upstream-secret" { + t.Error("caller credential reached provider:", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}`) + })) + defer up.Close() + if err := provider.Save(provider.Provider{ID: "plan", Name: "Plan", Key: "upstream-secret", Chat: up.URL + "/v1", Models: []string{"m1"}}); err != nil { + t.Fatal(err) + } + s := settings.Load() + s.LAN = true + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + const own = "cpa-family-key-0042" + if _, err := access.Update("add-key", access.Change{Name: "Family", Secret: own}); err != nil { + t.Fatal(err) + } + h := lanGuard(New().Handler()) // as the real server has it + call := func(from, key string) int { + r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(chatReq)) + r.RemoteAddr = from + r.Header.Set("Authorization", "Bearer "+key) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Code + } + if code := call("192.0.2.7:4000", own); code != 200 { + t.Fatal("from another computer", code) + } + if code := call("127.0.0.1:4000", own); code != 200 { + t.Fatal("from this computer", code) + } + if code := call("192.0.2.7:4000", own+"x"); code != http.StatusUnauthorized { + t.Fatal("a wrong key", code) + } + recs := usage.Load(time.Time{}) + if len(recs) != 2 { + t.Fatalf("records: %+v", recs) + } + for _, rec := range recs { + if rec.CallerKeyName != "Family" { + t.Fatalf("not counted as the key: %+v", rec) + } + } + keys, err := access.List() + if err != nil || len(keys) != 1 { + t.Fatal(keys, err) + } + if _, err := access.Update("off-key", access.Change{Key: keys[0].ID}); err != nil { + t.Fatal(err) + } + if code := call("192.0.2.7:4000", own); code != http.StatusUnauthorized { + t.Fatal("a disabled key from another computer", code) + } +} diff --git a/internal/gateway/lan.go b/internal/gateway/lan.go index f9eb8664..c3fc8937 100644 --- a/internal/gateway/lan.go +++ b/internal/gateway/lan.go @@ -342,10 +342,10 @@ func callerKeys(r *http.Request) []string { return out } -// managedKey: the request carries a named gateway key's form (sk-magpie-…) -// in any of the places a key is read. +// managedKey: the request carries a named gateway key's form (sk-magpie-…), +// or a key's own value a user gave it, in any of the places a key is read. func managedKey(r *http.Request) bool { - return slices.ContainsFunc(callerKeys(r), access.Managed) + return slices.ContainsFunc(callerKeys(r), access.Named) } // refusedKey says why a caller's key was turned away: none came, or the diff --git a/internal/gui/assets/caller-keys.js b/internal/gui/assets/caller-keys.js index 27c742e1..78734109 100644 --- a/internal/gui/assets/caller-keys.js +++ b/internal/gui/assets/caller-keys.js @@ -1,5 +1,8 @@ // Caller keys use the same account list and edit controls as provider keys. let gatewayKeys = null, gatewayKeyDraft = null, gatewayKeysBusy = false; +// gatewayOwnDraft is the value typed for a new key of the user's own (one +// its clients already send, love1sbug on X); "" lets magpie make one +let gatewayOwnDraft = ""; // gatewayAccountNames maps a key's stored account entry ("/", // #905) to how it is shown; what no provider has now is shown as it is kept let gatewayAccountNames = {}; @@ -103,23 +106,31 @@ function gatewayKeyForm() { const name = input(gatewayKeyDraft, t("Key name, e.g. Laptop")); name.setAttribute("aria-label", t("Gateway key name")); name.oninput = () => { gatewayKeyDraft = name.value; }; + const own = input(gatewayOwnDraft, t("Your own key (optional)"), "password"); + own.setAttribute("aria-label", t("Gateway key value")); + own.title = t("Leave empty and magpie makes a key. Or enter one your clients already send, from another gateway, so they keep working."); + own.autocomplete = "off"; + own.oninput = () => { gatewayOwnDraft = own.value; }; const add = el("button", "text primary", t("Create")); const go = async () => { add.disabled = true; - const out = await gatewayKeyAction("add-key", { name: name.value }); + const mine = own.value.trim() !== ""; + const out = await gatewayKeyAction("add-key", mine ? { name: name.value, secret: own.value } : { name: name.value }); if (!out) { add.disabled = false; return; } - status(t("Gateway key created. Use Copy on its row to connect a client."), "ok"); + gatewayOwnDraft = ""; + status(t(mine ? "Gateway key created. Clients that send this key reach magpie now." : "Gateway key created. Use Copy on its row to connect a client."), "ok"); }; add.onclick = go; - name.onkeydown = (e) => { + const cancelled = () => { gatewayKeyDraft = null; gatewayOwnDraft = ""; renderGatewayKeys(); }; + for (const i of [name, own]) i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter" && !add.disabled) go(); - else if (e.key === "Escape") { gatewayKeyDraft = null; renderGatewayKeys(); } + else if (e.key === "Escape") cancelled(); }; const cancel = el("button", "text", t("Cancel")); - cancel.onclick = () => { gatewayKeyDraft = null; renderGatewayKeys(); }; + cancel.onclick = cancelled; const fields = el("div", "kf"); - fields.append(name); + fields.append(name, own); const bar = el("div", "kb"); bar.append(el("span", "grow"), cancel, add); box.append(fields, bar); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 738ae57a..c6b1c39d 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -133,6 +133,14 @@ const I18N = { "Gateway key name": "网关密钥名称", "Key name, e.g. Laptop": "密钥名称,例如笔记本", "Create": "创建", + "Your own key (optional)": "自定义密钥(可选)", + "Gateway key value": "网关密钥的值", + "Leave empty and magpie makes a key. Or enter one your clients already send, from another gateway, so they keep working.": "留空则由 magpie 生成密钥。也可以填写客户端已经在用的密钥(例如从其他网关迁移过来的),客户端无需改动。", + "Gateway key created. Clients that send this key reach magpie now.": "网关密钥已创建,发送这个密钥的客户端现在就能连接 magpie。", + "Use a key between 8 and 256 characters": "密钥长度需在 8 到 256 个字符之间", + "A key can have only letters, digits and ASCII symbols, no spaces": "密钥只能包含字母、数字和 ASCII 符号,不能有空格", + "A key of your own can't start with sk-magpie-: leave the key empty and magpie makes one": "自定义密钥不能以 sk-magpie- 开头:留空即可由 magpie 生成", + "Another gateway key already has this key": "已有其他网关密钥使用了这个密钥", "Gateway key created. Use Copy on its row to connect a client.": "网关密钥已创建,用该行的复制按钮配置客户端。", "Copy gateway key": "复制网关密钥", "Rotate key": "轮换密钥", @@ -3717,6 +3725,14 @@ const I18N = { "Gateway key name": "ゲートウェイキー名", "Key name, e.g. Laptop": "キー名(例:ノートPC)", "Create": "作成", + "Your own key (optional)": "独自のキー(任意)", + "Gateway key value": "ゲートウェイキーの値", + "Leave empty and magpie makes a key. Or enter one your clients already send, from another gateway, so they keep working.": "空欄なら magpie がキーを作成します。他のゲートウェイから移行する場合など、クライアントが既に送っているキーを入力すると、クライアントはそのまま使えます。", + "Gateway key created. Clients that send this key reach magpie now.": "ゲートウェイキーを作成しました。このキーを送るクライアントはそのまま magpie に接続できます。", + "Use a key between 8 and 256 characters": "キーは 8〜256 文字にしてください", + "A key can have only letters, digits and ASCII symbols, no spaces": "キーに使えるのは英数字と ASCII 記号だけで、空白は使えません", + "A key of your own can't start with sk-magpie-: leave the key empty and magpie makes one": "独自のキーは sk-magpie- で始められません。空欄にすると magpie が作成します", + "Another gateway key already has this key": "このキーは別のゲートウェイキーで使われています", "Gateway key created. Use Copy on its row to connect a client.": "ゲートウェイキーを作成しました。行のコピーを使ってクライアントを接続してください。", "Copy gateway key": "ゲートウェイキーをコピー", "Rotate key": "キーを再発行", @@ -7296,6 +7312,14 @@ const I18N = { "Gateway key name": "Name des Zugangsschlüssels", "Key name, e.g. Laptop": "Schlüsselname, z. B. Laptop", "Create": "Erstellen", + "Your own key (optional)": "Eigener Schlüssel (optional)", + "Gateway key value": "Wert des Zugangsschlüssels", + "Leave empty and magpie makes a key. Or enter one your clients already send, from another gateway, so they keep working.": "Leer lassen, dann erzeugt magpie einen Schlüssel. Oder einen eingeben, den Clients schon senden (etwa von einem anderen Gateway), damit sie weiterlaufen.", + "Gateway key created. Clients that send this key reach magpie now.": "Zugangsschlüssel erstellt. Clients, die diesen Schlüssel senden, erreichen magpie jetzt.", + "Use a key between 8 and 256 characters": "Der Schlüssel muss 8 bis 256 Zeichen lang sein", + "A key can have only letters, digits and ASCII symbols, no spaces": "Ein Schlüssel darf nur Buchstaben, Ziffern und ASCII-Zeichen enthalten, keine Leerzeichen", + "A key of your own can't start with sk-magpie-: leave the key empty and magpie makes one": "Ein eigener Schlüssel darf nicht mit sk-magpie- beginnen: leer lassen, dann erzeugt magpie einen", + "Another gateway key already has this key": "Ein anderer Zugangsschlüssel hat diesen Schlüssel schon", "Gateway key created. Use Copy on its row to connect a client.": "Zugangsschlüssel erstellt. Mit „Kopieren“ in seiner Zeile einen Client verbinden.", "Copy gateway key": "Zugangsschlüssel kopieren", "Rotate key": "Schlüssel erneuern", diff --git a/internal/gui/tests/fixtures/caller-keys.cjs b/internal/gui/tests/fixtures/caller-keys.cjs index 4069aaf5..139bfa9c 100644 --- a/internal/gui/tests/fixtures/caller-keys.cjs +++ b/internal/gui/tests/fixtures/caller-keys.cjs @@ -105,10 +105,12 @@ function fixture(lang, theme, events, options = {}) { let secret = ""; if (action === "add-key") { if (!body.name.trim()) return route.fulfill({ status: 400, json: { error: "Use a name between 1 and 120 characters" } }); + // a value of the user's own, checked as access.ownSecret does + if (body.secret !== undefined && body.secret.trim().length < 8) return route.fulfill({ status: 400, json: { error: "Use a key between 8 and 256 characters" } }); const id = "new-key-" + (++serial); - secret = "fixture-created-" + serial; + secret = body.secret?.trim() || "fixture-created-" + serial; secrets.set(id, secret); - keys.push({ id, name: body.name, masked: "sk-magpie-key-…created" }); + keys.push({ id, name: body.name, masked: body.secret ? "…" + secret.slice(-4) : "sk-magpie-key-…created" }); } if (action === "rename-key") k.name = body.name; if (action === "rotate-key") { diff --git a/internal/gui/tests/gateway-own-key.test.cjs b/internal/gui/tests/gateway-own-key.test.cjs new file mode 100644 index 00000000..121171ed --- /dev/null +++ b/internal/gui/tests/gateway-own-key.test.cjs @@ -0,0 +1,62 @@ +// A gateway key can be created with a value of the user's own, one their +// clients already send from another gateway (love1sbug on X). +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); +const { fixture } = require("./fixtures/caller-keys.cjs"); + +const words = { + en: { create: "Create", name: "Gateway key name", own: "Gateway key value", placeholder: "Your own key (optional)", made: "Clients that send this key reach magpie now", short: "Use a key between 8 and 256 characters" }, + zh: { create: "创建", name: "网关密钥名称", own: "网关密钥的值", placeholder: "自定义密钥(可选)", made: "发送这个密钥的客户端现在就能连接 magpie", short: "密钥长度需在 8 到 256 个字符之间" }, + ja: { create: "作成", name: "ゲートウェイキー名", own: "ゲートウェイキーの値", placeholder: "独自のキー(任意)", made: "このキーを送るクライアントはそのまま magpie に接続できます", short: "キーは 8〜256 文字にしてください" }, + de: { create: "Erstellen", name: null, own: "Wert des Zugangsschlüssels", placeholder: "Eigener Schlüssel (optional)", made: "Clients, die diesen Schlüssel senden, erreichen magpie jetzt", short: "Der Schlüssel muss 8 bis 256 Zeichen lang sein" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of Object.keys(words)) { + for (const width of [1000, 440]) { + test(`${engine} ${lang} ${width}px: create a gateway key with a value of one's own`, async (t) => { + const w = words[lang]; + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width, height: 760 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(6000); + const events = [], errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", fixture(lang, "light", events, { lan: true })); + await page.goto("http://magpie.test/?view=gateway"); + await page.locator("#gatewayKeys .acc[data-key]").last().waitFor(); + await page.locator("#addGatewayKey").click(); + const own = page.getByRole("textbox", { name: w.own, exact: true }); + const name = page.locator("#gatewayKeys .adding .kf input").first(); + assert.equal(await own.getAttribute("placeholder"), w.placeholder); + assert.equal(await own.getAttribute("type"), "password"); + // both fields are wide enough to type in, side by side or stacked + for (const box of [await name.boundingBox(), await own.boundingBox()]) assert(box.width >= 140, `field ${box.width}px wide`); + await name.fill("Family"); + // a short value is refused and the form stays, with what was typed + await own.fill("short"); + await own.press("Enter"); + await page.waitForFunction((m) => document.body.textContent.includes(m), w.short); + assert.equal(await own.inputValue(), "short"); + await own.fill("cpa-family-key-0042"); + await page.getByRole("button", { name: w.create, exact: true }).click(); + const row = page.locator("#gatewayKeys .acc[data-key]", { hasText: "Family" }); + await row.waitFor(); + const sent = events.filter((e) => e.action === "add-key").at(-1).body; + assert.deepEqual(sent, { name: "Family", secret: "cpa-family-key-0042" }); + assert.match(await row.textContent(), /…0042/); + assert.doesNotMatch(await page.locator("#gatewayKeys").textContent(), /cpa-family/); + await page.waitForFunction((m) => document.body.textContent.includes(m), w.made); + // the next key starts empty: magpie makes it + await page.locator("#addGatewayKey").click(); + assert.equal(await page.getByRole("textbox", { name: w.own, exact: true }).inputValue(), ""); + await page.locator("#gatewayKeys .adding .kf input").first().fill("Tablet"); + await page.getByRole("button", { name: w.create, exact: true }).click(); + await page.locator("#gatewayKeys .acc[data-key]", { hasText: "Tablet" }).waitFor(); + assert.deepEqual(events.filter((e) => e.action === "add-key").at(-1).body, { name: "Tablet" }); + assert.deepEqual(errors, []); + }); + } + } +}