Check that what a node says when it joins is what this mesh reads
The enrolment request is a struct in each repository. A node now reports a third key — the one its secrets are sealed to — and that wiring had unit tests on each side and had never been run across the join. A field renamed on one side fails silently: enrolment succeeds, the key is absent, and the node looks joined until the first thing sealed to it cannot be opened, by which point nobody is looking at enrolment. So the host's suite writes a real request and this one reads it, the same way the declaration check already runs in the other direction. Both skip with a reason when the neighbour is not checked out. It does more than compare shapes: it seals something to the key that arrived and opens it with the private half the host kept. Confirmed to fail three ways — a renamed field, a value that is not a key, and a key that is present, correctly named and simply somebody else's. Only the last needs the sealing step, and it is the one a shape check would pass. Also `inventory.ForTest`, because the check lives beside the link and a second copy of the throwaway-database helper would be a second thing to keep true.
This commit is contained in:
@@ -165,6 +165,32 @@ the fake behaves as expected.
|
|||||||
Every test here has been confirmed to fail when the behaviour it defends is removed. Two did not,
|
Every test here has been confirmed to fail when the behaviour it defends is removed. Two did not,
|
||||||
when first written, and both are now commented with what they were missing.
|
when first written, and both are now commented with what they were missing.
|
||||||
|
|
||||||
|
## Checks that cross into the host's repository
|
||||||
|
|
||||||
|
Two things are agreed between this repository and `novox/mesh-host`, and each is a separate struct
|
||||||
|
on each side. A field renamed on one of them fails **silently** — the crossing succeeds and
|
||||||
|
something is simply absent — so both are checked by handing one side's real output to the other's
|
||||||
|
real parser. Neither runs by default; each skips with a reason, because a repository that fails
|
||||||
|
without its neighbour checked out is a repository nobody can build.
|
||||||
|
|
||||||
|
**What this mesh sends, read by the host that receives it:**
|
||||||
|
|
||||||
|
```
|
||||||
|
mesh-control: ./build/mesh-control plan <node> --json > /tmp/d.json
|
||||||
|
mesh-host: MESH_EMITTED=/tmp/d.json go test ./internal/declaration/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**What a node says when it joins, read by this mesh:**
|
||||||
|
|
||||||
|
```
|
||||||
|
mesh-host: MESH_ENROL_OUT=/tmp/enrol.json go test ./internal/link/
|
||||||
|
mesh-control: MESH_ENROL=/tmp/enrol.json make check
|
||||||
|
```
|
||||||
|
|
||||||
|
The second does more than compare shapes: it seals something to the key that arrived and opens it
|
||||||
|
with the private half the host kept. A key that is present, correctly named and simply *wrong*
|
||||||
|
passes every check that only looks at the message.
|
||||||
|
|
||||||
## The image
|
## The image
|
||||||
|
|
||||||
`FROM scratch`, holding one statically linked binary and nothing else — no shell, no package
|
`FROM scratch`, holding one statically linked binary and nothing else — no shell, no package
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-control/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForTest is a fresh inventory in a database of its own, dropped when the test ends.
|
||||||
|
//
|
||||||
|
// Exported because the check that this mesh reads what a node sends lives beside the link, and a
|
||||||
|
// second copy of this would be a second thing to keep true. It takes a *testing.T, so it cannot
|
||||||
|
// be called from anything that is not a test.
|
||||||
|
func ForTest(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
|
||||||
|
}
|
||||||
@@ -3,15 +3,10 @@ package inventory
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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
|
// Against a real PostgreSQL, for the reason novox/hq ADR 0017 gives: what is being tested here is
|
||||||
@@ -19,55 +14,10 @@ import (
|
|||||||
// two racing redemptions cannot both spend, a cascade that leaves no token behind. A fake would
|
// two racing redemptions cannot both spend, a cascade that leaves no token behind. A fake would
|
||||||
// assert that the fake enforces them.
|
// assert that the fake enforces them.
|
||||||
|
|
||||||
|
// fresh is a database of this test's own, made and dropped around it.
|
||||||
func fresh(t *testing.T) *Inventory {
|
func fresh(t *testing.T) *Inventory {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
admin := os.Getenv("MESH_TEST_POSTGRES")
|
return ForTest(t)
|
||||||
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) {
|
func TestANodeRecordRoundTrips(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package link_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/nacl/box"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-control/internal/inventory"
|
||||||
|
"github.com/novox/mesh-control/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a node says when it joins, as that node's own code writes it.
|
||||||
|
//
|
||||||
|
// The two ends are separate structs in separate repositories, and a field renamed on one side
|
||||||
|
// fails silently: enrolment succeeds, a key is simply absent, and the node looks joined until the
|
||||||
|
// first thing sealed to it cannot be opened — by which time nobody is looking at enrolment.
|
||||||
|
//
|
||||||
|
// Skipped unless MESH_ENROL names the file the host's suite wrote:
|
||||||
|
//
|
||||||
|
// mesh-host: MESH_ENROL_OUT=/tmp/enrol.json go test ./internal/link/
|
||||||
|
// mesh-control: MESH_ENROL=/tmp/enrol.json make check
|
||||||
|
//
|
||||||
|
// **What this does not cover**, said so nobody reads more into a pass than is there: the full
|
||||||
|
// enrolment path also issues a broker account, and that needs a broker. What is checked here is
|
||||||
|
// the shape the two sides agree on and that a key which arrives this way can actually be sealed
|
||||||
|
// to — which is the part that was newly wired and the part that fails quietly.
|
||||||
|
func TestWhatANodeSaysWhenItJoinsIsWhatThisMeshReads(t *testing.T) {
|
||||||
|
path := os.Getenv("MESH_ENROL")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("set MESH_ENROL to an enrolment request written by the host's suite")
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var request link.EnrolRequest
|
||||||
|
if err := json.Unmarshal(raw, &request); err != nil {
|
||||||
|
t.Fatalf("this mesh cannot read what a node sends:\n%v", err)
|
||||||
|
}
|
||||||
|
for what, got := range map[string]string{
|
||||||
|
"node": request.Node,
|
||||||
|
"secret": request.Secret,
|
||||||
|
"overlay key": request.OverlayKey,
|
||||||
|
"sealing key": request.SealingKey,
|
||||||
|
} {
|
||||||
|
if got == "" {
|
||||||
|
t.Fatalf("the %s did not survive the crossing — a field name differs", what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(request.PublicKey) != ed25519.PublicKeySize {
|
||||||
|
t.Fatalf("the identity arrived as %d bytes", len(request.PublicKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// And that a key arriving this way is one the mesh can actually seal to. Recording it is not
|
||||||
|
// the same as it being usable, and "recorded" is what a shape check on its own would prove.
|
||||||
|
inv := liveInventory(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
node, err := inv.AddNode(ctx, request.Node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other, err := inv.AddNode(ctx, "the-other-end")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RecordSealingKey(ctx, node.ID, request.SealingKey); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RecordSealingKey(ctx, other.ID, request.SealingKey); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, err := inv.SecretFor(ctx, "database", request.Node, "the-other-end")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nothing could be sealed to a key that arrived from a real node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opened with the private half the host's suite kept, so this asserts the node could read it
|
||||||
|
// rather than that a blob exists.
|
||||||
|
privateRaw, err := os.ReadFile(path + ".sealing-private")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("no private half beside %s, so this can only check the shape", path)
|
||||||
|
}
|
||||||
|
private, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(privateRaw)))
|
||||||
|
if err != nil || len(private) != 32 {
|
||||||
|
t.Fatal("the private half beside the request is not a key")
|
||||||
|
}
|
||||||
|
public, err := base64.StdEncoding.DecodeString(request.SealingKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var pub, priv [32]byte
|
||||||
|
copy(pub[:], public)
|
||||||
|
copy(priv[:], private)
|
||||||
|
blob, err := base64.StdEncoding.DecodeString(secret.ForConsumer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := box.OpenAnonymous(nil, blob, &pub, &priv); !ok {
|
||||||
|
t.Fatal("the node could not open what this mesh sealed to the key it sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveInventory is a database of its own for this test, skipping where there is none.
|
||||||
|
func liveInventory(t *testing.T) *inventory.Inventory {
|
||||||
|
t.Helper()
|
||||||
|
if os.Getenv("MESH_TEST_POSTGRES") == "" {
|
||||||
|
t.Skip("no MESH_TEST_POSTGRES; run `make check` to raise one")
|
||||||
|
}
|
||||||
|
return inventory.ForTest(t)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user