The postgres-backed test still expected a module that declares no broker secret to be a bus user, and failed on main since #270; it skips without a database, so the change's own run did not see it.
244 lines
8.5 KiB
Go
244 lines
8.5 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")
|
|
}
|
|
}
|
|
}
|