Files
mesh-catalog/modules/mesh-vault/cmd/mesh-vault/mint_test.go
T

388 lines
17 KiB
Go

package main
import (
"encoding/json"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// aWant is a wanted account of a module on ace, as the grants part writes it.
func aWant(module string) buscompose.WantedAccount {
return buscompose.WantedAccount{User: "ace." + module, Kind: buscompose.KindModule, Node: "ace", Module: module,
URL: "nats://novox.mesh:4222", Fingerprint: "ab:cd", Claims: []buscompose.ClaimedSeat{{Seat: "s", Scope: "node"}}}
}
func wantRaw(t *testing.T, w buscompose.WantedAccount) []byte {
t.Helper()
raw, err := json.Marshal(w)
if err != nil {
t.Fatal(err)
}
return raw
}
// mintingVault is a vault with a state of bus accounts, ace's sealing key stated and ace's modules assigned. No lease is
// heard: none turns minting, retirement or rotation (issue 1126).
func mintingVault(t *testing.T) (testVault, *fakeAccounts, string) {
t.Helper()
plans := assignments{"ace.mod-x": `{"node":"ace","module":"mod-x"}`, "ace.mod-y": `{"node":"ace","module":"mod-y"}`}
v := newVault(t, nil)
v.Askers = Declared{Read: plans.read, Store: v.Store}
accounts := &fakeAccounts{}
v.Accounts = accounts
pub, priv := aSealingKey(t)
if err := v.recordNodeKey("ace", pub, "node.ace"); err != nil {
t.Fatal(err)
}
return v, accounts, priv
}
// 774/AC7 (novox/hq ADR 0561 §1, §2, §4): a want with no value, minting on, is minted once: kept whole in the controller's
// shape as the module's broker, its hash in bus-accounts, said account-issued before secret-changed, and answered by own
// sealed to the node; a value held or a hash adopted is never minted.
func TestTheVaultMintsAWantedAccountOnceAndNothingItHolds(t *testing.T) {
v, accounts, nodePriv := mintingVault(t)
w := aWant("mod-x")
before := len(v.bus.events)
v.WantHeard(w.User, wantRaw(t, w), false)
rec, held, err := v.Store.Read("own/ace/mod-x/broker")
if err != nil || !held || rec.Generation != 1 || rec.Origin != OriginMade {
t.Fatalf("the minted account is kept as %+v (held %v, %v)", rec, held, err)
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
t.Fatal(err)
}
var got struct{ Password string }
_ = json.Unmarshal([]byte(value), &got)
shape, _ := buscompose.HeldCredential(w, got.Password)
if len(got.Password) != 40 || value != string(shape) {
t.Errorf("the minted value is %q, not the controller's shape %q", value, shape)
}
if !accounts.hashOf(w.User, got.Password, 1) {
t.Errorf("%s's hash is not the minted password's: %+v", w.User, accounts.keys[w.User])
}
var order []string
for _, e := range v.bus.events[before:] {
order = append(order, e["event"].(string))
}
if strings.Join(order, ",") != AccountHashedEvent+","+AccountIssuedEvent+","+ChangedEvent {
t.Errorf("said %v, want the hash, account-issued, then secret-changed", order)
}
for _, raw := range v.bus.raw[before:] {
if strings.Contains(raw, got.Password) || strings.Contains(raw, "$2a$") {
t.Errorf("a value or a hash was said: %s", raw)
}
}
own, err := v.OwnOfNode("node.ace")
if err != nil || len(own.Own) != 1 {
t.Fatalf("own answered %+v, %v", own, err)
}
if opened, err := seal.Open(own.Own[0].Sealed, nodePriv); err != nil || opened != value {
t.Errorf("own's sealed value opens to %q, %v", opened, err)
}
// Heard again, or for a value held, or for a hash adopted: nothing is minted, nothing is held back.
v.WantHeard(w.User, wantRaw(t, w), false)
if rec, _, _ := v.Store.Read("own/ace/mod-x/broker"); rec.Generation != 1 {
t.Errorf("a want heard again minted generation %d", rec.Generation)
}
accounts.keys["ace.mod-y"] = AccountHash{User: "ace.mod-y", Hash: "$2a$04$adopted", Adopted: true}
y := aWant("mod-y")
v.WantHeard(y.User, wantRaw(t, y), false)
if _, held, _ := v.Store.Read("own/ace/mod-y/broker"); held || accounts.keys["ace.mod-y"].Hash != "$2a$04$adopted" {
t.Errorf("an adopted user was minted")
}
if st := v.MintState(); len(st.HeldBack) != 0 || len(st.Minted) != 1 {
t.Errorf("vault_status says %+v: an adopted user is not held back, one account minted", st)
}
t.Run("ledgers", func(t *testing.T) {
for _, c := range []struct {
name string
run func(t *testing.T, v testVault, accounts *fakeAccounts, priv string)
}{
// 1966/AC24 (issue 1966 V1): a machine carrying node-tools, with no ledger account, has `ledgers` as {}.
{"an empty ledgers on a machine with node-tools", func(t *testing.T, v testVault, accounts *fakeAccounts, priv string) {
if _, ok := ledgersOf(t, v, priv); ok {
t.Fatal("ledgers is there before node-tools is wanted")
}
w := nodeToolsWant("ace")
v.WantHeard(w.User, wantRaw(t, w), false)
before := len(v.bus.events)
v.LedgersPass()
got, ok := ledgersOf(t, v, priv)
if !ok || len(got) != 0 {
t.Fatalf("ledgers is %v (held %v), want {}", got, ok)
}
v.LedgersPass()
if n := changesOf(v, before, "own/ace/node-tools/ledgers"); n != 0 {
t.Errorf("a second pass said secret-changed %d times for a value that did not change", n)
}
}},
// 1966/AC25 (issue 1966 V2): wanted ledger accounts for m and k on a machine are minted and hashed like every
// kind, kept together in the machine's `ledgers`, each mint said once as a change of `ledgers` and never of
// the account's own record.
{"ledger accounts minted into ledgers", func(t *testing.T, v testVault, accounts *fakeAccounts, priv string) {
tools := nodeToolsWant("ace")
v.WantHeard(tools.User, wantRaw(t, tools), false)
v.LedgersPass()
for i, module := range []string{"m", "k"} {
w := ledgerWant("ace", module)
before := len(v.bus.events)
v.WantHeard(w.User, wantRaw(t, w), false)
if n := changesOf(v, before, "own/ace/node-tools/ledgers"); n != 1 {
t.Errorf("minting %s said secret-changed %d times for ledgers, want once", module, n)
}
if n := eventsNamed(v, before, ChangedEvent); n != 1 {
t.Errorf("minting %s said %d secret-changed in all, want the one of ledgers", module, n)
}
got, _ := ledgersOf(t, v, priv)
if len(got) != i+1 {
t.Fatalf("after %s ledgers holds %d credentials, want %d", module, len(got), i+1)
}
}
got, _ := ledgersOf(t, v, priv)
for _, module := range []string{"m", "k"} {
var cred struct{ User, Password string }
if err := json.Unmarshal(got[module], &cred); err != nil || cred.User != "ledger.ace."+module || len(cred.Password) != 40 {
t.Fatalf("ledgers[%s] is %s (%v)", module, redacted(got[module]), err)
}
if !accounts.hashOf(cred.User, cred.Password, 1) {
t.Errorf("%s's hash is not in bus-accounts or not of the minted password", cred.User)
}
if _, held, _ := v.Store.Read("own/ace/" + module + "/ledgers"); held {
t.Errorf("the ledger account of %s is also kept as an own secret of its module", module)
}
}
}},
// 1966/AC26: m's wanted key deleted: m leaves ledgers at once, its hash is retired within ADR 0230's bounds,
// k stays; wanted again, m is back in ledgers.
{"a ledger account no longer wanted leaves ledgers", func(t *testing.T, v testVault, accounts *fakeAccounts, priv string) {
clock := time.Date(2026, 11, 3, 9, 0, 0, 0, time.UTC)
v.Now = func() time.Time { return clock }
for _, w := range []buscompose.WantedAccount{nodeToolsWant("ace"), ledgerWant("ace", "m"), ledgerWant("ace", "k")} {
v.WantHeard(w.User, wantRaw(t, w), false)
}
v.WantsRead()
before := len(v.bus.events)
v.WantHeard("ledger.ace.m", nil, true)
got, _ := ledgersOf(t, v, priv)
if _, in := got["m"]; in || len(got) != 1 {
t.Fatalf("after m's want went, ledgers holds %d credentials, m among them: %v", len(got), in)
}
if n := changesOf(v, before, "own/ace/node-tools/ledgers"); n != 1 {
t.Errorf("m's retirement said secret-changed %d times for ledgers, want once", n)
}
for i := 0; i < 6; i++ {
clock = clock.Add(3 * time.Minute)
v.RetirePass()
}
if _, ok := accounts.keys["ledger.ace.m"]; ok {
t.Errorf("m's hash is still in bus-accounts: %+v", v.RetireState())
}
if _, ok := accounts.keys["ledger.ace.k"]; !ok {
t.Error("k's hash was retired with m's")
}
if got, _ := ledgersOf(t, v, priv); len(got) != 1 {
t.Errorf("the retirement changed ledgers to %d credentials", len(got))
}
w := ledgerWant("ace", "m")
v.WantHeard(w.User, wantRaw(t, w), false)
if got, _ := ledgersOf(t, v, priv); len(got) != 2 || accounts.keys["ledger.ace.m"].Hash == "" {
t.Errorf("wanted again, m is not back in ledgers (%d) with its hash", len(got))
}
}},
// 1966/CAT-V2 (ADR 0597 §9): a person's rotate of a machine's `ledgers` is refused as a bus account's is: it
// would replace every module's credential with a value no ledger account holds.
{"a person's rotate of ledgers is refused", func(t *testing.T, v testVault, accounts *fakeAccounts, priv string) {
w := nodeToolsWant("ace")
v.WantHeard(w.User, wantRaw(t, w), false)
v.LedgersPass()
before, _ := ledgersOf(t, v, priv)
if _, err := v.Rotate("person.jochen", "own/ace/node-tools/ledgers", "jochen", "a test"); err == nil ||
!strings.Contains(err.Error(), "ledger accounts of its machine") {
t.Errorf("a person rotated ledgers: %v", err)
}
if after, _ := ledgersOf(t, v, priv); len(after) != len(before) {
t.Errorf("a refused rotation changed ledgers: %d, was %d", len(after), len(before))
}
}},
} {
t.Run(c.name, func(t *testing.T) {
v, accounts, priv := mintingVault(t)
c.run(t, v, accounts, priv)
})
}
})
}
// 774/AC8, after research 072's gap "vault mints after the lease": the vault is the one issuer, so a wanted account is
// minted with no lease asked (never an adopted one), vault_status says minting is on and names no lease (issue 1126),
// and a want held back is not said held back once the vault holds its value. During the delivery an older
// controller may still mint: its value handed with origin issued never replaces the account the vault minted.
func TestTheVaultMintsWhoeverHoldsTheLease(t *testing.T) {
v, accounts, _ := mintingVault(t)
accounts.keys = map[string]AccountHash{"ace.mod-y": {User: "ace.mod-y", Hash: "$2a$04$adopted", Adopted: true}}
for _, m := range []string{"mod-x", "mod-y"} {
w := aWant(m)
v.WantHeard(w.User, wantRaw(t, w), false)
}
minted, held, _ := v.Store.Read("own/ace/mod-x/broker")
if !held {
t.Fatalf("not minted: %+v", v.MintState())
}
if _, held, _ := v.Store.Read("own/ace/mod-y/broker"); held {
t.Error("an adopted account was minted")
}
st := v.MintState()
if !st.On || strings.Contains(st.Why, "lease") || len(st.HeldBack) != 0 {
t.Errorf("vault_status says %+v", st)
}
// An older controller's covered account, handed with origin issued: refused, the vault's value kept.
x := aWant("mod-x")
theirs, _ := buscompose.HeldCredential(x, "the-controllers-password")
if _, err := handIssued(t, v, ControllerCaller, "own/ace/mod-x/broker", string(theirs)); err == nil ||
!strings.Contains(err.Error(), "the vault minted") {
t.Errorf("a controller's issued value over the vault's mint: %v", err)
}
if rec, _, _ := v.Store.Read("own/ace/mod-x/broker"); rec.Generation != minted.Generation || rec.ChangedBy != "the vault" {
t.Errorf("the vault's minted account was replaced: %+v", rec)
}
// Held back, and then held (moved in, handed): no longer said held back.
z := aWant("mod-z")
v.minting.mu.Lock()
v.minting.wants[z.User] = z
v.minting.mu.Unlock()
v.holdBack(z.User, "held back for a reason that has passed")
value, _ := buscompose.HeldCredential(z, "moved-in")
if _, err := v.keep("own/ace/mod-z/broker", string(value), "the move", ControllerCaller, OriginMade); err != nil {
t.Fatal(err)
}
if st := v.MintState(); st.HeldBack[z.User] != "" {
t.Errorf("an account the vault holds is said held back: %+v", st.HeldBack)
}
}
// 774/AC10 issue-account (novox/hq ADR 0561 §9): the verb mints one wanted user now, read afresh, whoever holds the lease;
// it is refused for a node's account and the controller's, and for a user nobody wants; a user held already is said so.
func TestIssueAccountMintsOneWantedUserNowAndRefusesTheRest(t *testing.T) {
v, _, _ := mintingVault(t)
w := aWant("mod-x")
raw := wantRaw(t, w)
v.WantRead = func(user string) ([]byte, bool, error) {
if user == w.User {
return raw, true, nil
}
return nil, false, nil
}
for user, says := range map[string]string{
"node.ace": "never minted",
} {
if _, err := v.IssueAccount("person.jochen", user); err == nil || !strings.Contains(err.Error(), says) {
t.Errorf("%s: %v, want a refusal saying %q", user, err, says)
}
}
// Minted while the controller still holds the lease (issue 774): `module issue` forwards here.
if _, err := v.IssueAccount("person.jochen", "ace.nobody"); err == nil || !strings.Contains(err.Error(), "no wanted account") {
t.Errorf("a user nobody wants: %v", err)
}
got, err := v.IssueAccount("person.jochen", w.User)
if err != nil || got.(map[string]any)["minted"] != true {
t.Fatalf("issue-account: %v, %v", got, err)
}
if again, err := v.IssueAccount("person.jochen", w.User); err != nil || again.(map[string]any)["minted"] != false ||
!strings.Contains(again.(map[string]any)["why"].(string), "held already") {
t.Errorf("issue-account again: %v, %v", again, err)
}
}
// 774/AC10 the rotation's rule (novox/hq ADR 0561 §4): at most one rotation begins per revision of the loaded user list,
// over the second login `<user>--b`, never of a node's account, the controller's or an adopted hash, and `rotate` of a
// covered account stays refused, so nothing rotates in place.
func TestOneRotationBeginsPerRevisionOfTheLoadedList(t *testing.T) {
v, accounts, _ := mintingVault(t)
for _, m := range []string{"mod-x", "mod-y"} {
w := aWant(m)
v.WantHeard(w.User, wantRaw(t, w), false)
}
if _, err := v.BeginRotation("ace.mod-x"); err == nil || !strings.Contains(err.Error(), "not known yet") {
t.Errorf("began with no loaded revision known: %v", err)
}
v.LoadedListHeard(570)
r, err := v.BeginRotation("ace.mod-x")
if err != nil || r.Login != "ace.mod-x--b" || r.Revision != 570 || r.Generation != 2 || accounts.keys["ace.mod-x"].Rotating == nil {
t.Fatalf("the first rotation: %+v, %v", r, err)
}
if _, err := v.BeginRotation("ace.mod-y"); err == nil || !strings.Contains(err.Error(), "one rotation begins per revision") {
t.Errorf("a second rotation in one revision: %v", err)
}
v.LoadedListHeard(571)
if _, err := v.BeginRotation("ace.mod-y"); err != nil {
t.Errorf("the next revision's rotation: %v", err)
}
if _, err := v.BeginRotation("ace.mod-x"); err == nil || !strings.Contains(err.Error(), "rotating already") {
t.Errorf("an account rotating began again: %v", err)
}
accounts.keys["node.ace"] = AccountHash{User: "node.ace", Hash: "$2a$04$n", Adopted: true}
for _, u := range []string{"node.ace", "controller"} {
if _, err := v.BeginRotation(u); err == nil {
t.Errorf("%s began a rotation", u)
}
}
if _, err := v.RotateFor(ControllerCaller, "own/ace/mod-x/broker", Narrow{}, "the test", "a rotation in place"); err == nil {
t.Error("a covered account rotated in place")
}
}
// A mint that failed (here: no operator key yet, so nothing can be kept) is tried again at the pass, never lost.
func TestAFailedMintIsTriedAgainAtThePass(t *testing.T) {
v, accounts, _ := mintingVault(t)
key, _ := v.Store.OperatorKey()
if err := v.Store.setOperatorKey("", "the test", v.Now()); err != nil {
t.Fatal(err)
}
w := aWant("mod-x")
v.WantHeard(w.User, wantRaw(t, w), false)
if _, ok := accounts.keys[w.User]; ok {
t.Fatal("minted with no operator key")
}
if err := v.Store.setOperatorKey(key, "the test", v.Now()); err != nil {
t.Fatal(err)
}
v.MintOwed()
if _, ok := accounts.keys[w.User]; !ok {
t.Errorf("not minted at the pass after the failure: %+v", v.MintState())
}
}
// A held value that is no account (kept before the vault judged it: broker-nats-tools, broker-address and
// broker-management on 2026-10-30, issue 774) is replaced by a minted account when the grants part wants its user;
// a held account still never is.
func TestAHeldValueThatIsNoAccountIsReplacedByAMintedOne(t *testing.T) {
v, accounts, _ := mintingVault(t)
w := aWant("mod-x")
if _, err := v.Put("own/ace/mod-x/broker", `{"password":"pw"}`, "the test"); err != nil {
t.Fatal(err)
}
heldPlain(t, v.Vault, "own/ace/mod-x/broker", "a-random-value-the-controller-made")
v.WantHeard(w.User, wantRaw(t, w), false)
rec, _, _ := v.Store.Read("own/ace/mod-x/broker")
value, err := openAtRest(rec, v.Vault)
if err != nil || !BusAccountValue("own/ace/mod-x/broker", value) || rec.Generation != 3 || rec.Previous == nil {
t.Fatalf("not replaced by a minted account: generation %d, previous kept %v, %v", rec.Generation, rec.Previous != nil, err)
}
var got struct{ Password string }
_ = json.Unmarshal([]byte(value), &got)
if !accounts.hashOf(w.User, got.Password, 3) {
t.Errorf("%s's hash is not the minted password's: %+v", w.User, accounts.keys[w.User])
}
v.WantHeard(w.User, wantRaw(t, w), false)
if again, _, _ := v.Store.Read("own/ace/mod-x/broker"); again.Generation != 3 {
t.Errorf("a held account was minted anew: generation %d", again.Generation)
}
}