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// 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
16import (
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.
36func 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.
52func 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.
72type 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
83func (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
99func (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.
105func (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.
117func 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.
127func 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
144func 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.
207func 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.
235func 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
274func 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.
329func 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.
364func 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.
388func 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.
420func 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".
445func 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
462func 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
472func 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.
488func 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.
502func 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
520func 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
548func 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
564func 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
615func 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.
641func 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.
665func 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
678func 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.
710func 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
732func 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.
775func 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