| 1 | package console |
| 2 | |
| 3 | // The attach/detach core (domainattach.go): the one place both doors — the |
| 4 | // browser Settings form and the CLI API — decide what attaching a custom |
| 5 | // domain to an app MEANS. The properties that matter, in the order they'd |
| 6 | // hurt if lost: platform space is refused before anything is written, an |
| 7 | // app with a ready route gets an alias claim and NO route record (a second |
| 8 | // route row is the #129/#148 class this design exists to avoid), a lost |
| 9 | // claim race is neutral even when the caller's own account holds it (a 409 |
| 10 | // that named the holder would be a cross-account leak), the first-route |
| 11 | // path writes the CLAIM BEFORE the record (uniqueness is decided before |
| 12 | // anything else exists), a record write that fails takes the claim back |
| 13 | // with it, and detach unwinds in the reverse order behind a 404 that |
| 14 | // cannot tell "not attached" from "someone else's". |
| 15 | |
| 16 | import ( |
| 17 | "context" |
| 18 | "errors" |
| 19 | "net/url" |
| 20 | "os" |
| 21 | "path/filepath" |
| 22 | "strings" |
| 23 | "testing" |
| 24 | |
| 25 | "github.com/carlosframework/platform/internal/domains" |
| 26 | "github.com/carlosframework/platform/internal/provision" |
| 27 | "github.com/carlosframework/platform/internal/registry" |
| 28 | "github.com/carlosframework/platform/internal/release" |
| 29 | ) |
| 30 | |
| 31 | // attachFixture is server_test.go's console pointed at an EMPTY world for |
| 32 | // attach purposes: no registry path at all (so the ready-route lookup is |
| 33 | // skipped and every test starts on the first-route branch) and the |
| 34 | // deployment's apps domain set, which is what makes platform space |
| 35 | // refusable. Tests that want an alias point cfg.Registry at attachRegistry. |
| 36 | func attachFixture(t *testing.T) (*consoleFixture, *Account) { |
| 37 | t.Helper() |
| 38 | fx := newConsoleFixture(t) |
| 39 | fx.srv.cfg.Registry = "" |
| 40 | fx.srv.cfg.AppsDomain = "oncarlos.com" |
| 41 | acct, err := fx.st.AccountBySqid(fx.sqid) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | if acct == nil { |
| 46 | t.Fatalf("fixture account %s not found", fx.sqid) |
| 47 | } |
| 48 | return fx, acct |
| 49 | } |
| 50 | |
| 51 | // attachRegistry builds a registry holding routes and returns its path. |
| 52 | func attachRegistry(t *testing.T, routes ...registry.Route) string { |
| 53 | t.Helper() |
| 54 | path := filepath.Join(t.TempDir(), "registry.db") |
| 55 | reg, err := registry.Open(path) |
| 56 | if err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | defer reg.Close() |
| 60 | for _, rt := range routes { |
| 61 | if err := reg.Upsert(rt); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | } |
| 65 | return path |
| 66 | } |
| 67 | |
| 68 | // opStore records every store write in order — the only way to observe |
| 69 | // "claim THEN record" (and detach's reverse) rather than merely "both |
| 70 | // landed". failPut names one key whose Put fails, which is how the |
| 71 | // half-failed attach is reproduced without a broken bucket. |
| 72 | type opStore struct { |
| 73 | release.Store |
| 74 | ops []string |
| 75 | // failPut names a key whose Put fails before it lands. landThenFail |
| 76 | // makes the same key LAND and then report failure — the ambiguous |
| 77 | // outcome a timeout produces, and the one a one-sided rollback leaks a |
| 78 | // record through. |
| 79 | failPut string |
| 80 | landThenFail bool |
| 81 | } |
| 82 | |
| 83 | func (o *opStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 84 | if o.failPut != "" && key == o.failPut { |
| 85 | if o.landThenFail { |
| 86 | o.ops = append(o.ops, "put-then-fail "+key) |
| 87 | if _, err := o.Store.Put(ctx, key, body, ifMatch); err != nil { |
| 88 | return "", err |
| 89 | } |
| 90 | return "", errors.New("opStore: timed out after the object landed") |
| 91 | } |
| 92 | o.ops = append(o.ops, "put-fail "+key) |
| 93 | return "", errors.New("opStore: bucket unavailable") |
| 94 | } |
| 95 | o.ops = append(o.ops, "put "+key) |
| 96 | return o.Store.Put(ctx, key, body, ifMatch) |
| 97 | } |
| 98 | |
| 99 | func (o *opStore) Delete(ctx context.Context, key string) error { |
| 100 | o.ops = append(o.ops, "delete "+key) |
| 101 | return o.Store.Delete(ctx, key) |
| 102 | } |
| 103 | |
| 104 | // opIndex is where an op appears in the log, or -1. |
| 105 | func (o *opStore) opIndex(op string) int { |
| 106 | for i, got := range o.ops { |
| 107 | if got == op { |
| 108 | return i |
| 109 | } |
| 110 | } |
| 111 | return -1 |
| 112 | } |
| 113 | |
| 114 | // seedClaim plants a pre-existing claim — the "someone already holds this |
| 115 | // host" fixture. AttachClaim is the one claim writer left (#151), and |
| 116 | // creating against an empty key is its create path. |
| 117 | func seedClaim(t *testing.T, fx *consoleFixture, c domains.Claim) { |
| 118 | t.Helper() |
| 119 | created, _, err := domains.AttachClaim(context.Background(), fx.rel, c) |
| 120 | if err != nil || !created { |
| 121 | t.Fatalf("seed claim %s: created=%v err=%v", c.Host, created, err) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // auditedOnce asserts exactly one audit row for the action, naming host — |
| 126 | // the record a member (and an incident) reads the door's history from. |
| 127 | func auditedOnce(t *testing.T, fx *consoleFixture, acct *Account, app, action, host string) { |
| 128 | t.Helper() |
| 129 | events, err := fx.st.Events(acct.ID, app, 20) |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | n := 0 |
| 134 | for _, e := range events { |
| 135 | if e.Action == action && strings.Contains(e.Detail, host) { |
| 136 | n++ |
| 137 | } |
| 138 | } |
| 139 | if n != 1 { |
| 140 | t.Errorf("audit rows for %s %s = %d, want 1 (rows: %+v)", action, host, n, events) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestAttachAliasWhenAppHasReadyRoute(t *testing.T) { |
| 145 | fx, acct := attachFixture(t) |
| 146 | // Three candidate rows in host order: a platform mint and a route that |
| 147 | // is not ready come FIRST alphabetically, so picking the third proves |
| 148 | // the filter rather than "picked the only one". |
| 149 | fx.srv.cfg.Registry = attachRegistry(t, |
| 150 | registry.Route{Host: "aaa." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| 151 | Account: fx.sqid, Channel: "canary/live", Status: "ready", Platform: true}, |
| 152 | registry.Route{Host: "bbb." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| 153 | Account: fx.sqid, Channel: "canary/live", Status: "pending"}, |
| 154 | registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| 155 | Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| 156 | registry.Route{Host: "other." + fx.sqid + ".oncarlos.com", Kind: "static", App: "other", |
| 157 | Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| 158 | ) |
| 159 | const host = "story.carlosframework.com" |
| 160 | ops := &opStore{Store: fx.rel} |
| 161 | fx.srv.cfg.Releases = ops |
| 162 | res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 163 | if ref != nil { |
| 164 | t.Fatalf("attach: refused %+v", ref) |
| 165 | } |
| 166 | if want := "story." + fx.sqid + ".oncarlos.com"; res.Claim.Route != want { |
| 167 | t.Errorf("claim route = %q, want the app's first non-platform ready route %q", res.Claim.Route, want) |
| 168 | } |
| 169 | if res.FirstRoute { |
| 170 | t.Error("FirstRoute = true for an app that already has a route to alias") |
| 171 | } |
| 172 | if res.Channel != "" { |
| 173 | t.Errorf("Channel = %q, want empty for an alias attach", res.Channel) |
| 174 | } |
| 175 | got, err := domains.GetClaim(context.Background(), fx.rel, host) |
| 176 | if err != nil { |
| 177 | t.Fatalf("claim: %v", err) |
| 178 | } |
| 179 | if got.Schema != 1 || got.Host != host || got.Account != fx.sqid || got.App != "story" || |
| 180 | got.Route != res.Claim.Route || got.CreatedBy != "paul@keymail.dev" { |
| 181 | t.Errorf("stored claim = %+v, want the returned claim %+v", got, res.Claim) |
| 182 | } |
| 183 | keys, err := fx.rel.List(context.Background(), provision.RecordsPrefix(fx.sqid, "story")) |
| 184 | if err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | if len(keys) != 0 { |
| 188 | t.Errorf("records after an alias attach = %v, want none (the alias is derived box-side)", keys) |
| 189 | } |
| 190 | // The alias branch owes the same two side effects the first-route |
| 191 | // branch does: both doors get them from here, or neither does. |
| 192 | auditedOnce(t, fx, acct, "story", "domain-attach", host) |
| 193 | claimPut := ops.opIndex("put " + domains.ClaimKey(host)) |
| 194 | serial := ops.opIndex("put " + release.SerialKey) |
| 195 | if claimPut < 0 || serial < claimPut { |
| 196 | t.Errorf("ops %v: want the serial bumped after the claim it announces (claim=%d serial=%d)", ops.ops, claimPut, serial) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // TestAttachNeverAliasesAHostToItself is the operator-added custom domain |
| 201 | // (story.carlosframework.com, spec §7) arriving at the member's door: a |
| 202 | // ready route for the very host being attached must not become the claim's |
| 203 | // alias target — a claim naming its own host is a self-alias nothing can |
| 204 | // resolve. The attach lands on the first-route branch instead, where the |
| 205 | // declared record describes the row that already exists and #137's |
| 206 | // adopt-on-match transfers ownership. |
| 207 | func TestAttachNeverAliasesAHostToItself(t *testing.T) { |
| 208 | fx, acct := attachFixture(t) |
| 209 | shipVersion(t, fx, "story", "v1") |
| 210 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 211 | const host = "story.carlosframework.com" |
| 212 | fx.srv.cfg.Registry = attachRegistry(t, |
| 213 | registry.Route{Host: host, Kind: "static", App: "story", |
| 214 | Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| 215 | ) |
| 216 | res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 217 | if ref != nil { |
| 218 | t.Fatalf("attach: refused %+v", ref) |
| 219 | } |
| 220 | if res.Claim.Route != "" || !res.FirstRoute { |
| 221 | t.Errorf("result = %+v, want a first-route attach with an empty claim route", res) |
| 222 | } |
| 223 | if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); err != nil { |
| 224 | t.Errorf("record: %v, want a declared record describing the existing route", err) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // TestAttachBackfillTakesChannelFromTheExistingRoute is the spec-owner's |
| 229 | // ruling: on the self-host backfill the record's channel comes from the row |
| 230 | // that already serves the host, not from the app's channel listing. Two |
| 231 | // things turn on it — #137's adoption converges the record's channel onto |
| 232 | // that live row, so any other value SILENTLY REPOINTS a running operator |
| 233 | // route, and an app with several channels would otherwise be refused as |
| 234 | // ambiguous for a domain whose channel was never in question. |
| 235 | func TestAttachBackfillTakesChannelFromTheExistingRoute(t *testing.T) { |
| 236 | fx, acct := attachFixture(t) |
| 237 | shipVersion(t, fx, "story", "v1") |
| 238 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 239 | seedChannel(t, fx, "story", "stable", "v1") // ambiguous, if the listing decided |
| 240 | const host = "story.carlosframework.com" |
| 241 | fx.srv.cfg.Registry = attachRegistry(t, |
| 242 | registry.Route{Host: host, Kind: "static", App: "story", |
| 243 | Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| 244 | ) |
| 245 | res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 246 | if ref != nil { |
| 247 | t.Fatalf("attach: refused %+v, want the backfill to go through on the row's own channel", ref) |
| 248 | } |
| 249 | if res.Claim.Route != "" || !res.FirstRoute || res.Channel != "canary/live" { |
| 250 | t.Errorf("result = %+v, want a first-route attach on canary/live with an empty claim route", res) |
| 251 | } |
| 252 | rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host) |
| 253 | if err != nil { |
| 254 | t.Fatalf("record: %v", err) |
| 255 | } |
| 256 | if rec.Channel != "canary/live" { |
| 257 | t.Errorf("record channel = %q, want canary/live — the channel the live route already follows", rec.Channel) |
| 258 | } |
| 259 | // An explicit flag still wins: repointing is allowed when it is asked for. |
| 260 | const other = "docs.carlosframework.com" |
| 261 | fx.srv.cfg.Registry = attachRegistry(t, |
| 262 | registry.Route{Host: other, Kind: "static", App: "story", |
| 263 | Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| 264 | ) |
| 265 | res, ref = fx.srv.attachDomain(context.Background(), acct, "story", other, "", "stable", "paul@keymail.dev") |
| 266 | if ref != nil { |
| 267 | t.Fatalf("attach with --channel stable: refused %+v", ref) |
| 268 | } |
| 269 | if res.Channel != "stable" { |
| 270 | t.Errorf("Channel = %q, want the explicitly named stable", res.Channel) |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | func TestAttachFirstRouteWritesClaimThenRecord(t *testing.T) { |
| 275 | fx, acct := attachFixture(t) |
| 276 | shipVersion(t, fx, "story", "v1") |
| 277 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 278 | ops := &opStore{Store: fx.rel} |
| 279 | fx.srv.cfg.Releases = ops |
| 280 | |
| 281 | const host = "story.carlosframework.com" |
| 282 | res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 283 | if ref != nil { |
| 284 | t.Fatalf("attach: refused %+v", ref) |
| 285 | } |
| 286 | if !res.FirstRoute { |
| 287 | t.Error("FirstRoute = false for an app with no ready route") |
| 288 | } |
| 289 | if res.Channel != "canary/live" { |
| 290 | t.Errorf("Channel = %q, want the app's sole shipped channel canary/live", res.Channel) |
| 291 | } |
| 292 | if res.Claim.Route != "" { |
| 293 | t.Errorf("claim route = %q, want empty — this host IS its own primary", res.Claim.Route) |
| 294 | } |
| 295 | claim, err := domains.GetClaim(context.Background(), fx.rel, host) |
| 296 | if err != nil { |
| 297 | t.Fatalf("claim: %v", err) |
| 298 | } |
| 299 | if claim.Route != "" || claim.Account != fx.sqid || claim.App != "story" { |
| 300 | t.Errorf("stored claim = %+v", claim) |
| 301 | } |
| 302 | rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host) |
| 303 | if err != nil { |
| 304 | t.Fatalf("record: %v", err) |
| 305 | } |
| 306 | if rec.Schema != 1 || rec.Kind != "static" || rec.Channel != "canary/live" || |
| 307 | rec.CreatedBy != "paul@keymail.dev" || rec.Pool != "" || rec.Region != "" { |
| 308 | t.Errorf("record = %+v, want a schema-1 static record on canary/live in the shared pool", *rec) |
| 309 | } |
| 310 | claimPut := ops.opIndex("put " + domains.ClaimKey(host)) |
| 311 | recPut := ops.opIndex("put " + provision.RecordKey(fx.sqid, "story", host)) |
| 312 | if claimPut < 0 || recPut < 0 || claimPut > recPut { |
| 313 | t.Errorf("write order %v: want the claim put before the record put (claim=%d record=%d)", ops.ops, claimPut, recPut) |
| 314 | } |
| 315 | // The two things the doors get for free by this living in the core: the |
| 316 | // audit row, and the serial bump that makes the fleet converge in |
| 317 | // seconds instead of at the hourly floor. |
| 318 | auditedOnce(t, fx, acct, "story", "domain-attach", host) |
| 319 | if serial := ops.opIndex("put " + release.SerialKey); serial < recPut { |
| 320 | t.Errorf("ops %v: want the serial bumped AFTER the writes it announces (serial=%d record=%d)", ops.ops, serial, recPut) |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | // TestAttachIdempotentWhenRecordAlreadyExists is the retry after a partial |
| 325 | // attach (and the claim-released-but-record-kept shape): the record key is |
| 326 | // account+app scoped, so a create that loses the race there lost it to this |
| 327 | // app's own record. Refusing would make the host permanently unattachable — |
| 328 | // the claim would roll back on every retry and detach would 404. |
| 329 | func TestAttachIdempotentWhenRecordAlreadyExists(t *testing.T) { |
| 330 | fx, acct := attachFixture(t) |
| 331 | shipVersion(t, fx, "story", "v1") |
| 332 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 333 | const host = "story.carlosframework.com" |
| 334 | // A record left behind by an earlier attach, on a DIFFERENT channel so |
| 335 | // the answer proves which one is reported. |
| 336 | if err := provision.CreateRecord(context.Background(), fx.rel, provision.Record{ |
| 337 | Schema: 1, Host: host, App: "story", Account: fx.sqid, Kind: "static", Channel: "stable", |
| 338 | }); err != nil { |
| 339 | t.Fatal(err) |
| 340 | } |
| 341 | res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 342 | if ref != nil { |
| 343 | t.Fatalf("attach: refused %+v, want the idempotent success", ref) |
| 344 | } |
| 345 | if !res.FirstRoute || res.Channel != "stable" { |
| 346 | t.Errorf("result = %+v, want FirstRoute with the EXISTING record's channel", res) |
| 347 | } |
| 348 | if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil { |
| 349 | t.Errorf("claim: %v — the claim this call won must survive the idempotent path", err) |
| 350 | } |
| 351 | // And the second call is idempotent too (#151's same-owner contract): |
| 352 | // claim and record both stand, nothing is written, Created is false. |
| 353 | res, ref = fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 354 | if ref != nil { |
| 355 | t.Errorf("second attach = %+v, want the idempotent success", ref) |
| 356 | } else if res.Created { |
| 357 | t.Error("second attach: Created = true, want false — the claim already existed") |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | // TestAttachUnreadableExistingRecordRefuses422 is the other half: a record |
| 362 | // at the key that cannot be read as this app's (malformed, or a body that |
| 363 | // disagrees with its own path) is not ours to overwrite or delete. |
| 364 | func TestAttachUnreadableExistingRecordRefuses422(t *testing.T) { |
| 365 | fx, acct := attachFixture(t) |
| 366 | shipVersion(t, fx, "story", "v1") |
| 367 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 368 | const host = "story.carlosframework.com" |
| 369 | if _, err := fx.rel.Put(context.Background(), |
| 370 | provision.RecordKey(fx.sqid, "story", host), []byte("{not json"), ""); err != nil { |
| 371 | t.Fatal(err) |
| 372 | } |
| 373 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 374 | if ref == nil || ref.Status != 422 { |
| 375 | t.Fatalf("refusal = %+v, want 422 for an unreadable record at the key", ref) |
| 376 | } |
| 377 | if strings.Contains(ref.Msg, fx.sqid) { |
| 378 | t.Errorf("msg %q leaks identity — the member learns a record exists, nothing more", ref.Msg) |
| 379 | } |
| 380 | claimAbsent(t, fx, host) |
| 381 | } |
| 382 | |
| 383 | // TestAttachInstanceRecordAtTheKeyIsNotAdopted is the narrow half of the |
| 384 | // idempotent branch: an app with a custom manifest Domain can have `carlos |
| 385 | // instances create` write an INSTANCE record at this very key. Treating one |
| 386 | // of those as "an attach that already happened" would hand detach the |
| 387 | // authority to retire a tenant instance this door never created. |
| 388 | func TestAttachInstanceRecordAtTheKeyIsNotAdopted(t *testing.T) { |
| 389 | fx, acct := attachFixture(t) |
| 390 | shipVersion(t, fx, "story", "v1") |
| 391 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 392 | const host = "story.carlosframework.com" |
| 393 | // Kind "" is an instance record — what reconcile minted before Kind |
| 394 | // existed, and what the instance door writes today. |
| 395 | instance := provision.Record{ |
| 396 | Schema: 1, Host: host, App: "story", Account: fx.sqid, |
| 397 | Slug: "story", Channel: "stable", |
| 398 | } |
| 399 | if err := provision.CreateRecord(context.Background(), fx.rel, instance); err != nil { |
| 400 | t.Fatal(err) |
| 401 | } |
| 402 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 403 | if ref == nil || ref.Status != 422 { |
| 404 | t.Fatalf("refusal = %+v, want 422 — an instance record is not an attach to adopt", ref) |
| 405 | } |
| 406 | claimAbsent(t, fx, host) |
| 407 | got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host) |
| 408 | if err != nil { |
| 409 | t.Fatalf("record: %v, want the instance record untouched", err) |
| 410 | } |
| 411 | if got.Kind != "" || got.Slug != "story" || got.Channel != "stable" { |
| 412 | t.Errorf("record = %+v, want the instance record exactly as it was", *got) |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | // TestAttachAmbiguousRecordFailureUnwindsBoth: a write that LANDS and then |
| 417 | // reports failure (a timeout) must not leave the record behind the claim it |
| 418 | // rolls back — that record would materialize a route for a host anyone may |
| 419 | // now claim. |
| 420 | func TestAttachAmbiguousRecordFailureUnwindsBoth(t *testing.T) { |
| 421 | fx, acct := attachFixture(t) |
| 422 | shipVersion(t, fx, "story", "v1") |
| 423 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 424 | const host = "story.carlosframework.com" |
| 425 | ops := &opStore{Store: fx.rel, failPut: provision.RecordKey(fx.sqid, "story", host), landThenFail: true} |
| 426 | fx.srv.cfg.Releases = ops |
| 427 | |
| 428 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 429 | if ref == nil || ref.Status != 500 { |
| 430 | t.Fatalf("refusal = %+v, want 500 for an ambiguous record write", ref) |
| 431 | } |
| 432 | claimAbsent(t, fx, host) |
| 433 | if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); !errors.Is(err, os.ErrNotExist) { |
| 434 | t.Errorf("record after the rollback: %v, want it gone — a record outliving its claim is the leak", err) |
| 435 | } |
| 436 | recDel := ops.opIndex("delete " + provision.RecordKey(fx.sqid, "story", host)) |
| 437 | claimDel := ops.opIndex("delete " + domains.ClaimKey(host)) |
| 438 | if recDel < 0 || claimDel < 0 || recDel > claimDel { |
| 439 | t.Errorf("ops %v: want the record dropped before the claim (record=%d claim=%d)", ops.ops, recDel, claimDel) |
| 440 | } |
| 441 | } |
| 442 | |
| 443 | // TestAttachClaimWriteFailureIs500 pins the non-CAS fallback: only a lost |
| 444 | // race is "hostname unavailable". |
| 445 | func TestAttachClaimWriteFailureIs500(t *testing.T) { |
| 446 | fx, acct := attachFixture(t) |
| 447 | shipVersion(t, fx, "story", "v1") |
| 448 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 449 | const host = "story.carlosframework.com" |
| 450 | fx.srv.cfg.Releases = &opStore{Store: fx.rel, failPut: domains.ClaimKey(host)} |
| 451 | |
| 452 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 453 | if ref == nil || ref.Status != 500 { |
| 454 | t.Fatalf("refusal = %+v, want 500 for a claim write that failed for reasons other than a lost race", ref) |
| 455 | } |
| 456 | if ref.Msg == "hostname unavailable" { |
| 457 | t.Error("a broken bucket must not read as a taken host") |
| 458 | } |
| 459 | claimAbsent(t, fx, host) |
| 460 | } |
| 461 | |
| 462 | func TestAttachWithNothingShippedRefused(t *testing.T) { |
| 463 | fx, acct := attachFixture(t) |
| 464 | const host = "story.carlosframework.com" |
| 465 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 466 | if ref == nil || ref.Status != 422 || ref.Msg != "nothing shipped yet — carlos ship first" { |
| 467 | t.Fatalf("refusal = %+v, want 422 \"nothing shipped yet — carlos ship first\"", ref) |
| 468 | } |
| 469 | claimAbsent(t, fx, host) |
| 470 | } |
| 471 | |
| 472 | func TestAttachInvalidChannelFlagRefused(t *testing.T) { |
| 473 | fx, acct := attachFixture(t) |
| 474 | shipVersion(t, fx, "story", "v1") |
| 475 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 476 | const host = "story.carlosframework.com" |
| 477 | for _, bad := range []string{"../escape", "a/b/c", "canary live"} { |
| 478 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", bad, "paul@keymail.dev") |
| 479 | if ref == nil || ref.Status != 422 { |
| 480 | t.Fatalf("channel %q: refusal = %+v, want 422", bad, ref) |
| 481 | } |
| 482 | claimAbsent(t, fx, host) |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | // TestAttachWithoutBucketIs503: a console with no deployment bucket has |
| 487 | // nowhere to put a claim, and says so rather than panicking on a nil store. |
| 488 | func TestAttachWithoutBucketIs503(t *testing.T) { |
| 489 | fx, acct := attachFixture(t) |
| 490 | fx.srv.cfg.Releases = nil |
| 491 | if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "story.carlosframework.com", "", "", "paul@keymail.dev"); ref == nil || ref.Status != 503 { |
| 492 | t.Errorf("attach refusal = %+v, want 503", ref) |
| 493 | } |
| 494 | if ref := fx.srv.detachDomain(context.Background(), acct, "story", "story.carlosframework.com", "paul@keymail.dev"); ref == nil || ref.Status != 503 { |
| 495 | t.Errorf("detach refusal = %+v, want 503", ref) |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | // TestAttachFirstRouteRollsBackClaimOnRecordFailure is the half-failed |
| 500 | // attach: the claim landed, the record did not, and the host must be left |
| 501 | // attachable again rather than claimed by an app with no route. |
| 502 | func TestAttachFirstRouteRollsBackClaimOnRecordFailure(t *testing.T) { |
| 503 | fx, acct := attachFixture(t) |
| 504 | shipVersion(t, fx, "story", "v1") |
| 505 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 506 | const host = "story.carlosframework.com" |
| 507 | ops := &opStore{Store: fx.rel, failPut: provision.RecordKey(fx.sqid, "story", host)} |
| 508 | fx.srv.cfg.Releases = ops |
| 509 | |
| 510 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 511 | if ref == nil || ref.Status != 500 { |
| 512 | t.Fatalf("refusal = %+v, want a 500 for a record write that failed", ref) |
| 513 | } |
| 514 | claimAbsent(t, fx, host) |
| 515 | if ops.opIndex("delete "+domains.ClaimKey(host)) < 0 { |
| 516 | t.Errorf("ops %v: want the claim deleted after the record write failed", ops.ops) |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | func TestAttachAmbiguousChannelRefusesNamingAll(t *testing.T) { |
| 521 | fx, acct := attachFixture(t) |
| 522 | shipVersion(t, fx, "story", "v1") |
| 523 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 524 | seedChannel(t, fx, "story", "stable", "v1") |
| 525 | |
| 526 | const host = "story.carlosframework.com" |
| 527 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 528 | if ref == nil || ref.Status != 422 { |
| 529 | t.Fatalf("refusal = %+v, want 422 for two channels and no flag", ref) |
| 530 | } |
| 531 | for _, name := range []string{"canary/live", "stable"} { |
| 532 | if !strings.Contains(ref.Msg, name) { |
| 533 | t.Errorf("msg %q does not name channel %q — the member cannot pick one from it", ref.Msg, name) |
| 534 | } |
| 535 | } |
| 536 | claimAbsent(t, fx, host) |
| 537 | |
| 538 | // The same attach with the flag supplied goes through. |
| 539 | res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "stable", "paul@keymail.dev") |
| 540 | if ref != nil { |
| 541 | t.Fatalf("attach with --channel stable: refused %+v", ref) |
| 542 | } |
| 543 | if res.Channel != "stable" { |
| 544 | t.Errorf("Channel = %q, want the named stable", res.Channel) |
| 545 | } |
| 546 | } |
| 547 | |
| 548 | func TestAttachProxiedKindWithoutRouteRefused(t *testing.T) { |
| 549 | fx, acct := attachFixture(t) |
| 550 | shipVersion(t, fx, "story", "v1") |
| 551 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 552 | |
| 553 | const host = "story.carlosframework.com" |
| 554 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "instance", "", "paul@keymail.dev") |
| 555 | if ref == nil || ref.Status != 422 { |
| 556 | t.Fatalf("refusal = %+v, want 422 for a proxied kind with no process to serve", ref) |
| 557 | } |
| 558 | if !strings.Contains(ref.Msg, "static") { |
| 559 | t.Errorf("msg %q does not say what IS attachable", ref.Msg) |
| 560 | } |
| 561 | claimAbsent(t, fx, host) |
| 562 | } |
| 563 | |
| 564 | func TestAttachHeldHostIsNeutral409(t *testing.T) { |
| 565 | const host = "story.carlosframework.com" |
| 566 | t.Run("another account", func(t *testing.T) { |
| 567 | fx, acct := attachFixture(t) |
| 568 | shipVersion(t, fx, "story", "v1") |
| 569 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 570 | seedClaim(t, fx, domains.Claim{Schema: 1, Host: host, Account: "zzz", App: "rival"}) |
| 571 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 572 | if ref == nil || ref.Status != 409 || ref.Msg != "hostname unavailable" { |
| 573 | t.Fatalf("refusal = %+v, want exactly 409 \"hostname unavailable\"", ref) |
| 574 | } |
| 575 | }) |
| 576 | // This account's OTHER app holding it is the same refusal, byte for |
| 577 | // byte: the message may never be the thing that tells a prober WHICH |
| 578 | // owner stands in the way — GET is where you see your own claims. |
| 579 | t.Run("own account, another app", func(t *testing.T) { |
| 580 | fx, acct := attachFixture(t) |
| 581 | shipVersion(t, fx, "story", "v1") |
| 582 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 583 | seedClaim(t, fx, domains.Claim{Schema: 1, Host: host, Account: acct.Sqid, App: "docs"}) |
| 584 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 585 | if ref == nil || ref.Status != 409 || ref.Msg != "hostname unavailable" { |
| 586 | t.Fatalf("refusal = %+v, want exactly 409 \"hostname unavailable\" for the caller's own other app too", ref) |
| 587 | } |
| 588 | }) |
| 589 | // The SAME app re-attaching is not a refusal at all any more: #151's |
| 590 | // claim writer treats it as a converge (titogo's router re-attaches on |
| 591 | // every reload pass), and the core inherits that idempotency — Created |
| 592 | // false, nothing re-audited when nothing was written. |
| 593 | t.Run("same app is idempotent", func(t *testing.T) { |
| 594 | fx, acct := attachFixture(t) |
| 595 | shipVersion(t, fx, "story", "v1") |
| 596 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 597 | res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 598 | if ref != nil { |
| 599 | t.Fatalf("first attach: refused %+v", ref) |
| 600 | } |
| 601 | if !res.Created { |
| 602 | t.Error("first attach: Created = false, want true") |
| 603 | } |
| 604 | res, ref = fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 605 | if ref != nil { |
| 606 | t.Fatalf("re-attach: refused %+v, want the idempotent success", ref) |
| 607 | } |
| 608 | if res.Created { |
| 609 | t.Error("re-attach: Created = true, want false — the claim already existed") |
| 610 | } |
| 611 | auditedOnce(t, fx, acct, "story", "domain-attach", host) |
| 612 | }) |
| 613 | } |
| 614 | |
| 615 | func TestAttachPlatformSpaceRefused(t *testing.T) { |
| 616 | fx, acct := attachFixture(t) |
| 617 | fx.srv.cfg.ReservePlatformDomains = true |
| 618 | shipVersion(t, fx, "story", "v1") |
| 619 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 620 | |
| 621 | // "../control/serial" is the traversal shape (domainpolicy_test.go's |
| 622 | // case, ported): a host that would walk out of the domains/ prefix must |
| 623 | // die at the shape check, never reach a bucket key — claimAbsent can't |
| 624 | // even ASK about it (the store refuses the key), which is the point. |
| 625 | if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "../control/serial", "", "", "paul@keymail.dev"); ref == nil || ref.Status != 422 { |
| 626 | t.Fatalf("attach traversal host: refusal = %+v, want 422", ref) |
| 627 | } |
| 628 | for _, host := range []string{"oncarlos.com", "story." + fx.sqid + ".oncarlos.com", "not a host"} { |
| 629 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 630 | if ref == nil || ref.Status != 422 { |
| 631 | t.Fatalf("attach %q: refusal = %+v, want 422", host, ref) |
| 632 | } |
| 633 | claimAbsent(t, fx, host) |
| 634 | } |
| 635 | } |
| 636 | |
| 637 | // TestAttachPlatformSpaceOpenByDefault: without ReservePlatformDomains, the |
| 638 | // form door (attachDomain) accepts a name under the apps domain, even as |
| 639 | // the app's first route. This is the self-hosted case. Only the console's |
| 640 | // own hostname is still refused. |
| 641 | func TestAttachPlatformSpaceOpenByDefault(t *testing.T) { |
| 642 | fx, acct := attachFixture(t) |
| 643 | shipVersion(t, fx, "story", "v1") |
| 644 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 645 | |
| 646 | const host = "blog.oncarlos.com" |
| 647 | if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev"); ref != nil { |
| 648 | t.Fatalf("attach %q under the apps domain, unreserved: refused %+v", host, ref) |
| 649 | } |
| 650 | origin, err := url.Parse(fx.srv.cfg.Origin) |
| 651 | if err != nil { |
| 652 | t.Fatal(err) |
| 653 | } |
| 654 | if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", origin.Hostname(), "", "", "paul@keymail.dev"); ref == nil || ref.Status != 422 { |
| 655 | t.Fatalf("attach the console's own origin host: refusal = %+v, want 422", ref) |
| 656 | } |
| 657 | } |
| 658 | |
| 659 | // TestAttachRefusesReservedSidecarSuffix: the attach door must refuse a |
| 660 | // custom domain under provision.SidecarSuffix — never a panic, the family's |
| 661 | // ordinary 422 (the same status HostDenied's platform-space refusal above |
| 662 | // uses), and no claim left behind either. Defence in depth behind Task 3's |
| 663 | // structural refusal to SERVE a Kind=="sidecar" row: this stops the OTHER |
| 664 | // direction, a member attaching an unrelated app under a reserved name. |
| 665 | func TestAttachRefusesReservedSidecarSuffix(t *testing.T) { |
| 666 | fx, acct := attachFixture(t) |
| 667 | shipVersion(t, fx, "story", "v1") |
| 668 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 669 | |
| 670 | host := "bot.eleven.bdm" + provision.SidecarSuffix |
| 671 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 672 | if ref == nil || ref.Status != 422 { |
| 673 | t.Fatalf("attach %q: refusal = %+v, want 422", host, ref) |
| 674 | } |
| 675 | claimAbsent(t, fx, host) |
| 676 | } |
| 677 | |
| 678 | func TestDetachFirstRouteDeletesRecordThenClaim(t *testing.T) { |
| 679 | fx, acct := attachFixture(t) |
| 680 | shipVersion(t, fx, "story", "v1") |
| 681 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 682 | const host = "story.carlosframework.com" |
| 683 | if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev"); ref != nil { |
| 684 | t.Fatalf("attach: refused %+v", ref) |
| 685 | } |
| 686 | ops := &opStore{Store: fx.rel} |
| 687 | fx.srv.cfg.Releases = ops |
| 688 | |
| 689 | if ref := fx.srv.detachDomain(context.Background(), acct, "story", host, "paul@keymail.dev"); ref != nil { |
| 690 | t.Fatalf("detach: refused %+v", ref) |
| 691 | } |
| 692 | claimAbsent(t, fx, host) |
| 693 | if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); !errors.Is(err, os.ErrNotExist) { |
| 694 | t.Errorf("record after detach: %v, want gone", err) |
| 695 | } |
| 696 | recDel := ops.opIndex("delete " + provision.RecordKey(fx.sqid, "story", host)) |
| 697 | claimDel := ops.opIndex("delete " + domains.ClaimKey(host)) |
| 698 | if recDel < 0 || claimDel < 0 || recDel > claimDel { |
| 699 | t.Errorf("ops %v: want the record deleted before the claim (record=%d claim=%d)", ops.ops, recDel, claimDel) |
| 700 | } |
| 701 | auditedOnce(t, fx, acct, "story", "domain-detach", host) |
| 702 | if serial := ops.opIndex("put " + release.SerialKey); serial < claimDel { |
| 703 | t.Errorf("ops %v: want the serial bumped after the deletes it announces (serial=%d claim=%d)", ops.ops, serial, claimDel) |
| 704 | } |
| 705 | } |
| 706 | |
| 707 | // TestDetachAliasLeavesRecordsAlone: an alias claim (Route set) never had a |
| 708 | // record of its own, and detaching it must not delete at that key — the app |
| 709 | // may hold a record there for a route this claim only pointed at. |
| 710 | func TestDetachAliasLeavesRecordsAlone(t *testing.T) { |
| 711 | fx, acct := attachFixture(t) |
| 712 | const host = "echo.example.com" |
| 713 | fx.srv.cfg.Registry = attachRegistry(t, |
| 714 | registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| 715 | Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| 716 | ) |
| 717 | res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 718 | if ref != nil || res.Claim.Route == "" { |
| 719 | t.Fatalf("attach: %+v / %+v, want an alias claim", res, ref) |
| 720 | } |
| 721 | ops := &opStore{Store: fx.rel} |
| 722 | fx.srv.cfg.Releases = ops |
| 723 | if ref := fx.srv.detachDomain(context.Background(), acct, "story", host, "paul@keymail.dev"); ref != nil { |
| 724 | t.Fatalf("detach: refused %+v", ref) |
| 725 | } |
| 726 | claimAbsent(t, fx, host) |
| 727 | if i := ops.opIndex("delete " + provision.RecordKey(fx.sqid, "story", host)); i >= 0 { |
| 728 | t.Errorf("ops %v: an alias detach deleted a record key it never wrote", ops.ops) |
| 729 | } |
| 730 | } |
| 731 | |
| 732 | func TestDetachForeignClaimIs404(t *testing.T) { |
| 733 | fx, acct := attachFixture(t) |
| 734 | const host = "story.carlosframework.com" |
| 735 | seedClaim(t, fx, domains.Claim{Schema: 1, Host: host, Account: "zzz", App: "rival"}) |
| 736 | ref := fx.srv.detachDomain(context.Background(), acct, "story", host, "paul@keymail.dev") |
| 737 | if ref == nil || ref.Status != 404 { |
| 738 | t.Fatalf("refusal = %+v, want 404 for another account's claim", ref) |
| 739 | } |
| 740 | // The claim survives: a foreign detach must not be a delete door. |
| 741 | if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil { |
| 742 | t.Fatalf("claim after a foreign detach: %v, want it untouched", err) |
| 743 | } |
| 744 | // A claim held by this account for a DIFFERENT app is the same refusal. |
| 745 | const other = "docs.carlosframework.com" |
| 746 | seedClaim(t, fx, domains.Claim{Schema: 1, Host: other, Account: acct.Sqid, App: "docs"}) |
| 747 | if ref := fx.srv.detachDomain(context.Background(), acct, "story", other, "paul@keymail.dev"); ref == nil || ref.Status != 404 { |
| 748 | t.Fatalf("refusal = %+v, want 404 for this account's OTHER app's claim", ref) |
| 749 | } |
| 750 | // And a host nobody has claimed. |
| 751 | if ref := fx.srv.detachDomain(context.Background(), acct, "story", "unclaimed.example.com", "paul@keymail.dev"); ref == nil || ref.Status != 404 { |
| 752 | t.Fatalf("refusal = %+v, want 404 for an unclaimed host", ref) |
| 753 | } |
| 754 | } |
| 755 | |
| 756 | // TestAttachNeverAliasesOntoASidecar is the reserved-suffix refusal above |
| 757 | // taken from the other end. That one guards the host being ATTACHED; this |
| 758 | // one guards the host it is attached TO. |
| 759 | // |
| 760 | // readyRouteHost picks the alias source, and its doc says the selection is |
| 761 | // domains.firstReadyUpstream's "minus the upstream copy". The copy is not |
| 762 | // all that went: firstReadyUpstream also REQUIRES an upstream |
| 763 | // (Socket/Addr, or a channel for a static), and that requirement is the |
| 764 | // whole of what keeps a sidecar out of it — a sidecar is precisely the row |
| 765 | // with no socket and no addr. Dropping the clause dropped the guard with |
| 766 | // it, and everything left in the filter passes a sidecar: it belongs to the |
| 767 | // app, it belongs to the account, it is not Platform, and it is ready, |
| 768 | // because ready is what makes it runnable. |
| 769 | // |
| 770 | // Host order decides which row wins, so a bot sorts ahead of most hosts an |
| 771 | // app would actually serve from. The member gets a 200 and a claim naming a |
| 772 | // sidecar as its Route; the box then refuses that claim forever |
| 773 | // (ReconcileAliases' own c.Route check), so the domain never serves and |
| 774 | // nothing ever says why. |
| 775 | func TestAttachNeverAliasesOntoASidecar(t *testing.T) { |
| 776 | fx, acct := attachFixture(t) |
| 777 | shipVersion(t, fx, "story", "v1") |
| 778 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 779 | // Host order is reg.List's, and "bot." sorts before "story." — the |
| 780 | // sidecar is the FIRST candidate, not a straggler behind the real route. |
| 781 | fx.srv.cfg.Registry = attachRegistry(t, |
| 782 | registry.Route{Host: "bot.story." + fx.sqid + provision.SidecarSuffix, |
| 783 | Kind: provision.SidecarKind, App: "story", Account: fx.sqid, Status: "ready"}, |
| 784 | registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", |
| 785 | Account: fx.sqid, Channel: "canary/live", Status: "ready"}, |
| 786 | ) |
| 787 | |
| 788 | res, ref := fx.srv.attachDomain(context.Background(), acct, "story", "echo.example.com", "", "", "paul@keymail.dev") |
| 789 | if ref != nil { |
| 790 | t.Fatalf("attach: refused %+v", ref) |
| 791 | } |
| 792 | if strings.HasSuffix(res.Claim.Route, provision.SidecarSuffix) { |
| 793 | t.Fatalf("attach aliased onto %q — nothing routes to a sidecar", res.Claim.Route) |
| 794 | } |
| 795 | if want := "story." + fx.sqid + ".oncarlos.com"; res.Claim.Route != want { |
| 796 | t.Fatalf("attach aliased onto %q, want the app's real route %q", res.Claim.Route, want) |
| 797 | } |
| 798 | } |
| 799 | |