Modules talk over the bus, and the power draw reached the bar through a file. A block may now show state its contributor keeps, the contributor only its own; the holder on the same machine is granted the read without naming the module, and the template sees which machine it renders for.
344 lines
12 KiB
Go
344 lines
12 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// Reading the bus's user list out of the mesh's records, against a real store.
|
|
//
|
|
// What each of these is about is a user that would be **missing or wrong in a way nothing reports**:
|
|
// the server reads whatever file it is given, and a module whose user is absent fails on its first
|
|
// publish with an authorisation error that says nothing about a missing assignment.
|
|
|
|
func aMeshWith(t *testing.T, manifests ...catalogue.Manifest) (*Inventory, context.Context) {
|
|
t.Helper()
|
|
inv := ForTest(t)
|
|
ctx := context.Background()
|
|
for _, m := range manifests {
|
|
if err := inv.RegisterModule(ctx, m, Source{Repository: "/r"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return inv, ctx
|
|
}
|
|
|
|
func theSeatDeclarer() catalogue.Manifest {
|
|
return catalogue.Manifest{
|
|
Module: "telegram", Version: "1",
|
|
DefinesSeats: []catalogue.SeatDeclaration{{
|
|
Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered"},
|
|
}},
|
|
Claims: []catalogue.Claim{{Name: "telegram-sender", Scope: catalogue.ScopeMesh}},
|
|
}
|
|
}
|
|
|
|
// A module assigned to a machine becomes a user with the authority its manifest declared — and the
|
|
// protocol of a seat declared by a *different* module, which is the whole reason a seat exists.
|
|
func TestAnAssignedModuleBecomesAUserWithWhatItDeclared(t *testing.T) {
|
|
// It declares where its account is delivered: a module with no own secret named broker can never
|
|
// be issued one, and is no user at all (novox/hq issue 195) — the case asserted below.
|
|
shop := catalogue.Manifest{
|
|
Module: "shop", Version: "1",
|
|
Emits: []string{"order.placed"}, Tools: []string{"price"},
|
|
Uses: []string{"telegram-sender"},
|
|
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}},
|
|
}
|
|
quiet := catalogue.Manifest{Module: "quiet", Version: "1", Emits: []string{"thing.happened"}}
|
|
inv, ctx := aMeshWith(t, theSeatDeclarer(), shop, quiet)
|
|
if _, err := inv.AddNode(ctx, "one"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, module := range []string{"shop", "quiet"} {
|
|
if _, err := inv.Assign(ctx, "one", module); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
records, err := inv.BusRecords(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var on []broker.Declared
|
|
for _, d := range records.Assigned["one"] {
|
|
if d.Module == "shop" {
|
|
on = append(on, d)
|
|
}
|
|
}
|
|
if len(on) != 1 || on[0].NoAccount {
|
|
t.Fatalf("the machine's modules read as %+v", records.Assigned["one"])
|
|
}
|
|
if len(on[0].Uses) != 1 || on[0].Uses[0].Accepts[0] != "send" {
|
|
t.Fatalf("the seat it uses carries no protocol: %+v — so it would be granted nothing on a "+
|
|
"seat it was assigned to send to", on[0].Uses)
|
|
}
|
|
if len(on[0].Serves) != 1 || on[0].Serves[0] != "price" {
|
|
t.Fatalf("its tools read as %v, and a module that cannot subscribe its own tool subject "+
|
|
"serves nothing", on[0].Serves)
|
|
}
|
|
|
|
// And it derives into a user the server would accept.
|
|
users, err := broker.Users(records)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var found bool
|
|
for _, u := range users {
|
|
if u.Username() != "one.shop" {
|
|
continue
|
|
}
|
|
found = true
|
|
perms, err := broker.PermissionsFor(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Its tools are every one under its own name — the list in the manifest is a person's
|
|
// vocabulary for asking, not the module's permission to answer.
|
|
if !granted(perms.Publish, "mesh.mod.shop.event.order.placed") ||
|
|
!granted(perms.Publish, "mesh.seat.telegram-sender.accept.send") ||
|
|
!granted(perms.Subscribe, "mesh.mod.shop.tool.>") {
|
|
t.Fatalf("one.shop's authority is not what it declared: %+v", perms)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("no user was derived for the assigned module")
|
|
}
|
|
for _, u := range users {
|
|
if u.Username() == "one.quiet" {
|
|
t.Fatal("a module with nowhere to read an account was made a user (novox/hq issue 195)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// A machine holding a live token gets an enrolment user; one whose token is spent or expired does
|
|
// not. **An enrolment user outliving its token is a right to join that nobody issued.**
|
|
func TestOnlyAMachineWithALiveTokenHasAnEnrolmentUser(t *testing.T) {
|
|
inv, ctx := aMeshWith(t)
|
|
for _, name := range []string{"live", "expired", "none"} {
|
|
if _, err := inv.AddNode(ctx, name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := inv.IssueToken(ctx, "live", time.Hour); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Briefly, then waited out: a token with no lifetime is refused at issue, which is the right
|
|
// refusal and leaves this as the way to have an expired one.
|
|
if _, err := inv.IssueToken(ctx, "expired", 10*time.Millisecond); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
records, err := inv.BusRecords(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(records.Enrolling, ",") != "live" {
|
|
t.Fatalf("machines with a live token read as %v", records.Enrolling)
|
|
}
|
|
}
|
|
|
|
// A module assigned and absent from the catalogue is refused rather than composed with no authority.
|
|
//
|
|
// **The catalogue refuses to forget an assigned module, so this is the second line and not the
|
|
// first** — and it earns its place there: relying on another package's invariant is how a rule ends
|
|
// up enforced by nothing. Checked against the derivation directly, because the situation cannot be
|
|
// reached through the store.
|
|
func TestAnAssignmentWithNoManifestDerivesNoAuthority(t *testing.T) {
|
|
// What BusRecords would have produced had it composed a ghost: a module with nothing declared.
|
|
users, err := broker.Users(broker.Records{
|
|
Nodes: []string{"one"},
|
|
Assigned: map[string][]broker.Declared{"one": {{Module: "ghost"}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
perms, err := broker.PermissionsFor(users[len(users)-1])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Its inbox and its ack subject, and nothing it could say. That is a module which starts,
|
|
// connects, and is refused by the server on its first publish — an authorisation error that
|
|
// says nothing about a missing manifest, which is why BusRecords names it instead.
|
|
for _, p := range perms.Publish {
|
|
if strings.HasPrefix(p, "mesh.mod.ghost.event.") {
|
|
t.Fatalf("a module with no manifest was granted %s", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func granted(all []string, one string) bool {
|
|
for _, s := range all {
|
|
if s == one {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// **A token is an account on the bus, or it is a string nothing accepts** (novox/hq 04-ISSUES/146).
|
|
//
|
|
// The composed list names an enrolment user for every machine with a live token, and nothing minted
|
|
// a credential for it — so the composer left it out as a user with no password, and every enrolment
|
|
// since the mesh moved to this bus was refused by the server before the mesh heard of it. Nothing
|
|
// caught it because nothing had enrolled since.
|
|
//
|
|
// The password cannot be minted, because it is the token's own secret: the machine will present
|
|
// exactly that string. So this checks the two halves that make the account usable — that a row
|
|
// exists under the name the composer asks for, and that the secret handed out is what that row
|
|
// accepts.
|
|
func TestIssuingATokenRecordsTheAccountItIsThePasswordOf(t *testing.T) {
|
|
inv, ctx := aMeshWith(t)
|
|
if _, err := inv.AddNode(ctx, "joiner"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
issued, err := inv.IssueToken(ctx, "joiner", time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
name := broker.Principal{Kind: broker.KindEnrolment, Node: "joiner"}.Username()
|
|
users, err := inv.BusUsers(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user, has := users[name]
|
|
if !has {
|
|
t.Fatalf("no bus account for %q; the composer would leave the enrolment out and the "+
|
|
"machine would be refused before the mesh heard of it: %v", name, users)
|
|
}
|
|
if user.Kind != BusEnrolment || user.Node != "joiner" {
|
|
t.Errorf("the account is %+v, not this node's enrolment", user)
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(issued.Secret)); err != nil {
|
|
t.Error("the account does not accept the secret the token carries, so presenting the " +
|
|
"token would be refused by the server")
|
|
}
|
|
|
|
// And the composition contains it, which is the thing the server reads.
|
|
records, err := inv.BusRecords(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
derived, err := broker.Users(records)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hashes := map[string]string{}
|
|
for n, u := range users {
|
|
hashes[n] = u.PasswordHash
|
|
}
|
|
_, missing := broker.WithPasswords(derived, hashes)
|
|
for _, m := range missing {
|
|
if m == name {
|
|
t.Fatal("the enrolment user is composed without a password, which is a user nobody can be")
|
|
}
|
|
}
|
|
}
|
|
|
|
// The module holding mesh-broker is the bus, and its user is the bus's own: granted the snapshot API
|
|
// for the night's backup and nothing else, whatever tools it declares (novox/hq ADR 0235). Another
|
|
// module on the same machine is granted none of it.
|
|
func TestTheBussOwnModuleBecomesTheSnapshotUser(t *testing.T) {
|
|
bus := catalogue.Manifest{
|
|
Module: "nats", Version: "1",
|
|
Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}},
|
|
Claims: []catalogue.Claim{{Name: catalogue.BrokerSeat, Scope: catalogue.ScopeMesh}},
|
|
Tools: []string{"nats_streams"},
|
|
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}},
|
|
}
|
|
shop := catalogue.Manifest{Module: "shop", Version: "1", Emits: []string{"order.placed"},
|
|
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}
|
|
inv, ctx := aMeshWith(t, bus, shop)
|
|
if _, err := inv.AddNode(ctx, "one"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, module := range []string{"nats", "shop"} {
|
|
if _, err := inv.Assign(ctx, "one", module); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
records, err := inv.BusRecords(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
users, err := broker.Users(records)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, u := range users {
|
|
perms, err := broker.PermissionsFor(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snapshots := granted(perms.Publish, "$JS.API.STREAM.SNAPSHOT.*")
|
|
switch u.Username() {
|
|
case "one.nats":
|
|
seen[u.Username()] = true
|
|
if !snapshots || len(perms.Publish) != len(broker.BusSnapshotGrants.Publish) {
|
|
t.Fatalf("the bus's own user is granted %v, not the snapshot API alone", perms.Publish)
|
|
}
|
|
case "one.shop":
|
|
seen[u.Username()] = true
|
|
if snapshots {
|
|
t.Fatal("a module that is not the bus may snapshot it")
|
|
}
|
|
}
|
|
}
|
|
if !seen["one.nats"] || !seen["one.shop"] {
|
|
t.Fatalf("users derived: %v", seen)
|
|
}
|
|
}
|
|
|
|
// The bar's holder reads the state the modules beside it offer in its blocks (novox/hq ADR 0255),
|
|
// without naming them: granted on the machine where both are, and nowhere else.
|
|
func TestABarIsGrantedTheStateItsMachinesModulesOfferIt(t *testing.T) {
|
|
bar := catalogue.Manifest{Module: "a-bar", Version: "1",
|
|
Claims: []catalogue.Claim{{Name: catalogue.BarSeat, Scope: catalogue.ScopeNode}}}
|
|
power := catalogue.Manifest{Module: "power", Version: "1",
|
|
State: []catalogue.StateDeclaration{{Name: "draw"}},
|
|
Contributions: []catalogue.SeatContribution{{Seat: catalogue.BarSeat, Kind: catalogue.BarKindBlock,
|
|
Data: map[string]any{"bar": "bottom", "place": "status", "shows": "state",
|
|
"options": map[string]any{"state": "power.draw"}}}}}
|
|
inv, ctx := aMeshWith(t, bar, power)
|
|
for node, modules := range map[string][]string{"laptop": {"a-bar", "power"}, "desk": {"a-bar"}} {
|
|
if _, err := inv.AddNode(ctx, node); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, module := range modules {
|
|
if _, err := inv.Assign(ctx, node, module); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
records, err := inv.BusRecords(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reads := func(node, module string) []string {
|
|
for _, d := range records.Assigned[node] {
|
|
if d.Module == module {
|
|
return d.Reads
|
|
}
|
|
}
|
|
t.Fatalf("%s is not on %s", module, node)
|
|
return nil
|
|
}
|
|
if got := reads("laptop", "a-bar"); len(got) != 1 || got[0] != "power.draw" {
|
|
t.Fatalf("the laptop's bar reads %v", got)
|
|
}
|
|
if got := reads("desk", "a-bar"); len(got) != 0 {
|
|
t.Fatalf("a bar with no power beside it reads %v", got)
|
|
}
|
|
if got := reads("laptop", "power"); len(got) != 0 {
|
|
t.Fatalf("power reads %v", got)
|
|
}
|
|
}
|