| 1 | package hostops |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | func testWriter(t *testing.T) (*ControlWriter, *release.DirStore, func() time.Time) { |
| 15 | t.Helper() |
| 16 | raw := release.NewDirStore(t.TempDir()) |
| 17 | pub, _, err := ed25519.GenerateKey(nil) |
| 18 | if err != nil { |
| 19 | t.Fatal(err) |
| 20 | } |
| 21 | now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) |
| 22 | clock := func() time.Time { return now } |
| 23 | w, err := NewControlWriter(raw, pub, clock) |
| 24 | if err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | return w, raw, clock |
| 28 | } |
| 29 | |
| 30 | func seedAdmission(t *testing.T, s release.Store, account, app string, a AppAdmission) { |
| 31 | t.Helper() |
| 32 | body, _ := json.Marshal(a) |
| 33 | key, _ := AdmissionKey(account, app) |
| 34 | _, tag, _ := s.Get(context.Background(), key) |
| 35 | if _, err := s.Put(context.Background(), key, body, tag); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func openAdmission(appID string) AppAdmission { |
| 41 | return AppAdmission{V: 1, AppID: appID, Revision: 1, Epoch: 1, State: "open", Registrations: map[string]Registration{}, Effects: map[string]Effect{}} |
| 42 | } |
| 43 | |
| 44 | var testOwner = RegistrationOwner{Console: "console", Enrollment: "enrollment", Controller: "controller"} |
| 45 | |
| 46 | // Review P1-1: the data boundary refuses every canonical control object even |
| 47 | // when its checker would allow everything, and even with a live permit. |
| 48 | func TestDataBoundaryAlwaysRefusesControlObjects(t *testing.T) { |
| 49 | ctx := t.Context() |
| 50 | w, raw, clock := testWriter(t) |
| 51 | seedAdmission(t, raw, "bab", "demo", openAdmission("app")) |
| 52 | data, err := NewMutationBoundary(true, raw, func(context.Context, MutationTarget) error { return nil }) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | // A real permit in context changes nothing. |
| 57 | h, err := w.BeginExclusive(ctx, "bab", "demo", "app", AppOperation{ID: "op", Kind: OpAppMutation, AppID: "app"}, testOwner) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | permitted, settle, err := h.Runner(clock).Issue(ctx, Effect{ID: "op", Resource: "app-mutation", Action: "test"}) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | defer settle("joined", nil) |
| 66 | for _, key := range []string{"apps/bab/demo/admission.json", "apps/bab/demo/purge.json", "apps/bab/demo/purge-request.json", MailGateKey} { |
| 67 | if _, err := data.Put(permitted, key, []byte(`{}`), ""); !errors.Is(err, ErrControlKey) { |
| 68 | t.Fatalf("Put %s: %v", key, err) |
| 69 | } |
| 70 | if err := data.Delete(permitted, key); !errors.Is(err, ErrControlKey) { |
| 71 | t.Fatalf("Delete %s: %v", key, err) |
| 72 | } |
| 73 | if err := release.DeleteIfMatch(permitted, data, key, `"x"`); !errors.Is(err, ErrControlKey) { |
| 74 | t.Fatalf("DeleteIfMatch %s: %v", key, err) |
| 75 | } |
| 76 | } |
| 77 | if _, err := data.Put(permitted, "apps/BAD!/demo/x.json", []byte(`{}`), ""); !errors.Is(err, ErrIdentity) { |
| 78 | t.Fatalf("malformed app key: %v", err) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // Review P1-1: no transition can reopen a completed purge or forge |
| 83 | // last_purge / last_restore; the only way to last_purge is CompletePurge. |
| 84 | func TestTypedTransitionsCannotReopenOrForge(t *testing.T) { |
| 85 | ctx := t.Context() |
| 86 | w, raw, _ := testWriter(t) |
| 87 | done := openAdmission("old") |
| 88 | done.State, done.Revision, done.Epoch = "closed", 8, 3 |
| 89 | done.LastPurge = &PurgeMark{ID: "purge", AppID: "old"} |
| 90 | seedAdmission(t, raw, "bab", "demo", done) |
| 91 | for _, kind := range []string{OpDeleting, OpPlacing, OpAppMutation} { |
| 92 | if _, err := w.BeginExclusive(ctx, "bab", "demo", "old", AppOperation{ID: "new-" + kind, Kind: kind, AppID: "old"}, testOwner); !errors.Is(err, ErrPending) { |
| 93 | t.Fatalf("%s reopened completed admission: %v", kind, err) |
| 94 | } |
| 95 | } |
| 96 | if _, err := w.BeginExclusive(ctx, "bab", "demo", "old", AppOperation{ID: "p", Kind: OpPurging, AppID: "old"}, testOwner); !errors.Is(err, ErrIdentity) { |
| 97 | t.Fatalf("purging through BeginExclusive: %v", err) |
| 98 | } |
| 99 | if _, err := w.ResumeExclusive(ctx, "bab", "demo", "old", "purge", OpPurging, testOwner); !errors.Is(err, ErrPending) { |
| 100 | t.Fatalf("resumed a finished purge: %v", err) |
| 101 | } |
| 102 | // nextAdmission refuses any last_purge change outside purge completion. |
| 103 | before, err := ReadAdmission(ctx, raw, "bab", "demo", "old") |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | if _, err = nextAdmission(before, func(a *AppAdmission) error { a.LastPurge = &PurgeMark{ID: "forged", AppID: "old"}; return nil }); !errors.Is(err, ErrIdentity) { |
| 108 | t.Fatalf("forged last_purge: %v", err) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // Review P2-3: a crash after the receipt and before (or after) the tombstone |
| 113 | // delete is finished by RecoverMutation; nothing stays wedged. |
| 114 | func TestRestoreReceiptRecoversEveryCrashPoint(t *testing.T) { |
| 115 | for _, crash := range []string{"before-delete", "after-delete"} { |
| 116 | t.Run(crash, func(t *testing.T) { |
| 117 | ctx := t.Context() |
| 118 | w, raw, clock := testWriter(t) |
| 119 | seedAdmission(t, raw, "bab", "demo", openAdmission("app")) |
| 120 | if err := release.TrashApp(ctx, raw, "bab", "demo", "owner", clock()); err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | _, tag, err := raw.Get(ctx, release.AppTombstoneKey("bab", "demo")) |
| 124 | if err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | h, err := w.BeginExclusive(ctx, "bab", "demo", "app", AppOperation{ID: "op", Kind: OpAppMutation, AppID: "app", Intent: map[string]string{"action": "restore"}}, testOwner) |
| 128 | if err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | if _, _, err = h.Runner(clock).Issue(ctx, Effect{ID: "op", Resource: "app-mutation", Action: "restore"}); err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if err = h.PrepareRestore(ctx, tag, clock().Unix()); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if crash == "after-delete" { |
| 138 | if err = release.DeleteIfMatch(ctx, raw, release.AppTombstoneKey("bab", "demo"), tag); err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | } |
| 142 | // The issuer is gone; the effect is stuck at issued. |
| 143 | if err = w.RecoverMutation(ctx, "bab", "demo", "app", "op"); err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | a, err := ReadAdmission(ctx, raw, "bab", "demo", "app") |
| 147 | if err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | if a.Value.State != "open" || a.Value.Op != nil || a.Value.LastRestore == nil || a.Value.LastRestore.State != "complete" || a.Value.Effects["op"].State != "complete" { |
| 151 | t.Fatalf("restore not reconciled: %+v", a.Value) |
| 152 | } |
| 153 | if _, _, err = raw.Get(ctx, release.AppTombstoneKey("bab", "demo")); err == nil { |
| 154 | t.Fatal("tombstone survived a completed restore") |
| 155 | } |
| 156 | if _, err = w.RestoreProjection(ctx, "bab", "demo", "app"); !errors.Is(err, ErrPending) { |
| 157 | t.Fatalf("projection without its own operation: %v", err) |
| 158 | } |
| 159 | p, err := w.BeginExclusive(ctx, "bab", "demo", "app", AppOperation{ID: "projection", Kind: OpAppMutation, AppID: "app", Intent: map[string]string{"action": "projection"}}, testOwner) |
| 160 | if err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | pctx, settle, err := p.Runner(clock).Issue(ctx, Effect{ID: "projection", Resource: "app-mutation", Action: "projection"}) |
| 164 | if err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | if g, err := w.RestoreProjection(pctx, "bab", "demo", "app"); err != nil || g.Kind() != ProjectionRestore { |
| 168 | t.Fatalf("no projection grant inside its operation: %v", err) |
| 169 | } |
| 170 | if err = settle("joined", nil); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | if err = p.FinishMutation(ctx); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | }) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // The mail gate cannot be released or checked by a reader of the object. |
| 181 | func TestMailGatePossessionIsTheToken(t *testing.T) { |
| 182 | ctx := t.Context() |
| 183 | w, _, _ := testWriter(t) |
| 184 | owner := MailGateOwner{Console: "c", Enrollment: "e", Controller: "one"} |
| 185 | l, err := w.AcquireMailGate(ctx, owner, MailGateScope{}) |
| 186 | if err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | if _, err = w.AcquireMailGate(ctx, owner, MailGateScope{}); !errors.Is(err, ErrPending) { |
| 190 | t.Fatalf("second holder: %v", err) |
| 191 | } |
| 192 | forged := &MailGateLease{ID: l.ID, Owner: l.Owner, revision: l.revision, token: "guess"} |
| 193 | if err = w.ReleaseMailGate(ctx, forged); !errors.Is(err, ErrPending) { |
| 194 | t.Fatalf("forged release: %v", err) |
| 195 | } |
| 196 | if err = w.ReleaseMailGate(ctx, l); err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | if err = w.CheckMailGate(ctx, l); !errors.Is(err, ErrPending) { |
| 200 | t.Fatalf("released lease still checks: %v", err) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // Review P3: a non-matching earlier permit does not hide a valid later one. |
| 205 | func TestRequireAppMutationTriesEveryPermit(t *testing.T) { |
| 206 | ctx := t.Context() |
| 207 | w, raw, clock := testWriter(t) |
| 208 | seedAdmission(t, raw, "bab", "demo", openAdmission("app")) |
| 209 | h, err := w.BeginExclusive(ctx, "bab", "demo", "app", AppOperation{ID: "op", Kind: OpAppMutation, AppID: "app"}, testOwner) |
| 210 | if err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | stale := &AppMutationPermit{account: "bab", app: "demo", appID: "app", epoch: 1, operation: "op", effect: "missing", owner: testOwner, exclusive: true, now: clock} |
| 214 | stale.live.Store(true) |
| 215 | ctx = context.WithValue(ctx, appMutationPermitKey{}, []*AppMutationPermit{stale}) |
| 216 | issued, settle, err := h.Runner(clock).Issue(ctx, Effect{ID: "op", Resource: "app-mutation", Action: "test"}) |
| 217 | if err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | if err = RequireAppMutation(issued, raw, "bab", "demo", "app"); err != nil { |
| 221 | t.Fatalf("valid second permit ignored: %v", err) |
| 222 | } |
| 223 | if err = settle("joined", nil); err != nil { |
| 224 | t.Fatal(err) |
| 225 | } |
| 226 | if err = RequireAppMutation(issued, raw, "bab", "demo", "app"); !errors.Is(err, ErrPending) { |
| 227 | t.Fatalf("settled permit still live: %v", err) |
| 228 | } |
| 229 | } |
| 230 | |