package historyreadback import ( "context" "crypto/ed25519" "errors" "os" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/release" ) type fixture struct { directory hostactors.Directory signer release.Signer key ed25519.PrivateKey scope Scope now time.Time status release.HistoryMigrationStatus } func newFixture(t *testing.T) fixture { t.Helper() pub, key, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } _, boxKey, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } f := fixture{directory: hostactors.Directory{Store: release.NewDirStore(t.TempDir()), PublicKey: pub, Bucket: "bucket"}, signer: release.RootSigner(key), key: boxKey, now: time.Now().UTC()} f.scope = Scope{Bucket: "bucket", Box: "box", Enrollment: strings.Repeat("a", 32), Binding: release.HistoryBinding{Account: "abc", App: "web", Host: "web.example"}} roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Boxes: map[string]hostactors.Box{"box": {Enrollment: f.scope.Enrollment, Namespace: "platform", BoxID: "box", Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", CensusPublicKey: boxKey.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} wire, err := release.SignRootEnvelope(f.signer, roster) if err != nil { t.Fatal(err) } if err := publish(t.Context(), f.directory.Store, hostactors.Key, wire); err != nil { t.Fatal(err) } f.status = release.HistoryMigrationStatus{Schema: 1, Account: "abc", App: "web", HistoryReconstructionStatus: release.HistoryReconstructionStatus{Identity: strings.Repeat("c", 64), Revision: 1}, Hosts: []release.HistoryHostStatus{{HistoryReconstructionHost: release.HistoryReconstructionHost{Host: "web.example", Source: "canonical"}}}} return f } func (f fixture) snapshot(context.Context) (release.HistoryMigrationStatus, error) { return f.status, nil } func TestPlan14HistoryReadback(t *testing.T) { f := newFixture(t) q, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now) if err != nil { t.Fatal(err) } if _, err := ReadStatus(t.Context(), f.directory, q, f.now); !errors.Is(err, os.ErrNotExist) { t.Fatalf("delivery treated as completion: %v", err) } if err := Respond(t.Context(), f.directory, f.scope, f.key, f.now, f.snapshot); err != nil { t.Fatal(err) } got, err := ReadStatus(t.Context(), f.directory, q, f.now) if err != nil { t.Fatal(err) } if got.Status.Identity != f.status.Identity || got.Status.AppComplete || got.Status.Hosts[0].Programs.Complete || got.Status.RecoverySources != nil { t.Fatalf("readback invented completeness: %+v", got) } next, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now) if err != nil { t.Fatal(err) } if next.ID == q.ID { t.Fatal("challenge reused") } old, _, err := f.directory.Store.Get(t.Context(), ResponseKey(q)) if err != nil { t.Fatal(err) } if err := publish(t.Context(), f.directory.Store, ResponseKey(next), old); err != nil { t.Fatal(err) } if _, err := ReadStatus(t.Context(), f.directory, next, f.now); err == nil { t.Fatal("old signed status answered new challenge") } if err := Respond(t.Context(), f.directory, f.scope, f.key, f.now, f.snapshot); err != nil { t.Fatal(err) } if _, err := ReadStatus(t.Context(), f.directory, next, f.now); err != nil { t.Fatal(err) } } func TestPlan14HistoryReadbackAuthority(t *testing.T) { for _, fault := range []string{"request signature", "request kind", "request bucket", "request box", "request enrollment", "request host", "request account", "request app", "request expired", "request future", "response signature", "response kind", "response digest", "response future", "response predates", "response account", "response host", "key", "snapshot", "source", "namespace", "roster changed", "fence", "oversize"} { t.Run(fault, func(t *testing.T) { f := newFixture(t) q, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now) if err != nil { t.Fatal(err) } if strings.HasPrefix(fault, "request ") { bad := q signer := f.signer switch fault { case "request signature": _, key, _ := ed25519.GenerateKey(nil) signer = release.RootSigner(key) case "request kind": bad.Kind = "other" case "request bucket": bad.Scope.Bucket = "other" case "request box": bad.Scope.Box = "other" case "request enrollment": bad.Scope.Enrollment = strings.Repeat("d", 32) case "request host": bad.Scope.Binding.Host = "other.example" case "request account": bad.Scope.Binding.Account = "xyz" case "request app": bad.Scope.Binding.App = "other" case "request expired": bad.IssuedAt = f.now.Add(-25 * time.Hour) case "request future": bad.IssuedAt = f.now.Add(2 * time.Minute) } wire, err := release.SignRootEnvelope(signer, bad) if err != nil { t.Fatal(err) } if err := publish(t.Context(), f.directory.Store, RequestKey(f.scope), wire); err != nil { t.Fatal(err) } } calls := 0 snapshot := func(ctx context.Context) (release.HistoryMigrationStatus, error) { calls++ switch fault { case "snapshot": return release.HistoryMigrationStatus{}, os.ErrPermission case "source": f.status.Hosts[0].Source = "" case "namespace": f.status.Identity = "" case "oversize": f.status.Hosts[0].Source = strings.Repeat("x", release.MaxRootEnvelopeBytes) case "roster changed": observed, err := f.directory.Read(ctx) if err != nil { t.Fatal(err) } observed.Roster.Revision++ wire, err := release.SignRootEnvelope(f.signer, observed.Roster) if err != nil { t.Fatal(err) } if err := publish(ctx, f.directory.Store, hostactors.Key, wire); err != nil { t.Fatal(err) } case "fence": if err := publish(ctx, f.directory.Store, "control/box-fences/"+f.scope.Enrollment+".json", []byte("{}")); err != nil { t.Fatal(err) } } return f.status, nil } key := f.key if fault == "key" { _, key, _ = ed25519.GenerateKey(nil) } err = Respond(t.Context(), f.directory, f.scope, key, f.now, snapshot) if strings.HasPrefix(fault, "response ") { if err != nil { t.Fatal(err) } wire, _, err := f.directory.Store.Get(t.Context(), ResponseKey(q)) if err != nil { t.Fatal(err) } var receipt Receipt if err := release.VerifyRootEnvelope(f.key.Public().(ed25519.PublicKey), wire, &receipt); err != nil { t.Fatal(err) } key := f.key switch fault { case "response signature": _, key, _ = ed25519.GenerateKey(nil) case "response kind": receipt.Kind = "other" case "response digest": receipt.RequestDigest = strings.Repeat("e", 64) case "response future": receipt.ObservedAt = f.now.Add(2 * time.Minute) case "response predates": receipt.ObservedAt = q.IssuedAt.Add(-2 * time.Minute) case "response account": receipt.Status.Account = "xyz" case "response host": receipt.Status.Hosts[0].Host = "other.example" } wire, err = release.SignRootEnvelope(release.RootSigner(key), receipt) if err != nil { t.Fatal(err) } if err := publish(t.Context(), f.directory.Store, ResponseKey(q), wire); err != nil { t.Fatal(err) } if _, err := ReadStatus(t.Context(), f.directory, q, f.now); err == nil { t.Fatal("forged response accepted") } } else { if err == nil { t.Fatal("invalid readback published") } if _, _, err := f.directory.Store.Get(t.Context(), ResponseKey(q)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("refused readback left response: %v", err) } if strings.HasPrefix(fault, "request ") && calls != 0 { t.Fatal("invalid request reached private snapshot") } } }) } } func TestPlan14HistoryReadbackReenrollment(t *testing.T) { f := newFixture(t) q, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now) if err != nil { t.Fatal(err) } if err := Respond(t.Context(), f.directory, f.scope, f.key, f.now, f.snapshot); err != nil { t.Fatal(err) } observed, err := f.directory.Read(t.Context()) if err != nil { t.Fatal(err) } row := observed.Roster.Boxes[f.scope.Box] row.Enrollment = strings.Repeat("d", 32) observed.Roster.Boxes[f.scope.Box] = row wire, err := release.SignRootEnvelope(f.signer, observed.Roster) if err != nil { t.Fatal(err) } if err := publish(t.Context(), f.directory.Store, hostactors.Key, wire); err != nil { t.Fatal(err) } if _, err := ReadStatus(t.Context(), f.directory, q, f.now); err == nil { t.Fatal("retired enrollment accepted") } } func TestPlan14HistoryReadbackRequestRefusal(t *testing.T) { for _, fault := range []string{"wrong signer", "missing key", "quarantined", "no store", "bad scope", "clock"} { t.Run(fault, func(t *testing.T) { f := newFixture(t) switch fault { case "wrong signer": _, key, _ := ed25519.GenerateKey(nil) f.signer = release.RootSigner(key) case "missing key", "quarantined": observed, err := f.directory.Read(t.Context()) if err != nil { t.Fatal(err) } row := observed.Roster.Boxes[f.scope.Box] if fault == "missing key" { row.CensusPublicKey = nil } else { row.State = "quarantined" row.QuarantineRevision = 1 row.LossIDs = []string{"loss"} } observed.Roster.Boxes[f.scope.Box] = row raw, err := release.SignRootEnvelope(f.signer, observed.Roster) if err != nil { t.Fatal(err) } if err := publish(t.Context(), f.directory.Store, hostactors.Key, raw); err != nil { t.Fatal(err) } case "no store": f.directory.Store = nil case "bad scope": f.scope.Binding.Host = "../host" case "clock": f.now = time.Time{} } if _, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now); err == nil { t.Fatal("invalid request published") } }) } } type readbackStore struct { release.Store get func(string) fail string } func (s readbackStore) Get(ctx context.Context, key string) ([]byte, string, error) { raw, tag, err := s.Store.Get(ctx, key) if s.get != nil { s.get(key) } if key == s.fail { return nil, "", os.ErrPermission } return raw, tag, err } func TestPlan14HistoryReadbackReadRace(t *testing.T) { for _, fault := range []string{"roster", "fence", "response read", "roster read"} { t.Run(fault, func(t *testing.T) { f := newFixture(t) q, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now) if err != nil { t.Fatal(err) } if err := Respond(t.Context(), f.directory, f.scope, f.key, f.now, f.snapshot); err != nil { t.Fatal(err) } base := f.directory.Store wrapped := readbackStore{Store: base} if fault == "response read" { wrapped.fail = ResponseKey(q) } if fault == "roster read" { wrapped.fail = hostactors.Key } wrapped.get = func(key string) { if key != ResponseKey(q) { return } switch fault { case "roster": o, err := f.directory.Read(t.Context()) if err != nil { t.Fatal(err) } o.Roster.Revision++ wire, err := release.SignRootEnvelope(f.signer, o.Roster) if err != nil { t.Fatal(err) } if err := publish(t.Context(), base, hostactors.Key, wire); err != nil { t.Fatal(err) } case "fence": if err := publish(t.Context(), base, "control/box-fences/"+f.scope.Enrollment+".json", []byte("{}")); err != nil { t.Fatal(err) } } } f.directory.Store = wrapped if _, err := ReadStatus(t.Context(), f.directory, q, f.now); err == nil { t.Fatal("changed/unreadable readback accepted") } }) } }