The conflict in the old cmd/claude-licence-manager/tools_test.go resolves to both sides: main moved it to cmd/manager, and the join's count of binding.changed.<consumer> under its family goes into the moved TestResyncSaysEachOnce. The whole-module test's runtime refused binding.changed.<consumer> because the manifest names the family binding.changed: it now accepts a key that extends a named family by further parts, as A-134 says the event. Builds: design 47 G A-134 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
693 lines
26 KiB
Go
693 lines
26 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
. "claude-licence-manager/internal/licences"
|
|
)
|
|
|
|
var t0 = time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
|
|
|
|
// stubVendor rotates like the real one is presumed to: each refresh token exchanges once.
|
|
type stubVendor struct {
|
|
mu sync.Mutex
|
|
live map[string]bool
|
|
exchanged []string
|
|
issued int
|
|
account string
|
|
now func() time.Time
|
|
// usage is what the usage endpoint answers, and usageErr its failure; nil answers a session window alone.
|
|
usage map[string]any
|
|
usageErr error
|
|
}
|
|
|
|
func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
v.exchanged = append(v.exchanged, g.RefreshToken)
|
|
if !v.live[g.RefreshToken] {
|
|
return Refreshed{Status: 400, Reason: `{"error":"invalid_grant"}`}
|
|
}
|
|
delete(v.live, g.RefreshToken)
|
|
v.issued++
|
|
next := fmt.Sprintf("rt-%d", v.issued)
|
|
v.live[next] = true
|
|
g.AccessToken = fmt.Sprintf("at-%d", v.issued)
|
|
g.RefreshToken = next
|
|
g.ExpiresAt = v.now().Add(8 * time.Hour).UnixMilli()
|
|
exp := v.now().Add(30 * 24 * time.Hour).UnixMilli()
|
|
g.RefreshTokenExpiresAt = &exp
|
|
return Refreshed{OK: true, Grant: g, Account: v.account}
|
|
}
|
|
|
|
func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) {
|
|
if v.usageErr != nil {
|
|
return nil, v.usageErr
|
|
}
|
|
if v.usage != nil {
|
|
return v.usage, nil
|
|
}
|
|
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
|
|
}
|
|
|
|
// memGrants is the vault as the manager sees it: every licence's two secrets by name, the newest generation kept.
|
|
type memGrants struct {
|
|
mu sync.Mutex
|
|
held map[string]heldGrant
|
|
puts []string
|
|
// refuseAccess is the vault refusing a licence's access token to the manager's machine, as it does for a licence no
|
|
// binding there names (§17).
|
|
refuseAccess bool
|
|
}
|
|
|
|
type heldGrant struct {
|
|
generation int64
|
|
value string
|
|
}
|
|
|
|
func (g *memGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
name := SecretName(key, part)
|
|
if h, ok := g.held[name]; ok && h.generation == generation && h.value == value {
|
|
return nil // the vault keeps what it holds, unchanged
|
|
} else if ok && h.generation >= generation {
|
|
return fmt.Errorf("%s is held at generation %d, and generation %d is not newer", name, h.generation, generation)
|
|
}
|
|
g.held[name] = heldGrant{generation, value}
|
|
g.puts = append(g.puts, fmt.Sprintf("%s@%d", name, generation))
|
|
return nil
|
|
}
|
|
|
|
func (g *memGrants) Get(_ context.Context, key, part string) (string, error) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
h, ok := g.held[SecretName(key, part)]
|
|
if g.refuseAccess && part == PartAccess {
|
|
return "", fmt.Errorf("no consumer on this machine is bound to the licence %s", key)
|
|
}
|
|
if !ok {
|
|
return "", fmt.Errorf("the vault holds no secret named %s", SecretName(key, part))
|
|
}
|
|
return h.value, nil
|
|
}
|
|
|
|
type miniMesh struct {
|
|
grants *memGrants
|
|
m *Manager
|
|
store *BusStore
|
|
usage *memLog
|
|
audit *memLog
|
|
licences *memState
|
|
bindings *memState
|
|
leases *memState
|
|
offered *memState
|
|
vendor *stubVendor
|
|
logins map[string]FullGrant // node → what its credentials file holds
|
|
events []string
|
|
now time.Time
|
|
keys KeyPair
|
|
askDown bool
|
|
}
|
|
|
|
func newMesh(t *testing.T) *miniMesh {
|
|
t.Helper()
|
|
mm := &miniMesh{logins: map[string]FullGrant{}, now: t0,
|
|
grants: &memGrants{held: map[string]heldGrant{}}}
|
|
now := func() time.Time { return mm.now }
|
|
mm.usage, mm.audit = newMemLog(now), newMemLog(now)
|
|
mm.licences, mm.bindings = newMemState(now), newMemState(now)
|
|
mm.leases, mm.offered = newMemState(now), newMemState(now)
|
|
mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, LeasesState: mm.leases,
|
|
OfferedState: mm.offered, UsageLog: mm.usage, AuditLog: mm.audit, Now: now}
|
|
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
|
|
mm.keys, _ = GenerateKeyPair()
|
|
mm.m = &Manager{
|
|
Store: mm.store, Vendor: mm.vendor, Grants: mm.grants, Keys: mm.keys,
|
|
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
|
if mm.askDown {
|
|
return GrantAnswer{}, fmt.Errorf("503 no responders")
|
|
}
|
|
g, ok := mm.logins[node]
|
|
if !ok {
|
|
return GrantAnswer{}, nil
|
|
}
|
|
raw, _ := json.Marshal(g)
|
|
box, err := Seal(string(raw), publicKey)
|
|
return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err
|
|
},
|
|
Emit: func(event string, body map[string]any) error {
|
|
raw, _ := json.Marshal(body)
|
|
mm.events = append(mm.events, event+" "+string(raw))
|
|
return nil
|
|
},
|
|
Now: now, Log: func(string, ...any) {}, Holder: "test", Settings: Defaults,
|
|
}
|
|
return mm
|
|
}
|
|
|
|
// bound is a consumer's value in the state bindings, as its readers decode it.
|
|
func (mm *miniMesh) bound(consumer string) BindingState {
|
|
var b BindingState
|
|
if e, _ := mm.bindings.Get(consumer); e != nil {
|
|
_ = json.Unmarshal(e.Value, &b)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings {
|
|
h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"},
|
|
Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)}
|
|
if rt != "" {
|
|
h.Refresh.Present, h.Refresh.Fingerprint = true, Fingerprint(rt)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (mm *miniMesh) login(node, rt string, valid bool, at time.Time) Holdings {
|
|
mm.logins[node] = FullGrant{AccessToken: "local-" + node, RefreshToken: rt, ExpiresAt: mm.now.Add(time.Hour).UnixMilli()}
|
|
if valid {
|
|
mm.vendor.live[rt] = true
|
|
}
|
|
return mm.report(node, rt, at, "acct-1")
|
|
}
|
|
|
|
const licence1 = "acct-1@example.org"
|
|
|
|
func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchangesTheRest(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
older := mm.login("server", "rt-server", true, t0.Add(-time.Hour))
|
|
newest := mm.login("laptop", "rt-laptop", true, t0.Add(-time.Minute))
|
|
adopted, err := mm.m.Consider(ctx, []Holdings{older, newest})
|
|
if err != nil || len(adopted) != 1 || adopted[0] != licence1 {
|
|
t.Fatalf("adopted %v, %v", adopted, err)
|
|
}
|
|
if strings.Join(mm.vendor.exchanged, ",") != "rt-laptop" {
|
|
t.Fatalf("exchanged %v: an older login was exchanged although a newer one refreshed", mm.vendor.exchanged)
|
|
}
|
|
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-server")); o != Skipped {
|
|
t.Fatalf("the older login is %q, not skipped", o)
|
|
}
|
|
// A first binding follows the login: both nodes reported this account and were bound to nothing.
|
|
bs, _ := mm.store.Bindings(ctx)
|
|
if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
|
|
t.Fatalf("bindings %v, state %v", bs, mm.bindings.values)
|
|
}
|
|
// The adoption is said, and so is each binding it made, once (novox/hq issue 625; design 39 bus-and-switch §21).
|
|
events := strings.Join(mm.events, "|")
|
|
for _, want := range []string{"licence.adopted ", `"consumer":"laptop"`, `"consumer":"server"`} {
|
|
if strings.Count(events, want) != 1 {
|
|
t.Fatalf("events say %s other than once: %v", want, mm.events)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestALoginThatDoesNotRefreshAdoptsNothingAndIsNotTriedAgain(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
dead := mm.login("laptop", "rt-dead", false, t0)
|
|
if adopted, _ := mm.m.Consider(ctx, []Holdings{dead}); len(adopted) != 0 {
|
|
t.Fatalf("adopted %v", adopted)
|
|
}
|
|
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-dead")); o != Dead {
|
|
t.Fatalf("outcome %q", o)
|
|
}
|
|
if ls, _ := mm.store.Licences(ctx); len(ls) != 0 {
|
|
t.Fatalf("licences %v", ls)
|
|
}
|
|
if !strings.Contains(strings.Join(mm.events, "|"), "licence.refused") {
|
|
t.Fatalf("events %v", mm.events)
|
|
}
|
|
_, _ = mm.m.Consider(ctx, []Holdings{dead})
|
|
if len(mm.vendor.exchanged) != 1 {
|
|
t.Fatalf("a dead refresh token was exchanged %d times", len(mm.vendor.exchanged))
|
|
}
|
|
}
|
|
|
|
func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))})
|
|
before, _ := mm.store.Licence(ctx, licence1)
|
|
// §2: a licence says where it was adopted from, and a newer login keeps the latest reading the key holds.
|
|
pct := 42.0
|
|
before.Usage = &UsageReading{SessionPct: &pct, ReadAt: t0.Format(time.RFC3339Nano)}
|
|
if before.AdoptedFrom != "laptop" || mm.store.SaveLicence(ctx, *before) != nil {
|
|
t.Fatalf("the adopted licence is %+v", before)
|
|
}
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))})
|
|
if strings.Join(mm.vendor.exchanged, ",") != "rt-a" {
|
|
t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged)
|
|
}
|
|
mm.now = t0.Add(10 * time.Minute)
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("desktop", "rt-new", true, t0.Add(10*time.Minute))})
|
|
after, _ := mm.store.Licence(ctx, licence1)
|
|
if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" {
|
|
t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged)
|
|
}
|
|
if after.AdoptedFrom != "desktop" || after.Usage == nil || after.Usage.SessionPct == nil || *after.Usage.SessionPct != 42 {
|
|
t.Fatalf("after a newer login the licence is %+v", after)
|
|
}
|
|
if ls, _ := mm.store.Licences(ctx); len(ls) != 1 {
|
|
t.Fatalf("one account became %d licences", len(ls))
|
|
}
|
|
}
|
|
|
|
func TestAReportNamingAnotherAccountThanTheVendorAnsweredForIsRefused(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
mm.vendor.account = "someone-else"
|
|
if adopted, _ := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); len(adopted) != 0 {
|
|
t.Fatalf("adopted %v", adopted)
|
|
}
|
|
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-a")); o != Refused {
|
|
t.Fatalf("outcome %q", o)
|
|
}
|
|
}
|
|
|
|
func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
|
mm.now = t0.Add(5 * time.Hour)
|
|
second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Grants: mm.m.Grants, Keys: mm.m.Keys,
|
|
AskGrant: mm.m.AskGrant, Emit: mm.m.Emit, Now: mm.m.Now, Log: mm.m.Log, Holder: "another run",
|
|
Settings: mm.m.Settings}
|
|
var wg sync.WaitGroup
|
|
results := make([]map[string]any, 2)
|
|
for i, m := range []*Manager{mm.m, second} {
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }()
|
|
}
|
|
wg.Wait()
|
|
n := 0
|
|
for _, r := range results {
|
|
if r["refreshed"] == true {
|
|
n++
|
|
}
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("rotated %d times: %v", n, results)
|
|
}
|
|
if r, _ := second.Rotate(ctx, licence1, false); r["reason"] != "not due" {
|
|
t.Fatalf("a run just after was %v", r)
|
|
}
|
|
}
|
|
|
|
func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
|
g0 := mm.bound("laptop").Generation
|
|
_, _ = mm.m.Rotate(ctx, licence1, true)
|
|
g1 := mm.bound("laptop").Generation
|
|
if g1 <= g0 {
|
|
t.Fatalf("a rotation went from %d to %d", g0, g1)
|
|
}
|
|
file := filepath.Join(t.TempDir(), "key")
|
|
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
|
|
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mm.bound("laptop").Licence != "api" || mm.bound("laptop").Generation <= g1 {
|
|
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.bound("laptop"))
|
|
}
|
|
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := mm.bindings.values["laptop"]; ok {
|
|
t.Fatal("a released consumer still has a binding in the state")
|
|
}
|
|
}
|
|
|
|
// design 39 bus-and-switch §18, B7, B19: no answer of current holds a token; it names the licence's key and health.
|
|
func TestCurrentHoldsNoToken(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
|
answer, err := mm.m.Current(ctx, "laptop")
|
|
if err != nil || answer["kind"] != "subscription" || answer["key"] != KeyFor(licence1) || answer["health"] != "ok" ||
|
|
answer["accessExpiresAt"] == nil || answer["identity"] == nil {
|
|
t.Fatalf("%v %v", answer, err)
|
|
}
|
|
raw, _ := json.Marshal(answer)
|
|
for _, token := range []string{"rt-", "at-", "sealed"} {
|
|
if strings.Contains(string(raw), token) {
|
|
t.Fatalf("current answered %s: %s", token, raw)
|
|
}
|
|
}
|
|
l, _ := mm.store.Licence(ctx, licence1)
|
|
for failures, health := range map[int]string{0: "ok", 3: "failing"} {
|
|
l.Failures = failures
|
|
if got := Health(*l, mm.now, mm.m.Settings); got != health {
|
|
t.Errorf("%d failures: %s, want %s", failures, got, health)
|
|
}
|
|
}
|
|
l.Failures, l.RefreshExpiresAt = 0, mm.now.Add(48*time.Hour).UnixMilli()
|
|
if got := Health(*l, mm.now, mm.m.Settings); got != "expiring" {
|
|
t.Errorf("a refresh token expiring in two days: %s", got)
|
|
}
|
|
if a, _ := mm.m.Current(ctx, "nobody"); a != nil {
|
|
t.Fatalf("a consumer bound to nothing was answered %v", a)
|
|
}
|
|
if bs, _ := mm.m.BindingsOf(ctx, "laptop"); len(bs) != 1 || bs[0].Consumer != "laptop" {
|
|
t.Fatalf("bindings of laptop: %v", bs)
|
|
}
|
|
// design 39 bus-and-switch B25: the verb bindings answers each binding's licence key beside the §3 fields.
|
|
if bs, _ := mm.m.BindingsOf(ctx, ""); len(bs) == 0 || bs[0].Key != KeyFor(bs[0].Licence) {
|
|
t.Fatalf("bindings answered %+v", bs)
|
|
}
|
|
}
|
|
|
|
func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-secret-1", true, t0)})
|
|
_, _ = mm.m.Rotate(ctx, licence1, true)
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-secret-dead", false, t0.Add(time.Hour))})
|
|
_ = mm.m.ReadUsage(ctx)
|
|
ls, _ := mm.m.Licences(ctx)
|
|
bs, _ := mm.store.Bindings(ctx)
|
|
everything, _ := json.Marshal([]any{mm.events, mm.bindings.values, mm.licences.values, ls, bs})
|
|
for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} {
|
|
if strings.Contains(string(everything), token) {
|
|
t.Fatalf("%s was published: %s", token, everything)
|
|
}
|
|
}
|
|
row, _ := mm.store.Licence(ctx, licence1)
|
|
if raw, _ := json.Marshal(row); strings.Contains(string(raw), "rt-") || strings.Contains(string(raw), "at-") {
|
|
t.Fatalf("the licence keeps a token: %s", raw)
|
|
}
|
|
}
|
|
|
|
func TestANodeThatDoesNotAnswerIsAskedAgainAndOneWithNoLoginIsSettled(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
r := mm.login("laptop", "rt-a", true, t0)
|
|
mm.askDown = true
|
|
if adopted, _ := mm.m.Consider(ctx, []Holdings{r}); len(adopted) != 0 {
|
|
t.Fatalf("adopted %v from a node that did not answer", adopted)
|
|
}
|
|
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-a")); o != "" {
|
|
t.Fatalf("a node that was down was settled %q", o)
|
|
}
|
|
mm.askDown = false
|
|
if adopted, _ := mm.m.Consider(ctx, []Holdings{r}); len(adopted) != 1 {
|
|
t.Fatalf("adopted %v on the next pass", adopted)
|
|
}
|
|
gone := mm.report("server", "rt-gone", t0.Add(time.Second), "acct-2")
|
|
_, _ = mm.m.Consider(ctx, []Holdings{gone})
|
|
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-gone")); o != Gone {
|
|
t.Fatalf("outcome %q", o)
|
|
}
|
|
}
|
|
|
|
// design 39 bus-and-switch §23, §46, B12, B22: a holdings-changed, decoded from a copy of the agent module's form, is
|
|
// kept as its node's latest report, and one carrying a fingerprint the manager does not hold is a candidate; the
|
|
// manager asks every node with holdings.asked, its time and who asks.
|
|
func TestAHoldingsChangedIsHeardAndMakesACandidate(t *testing.T) {
|
|
mm := newMesh(t)
|
|
raw, err := os.ReadFile("testdata/holdings-changed.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
heard := &Heard{}
|
|
if _, err := heard.Take(raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
all := heard.All()
|
|
if len(all) != 1 || heard.Of("laptop") != 4 {
|
|
t.Fatalf("heard %+v", all)
|
|
}
|
|
h := all[0]
|
|
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" {
|
|
t.Fatalf("%+v", h)
|
|
}
|
|
if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil {
|
|
t.Fatalf("the report's time does not parse: %v", err)
|
|
}
|
|
if c, err := mm.m.CandidatesIn(context.Background(), all); err != nil || len(c["u-1"]) != 1 {
|
|
t.Fatalf("candidates %v %v", c, err)
|
|
}
|
|
if _, err := heard.Take([]byte(`{"kind":"subscription"}`)); err == nil {
|
|
t.Fatal("a report naming no node was kept")
|
|
}
|
|
if err := mm.m.AskHoldings(Itself); err != nil || len(mm.events) != 1 ||
|
|
mm.events[0] != HoldingsAsked+` {"at":"`+t0.UTC().Format(time.RFC3339Nano)+`","by":"claude-licence-manager"}` {
|
|
t.Fatalf("said %v %v", mm.events, err)
|
|
}
|
|
}
|
|
|
|
// A node whose report arrives after its account was adopted — with an older login, so never a candidate —
|
|
// is still bound to that account's licence, once.
|
|
func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
|
|
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
|
|
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
|
if mm.bound("laptop").Licence != licence1 {
|
|
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.bindings.values)
|
|
}
|
|
if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") {
|
|
t.Fatal("the older login was exchanged")
|
|
}
|
|
g := mm.bound("laptop").Generation
|
|
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
|
if mm.bound("laptop").Generation != g {
|
|
t.Fatal("a node already bound was bound again")
|
|
}
|
|
}
|
|
|
|
// A login to another account on one node adopts it and moves that node, and only that node (ADR 0209).
|
|
func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")})
|
|
if mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
|
|
t.Fatalf("%v", mm.bindings.values)
|
|
}
|
|
mm.now = t0.Add(time.Hour)
|
|
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1}
|
|
mm.vendor.live["rt-b"] = true
|
|
second := mm.report("laptop", "rt-b", t0.Add(time.Hour), "acct-2")
|
|
adopted, _ := mm.m.Consider(ctx, []Holdings{second, mm.report("server", "", t0, "acct-1")})
|
|
if len(adopted) != 1 || adopted[0] != "acct-2@example.org" {
|
|
t.Fatalf("adopted %v", adopted)
|
|
}
|
|
if mm.bound("laptop").Licence != "acct-2@example.org" {
|
|
t.Fatalf("the node a login was made on stayed bound to %v", mm.bound("laptop"))
|
|
}
|
|
if mm.bound("server").Licence != licence1 {
|
|
t.Fatalf("another node moved: %v", mm.bound("server"))
|
|
}
|
|
ls, _ := mm.store.Licences(ctx)
|
|
if len(ls) != 2 {
|
|
t.Fatalf("%d licences", len(ls))
|
|
}
|
|
}
|
|
|
|
// A login that does not refresh moves nothing.
|
|
func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
|
g := mm.bound("laptop")
|
|
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-dead", ExpiresAt: 1}
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")})
|
|
if mm.bound("laptop") != g {
|
|
t.Fatalf("a dead login moved its node: %v", mm.bound("laptop"))
|
|
}
|
|
}
|
|
|
|
// An API key sealed to the manager by a node is adopted, and opens only for the manager.
|
|
func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
box, _ := Seal("sk-ant-api03-test-0123456789abcdefghij\n", mm.keys.PublicKey)
|
|
r, err := mm.m.AdoptSealedKey(ctx, "api", box, "laptop")
|
|
if err != nil || r["adopted"] != true {
|
|
t.Fatalf("%v %v", r, err)
|
|
}
|
|
for _, part := range []string{PartGrant, PartAccess} {
|
|
if plain, _ := mm.grants.Get(ctx, "api", part); plain != "sk-ant-api03-test-0123456789abcdefghij" {
|
|
t.Fatalf("the vault holds %q as the %s", plain, part)
|
|
}
|
|
}
|
|
other, _ := GenerateKeyPair()
|
|
wrong, _ := Seal("sk", other.PublicKey)
|
|
if _, err := mm.m.AdoptSealedKey(ctx, "api2", wrong, "laptop"); err == nil {
|
|
t.Fatal("a key sealed to another key was adopted")
|
|
}
|
|
for _, e := range mm.events {
|
|
if strings.Contains(e, "sk-ant") {
|
|
t.Fatalf("the key was published: %s", e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A store rebuilt after a loss counts generations from one again (novox/hq issue 243): the first look at the
|
|
// reports moves the count past every generation a node says it applied, so no binding repeats one.
|
|
func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
laptop := mm.login("laptop", "rt-a", true, t0)
|
|
laptop.Generation = 16
|
|
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g := mm.bound("laptop").Generation; g <= 16 {
|
|
t.Fatalf("the laptop applied generation 16 and was given %d", g)
|
|
}
|
|
}
|
|
|
|
// What is not an API key is never adopted as one, and the refusal never repeats it (novox/hq ADR 0266).
|
|
func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
|
|
mm := newMesh(t)
|
|
ctx := context.Background()
|
|
for _, notAKey := range []string{"-----BEGIN OPENSSH PRIVATE KEY-----\nabc", "SUAOPERATORNKEYSEED", "sk-ant-short"} {
|
|
box, _ := Seal(notAKey, mm.keys.PublicKey)
|
|
_, err := mm.m.AdoptSealedKey(ctx, "api", box, "laptop")
|
|
if err == nil || strings.Contains(err.Error(), notAKey) {
|
|
t.Fatalf("%q: refused without repeating it, got %v", notAKey, err)
|
|
}
|
|
}
|
|
if l, _ := mm.store.Licence(ctx, "api"); l != nil {
|
|
t.Fatal("nothing was kept")
|
|
}
|
|
}
|
|
|
|
// changedSince is the binding.changed events said from mark on, decoded.
|
|
func (mm *miniMesh) changedSince(mark int) []map[string]any {
|
|
var out []map[string]any
|
|
for _, e := range mm.events[mark:] {
|
|
if rest, ok := strings.CutPrefix(e, BindingChanged+"."); ok {
|
|
// The consumer is the event's further part (A-134); one said under another consumer's name reads as none.
|
|
consumer, body, _ := strings.Cut(rest, " ")
|
|
var v map[string]any
|
|
_ = json.Unmarshal([]byte(body), &v)
|
|
if v["consumer"] != consumer {
|
|
v["consumer"] = "said as binding.changed." + consumer
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
|
|
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release. design 39
|
|
// bus-and-switch §21/AC1, §36/AC1, B7, B16: each says one binding.changed per consumer moved, with its licence's key,
|
|
// after its key is written or deleted; a switch says one licence.switched with §27's fields.
|
|
func TestEveryMoveSaysOneBindingChangedPerConsumer(t *testing.T) {
|
|
mm := newMesh(t)
|
|
var lines []string
|
|
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
|
|
ctx := context.Background()
|
|
once := func(mark int, consumer, licence, by string) map[string]any {
|
|
t.Helper()
|
|
got := mm.changedSince(mark)
|
|
if len(got) != 1 || got[0]["consumer"] != consumer || got[0]["licence"] != licence || got[0]["by"] != by {
|
|
t.Fatalf("binding.changed said %v, want one for %s to %q by %s", got, consumer, licence, by)
|
|
}
|
|
if licence != "" && got[0]["key"] != KeyFor(licence) {
|
|
t.Fatalf("binding.changed names the key %v", got[0]["key"])
|
|
}
|
|
if mm.bound(consumer).Licence != licence {
|
|
t.Fatalf("said before the key was written: %+v", mm.bound(consumer))
|
|
}
|
|
return got[0]
|
|
}
|
|
mark := len(mm.events)
|
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
|
if s := mm.bound("laptop"); !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
|
|
t.Fatalf("a login's binding said %+v", s)
|
|
}
|
|
once(mark, "laptop", licence1, Itself)
|
|
file := filepath.Join(t.TempDir(), "key")
|
|
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
|
|
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
mark = len(mm.events)
|
|
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
|
|
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e := once(mark, "laptop", "api", "g14.node-tools"); e["from"] != licence1 || e["kind"] != "api-key" || e["at"] == nil {
|
|
t.Fatalf("the switch said %v", e)
|
|
}
|
|
if s := mm.bound("laptop"); s.Why != "the switch verb" || s.By != "g14.node-tools" {
|
|
t.Fatalf("the node is told %+v", s)
|
|
}
|
|
said := strings.Join(lines, "\n")
|
|
if !strings.Contains(said, "switched laptop from "+licence1+" to api (generation ") ||
|
|
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
|
|
t.Fatalf("the journal said:\n%s", said)
|
|
}
|
|
var switched []string
|
|
for _, e := range mm.events {
|
|
if strings.HasPrefix(e, "licence.switched ") {
|
|
switched = append(switched, e)
|
|
}
|
|
}
|
|
if len(switched) != 1 {
|
|
t.Fatalf("licence.switched said %d times: %v", len(switched), mm.events)
|
|
}
|
|
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"to":"api"`, `"consumers":["laptop"]`, `"why":"the switch verb"`} {
|
|
if !strings.Contains(switched[0], want) {
|
|
t.Fatalf("the event %q lacks %s", switched[0], want)
|
|
}
|
|
}
|
|
all := mm.audit.under("switched")
|
|
if raw, _ := json.Marshal(all[len(all)-1]); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), `"to":"api"`) {
|
|
t.Fatalf("the audit kept %s", raw)
|
|
}
|
|
|
|
_, _ = mm.m.Bind(ctx, "server", licence1)
|
|
mark = len(mm.events)
|
|
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
|
|
once(mark, "server", licence1, "novox.node-tools")
|
|
|
|
mark = len(mm.events)
|
|
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e := once(mark, "laptop", "", "person.operator"); e["from"] != "api" {
|
|
t.Fatalf("the release said %v", e)
|
|
}
|
|
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
|
|
t.Fatalf("the journal said:\n%s", said)
|
|
}
|
|
for _, e := range mm.events {
|
|
for _, gone := range []string{"licence.bound ", "licence.released ", "licence.rotated "} {
|
|
if strings.HasPrefix(e, gone) {
|
|
t.Fatalf("an event no longer said was said: %s", e)
|
|
}
|
|
}
|
|
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
|
|
t.Fatalf("an event carries a secret: %s", e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A call with no cause is said as this module's own, never as nobody.
|
|
func TestNoCauseReadsAsTheModulesOwn(t *testing.T) {
|
|
if c := CauseOf(context.Background()); c.By != Itself || c.Why == "" {
|
|
t.Fatalf("no cause read as %+v", c)
|
|
}
|
|
}
|