package console // The attach/detach core (domainattach.go): the one place both doors — the // browser Settings form and the CLI API — decide what attaching a custom // domain to an app MEANS. The properties that matter, in the order they'd // hurt if lost: platform space is refused before anything is written, an // app with a ready route gets an alias claim and NO route record (a second // route row is the #129/#148 class this design exists to avoid), a lost // claim race is neutral even when the caller's own account holds it (a 409 // that named the holder would be a cross-account leak), the first-route // path writes the CLAIM BEFORE the record (uniqueness is decided before // anything else exists), a record write that fails takes the claim back // with it, and detach unwinds in the reverse order behind a 404 that // cannot tell "not attached" from "someone else's". import ( "context" "errors" "net/url" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // attachFixture is server_test.go's console pointed at an EMPTY world for // attach purposes: no registry path at all (so the ready-route lookup is // skipped and every test starts on the first-route branch) and the // deployment's apps domain set, which is what makes platform space // refusable. Tests that want an alias point cfg.Registry at attachRegistry. func attachFixture(t *testing.T) (*consoleFixture, *Account) { t.Helper() fx := newConsoleFixture(t) fx.srv.cfg.Registry = "" fx.srv.cfg.AppsDomain = "oncarlos.com" acct, err := fx.st.AccountBySqid(fx.sqid) if err != nil { t.Fatal(err) } if acct == nil { t.Fatalf("fixture account %s not found", fx.sqid) } return fx, acct } // attachRegistry builds a registry holding routes and returns its path. func attachRegistry(t *testing.T, routes ...registry.Route) string { t.Helper() path := filepath.Join(t.TempDir(), "registry.db") reg, err := registry.Open(path) if err != nil { t.Fatal(err) } defer reg.Close() for _, rt := range routes { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } return path } // opStore records every store write in order — the only way to observe // "claim THEN record" (and detach's reverse) rather than merely "both // landed". failPut names one key whose Put fails, which is how the // half-failed attach is reproduced without a broken bucket. type opStore struct { release.Store ops []string // failPut names a key whose Put fails before it lands. landThenFail // makes the same key LAND and then report failure — the ambiguous // outcome a timeout produces, and the one a one-sided rollback leaks a // record through. failPut string landThenFail bool } func (o *opStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { if o.failPut != "" && key == o.failPut { if o.landThenFail { o.ops = append(o.ops, "put-then-fail "+key) if _, err := o.Store.Put(ctx, key, body, ifMatch); err != nil { return "", err } return "", errors.New("opStore: timed out after the object landed") } o.ops = append(o.ops, "put-fail "+key) return "", errors.New("opStore: bucket unavailable") } o.ops = append(o.ops, "put "+key) return o.Store.Put(ctx, key, body, ifMatch) } func (o *opStore) Delete(ctx context.Context, key string) error { o.ops = append(o.ops, "delete "+key) return o.Store.Delete(ctx, key) } // opIndex is where an op appears in the log, or -1. func (o *opStore) opIndex(op string) int { for i, got := range o.ops { if got == op { return i } } return -1 } // seedClaim plants a pre-existing claim — the "someone already holds this // host" fixture. AttachClaim is the one claim writer left (#151), and // creating against an empty key is its create path. func seedClaim(t *testing.T, fx *consoleFixture, c domains.Claim) { t.Helper() created, _, err := domains.AttachClaim(context.Background(), fx.rel, c) if err != nil || !created { t.Fatalf("seed claim %s: created=%v err=%v", c.Host, created, err) } } // auditedOnce asserts exactly one audit row for the action, naming host — // the record a member (and an incident) reads the door's history from. func auditedOnce(t *testing.T, fx *consoleFixture, acct *Account, app, action, host string) { t.Helper() events, err := fx.st.Events(acct.ID, app, 20) if err != nil { t.Fatal(err) } n := 0 for _, e := range events { if e.Action == action && strings.Contains(e.Detail, host) { n++ } } if n != 1 { t.Errorf("audit rows for %s %s = %d, want 1 (rows: %+v)", action, host, n, events) } } func TestAttachAliasWhenAppHasReadyRoute(t *testing.T) { fx, acct := attachFixture(t) // Three candidate rows in host order: a platform mint and a route that // is not ready come FIRST alphabetically, so picking the third proves // the filter rather than "picked the only one". fx.srv.cfg.Registry = attachRegistry(t, registry.Route{Host: "aaa." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", Account: fx.sqid, Channel: "canary/live", Status: "ready", Platform: true}, registry.Route{Host: "bbb." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", Account: fx.sqid, Channel: "canary/live", Status: "pending"}, registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", Account: fx.sqid, Channel: "canary/live", Status: "ready"}, registry.Route{Host: "other." + fx.sqid + ".oncarlos.com", Kind: "static", App: "other", Account: fx.sqid, Channel: "canary/live", Status: "ready"}, ) const host = "story.carlosframework.com" ops := &opStore{Store: fx.rel} fx.srv.cfg.Releases = ops res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref != nil { t.Fatalf("attach: refused %+v", ref) } if want := "story." + fx.sqid + ".oncarlos.com"; res.Claim.Route != want { t.Errorf("claim route = %q, want the app's first non-platform ready route %q", res.Claim.Route, want) } if res.FirstRoute { t.Error("FirstRoute = true for an app that already has a route to alias") } if res.Channel != "" { t.Errorf("Channel = %q, want empty for an alias attach", res.Channel) } got, err := domains.GetClaim(context.Background(), fx.rel, host) if err != nil { t.Fatalf("claim: %v", err) } if got.Schema != 1 || got.Host != host || got.Account != fx.sqid || got.App != "story" || got.Route != res.Claim.Route || got.CreatedBy != "paul@keymail.dev" { t.Errorf("stored claim = %+v, want the returned claim %+v", got, res.Claim) } keys, err := fx.rel.List(context.Background(), provision.RecordsPrefix(fx.sqid, "story")) if err != nil { t.Fatal(err) } if len(keys) != 0 { t.Errorf("records after an alias attach = %v, want none (the alias is derived box-side)", keys) } // The alias branch owes the same two side effects the first-route // branch does: both doors get them from here, or neither does. auditedOnce(t, fx, acct, "story", "domain-attach", host) claimPut := ops.opIndex("put " + domains.ClaimKey(host)) serial := ops.opIndex("put " + release.SerialKey) if claimPut < 0 || serial < claimPut { t.Errorf("ops %v: want the serial bumped after the claim it announces (claim=%d serial=%d)", ops.ops, claimPut, serial) } } // TestAttachNeverAliasesAHostToItself is the operator-added custom domain // (story.carlosframework.com, spec §7) arriving at the member's door: a // ready route for the very host being attached must not become the claim's // alias target — a claim naming its own host is a self-alias nothing can // resolve. The attach lands on the first-route branch instead, where the // declared record describes the row that already exists and #137's // adopt-on-match transfers ownership. func TestAttachNeverAliasesAHostToItself(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") const host = "story.carlosframework.com" fx.srv.cfg.Registry = attachRegistry(t, registry.Route{Host: host, Kind: "static", App: "story", Account: fx.sqid, Channel: "canary/live", Status: "ready"}, ) res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref != nil { t.Fatalf("attach: refused %+v", ref) } if res.Claim.Route != "" || !res.FirstRoute { t.Errorf("result = %+v, want a first-route attach with an empty claim route", res) } if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); err != nil { t.Errorf("record: %v, want a declared record describing the existing route", err) } } // TestAttachBackfillTakesChannelFromTheExistingRoute is the spec-owner's // ruling: on the self-host backfill the record's channel comes from the row // that already serves the host, not from the app's channel listing. Two // things turn on it — #137's adoption converges the record's channel onto // that live row, so any other value SILENTLY REPOINTS a running operator // route, and an app with several channels would otherwise be refused as // ambiguous for a domain whose channel was never in question. func TestAttachBackfillTakesChannelFromTheExistingRoute(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") seedChannel(t, fx, "story", "stable", "v1") // ambiguous, if the listing decided const host = "story.carlosframework.com" fx.srv.cfg.Registry = attachRegistry(t, registry.Route{Host: host, Kind: "static", App: "story", Account: fx.sqid, Channel: "canary/live", Status: "ready"}, ) res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref != nil { t.Fatalf("attach: refused %+v, want the backfill to go through on the row's own channel", ref) } if res.Claim.Route != "" || !res.FirstRoute || res.Channel != "canary/live" { t.Errorf("result = %+v, want a first-route attach on canary/live with an empty claim route", res) } rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host) if err != nil { t.Fatalf("record: %v", err) } if rec.Channel != "canary/live" { t.Errorf("record channel = %q, want canary/live — the channel the live route already follows", rec.Channel) } // An explicit flag still wins: repointing is allowed when it is asked for. const other = "docs.carlosframework.com" fx.srv.cfg.Registry = attachRegistry(t, registry.Route{Host: other, Kind: "static", App: "story", Account: fx.sqid, Channel: "canary/live", Status: "ready"}, ) res, ref = fx.srv.attachDomain(context.Background(), acct, "story", other, "", "stable", "paul@keymail.dev") if ref != nil { t.Fatalf("attach with --channel stable: refused %+v", ref) } if res.Channel != "stable" { t.Errorf("Channel = %q, want the explicitly named stable", res.Channel) } } func TestAttachFirstRouteWritesClaimThenRecord(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") ops := &opStore{Store: fx.rel} fx.srv.cfg.Releases = ops const host = "story.carlosframework.com" res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref != nil { t.Fatalf("attach: refused %+v", ref) } if !res.FirstRoute { t.Error("FirstRoute = false for an app with no ready route") } if res.Channel != "canary/live" { t.Errorf("Channel = %q, want the app's sole shipped channel canary/live", res.Channel) } if res.Claim.Route != "" { t.Errorf("claim route = %q, want empty — this host IS its own primary", res.Claim.Route) } claim, err := domains.GetClaim(context.Background(), fx.rel, host) if err != nil { t.Fatalf("claim: %v", err) } if claim.Route != "" || claim.Account != fx.sqid || claim.App != "story" { t.Errorf("stored claim = %+v", claim) } rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host) if err != nil { t.Fatalf("record: %v", err) } if rec.Schema != 1 || rec.Kind != "static" || rec.Channel != "canary/live" || rec.CreatedBy != "paul@keymail.dev" || rec.Pool != "" || rec.Region != "" { t.Errorf("record = %+v, want a schema-1 static record on canary/live in the shared pool", *rec) } claimPut := ops.opIndex("put " + domains.ClaimKey(host)) recPut := ops.opIndex("put " + provision.RecordKey(fx.sqid, "story", host)) if claimPut < 0 || recPut < 0 || claimPut > recPut { t.Errorf("write order %v: want the claim put before the record put (claim=%d record=%d)", ops.ops, claimPut, recPut) } // The two things the doors get for free by this living in the core: the // audit row, and the serial bump that makes the fleet converge in // seconds instead of at the hourly floor. auditedOnce(t, fx, acct, "story", "domain-attach", host) if serial := ops.opIndex("put " + release.SerialKey); serial < recPut { t.Errorf("ops %v: want the serial bumped AFTER the writes it announces (serial=%d record=%d)", ops.ops, serial, recPut) } } // TestAttachIdempotentWhenRecordAlreadyExists is the retry after a partial // attach (and the claim-released-but-record-kept shape): the record key is // account+app scoped, so a create that loses the race there lost it to this // app's own record. Refusing would make the host permanently unattachable — // the claim would roll back on every retry and detach would 404. func TestAttachIdempotentWhenRecordAlreadyExists(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") const host = "story.carlosframework.com" // A record left behind by an earlier attach, on a DIFFERENT channel so // the answer proves which one is reported. if err := provision.CreateRecord(context.Background(), fx.rel, provision.Record{ Schema: 1, Host: host, App: "story", Account: fx.sqid, Kind: "static", Channel: "stable", }); err != nil { t.Fatal(err) } res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref != nil { t.Fatalf("attach: refused %+v, want the idempotent success", ref) } if !res.FirstRoute || res.Channel != "stable" { t.Errorf("result = %+v, want FirstRoute with the EXISTING record's channel", res) } if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil { t.Errorf("claim: %v — the claim this call won must survive the idempotent path", err) } // And the second call is idempotent too (#151's same-owner contract): // claim and record both stand, nothing is written, Created is false. res, ref = fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref != nil { t.Errorf("second attach = %+v, want the idempotent success", ref) } else if res.Created { t.Error("second attach: Created = true, want false — the claim already existed") } } // TestAttachUnreadableExistingRecordRefuses422 is the other half: a record // at the key that cannot be read as this app's (malformed, or a body that // disagrees with its own path) is not ours to overwrite or delete. func TestAttachUnreadableExistingRecordRefuses422(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") const host = "story.carlosframework.com" if _, err := fx.rel.Put(context.Background(), provision.RecordKey(fx.sqid, "story", host), []byte("{not json"), ""); err != nil { t.Fatal(err) } _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref == nil || ref.Status != 422 { t.Fatalf("refusal = %+v, want 422 for an unreadable record at the key", ref) } if strings.Contains(ref.Msg, fx.sqid) { t.Errorf("msg %q leaks identity — the member learns a record exists, nothing more", ref.Msg) } claimAbsent(t, fx, host) } // TestAttachInstanceRecordAtTheKeyIsNotAdopted is the narrow half of the // idempotent branch: an app with a custom manifest Domain can have `carlos // instances create` write an INSTANCE record at this very key. Treating one // of those as "an attach that already happened" would hand detach the // authority to retire a tenant instance this door never created. func TestAttachInstanceRecordAtTheKeyIsNotAdopted(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") const host = "story.carlosframework.com" // Kind "" is an instance record — what reconcile minted before Kind // existed, and what the instance door writes today. instance := provision.Record{ Schema: 1, Host: host, App: "story", Account: fx.sqid, Slug: "story", Channel: "stable", } if err := provision.CreateRecord(context.Background(), fx.rel, instance); err != nil { t.Fatal(err) } _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref == nil || ref.Status != 422 { t.Fatalf("refusal = %+v, want 422 — an instance record is not an attach to adopt", ref) } claimAbsent(t, fx, host) got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host) if err != nil { t.Fatalf("record: %v, want the instance record untouched", err) } if got.Kind != "" || got.Slug != "story" || got.Channel != "stable" { t.Errorf("record = %+v, want the instance record exactly as it was", *got) } } // TestAttachAmbiguousRecordFailureUnwindsBoth: a write that LANDS and then // reports failure (a timeout) must not leave the record behind the claim it // rolls back — that record would materialize a route for a host anyone may // now claim. func TestAttachAmbiguousRecordFailureUnwindsBoth(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") const host = "story.carlosframework.com" ops := &opStore{Store: fx.rel, failPut: provision.RecordKey(fx.sqid, "story", host), landThenFail: true} fx.srv.cfg.Releases = ops _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref == nil || ref.Status != 500 { t.Fatalf("refusal = %+v, want 500 for an ambiguous record write", ref) } claimAbsent(t, fx, host) if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); !errors.Is(err, os.ErrNotExist) { t.Errorf("record after the rollback: %v, want it gone — a record outliving its claim is the leak", err) } recDel := ops.opIndex("delete " + provision.RecordKey(fx.sqid, "story", host)) claimDel := ops.opIndex("delete " + domains.ClaimKey(host)) if recDel < 0 || claimDel < 0 || recDel > claimDel { t.Errorf("ops %v: want the record dropped before the claim (record=%d claim=%d)", ops.ops, recDel, claimDel) } } // TestAttachClaimWriteFailureIs500 pins the non-CAS fallback: only a lost // race is "hostname unavailable". func TestAttachClaimWriteFailureIs500(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") const host = "story.carlosframework.com" fx.srv.cfg.Releases = &opStore{Store: fx.rel, failPut: domains.ClaimKey(host)} _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref == nil || ref.Status != 500 { t.Fatalf("refusal = %+v, want 500 for a claim write that failed for reasons other than a lost race", ref) } if ref.Msg == "hostname unavailable" { t.Error("a broken bucket must not read as a taken host") } claimAbsent(t, fx, host) } func TestAttachWithNothingShippedRefused(t *testing.T) { fx, acct := attachFixture(t) const host = "story.carlosframework.com" _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref == nil || ref.Status != 422 || ref.Msg != "nothing shipped yet — carlos ship first" { t.Fatalf("refusal = %+v, want 422 \"nothing shipped yet — carlos ship first\"", ref) } claimAbsent(t, fx, host) } func TestAttachInvalidChannelFlagRefused(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") const host = "story.carlosframework.com" for _, bad := range []string{"../escape", "a/b/c", "canary live"} { _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", bad, "paul@keymail.dev") if ref == nil || ref.Status != 422 { t.Fatalf("channel %q: refusal = %+v, want 422", bad, ref) } claimAbsent(t, fx, host) } } // TestAttachWithoutBucketIs503: a console with no deployment bucket has // nowhere to put a claim, and says so rather than panicking on a nil store. func TestAttachWithoutBucketIs503(t *testing.T) { fx, acct := attachFixture(t) fx.srv.cfg.Releases = nil if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "story.carlosframework.com", "", "", "paul@keymail.dev"); ref == nil || ref.Status != 503 { t.Errorf("attach refusal = %+v, want 503", ref) } if ref := fx.srv.detachDomain(context.Background(), acct, "story", "story.carlosframework.com", "paul@keymail.dev"); ref == nil || ref.Status != 503 { t.Errorf("detach refusal = %+v, want 503", ref) } } // TestAttachFirstRouteRollsBackClaimOnRecordFailure is the half-failed // attach: the claim landed, the record did not, and the host must be left // attachable again rather than claimed by an app with no route. func TestAttachFirstRouteRollsBackClaimOnRecordFailure(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") const host = "story.carlosframework.com" ops := &opStore{Store: fx.rel, failPut: provision.RecordKey(fx.sqid, "story", host)} fx.srv.cfg.Releases = ops _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref == nil || ref.Status != 500 { t.Fatalf("refusal = %+v, want a 500 for a record write that failed", ref) } claimAbsent(t, fx, host) if ops.opIndex("delete "+domains.ClaimKey(host)) < 0 { t.Errorf("ops %v: want the claim deleted after the record write failed", ops.ops) } } func TestAttachAmbiguousChannelRefusesNamingAll(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") seedChannel(t, fx, "story", "stable", "v1") const host = "story.carlosframework.com" _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref == nil || ref.Status != 422 { t.Fatalf("refusal = %+v, want 422 for two channels and no flag", ref) } for _, name := range []string{"canary/live", "stable"} { if !strings.Contains(ref.Msg, name) { t.Errorf("msg %q does not name channel %q — the member cannot pick one from it", ref.Msg, name) } } claimAbsent(t, fx, host) // The same attach with the flag supplied goes through. res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "stable", "paul@keymail.dev") if ref != nil { t.Fatalf("attach with --channel stable: refused %+v", ref) } if res.Channel != "stable" { t.Errorf("Channel = %q, want the named stable", res.Channel) } } func TestAttachProxiedKindWithoutRouteRefused(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") const host = "story.carlosframework.com" _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "instance", "", "paul@keymail.dev") if ref == nil || ref.Status != 422 { t.Fatalf("refusal = %+v, want 422 for a proxied kind with no process to serve", ref) } if !strings.Contains(ref.Msg, "static") { t.Errorf("msg %q does not say what IS attachable", ref.Msg) } claimAbsent(t, fx, host) } func TestAttachHeldHostIsNeutral409(t *testing.T) { const host = "story.carlosframework.com" t.Run("another account", func(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") seedClaim(t, fx, domains.Claim{Schema: 1, Host: host, Account: "zzz", App: "rival"}) _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref == nil || ref.Status != 409 || ref.Msg != "hostname unavailable" { t.Fatalf("refusal = %+v, want exactly 409 \"hostname unavailable\"", ref) } }) // This account's OTHER app holding it is the same refusal, byte for // byte: the message may never be the thing that tells a prober WHICH // owner stands in the way — GET is where you see your own claims. t.Run("own account, another app", func(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") seedClaim(t, fx, domains.Claim{Schema: 1, Host: host, Account: acct.Sqid, App: "docs"}) _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref == nil || ref.Status != 409 || ref.Msg != "hostname unavailable" { t.Fatalf("refusal = %+v, want exactly 409 \"hostname unavailable\" for the caller's own other app too", ref) } }) // The SAME app re-attaching is not a refusal at all any more: #151's // claim writer treats it as a converge (titogo's router re-attaches on // every reload pass), and the core inherits that idempotency — Created // false, nothing re-audited when nothing was written. t.Run("same app is idempotent", func(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref != nil { t.Fatalf("first attach: refused %+v", ref) } if !res.Created { t.Error("first attach: Created = false, want true") } res, ref = fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref != nil { t.Fatalf("re-attach: refused %+v, want the idempotent success", ref) } if res.Created { t.Error("re-attach: Created = true, want false — the claim already existed") } auditedOnce(t, fx, acct, "story", "domain-attach", host) }) } func TestAttachPlatformSpaceRefused(t *testing.T) { fx, acct := attachFixture(t) fx.srv.cfg.ReservePlatformDomains = true shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") // "../control/serial" is the traversal shape (domainpolicy_test.go's // case, ported): a host that would walk out of the domains/ prefix must // die at the shape check, never reach a bucket key — claimAbsent can't // even ASK about it (the store refuses the key), which is the point. if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "../control/serial", "", "", "paul@keymail.dev"); ref == nil || ref.Status != 422 { t.Fatalf("attach traversal host: refusal = %+v, want 422", ref) } for _, host := range []string{"oncarlos.com", "story." + fx.sqid + ".oncarlos.com", "not a host"} { _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref == nil || ref.Status != 422 { t.Fatalf("attach %q: refusal = %+v, want 422", host, ref) } claimAbsent(t, fx, host) } } // TestAttachPlatformSpaceOpenByDefault: without ReservePlatformDomains, the // form door (attachDomain) accepts a name under the apps domain, even as // the app's first route. This is the self-hosted case. Only the console's // own hostname is still refused. func TestAttachPlatformSpaceOpenByDefault(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") const host = "blog.oncarlos.com" if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev"); ref != nil { t.Fatalf("attach %q under the apps domain, unreserved: refused %+v", host, ref) } origin, err := url.Parse(fx.srv.cfg.Origin) if err != nil { t.Fatal(err) } if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", origin.Hostname(), "", "", "paul@keymail.dev"); ref == nil || ref.Status != 422 { t.Fatalf("attach the console's own origin host: refusal = %+v, want 422", ref) } } // TestAttachRefusesReservedSidecarSuffix: the attach door must refuse a // custom domain under provision.SidecarSuffix — never a panic, the family's // ordinary 422 (the same status HostDenied's platform-space refusal above // uses), and no claim left behind either. Defence in depth behind Task 3's // structural refusal to SERVE a Kind=="sidecar" row: this stops the OTHER // direction, a member attaching an unrelated app under a reserved name. func TestAttachRefusesReservedSidecarSuffix(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") host := "bot.eleven.bdm" + provision.SidecarSuffix _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref == nil || ref.Status != 422 { t.Fatalf("attach %q: refusal = %+v, want 422", host, ref) } claimAbsent(t, fx, host) } func TestDetachFirstRouteDeletesRecordThenClaim(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") const host = "story.carlosframework.com" if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev"); ref != nil { t.Fatalf("attach: refused %+v", ref) } ops := &opStore{Store: fx.rel} fx.srv.cfg.Releases = ops if ref := fx.srv.detachDomain(context.Background(), acct, "story", host, "paul@keymail.dev"); ref != nil { t.Fatalf("detach: refused %+v", ref) } claimAbsent(t, fx, host) if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); !errors.Is(err, os.ErrNotExist) { t.Errorf("record after detach: %v, want gone", err) } recDel := ops.opIndex("delete " + provision.RecordKey(fx.sqid, "story", host)) claimDel := ops.opIndex("delete " + domains.ClaimKey(host)) if recDel < 0 || claimDel < 0 || recDel > claimDel { t.Errorf("ops %v: want the record deleted before the claim (record=%d claim=%d)", ops.ops, recDel, claimDel) } auditedOnce(t, fx, acct, "story", "domain-detach", host) if serial := ops.opIndex("put " + release.SerialKey); serial < claimDel { t.Errorf("ops %v: want the serial bumped after the deletes it announces (serial=%d claim=%d)", ops.ops, serial, claimDel) } } // TestDetachAliasLeavesRecordsAlone: an alias claim (Route set) never had a // record of its own, and detaching it must not delete at that key — the app // may hold a record there for a route this claim only pointed at. func TestDetachAliasLeavesRecordsAlone(t *testing.T) { fx, acct := attachFixture(t) const host = "echo.example.com" fx.srv.cfg.Registry = attachRegistry(t, registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", Account: fx.sqid, Channel: "canary/live", Status: "ready"}, ) res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") if ref != nil || res.Claim.Route == "" { t.Fatalf("attach: %+v / %+v, want an alias claim", res, ref) } ops := &opStore{Store: fx.rel} fx.srv.cfg.Releases = ops if ref := fx.srv.detachDomain(context.Background(), acct, "story", host, "paul@keymail.dev"); ref != nil { t.Fatalf("detach: refused %+v", ref) } claimAbsent(t, fx, host) if i := ops.opIndex("delete " + provision.RecordKey(fx.sqid, "story", host)); i >= 0 { t.Errorf("ops %v: an alias detach deleted a record key it never wrote", ops.ops) } } func TestDetachForeignClaimIs404(t *testing.T) { fx, acct := attachFixture(t) const host = "story.carlosframework.com" seedClaim(t, fx, domains.Claim{Schema: 1, Host: host, Account: "zzz", App: "rival"}) ref := fx.srv.detachDomain(context.Background(), acct, "story", host, "paul@keymail.dev") if ref == nil || ref.Status != 404 { t.Fatalf("refusal = %+v, want 404 for another account's claim", ref) } // The claim survives: a foreign detach must not be a delete door. if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil { t.Fatalf("claim after a foreign detach: %v, want it untouched", err) } // A claim held by this account for a DIFFERENT app is the same refusal. const other = "docs.carlosframework.com" seedClaim(t, fx, domains.Claim{Schema: 1, Host: other, Account: acct.Sqid, App: "docs"}) if ref := fx.srv.detachDomain(context.Background(), acct, "story", other, "paul@keymail.dev"); ref == nil || ref.Status != 404 { t.Fatalf("refusal = %+v, want 404 for this account's OTHER app's claim", ref) } // And a host nobody has claimed. if ref := fx.srv.detachDomain(context.Background(), acct, "story", "unclaimed.example.com", "paul@keymail.dev"); ref == nil || ref.Status != 404 { t.Fatalf("refusal = %+v, want 404 for an unclaimed host", ref) } } // TestAttachNeverAliasesOntoASidecar is the reserved-suffix refusal above // taken from the other end. That one guards the host being ATTACHED; this // one guards the host it is attached TO. // // readyRouteHost picks the alias source, and its doc says the selection is // domains.firstReadyUpstream's "minus the upstream copy". The copy is not // all that went: firstReadyUpstream also REQUIRES an upstream // (Socket/Addr, or a channel for a static), and that requirement is the // whole of what keeps a sidecar out of it — a sidecar is precisely the row // with no socket and no addr. Dropping the clause dropped the guard with // it, and everything left in the filter passes a sidecar: it belongs to the // app, it belongs to the account, it is not Platform, and it is ready, // because ready is what makes it runnable. // // Host order decides which row wins, so a bot sorts ahead of most hosts an // app would actually serve from. The member gets a 200 and a claim naming a // sidecar as its Route; the box then refuses that claim forever // (ReconcileAliases' own c.Route check), so the domain never serves and // nothing ever says why. func TestAttachNeverAliasesOntoASidecar(t *testing.T) { fx, acct := attachFixture(t) shipVersion(t, fx, "story", "v1") seedChannel(t, fx, "story", "canary/live", "v1") // Host order is reg.List's, and "bot." sorts before "story." — the // sidecar is the FIRST candidate, not a straggler behind the real route. fx.srv.cfg.Registry = attachRegistry(t, registry.Route{Host: "bot.story." + fx.sqid + provision.SidecarSuffix, Kind: provision.SidecarKind, App: "story", Account: fx.sqid, Status: "ready"}, registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story", Account: fx.sqid, Channel: "canary/live", Status: "ready"}, ) res, ref := fx.srv.attachDomain(context.Background(), acct, "story", "echo.example.com", "", "", "paul@keymail.dev") if ref != nil { t.Fatalf("attach: refused %+v", ref) } if strings.HasSuffix(res.Claim.Route, provision.SidecarSuffix) { t.Fatalf("attach aliased onto %q — nothing routes to a sidecar", res.Claim.Route) } if want := "story." + fx.sqid + ".oncarlos.com"; res.Claim.Route != want { t.Fatalf("attach aliased onto %q, want the app's real route %q", res.Claim.Route, want) } }