| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "crypto/ed25519" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/hostactors" |
| 13 | "github.com/carlosframework/platform/internal/hostbox" |
| 14 | "github.com/carlosframework/platform/internal/hostfloor" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | ) |
| 17 | |
| 18 | // The regional enrollment check runs on the production roster adapter: a |
| 19 | // roster revision bump (a create's actor hold is one) passes, and a |
| 20 | // quarantine and readmission since startup still refuses. |
| 21 | func TestClaimRegionalEnrollmentSurvivesRosterRevision(t *testing.T) { |
| 22 | key := ed25519.NewKeyFromSeed(make([]byte, 32)) |
| 23 | s := release.NewDirStore(t.TempDir()) |
| 24 | bucket := "deployment" |
| 25 | if err := hostfloor.Publish(t.Context(), s, release.RootSigner(key), bucket, hostfloor.Floor{Min: 1, MinRelease: "v0.1.0"}); err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | raw, _, _ := s.Get(t.Context(), hostfloor.Key) |
| 29 | auth, _, _ := s.Get(t.Context(), hostfloor.AuthKey) |
| 30 | dir := t.TempDir() |
| 31 | body, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: bucket, Mode: "enforced", Floor: raw, Authorization: auth}) |
| 32 | if err := os.WriteFile(filepath.Join(dir, "policy.json"), body, 0600); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: strings.Repeat("a", 32), Region: "ie", ControlBucket: bucket, Revision: 1, Binding: "1"}) |
| 36 | row := hostactors.Box{BoxID: "box", Enrollment: strings.Repeat("a", 32), Namespace: identity.Namespace, Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: identity.Account, Region: identity.AWSRegion, InstanceID: "i-0abc"}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"} |
| 37 | roster := hostactors.Roster{V: 1, DeploymentBucket: bucket, Revision: 1, RequestLimitNS: int64(hostbox.RequestLimit), Publication: "published", FloorSHA256: hostfloor.Digest(raw), Boxes: map[string]hostactors.Box{"box": row}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| 38 | write := func() { |
| 39 | t.Helper() |
| 40 | wire, err := release.SignRootEnvelope(release.RootSigner(key), roster) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | _, etag, _ := s.Get(t.Context(), hostactors.Key) |
| 45 | if _, err = s.Put(t.Context(), hostactors.Key, wire, etag); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | } |
| 49 | write() |
| 50 | identity.Roster = productionClaimRoster{Directory: hostactors.Directory{Store: s, PublicKey: key.Public().(ed25519.PublicKey), Bucket: bucket}, DataBucket: identity.Bucket, FloorDir: dir} |
| 51 | actor, err := identity.Verify(t.Context()) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | e := claimRegionalEnrollment{identity: identity, actor: actor} |
| 56 | check := func(step string, want error) { |
| 57 | t.Helper() |
| 58 | if err := e.VerifyRegional(t.Context(), actor.Region, actor.Bucket, hostbox.RequestLimit); !errors.Is(err, want) && !(want == nil && err == nil) { |
| 59 | t.Fatalf("%s: VerifyRegional = %v, want %v", step, err, want) |
| 60 | } |
| 61 | } |
| 62 | check("startup", nil) |
| 63 | |
| 64 | roster.Revision++ |
| 65 | write() |
| 66 | check("roster revision bump", nil) |
| 67 | |
| 68 | row.State, row.QuarantineRevision, row.LossIDs = "quarantined", 1, []string{"loss"} |
| 69 | roster.Boxes["box"], roster.Revision = row, roster.Revision+1 |
| 70 | write() |
| 71 | check("quarantined", hostbox.ErrChanged) |
| 72 | |
| 73 | row.State = "active" |
| 74 | roster.Boxes["box"], roster.Revision = row, roster.Revision+1 |
| 75 | write() |
| 76 | check("readmitted since startup", hostbox.ErrChanged) |
| 77 | } |
| 78 | |
| 79 | // Each enrollment field the roster carries still refuses when it changes |
| 80 | // under a running assembly; only the revision bump passes. |
| 81 | func TestClaimRegionalEnrollmentFieldChanges(t *testing.T) { |
| 82 | base := hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "region", FleetHomeRegion: "region", ControlBucket: "control", Revision: 1, Binding: "1"} |
| 83 | identity := handoverRecoveryIdentity(t, base) |
| 84 | actor, err := identity.Verify(t.Context()) |
| 85 | if err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | e := claimRegionalEnrollment{identity: identity, actor: actor} |
| 89 | for name, edit := range map[string]func(*hostbox.Actor){ |
| 90 | "revision": func(a *hostbox.Actor) { a.Revision++ }, |
| 91 | "eligible": func(a *hostbox.Actor) { a.Eligible = false }, |
| 92 | "quarantined": func(a *hostbox.Actor) { a.Quarantined = true }, |
| 93 | "binding": func(a *hostbox.Actor) { a.Binding += "-changed" }, |
| 94 | "box": func(a *hostbox.Actor) { a.Box = "other" }, |
| 95 | "enrollment": func(a *hostbox.Actor) { a.Enrollment = "re-enrolled" }, |
| 96 | "region": func(a *hostbox.Actor) { a.Region = "other" }, |
| 97 | "bucket": func(a *hostbox.Actor) { a.Bucket = "other" }, |
| 98 | "namespace": func(a *hostbox.Actor) { a.Namespace = "other" }, |
| 99 | "control bucket": func(a *hostbox.Actor) { a.ControlBucket = "other" }, |
| 100 | "fleet home region": func(a *hostbox.Actor) { a.FleetHomeRegion = "other" }, |
| 101 | "cloud account": func(a *hostbox.Actor) { a.Cloud.Account = "444455556666" }, |
| 102 | "cloud region": func(a *hostbox.Actor) { a.Cloud.Region = "eu-west-2" }, |
| 103 | "cloud instance": func(a *hostbox.Actor) { a.Cloud.InstanceID = "i-0def" }, |
| 104 | } { |
| 105 | t.Run(name, func(t *testing.T) { |
| 106 | changed := identity |
| 107 | row := actor |
| 108 | row.HomeRegion = "" |
| 109 | edit(&row) |
| 110 | changed.Roster = handoverRecoveryRoster{row} |
| 111 | e := e |
| 112 | e.identity = changed |
| 113 | err := e.VerifyRegional(t.Context(), actor.Region, actor.Bucket, hostbox.RequestLimit) |
| 114 | if name == "revision" { |
| 115 | if err != nil { |
| 116 | t.Fatalf("revision bump refused: %v", err) |
| 117 | } |
| 118 | return |
| 119 | } |
| 120 | // A new instance ID fails Verify's signed cloud evidence first, |
| 121 | // with that check's own error; every other change is ErrChanged. |
| 122 | if err == nil || (name != "cloud instance" && !errors.Is(err, hostbox.ErrChanged)) { |
| 123 | t.Fatalf("changed enrollment: %v, want ErrChanged", err) |
| 124 | } |
| 125 | }) |
| 126 | } |
| 127 | } |
| 128 | |