Found by reading the live mesh's own notes before touching it, which is where this was heading next. Composing the bus's user list was gated on the controller already being on the new bus. That cannot work: the server needs its user list *before* anything moves onto it. Step 2 of the whole change is exactly that — the server stands in the mesh carrying nothing, on its own ports, while every node stays where it is. Under the old gating that step was impossible: the module would come up, find no accounts file, and its entrypoint would wait for one the controller had decided not to write. So the only question is whether this machine runs the module that asked for the file. A mesh that never moves has written a user list nothing reads, costing a few hundred bytes on one node. The reverse cost a step that could not be taken. Pinned by a test over the records of a mesh mid-change: everything running, nothing on the new bus, and a user list that contains the controller — because a file without it is a bus its own writer cannot connect to.
242 lines
8.8 KiB
Go
242 lines
8.8 KiB
Go
package broker
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Deriving the bus's user list from the mesh's records.
|
|
//
|
|
// Every test here is about a way the list could be wrong that the server would not tell anybody
|
|
// about: a user missing, a user named twice, a user with authority it did not declare.
|
|
|
|
func someRecords() Records {
|
|
return Records{
|
|
Nodes: []string{"two", "one"},
|
|
Assigned: map[string][]Declared{
|
|
"one": {{Module: "telegram", Serves: []string{"status"}}},
|
|
"two": {{Module: "shop", Emits: []string{"order.placed"}}},
|
|
},
|
|
Enrolling: []string{"three"},
|
|
People: map[string][]string{"ada": {"mesh-catalog.catalog_tools"}},
|
|
}
|
|
}
|
|
|
|
func namesOf(t *testing.T, r Records) []string {
|
|
t.Helper()
|
|
users, err := Users(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := make([]string, 0, len(users))
|
|
for _, u := range users {
|
|
out = append(out, u.Username())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The controller is always there. A mesh whose own controller is not in the file is a mesh that
|
|
// cannot be told anything, and there is no state of the records in which that is correct.
|
|
func TestTheControllerIsAlwaysInTheList(t *testing.T) {
|
|
for _, r := range []Records{{}, someRecords()} {
|
|
names := namesOf(t, r)
|
|
if len(names) == 0 || names[0] != "controller" {
|
|
t.Fatalf("the controller is not first in %v", names)
|
|
}
|
|
}
|
|
}
|
|
|
|
// One user per node, one per module per node, one per live token and one per person — and nothing
|
|
// else, because a user nobody derived is a user nobody can explain.
|
|
func TestEveryRecordBecomesExactlyOneUser(t *testing.T) {
|
|
names := namesOf(t, someRecords())
|
|
want := []string{
|
|
"controller",
|
|
"node.one", "one.telegram",
|
|
"node.two", "two.shop",
|
|
"enrol.three",
|
|
"person.ada",
|
|
}
|
|
if strings.Join(names, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("derived %v\n want %v", names, want)
|
|
}
|
|
}
|
|
|
|
// Two users with one name is a file the server reads as one of them, and which one depends on the
|
|
// order. Refused here, where both can be named, rather than left to be whichever the server picked.
|
|
func TestTwoUsersWithOneNameAreRefused(t *testing.T) {
|
|
r := someRecords()
|
|
r.Assigned["one"] = append(r.Assigned["one"], Declared{Module: "telegram"})
|
|
_, err := Users(r)
|
|
if err == nil {
|
|
t.Fatal("a module assigned twice to one node composed two users with one name")
|
|
}
|
|
if !strings.Contains(err.Error(), "one.telegram") {
|
|
t.Fatalf("the refusal does not name the user: %v", err)
|
|
}
|
|
}
|
|
|
|
// A module's authority is what it declared and nothing more, carried through the derivation intact —
|
|
// because this is the step where a mistake would grant something no manifest asked for.
|
|
func TestAModulesAuthorityIsWhatItDeclared(t *testing.T) {
|
|
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered"}}
|
|
users, err := Users(Records{
|
|
Nodes: []string{"one"},
|
|
Assigned: map[string][]Declared{"one": {{
|
|
Module: "shop", Emits: []string{"order.placed"}, Uses: []Seat{seat},
|
|
}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
perms, err := PermissionsFor(users[len(users)-1])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
has(t, perms.Publish, "mesh.mod.shop.event.order.placed")
|
|
has(t, perms.Publish, "mesh.seat.telegram-sender.accept.send")
|
|
// A seat it uses, not one it holds: it may submit work and may not publish the seat's own
|
|
// events, or it could lie about outcomes on a role somebody else fills.
|
|
hasNot(t, perms.Publish, "mesh.seat.telegram-sender.event.delivered")
|
|
hasNot(t, perms.Subscribe, "mesh.seat.telegram-sender.accept.send")
|
|
}
|
|
|
|
// A user the mesh has never minted a password for is named rather than silently dropped or
|
|
// composed as a user anybody is. It is an ordinary situation — a module assigned a moment ago — and
|
|
// the remedy is to mint one, so the caller decides whether to write a partial file.
|
|
func TestAUserWithNoPasswordIsNamedRatherThanWritten(t *testing.T) {
|
|
users, err := Users(someRecords())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
filled, missing := WithPasswords(users, map[string]string{
|
|
"controller": "$2a$hash", "node.one": "$2a$hash",
|
|
})
|
|
if len(filled) != 2 {
|
|
t.Fatalf("composed %d users from two hashes", len(filled))
|
|
}
|
|
if len(missing) != len(users)-2 {
|
|
t.Fatalf("%d users are missing a password, of %d: %v", len(missing), len(users), missing)
|
|
}
|
|
for _, p := range filled {
|
|
if p.PasswordHash == "" {
|
|
t.Fatalf("%s was kept with no password, which is a user anybody is", p.Username())
|
|
}
|
|
}
|
|
}
|
|
|
|
// And the whole thing composes: records in, a file the server would read out.
|
|
func TestRecordsComposeIntoAFile(t *testing.T) {
|
|
users, err := Users(someRecords())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hashes := map[string]string{}
|
|
for _, u := range users {
|
|
hashes[u.Username()] = "$2a$11$" + strings.Repeat("x", 22)
|
|
}
|
|
filled, missing := WithPasswords(users, hashes)
|
|
if len(missing) != 0 {
|
|
t.Fatalf("users with no password: %v", missing)
|
|
}
|
|
got, err := Compose(Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data",
|
|
TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"}, filled)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{
|
|
`user: "controller"`, `user: "node.one"`, `user: "one.telegram"`,
|
|
`user: "enrol.three"`, `user: "person.ada"`,
|
|
`"_INBOX.enrol.three.>"`, `"mesh.mod.mesh-catalog.tool.catalog_tools"`,
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("the composed file does not contain %s", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The accounts block alone is what the mesh writes, and it holds nothing about the server.
|
|
//
|
|
// **The split is the whole design decision** (ComposeAccounts): ports, TLS paths and a store
|
|
// directory are properties of the container the module raises, and a controller that wrote them
|
|
// would have to be kept in step with a Dockerfile it never sees. So this test says what must not be
|
|
// in the file as plainly as what must.
|
|
func TestWhatTheMeshWritesIsUsersAndNothingAboutTheServer(t *testing.T) {
|
|
users, err := Users(someRecords())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hashes := map[string]string{}
|
|
for _, u := range users {
|
|
hashes[u.Username()] = "$2a$11$" + strings.Repeat("x", 22)
|
|
}
|
|
filled, missing := WithPasswords(users, hashes)
|
|
if len(missing) != 0 {
|
|
t.Fatalf("users with no password: %v", missing)
|
|
}
|
|
got, err := ComposeAccounts(filled)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, want := range []string{"accounts {", `user: "controller"`, `user: "one.telegram"`} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("the accounts file does not contain %s", want)
|
|
}
|
|
}
|
|
// None of the server's own settings. Each of these in the mesh's file is a value the controller
|
|
// would then own, and the module could no longer change its own image without the mesh agreeing.
|
|
for _, absent := range []string{"port:", "http:", "jetstream", "tls {", "store_dir", "cert_file"} {
|
|
if strings.Contains(got, absent) {
|
|
t.Errorf("the accounts file contains %q, which belongs to the module that raises the "+
|
|
"server, not to the mesh", absent)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A user with no password is refused here too, not only by the whole-file composition: this is the
|
|
// function the controller actually calls, and a user without a password is a user anybody is.
|
|
func TestTheAccountsFileRefusesAUserWithNoPassword(t *testing.T) {
|
|
if _, err := ComposeAccounts([]Principal{{Kind: KindController}}); err == nil {
|
|
t.Fatal("a user with no password hash was written")
|
|
}
|
|
}
|
|
|
|
// **A user list is composed for a bus the mesh has not moved onto yet**, and that is the whole of
|
|
// step 2 (novox/hq ADR 0116): the server stands in the mesh carrying nothing, on its own ports, while
|
|
// every node is still on the bus it was on.
|
|
//
|
|
// Pinned because the first version of the composing step got it backwards — it wrote the list only
|
|
// once the controller was already on the new bus, which is a step that cannot be taken: the module
|
|
// comes up, finds no accounts file, and waits for one the controller had decided not to write.
|
|
func TestAUserListIsComposedBeforeAnythingMovesOntoTheBus(t *testing.T) {
|
|
// Exactly the records of a mesh mid-change: everything running, nothing on the new bus.
|
|
users, err := Users(Records{
|
|
Nodes: []string{"anchor"},
|
|
Assigned: map[string][]Declared{"anchor": {{Module: "nats"}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hashes := map[string]string{}
|
|
for _, u := range users {
|
|
hashes[u.Username()] = "$2a$11$" + strings.Repeat("x", 22)
|
|
}
|
|
filled, missing := WithPasswords(users, hashes)
|
|
if len(missing) != 0 {
|
|
t.Fatalf("users with no credential: %v", missing)
|
|
}
|
|
accounts, err := ComposeAccounts(filled)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The controller's own user above all: a file without it is a bus its writer cannot connect to,
|
|
// which is what the server would be left holding the moment it starts.
|
|
if !strings.Contains(accounts, `user: "controller"`) {
|
|
t.Fatalf("the composed list does not contain the controller:\n%s", accounts)
|
|
}
|
|
if !strings.Contains(accounts, `user: "node.anchor"`) {
|
|
t.Errorf("the composed list does not contain the machine running the bus")
|
|
}
|
|
}
|