carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// claim_invariant_world_test.go builds the console TestClaimConsoleMutationInvariant
4// runs against, and records every effect that reaches a raw writer.
5//
6// The console is built the way cmd/console builds it with CARLOS_HOST_CLAIM
7// on: OpenGated, then NewServer with HostClaim already true, then
8// ConfigureClaimRuntime. Nothing switches the flag after construction. The
9// raw bucket, the raw versioned store and the four raw backends handed to
10// Config, and the outbound mail HTTP transport, are recorders. The test sees what got past every
11// boundary the production constructor installs over them, and nothing else.
12//
13// Recorders sit BELOW the boundaries. A write the boundary refused never
14// reaches them, so a refusal can never be counted as an effect, and an
15// effect can never be hidden by a refusal elsewhere in the same request.
16
17import (
18 "context"
19 "crypto/ed25519"
20 "crypto/sha256"
21 "encoding/hex"
22 "encoding/json"
23 "errors"
24 "fmt"
25 "io"
26 "net/http"
27 "net/http/httptest"
28 "net/url"
29 "os"
30 "path/filepath"
31 "slices"
32 "sort"
33 "strings"
34 "sync"
35 "testing"
36 "time"
37
38 "github.com/carlosframework/platform/internal/dnsrec"
39 "github.com/carlosframework/platform/internal/hostactors"
40 "github.com/carlosframework/platform/internal/hostfloor"
41 "github.com/carlosframework/platform/internal/hostops"
42 "github.com/carlosframework/platform/internal/lease"
43 "github.com/carlosframework/platform/internal/lease/leasetest"
44 "github.com/carlosframework/platform/internal/mail"
45 "github.com/carlosframework/platform/internal/objstore"
46 "github.com/carlosframework/platform/internal/release"
47)
48
49const (
50 claimFxConsole = "console-a"
51 claimFxBucket = "bucket"
52 claimFxOwner = "paul@keymail.dev"
53 claimFxNamespace = "platform"
54 claimFxForeignOp = "claim-fixture-foreign"
55 claimFxRouterSHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
56 claimFxConsoleSHA = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
57)
58
59var claimFxConsoleEnrollment = strings.Repeat("c", 32)
60
61// claimAppRef is one app incarnation a door affects. Fresh marks a name that
62// has no incarnation before the request: the door creates it.
63type claimAppRef struct {
64 Account, App string
65 Fresh bool
66}
67
68func (r claimAppRef) String() string { return r.Account + "/" + r.App }
69
70func (r claimAppRef) scope() hostops.AppScope {
71 return hostops.AppScope{Account: r.Account, App: r.App}
72}
73
74// ---- the recorder -----------------------------------------------------------
75
76// claimRawEvent is one effect that reached a raw writer.
77type claimRawEvent struct {
78 Surface string // "bucket", "versioned:<bucket>", or a backend interface such as "objstore.Provisioner"
79 Method string
80 Key string
81 Scopes []hostops.AppScope // the production classifier's affected apps
82 Control bool // a canonical control object (hostops.IsControlOnlyKey)
83 NonApp string // the classifier's reviewed non-app reason
84 Err error // the classifier refuses this key: production should never have let it through
85 // Unscoped is a backend call or a data-bucket version write: no bucket
86 // classifier names its app, so it counts against every affected app.
87 Unscoped bool
88}
89
90func (e claimRawEvent) String() string {
91 return fmt.Sprintf("%s %s %s", e.Surface, e.Method, e.Key)
92}
93
94func (e claimRawEvent) touches(refs []claimAppRef) bool {
95 for _, s := range e.Scopes {
96 for _, r := range refs {
97 if s == r.scope() {
98 return true
99 }
100 }
101 }
102 return false
103}
104
105// admissionWrite reports a write to one of ref's canonical control objects.
106func (e claimRawEvent) admissionWrite(ref claimAppRef) bool {
107 return e.Control && e.Key == "apps/"+ref.Account+"/"+ref.App+"/admission.json"
108}
109
110type claimRecorder struct {
111 mu sync.Mutex
112 events []claimRawEvent
113 read release.Store // the raw bucket, read-only, for the classifier
114 // control is the production control writer the classifier verifies lifecycle
115 // roots against. It exists only once ConfigureClaimRuntime has run, so it
116 // is installed after construction (setControl), never nil afterwards.
117 control *hostops.ControlWriter
118}
119
120func (r *claimRecorder) setControl(w *hostops.ControlWriter) {
121 r.mu.Lock()
122 r.control = w
123 r.mu.Unlock()
124}
125
126func (r *claimRecorder) controlWriter() *hostops.ControlWriter {
127 r.mu.Lock()
128 defer r.mu.Unlock()
129 return r.control
130}
131
132func (r *claimRecorder) add(e claimRawEvent) {
133 r.mu.Lock()
134 r.events = append(r.events, e)
135 r.mu.Unlock()
136}
137
138func (r *claimRecorder) mark() int {
139 r.mu.Lock()
140 defer r.mu.Unlock()
141 return len(r.events)
142}
143
144func (r *claimRecorder) since(i int) []claimRawEvent {
145 r.mu.Lock()
146 defer r.mu.Unlock()
147 return append([]claimRawEvent(nil), r.events[i:]...)
148}
149
150// classifyBucket runs the production classifier over one raw bucket write,
151// BEFORE it lands, so owner-record families see the old owner too.
152func (r *claimRecorder) classifyBucket(ctx context.Context, surface, method, key, etag string, after []byte) claimRawEvent {
153 e := claimRawEvent{Surface: surface, Method: method, Key: key}
154 if hostops.IsControlOnlyKey(key) {
155 e.Control = true
156 if p := strings.Split(key, "/"); len(p) == 4 && p[0] == "apps" {
157 e.Scopes = []hostops.AppScope{{Account: p[1], App: p[2]}}
158 }
159 return e
160 }
161 ctx = context.WithoutCancel(ctx)
162 before, _, err := r.read.Get(ctx, key)
163 if err != nil {
164 before = nil
165 }
166 c, err := hostops.ClassifyMutation(ctx, r.read, r.controlWriter(), hostops.MutationTarget{Surface: "bucket", Method: method, Key: key, ETag: etag, Before: before, After: after})
167 e.Scopes, e.NonApp, e.Err = c.Scopes, c.NonApp, err
168 return e
169}
170
171// backend is the check the generated backend boundaries run before the raw
172// fake: it records the attempt and lets it through. These boundaries are the
173// RAW side here; the console installs its own wrappers above them.
174func (r *claimRecorder) backend(_ context.Context, t hostops.MutationTarget) error {
175 r.add(claimRawEvent{Surface: t.Surface, Method: t.Method, Key: t.Key, Unscoped: true})
176 return nil
177}
178
179// claimRawStore is the raw deployment bucket handed to Config.Releases.
180// Named private fields; it forwards the optional interfaces the directory
181// store really has and records each mutation that succeeds.
182type claimRawStore struct {
183 inner release.Store // deletableStore: Store plus DeleteIfMatch
184 dir *release.DirStore // the same directory, for the optional listers
185 rec *claimRecorder
186}
187
188var (
189 _ release.Store = (*claimRawStore)(nil)
190 _ release.ConditionalDeleter = (*claimRawStore)(nil)
191 _ release.Opener = (*claimRawStore)(nil)
192 _ release.ModLister = (*claimRawStore)(nil)
193 _ release.SizeLister = (*claimRawStore)(nil)
194 _ release.PageLister = (*claimRawStore)(nil)
195)
196
197func (s *claimRawStore) Get(ctx context.Context, key string) ([]byte, string, error) {
198 return s.inner.Get(ctx, key)
199}
200func (s *claimRawStore) List(ctx context.Context, prefix string) ([]string, error) {
201 return s.inner.List(ctx, prefix)
202}
203func (s *claimRawStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
204 return s.inner.ListDirs(ctx, prefix)
205}
206func (s *claimRawStore) Open(ctx context.Context, key string) (io.ReadCloser, string, error) {
207 return s.dir.Open(ctx, key)
208}
209func (s *claimRawStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
210 return s.dir.ListMod(ctx, prefix)
211}
212func (s *claimRawStore) ListSizes(ctx context.Context, prefix string) ([]release.KeySize, error) {
213 return s.dir.ListSizes(ctx, prefix)
214}
215func (s *claimRawStore) ListPage(ctx context.Context, prefix, startAfter string, limit int) ([]string, error) {
216 return s.dir.ListPage(ctx, prefix, startAfter, limit)
217}
218func (s *claimRawStore) Put(ctx context.Context, key string, body []byte, tag string) (string, error) {
219 e := s.rec.classifyBucket(ctx, "bucket", "Put", key, tag, body)
220 out, err := s.inner.Put(ctx, key, body, tag)
221 if err == nil {
222 s.rec.add(e)
223 }
224 return out, err
225}
226func (s *claimRawStore) Delete(ctx context.Context, key string) error {
227 e := s.rec.classifyBucket(ctx, "bucket", "Delete", key, "", nil)
228 err := s.inner.Delete(ctx, key)
229 if err == nil {
230 s.rec.add(e)
231 }
232 return err
233}
234func (s *claimRawStore) DeleteIfMatch(ctx context.Context, key, tag string) error {
235 e := s.rec.classifyBucket(ctx, "bucket", "DeleteIfMatch", key, tag, nil)
236 err := release.DeleteIfMatch(ctx, s.inner, key, tag)
237 if err == nil {
238 s.rec.add(e)
239 }
240 return err
241}
242
243// claimRawVersions is the raw exact-version store handed to Config.DBVersions.
244type claimRawVersions struct {
245 inner *leasetest.VersionedBucket
246 rec *claimRecorder
247}
248
249var (
250 _ lease.VersionedStore = (*claimRawVersions)(nil)
251 _ lease.VersionRangeOpener = (*claimRawVersions)(nil)
252)
253
254func (v *claimRawVersions) ListVersions(ctx context.Context, bucket, prefix string) ([]lease.ObjectVersion, error) {
255 return v.inner.ListVersions(ctx, bucket, prefix)
256}
257func (v *claimRawVersions) OpenVersion(ctx context.Context, bucket, key, versionID string) (io.ReadCloser, error) {
258 return v.inner.OpenVersion(ctx, bucket, key, versionID)
259}
260func (v *claimRawVersions) GetVersion(ctx context.Context, bucket, key, versionID string) ([]byte, error) {
261 return v.inner.GetVersion(ctx, bucket, key, versionID)
262}
263func (v *claimRawVersions) OpenVersionRange(ctx context.Context, bucket, key, versionID string, offset, length int64) (io.ReadCloser, error) {
264 return v.inner.OpenVersionRange(ctx, bucket, key, versionID, offset, length)
265}
266func (v *claimRawVersions) event(ctx context.Context, bucket, method, key string, body []byte) claimRawEvent {
267 if bucket == claimFxBucket {
268 return v.rec.classifyBucket(ctx, "versioned:"+bucket, method, key, "", body)
269 }
270 // A data bucket has no bucket classifier: the effect is unscoped, and an
271 // unscoped effect counts against every affected app (strict).
272 return claimRawEvent{Surface: "versioned:" + bucket, Method: method, Key: key, Unscoped: true}
273}
274func (v *claimRawVersions) CopyVersion(ctx context.Context, srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, createOnly bool) (string, string, error) {
275 e := v.event(ctx, dstBucket, "CopyVersion", dstKey, nil)
276 id, tag, err := v.inner.CopyVersion(ctx, srcBucket, srcKey, srcVersionID, dstBucket, dstKey, createOnly)
277 if err == nil {
278 v.rec.add(e)
279 }
280 return id, tag, err
281}
282func (v *claimRawVersions) DeleteVersion(ctx context.Context, bucket, key, versionID string) error {
283 e := v.event(ctx, bucket, "DeleteVersion", key, nil)
284 err := v.inner.DeleteVersion(ctx, bucket, key, versionID)
285 if err == nil {
286 v.rec.add(e)
287 }
288 return err
289}
290func (v *claimRawVersions) PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (string, error) {
291 e := v.event(ctx, bucket, "PutIfAbsent", key, body)
292 id, err := v.inner.PutIfAbsent(ctx, bucket, key, body)
293 if err == nil {
294 v.rec.add(e)
295 }
296 return id, err
297}
298
299// ---- the host-actor world ---------------------------------------------------
300
301// claimFixtureEvidence answers the census the way a healthy fleet would: one
302// router per roster box and this console, all on the published system
303// release. Identity and termination fences are the real ExactEvidence reads.
304// It replaces only fleet REPORTS; admission, permits and every boundary stay
305// the production ones.
306type claimFixtureEvidence struct {
307 exact hostactors.ExactEvidence
308 floor string
309}
310
311var _ hostactors.Evidence = claimFixtureEvidence{}
312
313func (e claimFixtureEvidence) report(component, actor, enrollment, instance, sha string) hostactors.Report {
314 return hostactors.Report{Component: component, Actor: actor, Enrollment: enrollment, InstanceID: instance,
315 ProcessID: "claim-fixture-process", GeneratedAt: time.Now().UTC(), Release: "v1.0.0", ReleaseTag: "v1.0.0",
316 Platform: "linux-amd64", BinarySHA256: sha, MovesProtocol: 1, BootID: "claim-fixture-boot",
317 FloorSHA256: e.floor, FloorGuardReady: true, HostClaimEnabled: true}
318}
319func (e claimFixtureEvidence) BoxReport(_ context.Context, id string, b hostactors.Box) (hostactors.Report, error) {
320 return e.report("router", id, b.Enrollment, b.Cloud.InstanceID, claimFxRouterSHA), nil
321}
322func (e claimFixtureEvidence) ConsoleReport(_ context.Context, id string, c hostactors.Console) (hostactors.Report, error) {
323 return e.report("console", id, c.Enrollment, c.InstanceID, claimFxConsoleSHA), nil
324}
325func (e claimFixtureEvidence) LocalConsole(context.Context) (hostactors.Report, error) {
326 return e.report("console", claimFxConsole, claimFxConsoleEnrollment, claimFxConsole, claimFxConsoleSHA), nil
327}
328func (e claimFixtureEvidence) Identity(ctx context.Context, tag string) ([]byte, error) {
329 return e.exact.Identity(ctx, tag)
330}
331func (e claimFixtureEvidence) Fence(ctx context.Context, o hostactors.Observed, ref string, now time.Time) (bool, error) {
332 return e.exact.Fence(ctx, o, ref, now)
333}
334
335// claimActorWorld publishes the system release, the floor and a roster with
336// this console and one active box per pool in region "ie". It writes the raw
337// bucket before the console exists. It returns the floor digest.
338func claimActorWorld(t *testing.T, store release.Store, key ed25519.PrivateKey, pub ed25519.PublicKey) string {
339 t.Helper()
340 ctx := context.Background()
341 sg := release.RootSigner(key)
342 sys := release.SystemRelease{Version: "v1.0.0", Source: "release:v1.0.0", ReleaseTag: "v1.0.0", MovesProtocol: 1,
343 Artifacts: map[string]release.SystemArtifact{"linux-amd64": {
344 Key: release.SystemArtifactKey("v1.0.0", "linux-amd64"), SHA256: claimFxRouterSHA}},
345 ConsoleArtifacts: map[string]release.SystemArtifact{"linux-amd64": {
346 Key: "bootstrap/console/v1.0.0/carlos-linux-amd64", SHA256: claimFxConsoleSHA}}}
347 if _, err := release.PromoteSystem(ctx, store, sg, release.SystemComponentRouter, "stable", sys, release.SystemPromoteOptions{}); err != nil {
348 t.Fatalf("actor world: promote system release: %v", err)
349 }
350 pointer, _, err := store.Get(ctx, release.SystemPointerKey(release.SystemComponentRouter, "stable"))
351 if err != nil {
352 t.Fatal(err)
353 }
354 if err := release.PublishSystemIdentity(ctx, store, pub, pointer); err != nil {
355 t.Fatalf("actor world: publish identity: %v", err)
356 }
357 if err := hostfloor.Publish(ctx, store, sg, claimFxBucket, hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}); err != nil {
358 t.Fatalf("actor world: publish floor: %v", err)
359 }
360 floor, err := hostfloor.Read(ctx, store, pub, claimFxBucket)
361 if err != nil {
362 t.Fatal(err)
363 }
364 box := func(id, pool, enrollment, instance string) hostactors.Box {
365 return hostactors.Box{BoxID: id, Namespace: claimFxNamespace, Label: id, Region: "ie", Pool: pool,
366 Cloud: hostactors.Cloud{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: instance},
367 Enrollment: enrollment, EvidenceSHA256: strings.Repeat("e", 64), State: "active"}
368 }
369 roster := hostactors.Roster{V: 1, DeploymentBucket: claimFxBucket, Revision: 1, Publication: "published", FloorSHA256: floor.Digest,
370 Regions: map[string]hostactors.Region{"ie": {Namespaces: []string{claimFxNamespace}, Pools: []string{"default", "tito"},
371 Account: "123456789012", AWSRegion: "eu-west-1"}},
372 Boxes: map[string]hostactors.Box{
373 "box-ie-default": box("box-ie-default", "default", strings.Repeat("b", 32), "i-claim-default"),
374 "box-ie-tito": box("box-ie-tito", "tito", strings.Repeat("d", 32), "i-claim-tito"),
375 },
376 Consoles: map[string]hostactors.Console{claimFxConsole: {Enrollment: claimFxConsoleEnrollment, InstanceID: claimFxConsole,
377 Namespaces: []string{claimFxNamespace}, DeploymentBucket: claimFxBucket}},
378 Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
379 envelope, err := release.SignRootEnvelope(sg, roster)
380 if err != nil {
381 t.Fatal(err)
382 }
383 if _, err := store.Put(ctx, hostactors.Key, envelope, ""); err != nil {
384 t.Fatal(err)
385 }
386 return floor.Digest
387}
388
389// ---- the case ---------------------------------------------------------------
390
391// claimCase is one enabled console and everything a fixture needs to drive
392// it. It embeds the ordinary consoleFixture so the existing helpers
393// (signUp, cliToken, postGated, completeStepUp, declareEmail, grantStore,
394// enableInstances, ...) work unchanged. fx.rel is the RAW bucket: helpers
395// that seed through it stage the world outside every measured window.
396type claimCase struct {
397 *consoleFixture
398 rec *claimRecorder
399 rawStore *claimRawStore
400 rawVersions *claimRawVersions
401 versions *leasetest.VersionedBucket
402 storeFake *objstore.MemProvisioner
403 scanFake *objstore.MemScanner
404 mailFake *mail.MemProvisioner
405 dnsFake *dnsrec.MemWriter
406 rawProv objstore.Provisioner
407 rawScanner objstore.Scanner
408 rawMail mail.Provisioner
409 rawDNS dnsrec.Writer
410 secretsPriv string
411 browser *http.Client
412 csrf string
413 bearer string
414 vars map[string]string
415 appIDs map[claimAppRef]string
416 sessions int
417 // hits records which mux pattern each request through the listener
418 // matched, so a probe proves its request reached the entry it covers
419 // (and not another route, a 404 or the front page).
420 hits *claimHits
421}
422
423// claimHits is the list of mux patterns the listener's requests matched, in
424// arrival order.
425type claimHits struct {
426 mu sync.Mutex
427 seen []string
428}
429
430func (h *claimHits) add(pattern string) {
431 h.mu.Lock()
432 h.seen = append(h.seen, pattern)
433 h.mu.Unlock()
434}
435
436func (h *claimHits) mark() int {
437 h.mu.Lock()
438 defer h.mu.Unlock()
439 return len(h.seen)
440}
441
442func (h *claimHits) since(i int) []string {
443 h.mu.Lock()
444 defer h.mu.Unlock()
445 return append([]string(nil), h.seen[i:]...)
446}
447
448// newClaimCase builds the enabled console. Config.HostClaim is true at
449// NewServer; ConfigureClaimRuntime runs exactly as cmd/console runs it.
450func newClaimCase(t *testing.T) *claimCase {
451 return newClaimCaseWith(t)
452}
453
454// newClaimCaseWith is newClaimCase with the Config edited before NewServer,
455// for entries that exist only behind a switch (CARLOS_MOVES_PHASE1).
456func newClaimCaseWith(t *testing.T, opts ...func(*Config)) *claimCase {
457 t.Helper()
458 ctx := context.Background()
459 regPath, inner, priv, pub := buildFixtures(t)
460 deletable, ok := inner.(*deletableStore)
461 if !ok {
462 t.Fatalf("buildFixtures returned %T, want *deletableStore", inner)
463 }
464 dir, ok := deletable.Store.(*release.DirStore)
465 if !ok {
466 t.Fatalf("buildFixtures' bucket is %T, want *release.DirStore", deletable.Store)
467 }
468 floor := claimActorWorld(t, inner, priv, pub)
469
470 rec := &claimRecorder{read: inner}
471 rawStore := &claimRawStore{inner: inner, dir: dir, rec: rec}
472 versions := leasetest.NewVersionedBucket()
473 versions.AddBucket(claimFxBucket)
474 rawVersions := &claimRawVersions{inner: versions, rec: rec}
475
476 storeFake := objstore.NewMemProvisioner("eu-west-1")
477 scanFake := objstore.NewMemScanner()
478 mailFake := mail.NewMemProvisioner("1")
479 dnsFake := dnsrec.NewMemWriter()
480 rawProv, err := NewStoreProvisionerBoundary(storeFake, rec.backend)
481 if err != nil {
482 t.Fatal(err)
483 }
484 rawScanner, err := NewScannerBoundary(scanFake, rec.backend)
485 if err != nil {
486 t.Fatal(err)
487 }
488 rawMail, err := NewMailProvisionerBoundary(mailFake, rec.backend)
489 if err != nil {
490 t.Fatal(err)
491 }
492 rawDNS, err := NewDNSWriterBoundary(dnsFake, rec.backend)
493 if err != nil {
494 t.Fatal(err)
495 }
496 secretsPub, secretsPriv, _ := sealedFor(t, "irrelevant")
497
498 st, err := OpenGated(filepath.Join(t.TempDir(), "console.db"))
499 if err != nil {
500 t.Fatalf("OpenGated: %v", err)
501 }
502 t.Cleanup(func() { st.Close() })
503 fk := newFakeKeymail(t, claimFxOwner)
504 var srv *Server
505 hits := &claimHits{}
506 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
507 _, pattern := srv.mux.Handler(r)
508 hits.add(pattern)
509 srv.ServeHTTP(w, r)
510 }))
511 t.Cleanup(ts.Close)
512 operators := []string{claimFxOwner}
513 cfg := Config{
514 HostClaim: true,
515 HostConsoleID: claimFxConsole,
516 Bucket: claimFxBucket,
517 DBVersions: rawVersions,
518 Origin: ts.URL,
519 Keymail: fk.srv.URL,
520 Registry: regPath,
521 Releases: rawStore,
522 ReleasePub: pub,
523 Key: priv,
524 AppScheme: "http",
525 AppsDomain: "oncarlos.com",
526 PlatformDomain: "oncarlos.com",
527 SecretsPub: secretsPub,
528 TemporaryApps: true,
529 StoreProvisioner: rawProv,
530 StoreScanner: rawScanner,
531 CallbackSigner: callbackTestSigner(t, "claim-fixture-webhook-master", ""),
532 CallbackTransport: claimOKTransport{rec: rec},
533 StoreDeployment: objstore.Deployment{Backend: "mem", Region: "eu-west-1", BucketPrefix: "carlos-"},
534 MailProvisioner: rawMail,
535 DNSWriter: rawDNS,
536 MailDeployment: mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.com",
537 SNSTopicARN: "arn:aws:sns:eu-west-1:1:carlos-mail"},
538 RelayRegions: []string{"eu-west-1"},
539 StoreOperators: operators,
540 MailOperators: operators,
541 RelayOperators: operators,
542 SystemOperators: operators,
543 LookupTXT: func(context.Context, string) ([]string, error) {
544 return nil, errors.New("claim fixture: no DNS")
545 },
546 }
547 for _, opt := range opts {
548 opt(&cfg)
549 }
550 if home := cfg.dbClaimHomeRegion(); home != "" {
551 d := hostactors.Directory{Store: rawStore, PublicKey: pub, Bucket: claimFxBucket}
552 o, e := d.Read(ctx)
553 if e != nil {
554 t.Fatal(e)
555 }
556 r := o.Roster
557 r.HomeRegion = home
558 if _, e = d.Replace(ctx, release.RootSigner(priv), o, r); e != nil {
559 t.Fatal(e)
560 }
561 }
562 srv, err = NewServer(cfg, st)
563 if err != nil {
564 t.Fatalf("NewServer with HostClaim: %v", err)
565 }
566 if err := srv.ConfigureClaimRuntime(ctx); err != nil {
567 t.Fatalf("ConfigureClaimRuntime: %v", err)
568 }
569 if srv.cfg.ClaimAuthority == nil {
570 t.Fatal("ConfigureClaimRuntime installed no claim authority")
571 }
572 if srv.cfg.ClaimAuthority.Control == nil {
573 t.Fatal("ConfigureClaimRuntime installed no control writer")
574 }
575 rec.setControl(srv.cfg.ClaimAuthority.Control)
576 srv.cfg.ClaimAuthority.Evidence = claimFixtureEvidence{
577 exact: hostactors.ExactEvidence{PublicKey: pub, Store: inner}, floor: floor}
578 srv.instances.timeout = 100 * time.Millisecond
579 srv.lookupCNAME = func(context.Context, string) (string, error) {
580 return "", errors.New("claim fixture: no DNS")
581 }
582 srv.storeListerFor = func(context.Context, *objstore.Provisioned) (objstore.ObjectLister, error) {
583 return claimOneObjectLister{}, nil
584 }
585 if err := st.AddMember(OpsAccountID, claimFxOwner, RoleOwner, time.Now()); err != nil {
586 t.Fatal(err)
587 }
588 if err := st.SetOperator(claimFxOwner, true); err != nil {
589 t.Fatal(err)
590 }
591 cx := &claimCase{
592 consoleFixture: &consoleFixture{ts: ts, srv: srv, st: st, fk: fk, rel: inner, regPath: regPath,
593 priv: priv, pub: pub, sqid: release.OpsAccount},
594 rec: rec, rawStore: rawStore, rawVersions: rawVersions, versions: versions,
595 storeFake: storeFake, scanFake: scanFake, mailFake: mailFake, dnsFake: dnsFake,
596 rawProv: rawProv, rawScanner: rawScanner, rawMail: rawMail, rawDNS: rawDNS,
597 secretsPriv: secretsPriv, vars: map[string]string{}, appIDs: map[claimAppRef]string{},
598 hits: hits,
599 }
600 cx.enroll(t, release.OpsAccount, "kass")
601 cx.enroll(t, release.OpsAccount, "titogo")
602 cx.browser = newBrowser(t)
603 signUp(t, cx.consoleFixture, cx.browser, "code-claim-"+claimSafeName(t.Name()))
604 cx.csrf = sessionToken(t, cx.consoleFixture, cx.browser)
605 cx.bearer = mintUserToken(t, st, claimFxOwner, time.Now())
606 cx.quiesce()
607 return cx
608}
609
610func claimSafeName(s string) string {
611 return strings.Map(func(r rune) rune {
612 if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
613 return r
614 }
615 return '-'
616 }, s)
617}
618
619// claimOneObjectLister lists one plaintext object, so a sweep reaches the
620// scanner's Scan rather than finishing on an empty bucket.
621type claimOneObjectLister struct{}
622
623func (claimOneObjectLister) ListPage(_ context.Context, _, startAfter string, _ int32) ([]objstore.ScanObject, bool, error) {
624 if startAfter != "" {
625 return nil, false, nil
626 }
627 return []objstore.ScanObject{{Key: "claim-fixture.txt", ETag: `"claim-fixture"`, Size: 3, Modified: time.Now().Add(-time.Hour)}}, false, nil
628}
629
630// enroll stages an existing, open incarnation: the console's row bound to
631// an app_id (through the test-only seed scope, the SQL gate's own fixture
632// door) and the canonical admission object in the raw bucket.
633func (cx *claimCase) enroll(t *testing.T, account, app string) string {
634 t.Helper()
635 acct, err := cx.st.AccountBySqid(account)
636 if err != nil || acct == nil {
637 t.Fatalf("enroll %s/%s: account %+v %v", account, app, acct, err)
638 }
639 appID := "claim-" + account + "-" + app
640 scope := []claimSQLScope{{AccountID: acct.ID, App: app, AppID: appID, Mode: claimModeEffect}}
641 err = cx.st.withClaimSQL(gateCtx(t), scope, allowAllClaimSQL, func(v *Store) error {
642 if _, err := v.CreateAppIn(acct.ID, app, claimFxOwner, time.Now()); err != nil {
643 return err
644 }
645 return v.BindClaimAppIdentity(acct.ID, app, appID)
646 })
647 if err != nil {
648 t.Fatalf("enroll %s/%s: %v", account, app, err)
649 }
650 cx.putAdmission(t, account, app, hostops.AppAdmission{V: 1, AppID: appID, Revision: 1, Epoch: 1, State: "open",
651 Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}})
652 cx.appIDs[claimAppRef{Account: account, App: app}] = appID
653 return appID
654}
655
656func admissionKeyOf(account, app string) string {
657 return "apps/" + account + "/" + app + "/admission.json"
658}
659
660// putAdmission writes the canonical admission object through the RAW bucket:
661// world staging, never a door under test.
662func (cx *claimCase) putAdmission(t *testing.T, account, app string, a hostops.AppAdmission) {
663 t.Helper()
664 ctx := context.Background()
665 key := admissionKeyOf(account, app)
666 _, tag, err := cx.rel.Get(ctx, key)
667 if errors.Is(err, os.ErrNotExist) {
668 tag = ""
669 } else if err != nil {
670 t.Fatal(err)
671 }
672 body, err := json.Marshal(a)
673 if err != nil {
674 t.Fatal(err)
675 }
676 if _, err := cx.rel.Put(ctx, key, body, tag); err != nil {
677 t.Fatalf("stage admission %s: %v", key, err)
678 }
679}
680
681// foreign closes ref under an exclusive operation this console does not own,
682// and returns the function that puts the observed admission back. A fresh
683// name gets a predecessor incarnation closed under that operation.
684func (cx *claimCase) foreign(t *testing.T, ref claimAppRef) func() {
685 t.Helper()
686 ctx := context.Background()
687 key := admissionKeyOf(ref.Account, ref.App)
688 body, _, err := cx.rel.Get(ctx, key)
689 if errors.Is(err, os.ErrNotExist) {
690 appID := "claim-foreign-" + ref.Account + "-" + ref.App
691 cx.putAdmission(t, ref.Account, ref.App, hostops.AppAdmission{V: 1, AppID: appID, Revision: 1, Epoch: 1, State: "closed",
692 Op: &hostops.AppOperation{ID: claimFxForeignOp, Kind: hostops.OpAppMutation, AppID: appID, Phase: "issued", Intent: map[string]string{"action": claimFxForeignOp}},
693 Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}})
694 return func() {
695 if err := cx.rel.Delete(ctx, key); err != nil {
696 t.Fatalf("remove the foreign predecessor of %s: %v", ref, err)
697 }
698 }
699 }
700 if err != nil {
701 t.Fatal(err)
702 }
703 var original hostops.AppAdmission
704 if err := json.Unmarshal(body, &original); err != nil {
705 t.Fatalf("admission %s: %v", key, err)
706 }
707 closed := original
708 closed.Revision++
709 closed.State = "closed"
710 closed.Op = &hostops.AppOperation{ID: claimFxForeignOp, Kind: hostops.OpAppMutation, AppID: original.AppID, Phase: "issued",
711 Intent: map[string]string{"action": claimFxForeignOp}}
712 closed.Registrations = map[string]hostops.Registration{}
713 cx.putAdmission(t, ref.Account, ref.App, closed)
714 return func() {
715 back := original
716 back.Revision = closed.Revision + 1
717 cx.putAdmission(t, ref.Account, ref.App, back)
718 }
719}
720
721// quiesce waits until no raw effect has arrived for two consecutive 100ms
722// windows (at most 5s), so a goroutine a handler started is measured with its
723// request. Two windows, not one: the invariant runs its doors in parallel
724// (claimCaseParallelism), and a goroutine starved of CPU for one window must
725// still land in its own request's measurement, not the next one's.
726func (cx *claimCase) quiesce() {
727 const window = 100 * time.Millisecond
728 deadline := time.Now().Add(5 * time.Second)
729 n, quiet := cx.rec.mark(), 0
730 for time.Now().Before(deadline) {
731 time.Sleep(window)
732 m := cx.rec.mark()
733 if m != n {
734 n, quiet = m, 0
735 continue
736 }
737 if quiet++; quiet == 2 {
738 return
739 }
740 }
741}
742
743// ---- SQL digests ------------------------------------------------------------
744
745// sqlDigest is one hash per gated table over ref's rows, read through the
746// gated handle (reads need no permit). Only tables the census classified as
747// app-scoped are read: they are exactly the tables the triggers guard.
748func (cx *claimCase) sqlDigest(t *testing.T, ref claimAppRef) map[string]string {
749 t.Helper()
750 out := map[string]string{}
751 acct, err := cx.st.AccountBySqid(ref.Account)
752 if err != nil {
753 t.Fatal(err)
754 }
755 if acct == nil {
756 return out
757 }
758 names := make([]string, 0, len(claimSQLTables))
759 for name := range claimSQLTables {
760 names = append(names, name)
761 }
762 sort.Strings(names)
763 for _, name := range names {
764 tb := claimSQLTables[name]
765 if tb.NonApp != "" {
766 continue
767 }
768 where := []string{tb.col("t", tb.App) + " = ?"}
769 args := []any{ref.App}
770 if tb.Account != "" {
771 where = append(where, tb.col("t", tb.Account)+" = ?")
772 args = append(args, acct.ID)
773 }
774 if tb.Sqid != "" {
775 where = append(where, tb.col("t", tb.Sqid)+" = ?")
776 args = append(args, ref.Account)
777 }
778 rows, err := cx.st.db.QueryContext(context.Background(), `SELECT * FROM `+claimQ(name)+` t WHERE `+strings.Join(where, " AND "), args...)
779 if err != nil {
780 if strings.Contains(err.Error(), "no such table") {
781 continue
782 }
783 t.Fatalf("digest %s: %v", name, err)
784 }
785 cols, err := rows.Columns()
786 if err != nil {
787 rows.Close()
788 t.Fatal(err)
789 }
790 var lines []string
791 for rows.Next() {
792 vals := make([]any, len(cols))
793 ptrs := make([]any, len(cols))
794 for i := range vals {
795 ptrs[i] = &vals[i]
796 }
797 if err := rows.Scan(ptrs...); err != nil {
798 rows.Close()
799 t.Fatal(err)
800 }
801 parts := make([]string, len(vals))
802 for i, v := range vals {
803 if b, ok := v.([]byte); ok {
804 v = string(b)
805 }
806 parts[i] = fmt.Sprintf("%v", v)
807 }
808 lines = append(lines, strings.Join(parts, "\x1f"))
809 }
810 err = rows.Err()
811 rows.Close()
812 if err != nil {
813 t.Fatal(err)
814 }
815 sort.Strings(lines)
816 sum := sha256.Sum256([]byte(strings.Join(lines, "\x1e")))
817 out[name] = hex.EncodeToString(sum[:])
818 }
819 return out
820}
821
822func claimChangedTables(before, after map[string]string) []string {
823 var out []string
824 for name, a := range after {
825 if before[name] != a {
826 out = append(out, name)
827 }
828 }
829 for name := range before {
830 if _, ok := after[name]; !ok {
831 out = append(out, name)
832 }
833 }
834 sort.Strings(out)
835 return slices.Compact(out)
836}
837
838// ---- requests ---------------------------------------------------------------
839
840// api sends one authenticated CLI request as the owner and returns its status.
841func (cx *claimCase) api(t *testing.T, method, path, body string) int {
842 t.Helper()
843 status, _, _ := rawReq(t, method, cx.ts.URL+path, cx.bearer, body)
844 return status
845}
846
847// mustAPI is api for staging: anything but a wanted status stops the case.
848func (cx *claimCase) mustAPI(t *testing.T, method, path, body string, want ...int) string {
849 t.Helper()
850 status, _, out := rawReq(t, method, cx.ts.URL+path, cx.bearer, body)
851 if len(want) == 0 {
852 want = []int{http.StatusOK, http.StatusCreated, http.StatusAccepted}
853 }
854 if !slices.Contains(want, status) {
855 t.Fatalf("stage %s %s = %d %s", method, path, status, out)
856 }
857 return out
858}
859
860// form posts one browser form as the signed-in owner, with the session's
861// CSRF token, and walks the passkey step-up when the door asks for one.
862func (cx *claimCase) form(t *testing.T, path string, kv ...string) int {
863 t.Helper()
864 v := url.Values{"csrf": {cx.csrf}}
865 for i := 0; i+1 < len(kv); i += 2 {
866 v.Add(kv[i], kv[i+1])
867 }
868 status, _, _ := postGated(t, cx.consoleFixture, cx.browser, cx.ts.URL+path, v)
869 return status
870}
871
872func (cx *claimCase) mustForm(t *testing.T, path string, kv ...string) {
873 t.Helper()
874 if status := cx.form(t, path, kv...); status == 0 || status >= 400 {
875 t.Fatalf("stage form %s = %d", path, status)
876 }
877}
878
879// jsonField reads one string field out of a staging reply.
880func claimReplyField(t *testing.T, body, field string) string {
881 t.Helper()
882 var m map[string]any
883 if err := json.Unmarshal([]byte(body), &m); err != nil {
884 t.Fatalf("reply %q: %v", body, err)
885 }
886 v, _ := m[field].(string)
887 if v == "" {
888 t.Fatalf("reply %q has no %s", body, field)
889 }
890 return v
891}
892