Files
mesh-controller/internal/licences/adapters/adapters_test.go
T
jschoubben 2e33c5e80e model access B: refreshable-grant machinery — manager, at-rest refresh token, refresh flow
The ADR 0050 carve-out, built generic and vendor-neutral. A refreshable-grant
licence records one manager node; that node holds the refresh token encrypted at
rest, access tokens are still sealed per holder, and the refresh token is never in
a holder's delivery. Bounded on the three stated axes: refreshable-grant vendors
only, the refresh token only, the manager node only. Anthropic's actual OAuth
refresh stays a Phase-C plug-in behind a clean seam.

- New at-rest crypto (secrets.SealAtRest/OpenAtRest): envelope encryption distinct
  from the per-holder anonymous-box seal. The refresh token is under a symmetric
  data key (secretbox); the data key is wrapped to the manager node's public
  sealing key. The database alone holds ciphertext and a wrapped key with no
  private half to open either — only the manager node reads it back.

- Refreshable-grant adapter dispatch: anthropic is now refreshable-grant,
  anthropic-api-key the static-key second case. The adapter implements the
  Refresher seam by delegating to an injected VendorRefresher (the Phase-C plug,
  none shipped). static-key is untouched. The type assertion to Refresher is what
  gates the carve-out to refreshable-grant vendors.

- Refresh lease/rotate/publish flow (Licences.Refresh): a transaction-scoped
  advisory lock is the single-refresher lease; the new access token comes from the
  vendor refresh, is sealed per holder (secrets.Seal, as Accept does) and delivered
  on the next push — doc 13's reseal-and-publish half, all-or-nothing. The refresh
  token stays put, re-encrypted at rest only if the vendor rotated it.

- Manager and refresh_grant schema: consolidated into migrations/0001 and carried
  by a new incremental 0003 (the dual-write rule).

- 17 new tests, including the four security checks: KeyFor never carries the
  refresh token, a static key has no manager and cannot be refreshed, the at-rest
  token needs the manager's key, and a refresh delivers a new sealed access token.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-07 00:23:35 +02:00

113 lines
3.6 KiB
Go

package adapters
import (
"context"
"strings"
"testing"
"github.com/novox/mesh-control/internal/secrets"
)
func TestTheTwoShapesAreSelectedByVendor(t *testing.T) {
static, err := For("anthropic-api-key")
if err != nil {
t.Fatal(err)
}
if static.Shape() != StaticKey {
t.Fatalf("anthropic-api-key is %q, expected static-key", static.Shape())
}
grant, err := For("anthropic")
if err != nil {
t.Fatal(err)
}
if grant.Shape() != RefreshableGrant {
t.Fatalf("anthropic is %q, expected refreshable-grant", grant.Shape())
}
}
// The type assertion is what gates the carve-out: a static key is not a Refresher, so it can never
// reach the machinery that holds a token readably. A refreshable grant is one.
func TestOnlyARefreshableGrantIsARefresher(t *testing.T) {
static, _ := For("anthropic-api-key")
if _, ok := static.(Refresher); ok {
t.Fatal("a static-key adapter is a Refresher, so the carve-out is not gated by shape")
}
grant, _ := For("anthropic")
if _, ok := grant.(Refresher); !ok {
t.Fatal("a refreshable-grant adapter is not a Refresher, so it cannot be refreshed")
}
}
// With nothing plugged in, a refresh is refused in a way that names why — not answered with a
// silent no-op that would look like a refresh that changed nothing.
func TestARefreshWithNoRefresherPluggedInIsRefused(t *testing.T) {
grant, _ := For("anthropic")
r := grant.(Refresher)
_, err := r.Refresh(context.Background(), RefreshInput{Licence: "personal"})
if err == nil {
t.Fatal("a refresh succeeded with no vendor refresher plugged in")
}
if !strings.Contains(err.Error(), "Phase C") {
t.Fatalf("the refusal does not point at the missing plug-in: %v", err)
}
}
type fakeVendor struct {
result RefreshResult
got RefreshInput
}
func (f *fakeVendor) Refresh(_ context.Context, in RefreshInput) (RefreshResult, error) {
f.got = in
return f.result, nil
}
// A plugged-in refresher is dispatched to, and is handed the at-rest envelope (never a plaintext
// refresh token) plus which node is the manager.
func TestAPluggedInRefresherIsDispatchedTo(t *testing.T) {
fake := &fakeVendor{result: RefreshResult{AccessToken: "at-new"}}
RegisterRefresher("anthropic", fake)
defer RegisterRefresher("anthropic", nil)
grant, _ := For("anthropic")
r := grant.(Refresher)
in := RefreshInput{
Licence: "personal", Manager: "workstation",
AtRest: secrets.AtRest{Token: "tok", WrappedKey: "wk", ManagerKey: "mk"},
}
out, err := r.Refresh(context.Background(), in)
if err != nil {
t.Fatal(err)
}
if out.AccessToken != "at-new" {
t.Fatalf("the dispatched result did not come back: %q", out.AccessToken)
}
if fake.got.Manager != "workstation" || fake.got.AtRest.Token != "tok" {
t.Fatalf("the refresher was handed the wrong input: %+v", fake.got)
}
}
// A vendor this build has no adapter for is refused, and the refusal lists what it does know so a
// typo and an unsupported vendor are told apart.
func TestAnUnknownVendorIsRefusedWithTheList(t *testing.T) {
_, err := For("acme-models")
if err == nil {
t.Fatal("an unknown vendor returned an adapter")
}
if !strings.Contains(err.Error(), "anthropic") {
t.Fatalf("the refusal does not list the known vendors: %v", err)
}
}
// Both shapes deliver their stored blob unchanged: a static key has no vendor step, and a
// refreshable grant's holder row holds an access token with no refresh token to strip.
func TestBothShapesDeliverTheStoredBlobUnchanged(t *testing.T) {
for _, vendor := range []string{"anthropic", "anthropic-api-key"} {
a, _ := For(vendor)
if got := a.Deliver("sealed-blob"); got != "sealed-blob" {
t.Fatalf("%s changed the delivered blob to %q", vendor, got)
}
}
}