Skills, subagents, commands and hooks had no machine-wide place, so they were copied into homes by hand and drifted. Each is now registered once through the module's tools, kept in its config state, and written per node: the plugin in the managed directory, settings and instructions in the managed files, or the account's own directory, touching only what the module placed. hq ADR 0216.
470 lines
19 KiB
Go
470 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var now = time.Now().UnixMilli()
|
|
|
|
func node(t *testing.T, name string) (Paths, map[string]string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
p := Paths{State: filepath.Join(root, "state"), Facts: filepath.Join(root, "state", "facts.json"),
|
|
Settings: filepath.Join(root, "state", "settings.json"), Home: filepath.Join(root, "home"), Node: name}
|
|
_ = os.MkdirAll(p.State, 0o700)
|
|
_ = os.MkdirAll(filepath.Join(p.Home, ".claude"), 0o700)
|
|
_ = os.WriteFile(p.Facts, []byte(`{"node":"`+name+`","console":"http://127.0.0.1:4270/mcp"}`), 0o600)
|
|
_ = os.WriteFile(p.Settings, []byte(`{"role":"","mcp_servers":{}}`), 0o600)
|
|
return p, map[string]string{}
|
|
}
|
|
|
|
func writer(w map[string]string) WriteManaged {
|
|
return func(name, content string) (string, error) { w[name] = content; return name + ": written", nil }
|
|
}
|
|
|
|
func writeFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func creds(t *testing.T, p Paths) map[string]any {
|
|
t.Helper()
|
|
var c map[string]any
|
|
raw, _ := os.ReadFile(p.credentials())
|
|
if err := json.Unmarshal(raw, &c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c["claudeAiOauth"].(map[string]any)
|
|
}
|
|
|
|
// ---- the renderer, held to the TypeScript it replaced -------------------------------------------------
|
|
|
|
func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
|
|
raw, err := os.ReadFile("testdata/rendered-by-typescript.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var f struct {
|
|
Facts Facts `json:"facts"`
|
|
Settings Settings `json:"settings"`
|
|
Registered Servers `json:"registered"`
|
|
WithKey map[string]string `json:"withKey"`
|
|
Plain map[string]string `json:"plain"`
|
|
}
|
|
if err := json.Unmarshal(raw, &f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
same := func(label string, got, want map[string]string) {
|
|
if got["CLAUDE.md"] != want["CLAUDE.md"] {
|
|
t.Errorf("%s: CLAUDE.md differs from the TypeScript's:\n--- go\n%s\n--- typescript\n%s", label, got["CLAUDE.md"], want["CLAUDE.md"])
|
|
}
|
|
for _, file := range []string{"managed-mcp.json", "managed-settings.json"} {
|
|
var a, b any
|
|
_ = json.Unmarshal([]byte(got[file]), &a)
|
|
// The plugin's two keys are new since the TypeScript (ADR 0216); everything else means the same.
|
|
if m, ok := a.(map[string]any); ok && file == "managed-settings.json" {
|
|
for k := range MarketplaceKeys() {
|
|
delete(m, k)
|
|
}
|
|
}
|
|
_ = json.Unmarshal([]byte(want[file]), &b)
|
|
if !reflect.DeepEqual(a, b) {
|
|
t.Errorf("%s: %s means something else:\n--- go\n%s\n--- typescript\n%s", label, file, got[file], want[file])
|
|
}
|
|
}
|
|
}
|
|
same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered, Config{}), f.WithKey)
|
|
same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}, Config{}), f.Plain)
|
|
}
|
|
|
|
func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) {
|
|
out := Render(Facts{Node: "w", Console: "http://127.0.0.1:4270/mcp"},
|
|
Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil, Config{})
|
|
var mcp struct {
|
|
MCPServers map[string]map[string]any `json:"mcpServers"`
|
|
}
|
|
_ = json.Unmarshal([]byte(out["managed-mcp.json"]), &mcp)
|
|
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
|
t.Fatalf("%v", mcp.MCPServers)
|
|
}
|
|
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) {
|
|
t.Fatal("rendering is not deterministic")
|
|
}
|
|
}
|
|
|
|
func TestTheOperatorsManagedSettingsAreLaidUnderTheMeshsOwnKeys(t *testing.T) {
|
|
settings := Settings{ManagedSettings: map[string]any{
|
|
"autoMode": map[string]any{"allow": []any{"merging an approved pull request"}},
|
|
"permissions": map[string]any{"deny": []any{"Bash(rm -rf /)"}},
|
|
"attribution": map[string]any{"commit": "made by a machine"},
|
|
"allowAllClaudeAiMcps": false,
|
|
"apiKeyHelper": "/somewhere/else",
|
|
}}
|
|
read := func(binding *Binding) map[string]any {
|
|
var m map[string]any
|
|
out := Render(Facts{Console: "x"}, settings, binding, "/h", nil, Config{})
|
|
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return m
|
|
}
|
|
m := read(nil)
|
|
if allow := m["autoMode"].(map[string]any)["allow"].([]any); len(allow) != 1 || allow[0] != "merging an approved pull request" {
|
|
t.Errorf("the operator's auto mode was not carried: %v", m["autoMode"])
|
|
}
|
|
if m["permissions"] == nil {
|
|
t.Errorf("the operator's permissions were not carried: %v", m)
|
|
}
|
|
if !reflect.DeepEqual(m["attribution"], map[string]any{"commit": "", "pr": ""}) || m["allowAllClaudeAiMcps"] != true {
|
|
t.Errorf("a setting replaced the mesh's own keys: %v", m)
|
|
}
|
|
if _, ok := m["apiKeyHelper"]; ok {
|
|
t.Errorf("a setting named a key-helper the licence did not: %v", m)
|
|
}
|
|
if helper := read(&Binding{Licence: "api", Kind: "api-key"})["apiKeyHelper"]; helper != "/h" {
|
|
t.Errorf("an API-key licence's key-helper was replaced: %v", helper)
|
|
}
|
|
}
|
|
|
|
// ---- the credentials file -----------------------------------------------------------------------------
|
|
|
|
func i64(v int64) *int64 { return &v }
|
|
|
|
func TestTheLineageRules(t *testing.T) {
|
|
const hour = 3_600_000
|
|
g := func(at string, exp int64, rtExp int64) Grant {
|
|
return Grant{AccessToken: at, ExpiresAt: exp, RefreshTokenExpiresAt: i64(rtExp)}
|
|
}
|
|
month := now + 30*24*hour
|
|
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + hour, RefreshTokenExpiresAt: i64(month)}, g("B", now+2*hour, month), false); !d.Apply {
|
|
t.Fatal("a newer rotation was refused")
|
|
}
|
|
if d := DecideApply(&Grant{AccessToken: "new", ExpiresAt: now + 2*hour, RefreshTokenExpiresAt: i64(month)}, g("old", now+hour, month), false); d.Apply || d.Reason != "not-newer" {
|
|
t.Fatalf("a late older rotation: %+v", d)
|
|
}
|
|
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + 8*hour, RefreshTokenExpiresAt: i64(month)}, g("re", now+hour, now+5*24*hour), false); !d.Apply || !d.Reissued {
|
|
t.Fatalf("a re-issued grant: %+v", d)
|
|
}
|
|
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + 8*hour}, g("other", now+hour, month), true); !d.Apply {
|
|
t.Fatal("a switch was refused")
|
|
}
|
|
if d := DecideApply(&Grant{AccessToken: "A"}, Grant{AccessToken: "A"}, true); d.Apply || d.Reason != "already-current" {
|
|
t.Fatalf("the same token: %+v", d)
|
|
}
|
|
}
|
|
|
|
func TestALoginIsSeenAndStrippedWhenTheNodesOwnGrantIsWritten(t *testing.T) {
|
|
p, _ := node(t, "laptop")
|
|
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-login","refreshToken":"rt-login","expiresAt":1700000000000},"other":1}`)
|
|
login := ReadCredentials(p.credentials())
|
|
if !HoldsLogin(login) {
|
|
t.Fatal("a login was not seen")
|
|
}
|
|
if err := WriteCredentials(p.credentials(), WithGrant(login, Grant{AccessToken: "at-mesh", ExpiresAt: 1, Scopes: []string{"user:inference"}})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back := ReadCredentials(p.credentials())
|
|
raw, _ := os.ReadFile(p.credentials())
|
|
info, _ := os.Stat(p.credentials())
|
|
if HoldsLogin(back) || GrantOf(back).AccessToken != "at-mesh" || back["other"] == nil || strings.Contains(string(raw), "rt-login") || info.Mode().Perm() != 0o600 {
|
|
t.Fatalf("written %s (mode %v)", raw, info.Mode())
|
|
}
|
|
}
|
|
|
|
func TestTheAccountIsReadFromTheAgentsStateFileAndNeverGuessed(t *testing.T) {
|
|
p, _ := node(t, "laptop")
|
|
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@example.org"},"other":2}`)
|
|
if id := ReadIdentity(p.account()); id == nil || id.AccountUUID != "u-1" || id.EmailAddress != "a@example.org" {
|
|
t.Fatalf("%+v", id)
|
|
}
|
|
if ReadIdentity("/nonexistent/.claude.json") != nil {
|
|
t.Fatal("an identity from nothing")
|
|
}
|
|
writeFile(t, p.account(), `{}`)
|
|
if ReadIdentity(p.account()) != nil {
|
|
t.Fatal("an identity from an empty file")
|
|
}
|
|
}
|
|
|
|
// ---- the licence, ADR 0206 ----------------------------------------------------------------------------
|
|
|
|
func TestWhatANodeHoldsIsReportedWithFingerprintsAndItsAccountNeverAToken(t *testing.T) {
|
|
p, _ := node(t, "laptop")
|
|
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-secret","refreshToken":"rt-secret","expiresAt":2000,"refreshTokenExpiresAt":9000}}`)
|
|
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@example.org"}}`)
|
|
h := HoldingsOf(p)
|
|
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || *h.Kind != "subscription" || !h.Refresh.Present ||
|
|
!strings.HasPrefix(*h.Refresh.Fingerprint, "sha256:") || h.Access.ExpiresAt != 2000 || h.ChangedAt == nil {
|
|
t.Fatalf("%+v", h)
|
|
}
|
|
raw, _ := json.Marshal(h)
|
|
if strings.Contains(string(raw), "at-secret") || strings.Contains(string(raw), "rt-secret") {
|
|
t.Fatalf("a token is in the report: %s", raw)
|
|
}
|
|
var keys map[string]any
|
|
_ = json.Unmarshal(raw, &keys)
|
|
for k := range keys {
|
|
if strings.Contains(strings.ToLower(k), "token") || strings.Contains(strings.ToLower(k), "secret") {
|
|
t.Fatalf("a field the runtime would refuse: %s", k)
|
|
}
|
|
}
|
|
// The manager reads exactly this shape.
|
|
if _, err := time.Parse(time.RFC3339Nano, *h.ChangedAt); err != nil {
|
|
t.Fatalf("the manager cannot read the report's time: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheGrantAnswersOnlyAWaitingLoginSealedToTheManagersKey(t *testing.T) {
|
|
p, _ := node(t, "laptop")
|
|
manager, _ := GenerateKeyPair()
|
|
if a, _ := GrantFor(p, manager.PublicKey); a.Sealed != nil || a.Waiting == nil || *a.Waiting {
|
|
t.Fatalf("%+v", a)
|
|
}
|
|
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at","refreshToken":"rt-login","expiresAt":1}}`)
|
|
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-9"}}`)
|
|
a, err := GrantFor(p, manager.PublicKey)
|
|
if err != nil || a.Identity.AccountUUID != "u-9" {
|
|
t.Fatalf("%+v %v", a, err)
|
|
}
|
|
plain, _ := Open(*a.Sealed, manager.PrivateKey)
|
|
if !strings.Contains(plain, `"refreshToken":"rt-login"`) {
|
|
t.Fatalf("opened %s", plain)
|
|
}
|
|
raw, _ := json.Marshal(a)
|
|
if strings.Contains(string(raw), "rt-login") {
|
|
t.Fatal("the refresh token crossed in the clear")
|
|
}
|
|
}
|
|
|
|
// seat answers `current` as the manager does: the grant sealed to the key the node sent.
|
|
func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask {
|
|
return func(address string, args any) (json.RawMessage, error) {
|
|
*asked = append(*asked, address)
|
|
key := args.(map[string]any)["public_key"].(string)
|
|
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000})
|
|
box, err := Seal(string(g), key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return json.Marshal(Current{Licence: licence, Kind: "subscription", Generation: gen, Sealed: &box})
|
|
}
|
|
}
|
|
|
|
func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
|
|
p, w := node(t, "laptop")
|
|
var asked []string
|
|
ask := seat(t, "personal", "at-1", 3, &asked)
|
|
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(asked) != 1 || asked[0] != "seat:anthropic-licence-manager.current" || creds(t, p)["accessToken"] != "at-1" {
|
|
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
|
|
}
|
|
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 1 {
|
|
t.Fatal("an equal generation asked again")
|
|
}
|
|
if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" {
|
|
t.Fatal("the generation or the managed files were not written")
|
|
}
|
|
}
|
|
|
|
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
|
|
p, w := node(t, "laptop")
|
|
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
|
|
strings.TrimSpace(string(mustJSON(now+7_200_000)))+`}}`)
|
|
var asked []string
|
|
out, err := Pull(p, seat(t, "personal", "at-new", 1, &asked), writer(w))
|
|
if err != nil || out["applied"] != true {
|
|
t.Fatalf("%v %v", out, err)
|
|
}
|
|
c := creds(t, p)
|
|
if c["accessToken"] != "at-new" || c["refreshToken"] != nil || HoldingsOf(p).Refresh.Present {
|
|
t.Fatalf("%v", c)
|
|
}
|
|
}
|
|
|
|
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
|
|
|
|
// ---- MCP servers in state, ADR 0201 -------------------------------------------------------------------
|
|
|
|
// bus is the `servers` state as every node in a test shares it, with each node's watch.
|
|
type bus struct {
|
|
kept map[string]map[string]any
|
|
watchers []func(ServerChange)
|
|
}
|
|
|
|
func (b *bus) Put(key string, value any) error {
|
|
v := value.(map[string]any)
|
|
b.kept[key] = v
|
|
for _, w := range b.watchers {
|
|
w(ServerChange{Key: key, Op: "put", Value: v})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *bus) Delete(key string) error {
|
|
delete(b.kept, key)
|
|
for _, w := range b.watchers {
|
|
w(ServerChange{Key: key, Op: "delete"})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *bus) Keys() ([]string, error) {
|
|
var out []string
|
|
for k := range b.kept {
|
|
out = append(out, k)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// join is a node joining: its view takes the current state, then every change.
|
|
func (b *bus) join(p Paths, w map[string]string) *ServerView {
|
|
v := NewServerView(p)
|
|
for k, val := range b.kept {
|
|
_, _ = OnServerChange(v, ServerChange{Key: k, Op: "put", Value: val}, p, writer(w))
|
|
}
|
|
b.watchers = append(b.watchers, func(c ServerChange) { _, _ = OnServerChange(v, c, p, writer(w)) })
|
|
return v
|
|
}
|
|
|
|
func noOthers() ([]string, error) { return nil, nil }
|
|
|
|
func TestRegisteringHerePutsItUnderThisNodesKeyAndAsksAboutTheOthers(t *testing.T) {
|
|
p, w := node(t, "laptop")
|
|
b := &bus{kept: map[string]map[string]any{}}
|
|
v := b.join(p, w)
|
|
r, err := RegisterServer(p, Registration{Name: "search", Entry: map[string]any{"type": "http", "url": "https://s.example/mcp"}}, b, v, writer(w),
|
|
func() ([]string, error) { return []string{"laptop", "server", "desktop"}, nil })
|
|
if err != nil || r["here"] != "changed" || !strings.Contains(r["also"].(string), "server, desktop") || b.kept["laptop.search"] == nil {
|
|
t.Fatalf("%v %v %v", r, err, b.kept)
|
|
}
|
|
if !strings.Contains(w["managed-mcp.json"], `"search"`) {
|
|
t.Fatal("not rendered")
|
|
}
|
|
}
|
|
|
|
func TestEveryNodeRegistrationReachesTheOthersAndALateNodeReadsIt(t *testing.T) {
|
|
a, wa := node(t, "laptop")
|
|
s, ws := node(t, "server")
|
|
b := &bus{kept: map[string]map[string]any{}}
|
|
va := b.join(a, wa)
|
|
b.join(s, ws)
|
|
_, _ = RegisterServer(a, Registration{Name: "docs", Entry: map[string]any{"type": "stdio", "command": "docs-mcp"}, Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
|
|
if Registered(s)["docs"] == nil || !strings.Contains(ws["managed-mcp.json"], "docs-mcp") {
|
|
t.Fatalf("the other node did not take it: %v", Registered(s))
|
|
}
|
|
late, wl := node(t, "desktop")
|
|
b.join(late, wl)
|
|
if Registered(late)["docs"] == nil {
|
|
t.Fatal("a node joining later did not read the current set")
|
|
}
|
|
_, _ = RegisterServer(a, Registration{Name: "docs", Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
|
|
if Registered(s)["docs"] != nil || Registered(late)["docs"] != nil {
|
|
t.Fatal("an unregistration did not reach every node")
|
|
}
|
|
}
|
|
|
|
func TestANodesOwnRegistrationOverridesTheOneForEveryNode(t *testing.T) {
|
|
a, wa := node(t, "laptop")
|
|
s, ws := node(t, "server")
|
|
b := &bus{kept: map[string]map[string]any{}}
|
|
va := b.join(a, wa)
|
|
b.join(s, ws)
|
|
_, _ = RegisterServer(a, Registration{Name: "x", Entry: map[string]any{"type": "http", "url": "https://all"}, Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
|
|
_, _ = RegisterServer(a, Registration{Name: "x", Entry: map[string]any{"type": "http", "url": "https://laptop"}}, b, va, writer(wa), noOthers)
|
|
if Registered(a)["x"]["url"] != "https://laptop" || Registered(s)["x"]["url"] != "https://all" {
|
|
t.Fatalf("%v %v", Registered(a), Registered(s))
|
|
}
|
|
r, _ := RegisterServer(a, Registration{Name: "x"}, b, va, writer(wa), noOthers)
|
|
if !strings.Contains(r["still"].(string), "still applies here") || Registered(a)["x"]["url"] != "https://all" {
|
|
t.Fatalf("%v", r)
|
|
}
|
|
}
|
|
|
|
func TestABadEntryIsRefusedBeforeAnythingIsPut(t *testing.T) {
|
|
p, w := node(t, "laptop")
|
|
b := &bus{kept: map[string]map[string]any{}}
|
|
v := b.join(p, w)
|
|
r, _ := RegisterServer(p, Registration{Name: "mesh", Entry: map[string]any{"type": "http", "url": "https://x"}}, b, v, writer(w), noOthers)
|
|
if r["registered"] != false || len(b.kept) != 0 {
|
|
t.Fatalf("%v %v", r, b.kept)
|
|
}
|
|
if done, _ := OnServerChange(v, ServerChange{Key: "server.b", Op: "put", Value: map[string]any{"type": "http", "url": "https://b"}}, p, writer(w)); done != "" {
|
|
t.Fatal("another node's key changed this one")
|
|
}
|
|
}
|
|
|
|
// An API key goes to the manager sealed to its key, never in the clear, and its file is gone afterwards.
|
|
func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
|
|
p, _ := node(t, "laptop")
|
|
manager, _ := GenerateKeyPair()
|
|
file := filepath.Join(p.Home, "api-key")
|
|
writeFile(t, file, "sk-ant-api03-secret\n")
|
|
var sent []string
|
|
ask := func(address string, args any) (json.RawMessage, error) {
|
|
raw, _ := json.Marshal(args)
|
|
sent = append(sent, address+" "+string(raw))
|
|
switch address {
|
|
case "seat:anthropic-licence-manager.public-key":
|
|
return json.Marshal(map[string]any{"public_key": manager.PublicKey})
|
|
case "seat:anthropic-licence-manager.adopt":
|
|
box := args.(map[string]any)["sealed"].(SealedBox)
|
|
if key, err := Open(box, manager.PrivateKey); err != nil || key != "sk-ant-api03-secret" {
|
|
t.Fatalf("the manager opened %q, %v", key, err)
|
|
}
|
|
return json.Marshal(map[string]any{"adopted": true})
|
|
case "seat:anthropic-licence-manager.switch":
|
|
return json.Marshal(map[string]any{"licence": "api"})
|
|
}
|
|
t.Fatalf("asked %s", address)
|
|
return nil, nil
|
|
}
|
|
out, err := AddAPIKey(p, "api", file, true, ask)
|
|
if err != nil || out["file"] != "removed" || out["switched"] == nil {
|
|
t.Fatalf("%v %v", out, err)
|
|
}
|
|
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
|
t.Fatal("the key file is still there")
|
|
}
|
|
if strings.Contains(strings.Join(sent, "\n"), "sk-ant") {
|
|
t.Fatalf("the key crossed in the clear: %v", sent)
|
|
}
|
|
}
|
|
|
|
// A manager whose store was rebuilt counts generations from one again (novox/hq issue 243): a binding with a
|
|
// lower generation than the one applied is still a binding to apply, and only the one applied is skipped.
|
|
func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
|
|
p, w := node(t, "laptop")
|
|
var asked []string
|
|
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16},
|
|
seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The rotation that came with it: a later token, as a refresh hands one over.
|
|
later := func(address string, args any) (json.RawMessage, error) {
|
|
asked = append(asked, address)
|
|
g, _ := json.Marshal(Grant{AccessToken: "at-new", ExpiresAt: now + 7_200_000})
|
|
box, err := Seal(string(g), args.(map[string]any)["public_key"].(string))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 3, Sealed: &box})
|
|
}
|
|
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
|
|
later, writer(w)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(asked) != 2 || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 3 {
|
|
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
|
|
}
|
|
}
|