| new file mode 100644 |
| index 000000000..57b12a1d1 |
| --- /dev/null |
| +++ b/internal/console/domainattach_test.go |
| @@ -0,0 +1,393 @@ |
| +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" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/domainclaim" |
| + "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 string |
| +} |
| + |
| +func (o *opStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if o.failPut != "" && key == o.failPut { |
| + 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 |
| +} |
| + |
| +// claimAbsent asserts no claim exists for host — the "nothing was written" |
| +// half of every refusal test. |
| +func claimAbsent(t *testing.T, fx *consoleFixture, host string) { |
| + t.Helper() |
| + _, _, err := domainclaim.Get(context.Background(), fx.rel, host) |
| + if !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("claim for %s: got %v, want a claim that was never written", host, err) |
| + } |
| +} |
| + |
| +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" |
| + 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 := domainclaim.Get(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) |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| +} |
| + |
| +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 := domainclaim.Get(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 " + domainclaim.Key(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) |
| + } |
| +} |
| + |
| +// 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 "+domainclaim.Key(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" |
| + held := func(t *testing.T, fx *consoleFixture, c domainclaim.Claim) { |
| + t.Helper() |
| + if err := domainclaim.Create(context.Background(), fx.rel, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + t.Run("another account", func(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + held(t, fx, domainclaim.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 != "host unavailable" { |
| + t.Fatalf("refusal = %+v, want exactly 409 \"host unavailable\"", ref) |
| + } |
| + }) |
| + // The caller's OWN account holding it is the same refusal, byte for |
| + // byte: the message may never be the thing that tells a prober whether |
| + // they hold a host — GET is where you see your own claims. |
| + t.Run("own account", func(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + held(t, fx, domainclaim.Claim{Schema: 1, Host: host, Account: acct.Sqid, App: "story"}) |
| + _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| + if ref == nil || ref.Status != 409 || ref.Msg != "host unavailable" { |
| + t.Fatalf("refusal = %+v, want exactly 409 \"host unavailable\" for the caller's own claim too", ref) |
| + } |
| + }) |
| +} |
| + |
| +func TestAttachPlatformSpaceRefused(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + shipVersion(t, fx, "story", "v1") |
| + seedChannel(t, fx, "story", "canary/live", "v1") |
| + |
| + 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) |
| + } |
| +} |
| + |
| +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 " + domainclaim.Key(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) |
| + } |
| +} |
| + |
| +func TestDetachForeignClaimIs404(t *testing.T) { |
| + fx, acct := attachFixture(t) |
| + const host = "story.carlosframework.com" |
| + if err := domainclaim.Create(context.Background(), fx.rel, |
| + domainclaim.Claim{Schema: 1, Host: host, Account: "zzz", App: "rival"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + 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 := domainclaim.Get(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" |
| + if err := domainclaim.Create(context.Background(), fx.rel, |
| + domainclaim.Claim{Schema: 1, Host: other, Account: acct.Sqid, App: "docs"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + 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) |
| + } |
| +} |