diff --git a/internal/licences/licences_test.go b/internal/licences/licences_test.go index 2969c18..f81af90 100644 --- a/internal/licences/licences_test.go +++ b/internal/licences/licences_test.go @@ -160,3 +160,151 @@ func TestUsingALicenceThatDoesNotExistIsRefused(t *testing.T) { t.Fatalf("the refusal does not name the licence: %v", err) } } + +// Two sessions on one machine, each on its own licence (novox/hq work breakdown 1.2). +// +// **The case ADR 0026 creates.** The control-plane node runs its own node session and the mesh's, +// so a machine is no longer a usable answer to *whose licence is this*. `14-model-access.md` +// records that gap as "a consumer that is not a machine", and the question this answers is whether +// it needs a new consumer identity or whether the existing one already distinguishes them. +// +// It does. The two sessions are two modules — the same mechanism started in different context +// roots (ADR 0026), and a context root is what a module delivers — so `(node, module)` names them +// apart without a schema knowing anything about sessions. +func TestTwoSessionsOnOneMachineHoldDifferentLicences(t *testing.T) { + held, ctx := fresh(t) + for _, l := range []struct{ name, provider string }{ + {"personal", "anthropic"}, + {"company", "anthropic"}, + } { + if err := held.Add(ctx, l.name, l.provider, map[string]any{"model": "a-model"}); err != nil { + t.Fatal(err) + } + } + + // Both on the machine that holds the control plane. + const machine = "anchor" + if err := held.Use(ctx, "personal", machine, "node-session"); err != nil { + t.Fatal(err) + } + if err := held.Use(ctx, "company", machine, "mesh-session"); err != nil { + t.Fatal(err) + } + + // Each is asked for separately, and neither answer is the other's. + node, err := held.Chosen(ctx, machine, "node-session") + if err != nil { + t.Fatal(err) + } + mesh, err := held.Chosen(ctx, machine, "mesh-session") + if err != nil { + t.Fatal(err) + } + if node != "personal" || mesh != "company" { + t.Fatalf("the node session is on %q and the mesh session on %q; expected personal and company", + node, mesh) + } + + // And the keys are separate too, which is the half that matters: a machine-wide answer would + // hand both sessions whichever key was sealed last. + sealing := aKey(t) + for _, l := range []struct{ name, value string }{ + {"personal", "sk-the-operators-own-key"}, + {"company", "sk-the-companys-key"}, + } { + if _, err := held.Accept(ctx, l.name, l.value, func(string) (string, error) { + return sealing, nil + }); err != nil { + t.Fatal(err) + } + } + + first, err := held.KeyFor(ctx, "personal", machine, "node-session") + if err != nil { + t.Fatal(err) + } + second, err := held.KeyFor(ctx, "company", machine, "mesh-session") + if err != nil { + t.Fatal(err) + } + if first == "" || second == "" { + t.Fatal("a session on a licence was given no key") + } + if first == second { + t.Fatal("both sessions were given the same sealed key, so the machine answered rather " + + "than the session") + } +} + +// A session put on no licence is not silently given the machine's other one. +func TestASessionOnNoLicenceIsAnsweredWithNothing(t *testing.T) { + held, ctx := fresh(t) + if err := held.Add(ctx, "personal", "anthropic", map[string]any{"model": "a-model"}); err != nil { + t.Fatal(err) + } + if err := held.Use(ctx, "personal", "anchor", "node-session"); err != nil { + t.Fatal(err) + } + + chosen, err := held.Chosen(ctx, "anchor", "mesh-session") + if err != nil { + t.Fatal(err) + } + if chosen != "" { + t.Fatalf("a session nobody put on a licence was answered with %q, which belongs to "+ + "something else on the same machine", chosen) + } +} + +// Two sessions on one machine and the SAME licence are still two holders. +// +// **Written because a fault injection stayed silent.** The test above puts them on different +// licences, so the licence alone tells them apart and the module argument is never load-bearing — +// removing it from the query changed nothing and every assertion still passed. This is the case +// that needs `(node, module)` to be the identity: releasing one session must leave the other, +// and a machine-shaped answer would take both. +func TestReleasingOneSessionLeavesTheOtherOnTheSameMachine(t *testing.T) { + held, ctx := fresh(t) + if err := held.Add(ctx, "company", "anthropic", map[string]any{"model": "a-model"}); err != nil { + t.Fatal(err) + } + const machine = "anchor" + for _, session := range []string{"node-session", "mesh-session"} { + if err := held.Use(ctx, "company", machine, session); err != nil { + t.Fatal(err) + } + } + if _, err := held.Accept(ctx, "company", "sk-the-companys-key", func(string) (string, error) { + return aKey(t), nil + }); err != nil { + t.Fatal(err) + } + + if err := held.StopUsing(ctx, "company", machine, "node-session"); err != nil { + t.Fatal(err) + } + + gone, err := held.Chosen(ctx, machine, "node-session") + if err != nil { + t.Fatal(err) + } + if gone != "" { + t.Errorf("the released session is still on %q", gone) + } + + kept, err := held.Chosen(ctx, machine, "mesh-session") + if err != nil { + t.Fatal(err) + } + if kept != "company" { + t.Fatal("releasing one session took the other's licence with it, so the machine was " + + "released rather than the session") + } + key, err := held.KeyFor(ctx, "company", machine, "mesh-session") + if err != nil { + t.Fatal(err) + } + if key == "" { + t.Fatal("the session that was kept lost its key") + } +}