diff --git a/modules/mesh-vault/cmd/mesh-vault/ledger_test.go b/modules/mesh-vault/cmd/mesh-vault/ledger_test.go index 63b838da..28e56c0d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ledger_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/ledger_test.go @@ -53,15 +53,15 @@ func TestTheLedgerHoldsFingerprintsNeverTheValue(t *testing.T) { } } -func TestWithdrawingForgetsAHolderAndListingIsByLogin(t *testing.T) { +func TestDeletingForgetsAHolderAndListingIsByLogin(t *testing.T) { l, _ := NewLedger(t.TempDir()) _, _, _ = l.Record("b-app", "b", "x", time.Now()) _, _, _ = l.Record("a-app", "a", "y", time.Now()) if got := logins(l.List()); got != "a-app,b-app" { t.Errorf("listed %s", got) } - if !l.Withdraw("a-app") || l.Withdraw("a-app") { - t.Error("withdrawing found the wrong thing") + if !l.Delete("a-app") || l.Delete("a-app") { + t.Error("deleting found the wrong thing") } if got := logins(l.List()); got != "b-app" { t.Errorf("listed %s", got) @@ -120,49 +120,230 @@ func TestWhatTheMeshDeliversIsFingerprintedNeverReturned(t *testing.T) { } } -// The custody pass: a delivered value is recorded and announced by fingerprint, a changed one is a rotation, -// a login no longer asked for is withdrawn — and an unreadable contributions file withdraws nobody. -func TestTheCustodyPass(t *testing.T) { +// custodyAt is a custody pass over a contributions file in dir, on a clock the test moves. +type custodyAt struct { + *Custody + bus *published + receives string + dir string + clock *time.Time +} + +func newCustody(t *testing.T) custodyAt { dir := t.TempDir() l, _ := NewLedger(filepath.Join(dir, "ledger")) bus := &published{} - secret := filepath.Join(dir, "a.secret") - receives := filepath.Join(dir, "mesh.json") - write := func(given ...map[string]string) { - doc, _ := json.Marshal(map[string]any{"given": given}) - _ = os.WriteFile(receives, doc, 0o600) - } - c := Custody{Ledger: l, Receives: receives, Emit: bus.emit, Now: time.Now, Log: t.Logf} + clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC) + c := custodyAt{bus: bus, receives: filepath.Join(dir, "mesh.json"), dir: dir, clock: &clock} + c.Custody = &Custody{Ledger: l, Receives: c.receives, Emit: bus.emit, Now: func() time.Time { return *c.clock }, Log: t.Logf} + return c +} - _ = os.WriteFile(secret, []byte("v1"), 0o600) - write(map[string]string{"node": "anchor", "as": "anchor-redis", "secret": secret}) - c.Pass() - _ = os.WriteFile(secret, []byte("v2"), 0o600) - c.Pass() - _ = os.WriteFile(receives, []byte("{not json"), 0o600) - c.Pass() - if _, ok := l.Get("anchor-redis"); !ok { - t.Fatal("an unreadable contributions file withdrew a holder") +// ask writes a contributions file asking for these logins, each with its value. +func (c custodyAt) ask(values map[string]string) { + given := []map[string]string{} + for as, v := range values { + f := filepath.Join(c.dir, as+".secret") + _ = os.WriteFile(f, []byte(v), 0o600) + given = append(given, map[string]string{"node": "anchor", "as": as, "secret": f}) } - write() - c.Pass() - if _, ok := l.Get("anchor-redis"); ok { - t.Fatal("a holder no longer asked for was kept") + doc, _ := json.Marshal(map[string]any{"requirement": "secret", "given": given}) + _ = os.WriteFile(c.receives, doc, 0o600) +} + +// passes runs n passes a minute apart. +func (c custodyAt) passes(n int) { + for i := 0; i < n; i++ { + c.Pass() + *c.clock = c.clock.Add(time.Minute) } - var said []string - for _, e := range bus.events { - said = append(said, e["event"].(string)) +} + +func (c custodyAt) changes() []string { + var out []string + for _, e := range c.bus.events { + if e["event"] == EventRetirement { + out = append(out, e["body"].(map[string]any)["change"].(string)) + } else { + out = append(out, e["event"].(string)) + } } - if strings.Join(said, ",") != "provisioned,rotated,deprovisioned" { - t.Errorf("announced %v", said) + return out +} + +// The custody pass: a delivered value is recorded and announced by fingerprint, a changed one is a rotation. +func TestTheCustodyPassRecordsAndRotates(t *testing.T) { + c := newCustody(t) + c.ask(map[string]string{"anchor-redis": "v1"}) + c.passes(1) + c.ask(map[string]string{"anchor-redis": "v2"}) + c.passes(1) + if got := strings.Join(c.changes(), ","); got != "provisioned,rotated" { + t.Errorf("announced %s", got) } - for _, raw := range bus.raw { + for _, raw := range c.bus.raw { if strings.Contains(raw, `"v1"`) || strings.Contains(raw, `"v2"`) { t.Errorf("announced a value: %s", raw) } } } +// A contributions file that does not say who is asked for — unreadable, not JSON, without a given list, a +// null one, or for another requirement — is no answer: nobody is retired, and the count starts again. +func TestAFileThatSaysNothingRetiresNobody(t *testing.T) { + for _, doc := range []string{"{not json", `{"requirement":"secret"}`, `{"requirement":"secret","given":null}`, + `{"requirement":"redis-cache","given":[]}`} { + c := newCustody(t) + c.ask(map[string]string{"anchor-redis": "v1"}) + c.passes(1) + _ = os.WriteFile(c.receives, []byte(doc), 0o600) + c.passes(30) + if h, ok := c.Ledger.Get("anchor-redis"); !ok || h.Retired != nil { + t.Errorf("%s: the holder was retired or lost: %+v %v", doc, h, ok) + } + if _, err := Contributions(c.receives); err == nil { + t.Errorf("%s was read as an answer", doc) + } + } + if _, err := Contributions(filepath.Join(t.TempDir(), "missing.json")); err == nil { + t.Error("a missing file was read as an answer") + } +} + +// A holder the mesh stops asking for is retired only after five passes AND ten minutes, is never deleted +// by a pass, keeps its history, and is re-enabled when asked for again. +func TestAHolderNoLongerAskedForIsRetiredNotDeleted(t *testing.T) { + c := newCustody(t) + c.ask(map[string]string{"a-app": "1", "b-app": "2", "c-app": "3"}) + c.passes(1) + c.ask(map[string]string{"a-app": "1", "b-app": "2"}) + c.Pass() // the first pass that sees c-app unasked + for i := 0; i < 8; i++ { + *c.clock = c.clock.Add(10 * time.Second) // five passes, but in under ten minutes + c.Pass() + } + if h, _ := c.Ledger.Get("c-app"); h.Retired != nil { + t.Fatal("retired before ten minutes had passed") + } + *c.clock = c.clock.Add(10 * time.Minute) + c.Pass() + h, ok := c.Ledger.Get("c-app") + if !ok || h.Retired == nil || h.Retired.Why == "" { + t.Fatalf("not retired, or retired without a why: %+v %v", h, ok) + } + if h.Fingerprint != Fingerprint("3") { + t.Error("retiring lost what the ledger knew of the holder") + } + if _, err := os.Stat(filepath.Join(c.dir, "ledger", "c-app.json")); err != nil { + t.Error("a pass deleted a ledger entry") + } + c.ask(map[string]string{"a-app": "1", "b-app": "2", "c-app": "3"}) + c.Pass() + if h, _ := c.Ledger.Get("c-app"); h.Retired != nil { + t.Error("asked for again, the holder stayed retired") + } + if got := strings.Join(c.changes(), ","); got != "provisioned,provisioned,provisioned,retired,reenabled" { + t.Errorf("announced %s", got) + } +} + +// A different set, or a file that says nothing, starts the count again. +func TestTheCountStartsAgain(t *testing.T) { + c := newCustody(t) + c.ask(map[string]string{"a-app": "1", "b-app": "2", "c-app": "3"}) + c.passes(1) + c.ask(map[string]string{"a-app": "1", "b-app": "2"}) + c.passes(4) + _ = os.WriteFile(c.receives, []byte(`{"given":null}`), 0o600) + c.passes(1) + c.ask(map[string]string{"a-app": "1", "b-app": "2"}) + c.passes(4) + *c.clock = c.clock.Add(time.Hour) + if h, _ := c.Ledger.Get("c-app"); h.Retired != nil { + t.Fatal("retired though the count was broken") + } + c.passes(1) + if h, _ := c.Ledger.Get("c-app"); h.Retired == nil { + t.Fatal("not retired after five unbroken passes and an hour") + } +} + +// Too many at once waits for a person: approve retires exactly that set, reject keeps it. +func TestTooManyAtOnceWaitsForAPerson(t *testing.T) { + all := map[string]string{"a-app": "1", "b-app": "2", "c-app": "3", "d-app": "4", "e-app": "5"} + for _, decide := range []string{"approve", "reject"} { + c := newCustody(t) + c.ask(all) + c.passes(1) + c.ask(map[string]string{"a-app": "1"}) + c.passes(5) + *c.clock = c.clock.Add(time.Hour) + c.passes(1) + r := c.Retirement() + if r["waiting"] == nil { + t.Fatalf("four of five no longer asked for did not wait: %v", r) + } + for _, h := range c.Ledger.List() { + if h.Retired != nil { + t.Fatalf("%s retired while the set waited", h.As) + } + } + if _, err := c.Approve("person.jochen", ""); err == nil { + t.Error("an approval without a why was taken") + } + if decide == "approve" { + set, err := c.Approve("person.jochen", "the four apps are gone") + if err != nil || len(set) != 4 { + t.Fatalf("%v %v", set, err) + } + if h, _ := c.Ledger.Get("b-app"); h.Retired == nil { + t.Error("approved, not retired") + } + if h, _ := c.Ledger.Get("a-app"); h.Retired != nil { + t.Error("an asked-for holder was retired") + } + } else { + if _, err := c.Reject("person.jochen", "they come back"); err != nil { + t.Fatal(err) + } + c.passes(10) + if h, _ := c.Ledger.Get("b-app"); h.Retired != nil { + t.Error("rejected, but retired") + } + if c.Retirement()["waiting"] != nil { + t.Error("a rejected set was asked about again") + } + } + } +} + +// Only a person deletes, with a why, and only a retired holder the mesh no longer asks for. +func TestOnlyAPersonDeletesARetiredHolder(t *testing.T) { + c := newCustody(t) + c.ask(map[string]string{"a-app": "1", "b-app": "2", "c-app": "3"}) + c.passes(1) + if err := c.Delete("c-app", "person.jochen", "gone"); err == nil { + t.Error("an active holder was deleted") + } + c.ask(map[string]string{"a-app": "1", "b-app": "2"}) + c.passes(5) + *c.clock = c.clock.Add(time.Hour) + c.passes(1) + if err := c.Delete("c-app", "person.jochen", ""); err == nil { + t.Error("deleted without a why") + } + if err := c.Delete("c-app", "person.jochen", "gitea's redis is gone for good"); err != nil { + t.Fatal(err) + } + if _, ok := c.Ledger.Get("c-app"); ok { + t.Error("deleted, but still in the ledger") + } + last := c.changes() + if last[len(last)-1] != "deleted" { + t.Errorf("announced %v", last) + } +} + func logins(hs []Held) string { var out []string for _, h := range hs {