Files
mesh-controller/internal/inventory/inventory_test.go
T
jschoubben 66768208d2 Node records, and the right to join once
The next step after the schema: inventory now holds node records and enrolment
tokens, and mesh-control has the commands to work with them.

A token is issued for a node record, which is where re-enrolment gets decided
-- what an identity binds to is settled when the token is made, not when it is
presented, so the machine presenting one does not need to know whether it is
joining or returning.

What the token guarantees, each with a test confirmed to fail when the
behaviour is removed: the secret is 256 random bits, shown once and stored only
as a hash; it works exactly once; it stops working when it expires; issuing
again for a node invalidates the outstanding one, because two live tokens are
two machines able to join as the same node. Redemption is a single statement
that finds and spends together, so eight concurrent attempts on one secret
produce exactly one winner rather than a race between a check and a write.

Refusals are deliberately identical for unknown, spent and expired. Somebody
guessing must not learn which guess was a real token that had merely aged out.

SHA-256 rather than a password hash, and that is a choice not a shortcut: the
secret is high-entropy random, so there is nothing to guess and a slow hash
would buy nothing while making every redemption expensive.

It stops before what a node receives in exchange. What a machine presents
afterwards to prove it is that node is not decided anywhere, and a migration is
the most expensive place here to guess.

So a token carries one of the four things ADR 0004 requires. The command prints
the secret and then says exactly that -- the broker's address, its certificate
fingerprint and the control plane's signing identity do not exist yet. Better
than emitting something that looks complete and silently cannot be used.
2026-08-29 14:46:03 +02:00

293 lines
8.3 KiB
Go

package inventory
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-control/internal/store"
)
// Against a real PostgreSQL, for the reason novox/hq ADR 0017 gives: what is being tested here is
// that the database enforces what this code relies on it enforcing — a unique name, a token that
// two racing redemptions cannot both spend, a cascade that leaves no token behind. A fake would
// assert that the fake enforces them.
func fresh(t *testing.T) *Inventory {
t.Helper()
admin := os.Getenv("MESH_TEST_POSTGRES")
if admin == "" {
t.Skip("no MESH_TEST_POSTGRES; run `make check` to raise one")
}
name := fmt.Sprintf("inv_%d_%s", time.Now().UnixNano()%1_000_000,
strings.ToLower(strings.NewReplacer("/", "", "-", "").Replace(t.Name())))
if len(name) > 60 {
name = name[:60]
}
conn, err := pgx.Connect(t.Context(), admin)
if err != nil {
t.Fatalf("cannot reach the test PostgreSQL: %v", err)
}
if _, err := conn.Exec(t.Context(), "create database "+name); err != nil {
t.Fatalf("cannot create %s: %v", name, err)
}
conn.Close(t.Context())
cut := strings.LastIndex(admin, "/")
t.Setenv(store.Variable(Name), admin[:cut]+"/"+name+"?sslmode=disable")
inv, err := Open(t.Context())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
inv.Close()
c, err := pgx.Connect(context.Background(), admin)
if err != nil {
return
}
defer c.Close(context.Background())
_, _ = c.Exec(context.Background(), "drop database if exists "+name+" with (force)")
})
if err := inv.Ready(t.Context(), 20*time.Second); err != nil {
t.Fatal(err)
}
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
if _, err := inv.store.Migrate(t.Context(), migrations); err != nil {
t.Fatal(err)
}
return inv
}
func TestANodeRecordRoundTrips(t *testing.T) {
inv := fresh(t)
made, err := inv.AddNode(t.Context(), "workstation")
if err != nil {
t.Fatal(err)
}
found, err := inv.NodeByName(t.Context(), "workstation")
if err != nil {
t.Fatal(err)
}
if found.ID != made.ID {
t.Errorf("added %s and found %s", made.ID, found.ID)
}
}
func TestTwoNodesCannotShareAName(t *testing.T) {
// A name is how a token is issued for a node. Two records with one name makes that command
// ambiguous at the moment it grants access to the mesh.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "workstation"); err != nil {
t.Fatal(err)
}
_, err := inv.AddNode(t.Context(), "workstation")
if !errors.Is(err, ErrNameTaken) {
t.Fatalf("a duplicate name gave %v; it must be a plain answer a person can act on", err)
}
}
func TestAnUnknownNodeIsNotAnEmptyRecord(t *testing.T) {
inv := fresh(t)
_, err := inv.NodeByName(t.Context(), "never-existed")
if !errors.Is(err, ErrNoSuchNode) {
t.Fatalf("expected ErrNoSuchNode, got %v", err)
}
}
func TestATokenIsRedeemableExactlyOnce(t *testing.T) {
// "Useless once used" (novox/hq ADR 0004). Without it a token that leaked after a successful
// join is a second machine's way in, and nothing would have noticed the first.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
issued, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
if err != nil {
t.Fatal(err)
}
node, err := inv.Redeem(t.Context(), issued.Secret)
if err != nil {
t.Fatalf("a fresh token was refused: %v", err)
}
if node.Name != "laptop" {
t.Errorf("redeemed a token for %q", node.Name)
}
if _, err := inv.Redeem(t.Context(), issued.Secret); !errors.Is(err, ErrTokenRefused) {
t.Fatal("the same token was redeemed twice")
}
}
func TestAnExpiredTokenIsRefused(t *testing.T) {
// "Useless after it expires" — the other half, and the one nothing notices, because a token
// ages out with nobody watching. It has to be read from the row rather than from a status
// something would have had to write.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
issued, err := inv.IssueToken(t.Context(), "laptop", 40*time.Millisecond)
if err != nil {
t.Fatal(err)
}
time.Sleep(120 * time.Millisecond)
if _, err := inv.Redeem(t.Context(), issued.Secret); !errors.Is(err, ErrTokenRefused) {
t.Fatal("an expired token was accepted")
}
}
func TestATokenWithNoLifetimeIsRefused(t *testing.T) {
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
if _, err := inv.IssueToken(t.Context(), "laptop", 0); err == nil {
t.Fatal("a token that never expires was issued")
}
}
func TestIssuingAgainInvalidatesTheOutstandingToken(t *testing.T) {
// Two live tokens for one node record are two machines able to join as the same node, with
// nothing downstream able to tell which was meant.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
first, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
if err != nil {
t.Fatal(err)
}
second, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
if err != nil {
t.Fatal(err)
}
if _, err := inv.Redeem(t.Context(), first.Secret); !errors.Is(err, ErrTokenRefused) {
t.Error("the first token still worked after a second was issued")
}
if _, err := inv.Redeem(t.Context(), second.Secret); err != nil {
t.Errorf("the newest token was refused: %v", err)
}
}
func TestTheSecretIsNotStored(t *testing.T) {
// A copy of this database must not be a set of working credentials.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
issued, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
if err != nil {
t.Fatal(err)
}
var stored string
if err := inv.store.Pool().QueryRow(t.Context(),
`select secret from enrolment_token limit 1`).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored == issued.Secret {
t.Fatal("the token secret is stored verbatim; this table would be a set of live credentials")
}
if strings.Contains(stored, issued.Secret) {
t.Fatal("the stored value contains the secret")
}
}
func TestTwoRedemptionsOfOneSecretCannotBothWin(t *testing.T) {
// The check and the spend are one statement for this reason. Reading first and writing second
// leaves a window where two machines both pass the check and both join as the same node.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
issued, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
results := make([]error, 8)
for i := range results {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, results[i] = inv.Redeem(context.Background(), issued.Secret)
}(i)
}
wg.Wait()
won := 0
for _, err := range results {
if err == nil {
won++
}
}
if won != 1 {
t.Errorf("%d of 8 concurrent redemptions succeeded; exactly one may", won)
}
}
func TestRemovingANodeTakesItsTokensWithIt(t *testing.T) {
// A token outliving the record it was issued for is a right to join as nobody.
inv := fresh(t)
node, err := inv.AddNode(t.Context(), "laptop")
if err != nil {
t.Fatal(err)
}
if _, err := inv.IssueToken(t.Context(), "laptop", time.Hour); err != nil {
t.Fatal(err)
}
if _, err := inv.store.Pool().Exec(t.Context(), `delete from node where id = $1`, node.ID); err != nil {
t.Fatal(err)
}
var left int
if err := inv.store.Pool().QueryRow(t.Context(),
`select count(*) from enrolment_token`).Scan(&left); err != nil {
t.Fatal(err)
}
if left != 0 {
t.Errorf("%d token(s) outlived the node record they were issued for", left)
}
}
func TestAnUnknownSecretIsRefusedTheSameWayAsAnExpiredOne(t *testing.T) {
// One error for every reason. Somebody guessing must not learn which of their guesses was a
// real token that had merely expired.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
t.Fatal(err)
}
expired, err := inv.IssueToken(t.Context(), "laptop", 30*time.Millisecond)
if err != nil {
t.Fatal(err)
}
time.Sleep(100 * time.Millisecond)
_, unknownErr := inv.Redeem(t.Context(), "not-a-token-at-all")
_, expiredErr := inv.Redeem(t.Context(), expired.Secret)
if unknownErr == nil || expiredErr == nil {
t.Fatal("one of them was accepted")
}
if unknownErr.Error() != expiredErr.Error() {
t.Errorf("the two are distinguishable:\n unknown: %v\n expired: %v", unknownErr, expiredErr)
}
}