388 lines
17 KiB
Go
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)
|
|
}
|
|
}
|