package console // The browser Settings door for domain attach/detach (appdomains.go, #82 // Task 4): thin handlers over the attachDomain/detachDomain core proven in // domainattach_test.go. What these tests own is the door's OWN shape — // session, CSRF, the owner-only gate matching handleAppDelete's, and // detach's typed-host confirm — not the attach/detach policy itself. import ( "context" "net/http" "net/url" "strings" "testing" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/edgesfile" "github.com/carlosframework/platform/internal/status" ) // TestAppDomainAttachHappyPathAliases exercises the default fixture's kass // app, which already carries one ready route (kass.carlosapp.dev, view_test's // buildFixtures) — the alias branch needs nothing more set up than that. func TestAppDomainAttachHappyPathAliases(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) const host = "docs.example.com" status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), url.Values{"csrf": {csrf}, "host": {host}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") { t.Fatalf("attach = %d -> %q, want 303 -> settings:\n%s", status, loc, body) } claim, err := domains.GetClaim(context.Background(), fx.rel, host) if err != nil { t.Fatalf("claim: %v", err) } if claim.Account != fx.sqid || claim.App != "kass" || claim.Route == "" { t.Errorf("claim = %+v, want an alias claim naming kass's existing route", claim) } } // TestAppDomainAttachNeedsCSRF matches TestRenameHandlerNeedsCSRF's shape: a // cross-site request is refused before the host is ever looked at, and // nothing is claimed. func TestAppDomainAttachNeedsCSRF(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) const host = "docs.example.com" status, _, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), url.Values{"csrf": {"not-the-token"}, "host": {host}}) if status != http.StatusForbidden { t.Fatalf("attach with a bad csrf = %d, want 403:\n%s", status, body) } if !strings.Contains(body, "Cross-site request blocked.") { t.Fatalf("403 body does not name the reason:\n%s", body) } claimAbsent(t, fx, host) } // TestAppDomainAttachLowercasesHost: a member typing Docs.Example.com must // not be refused by ValidHostShape's lowercase-only label regex — the door // normalizes case before the host ever reaches the core, and the claim // lands at the normalized key, not the one typed. func TestAppDomainAttachLowercasesHost(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) const mixed = "Docs.Example.com" const lower = "docs.example.com" status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), url.Values{"csrf": {csrf}, "host": {mixed}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") { t.Fatalf("attach with a mixed-case host = %d -> %q, want 303 -> settings:\n%s", status, loc, body) } if _, err := domains.GetClaim(context.Background(), fx.rel, lower); err != nil { t.Errorf("claim at the lowercase key: %v, want it there", err) } if _, err := domains.GetClaim(context.Background(), fx.rel, mixed); err == nil { t.Error("a claim also landed at the mixed-case key — the door did not normalize") } } // TestAppDomainAttachIsOwnerOnly matches TestAppDeleteIsOwnerOnly's shape: // a member gets the same byte-identical 404 a total stranger would, and // nothing is claimed. func TestAppDomainAttachIsOwnerOnly(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } fx.setUser("sam@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-sam-appdomainattach") csrf := sessionToken(t, fx, c) const host = "docs.example.com" status, _, memberBody := postForm(t, c, fx.u("/apps/kass/domains/attach"), url.Values{"csrf": {csrf}, "host": {host}}) if status != http.StatusNotFound { t.Fatalf("member attaching a domain = %d, want 404", status) } strangerBody := strangerRefusal(t, fx, "code-zoe-appdomainattach", fx.u("/apps/kass/domains/attach"), url.Values{"host": {host}}) if memberBody != strangerBody { t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) } claimAbsent(t, fx, host) } // domainAttachedFixture attaches host to app via the Task 3 core directly // (the doors' shared, already-tested path) and returns the fixture and // signed-in owner client — the setup every detach test needs and none of // them is testing. func domainAttachedFixture(t *testing.T, app, host string) (*consoleFixture, *http.Client, string) { t.Helper() fx := newConsoleFixture(t) acct, err := fx.st.AccountBySqid(fx.sqid) if err != nil || acct == nil { t.Fatalf("fixture account %s: %v", fx.sqid, err) } if _, ref := fx.srv.attachDomain(context.Background(), acct, app, host, "", "", "paul@keymail.dev"); ref != nil { t.Fatalf("attach setup: refused %+v", ref) } c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) return fx, c, csrf } // TestAppDomainDetachNeedsMatchingConfirm is the binding detail from the // brief: a confirm that does not equal the host is refused 422 BEFORE the // core is ever called, so the claim must survive untouched. func TestAppDomainDetachNeedsMatchingConfirm(t *testing.T) { const host = "docs.example.com" fx, c, csrf := domainAttachedFixture(t, "kass", host) status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"), url.Values{"csrf": {csrf}, "host": {host}, "confirm": {"not-" + host}}) if status != http.StatusUnprocessableEntity { t.Fatalf("detach with a wrong confirm = %d, want 422:\n%s", status, body) } if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil { t.Errorf("claim after a refused detach: %v, want it untouched", err) } } // TestAppDomainDetachHappyPath: the typed confirm matches, the claim goes, // and the member lands back on Settings. func TestAppDomainDetachHappyPath(t *testing.T) { const host = "docs.example.com" fx, c, csrf := domainAttachedFixture(t, "kass", host) status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/detach"), url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}) if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") { t.Fatalf("detach = %d -> %q, want 303 -> settings:\n%s", status, loc, body) } claimAbsent(t, fx, host) } // TestAppDomainDetachUnattachedHostIs404 exercises renderDoorRefusal's 404 // branch: the owner's own gates all pass (CSRF, RoleOwner, confirm == host), // but the core has nothing to release, so its doorRefusal{404,...} must // render as the stranger's own byte-identical 404 — never a message that // would tell a prober "an app exists here, but this host does not". func TestAppDomainDetachUnattachedHostIs404(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) const host = "never-attached.example.com" status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"), url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}) if status != http.StatusNotFound { t.Fatalf("detach of an unattached host = %d, want 404:\n%s", status, body) } strangerBody := strangerRefusal(t, fx, "code-zoe-appdomaindetach-unattached", fx.u("/apps/kass/domains/detach"), url.Values{"host": {host}, "confirm": {host}}) if body != strangerBody { t.Fatalf("owner's unattached-host 404 is not byte-identical to the stranger's:\n(owner) %q\n(stranger) %q", body, strangerBody) } claimAbsent(t, fx, host) } // TestAppDomainDetachIsOwnerOnly mirrors the attach case: a member gets the // same byte-identical 404 a stranger would. func TestAppDomainDetachIsOwnerOnly(t *testing.T) { const host = "docs.example.com" fx, _, _ := domainAttachedFixture(t, "kass", host) if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } fx.setUser("sam@keymail.dev") sc := newBrowser(t) signUp(t, fx, sc, "code-sam-appdomaindetach") scsrf := sessionToken(t, fx, sc) status, _, memberBody := postForm(t, sc, fx.u("/apps/kass/domains/detach"), url.Values{"csrf": {scsrf}, "host": {host}, "confirm": {host}}) if status != http.StatusNotFound { t.Fatalf("member detaching a domain = %d, want 404", status) } strangerBody := strangerRefusal(t, fx, "code-zoe-appdomaindetach", fx.u("/apps/kass/domains/detach"), url.Values{"host": {host}, "confirm": {host}}) if memberBody != strangerBody { t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) } if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil { t.Errorf("claim after a member's refused detach: %v, want it untouched", err) } } // TestAppDomainAttachRefusesADeploymentRouteNeutrally: the BROWSER door's // half of the fleet-wide route collision (Task 5 review round 2). Both doors // share attachDomain, which is where the guard now lives — immediately // before each of its two AttachClaim calls — so this door is covered by the // same move that closed the CLI's kind=static branch. This test is what // makes that coverage a fact rather than an inference. // // The property is the one the CLI door keeps: a hostname a live box already // serves as a REAL route is refused with the refusal a hostname held by // another account gets, and the two are indistinguishable. Compared page // against page, not against a literal, so they cannot drift apart. func TestAppDomainAttachRefusesADeploymentRouteNeutrally(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // go.tito.io is a real, NON-ALIAS route published by a live box. Both // halves are required: DeploymentRoutes drops any edges file whose box // has no fresh status snapshot. if _, err := fx.rel.Put(ctx, edgesfile.Key("annie"), []byte(`{"v":1,"box":"annie","eip":"198.51.100.9","hosts":{"go.tito.io":{"role":"home"}}}`), ""); err != nil { t.Fatal(err) } if err := status.WriteBox(ctx, fx.rel, status.Box{Label: "annie", GeneratedAt: time.Now()}); err != nil { t.Fatal(err) } // The yardstick: a hostname held by a different account entirely. if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ Host: "claimed-by-someone-else.com", Account: "cdef", App: "widgets", Route: "widgets.example.net", CreatedAt: time.Now().UTC().Format(time.RFC3339), }); err != nil { t.Fatal(err) } routeStatus, _, routeBody := postForm(t, c, fx.u("/apps/kass/domains/attach"), url.Values{"csrf": {csrf}, "host": {"go.tito.io"}}) heldStatus, _, heldBody := postForm(t, c, fx.u("/apps/kass/domains/attach"), url.Values{"csrf": {csrf}, "host": {"claimed-by-someone-else.com"}}) if routeStatus != http.StatusConflict { t.Fatalf("browser attach of another box's route = %d, want 409:\n%s", routeStatus, routeBody) } if routeStatus != heldStatus || routeBody != heldBody { t.Fatalf("browser refusal %d differs from the held-hostname refusal %d — that is an existence oracle:\n--- route ---\n%s\n--- held ---\n%s", routeStatus, heldStatus, routeBody, heldBody) } claimAbsent(t, fx, "go.tito.io") } // The browser door's own version of the ownership exemption: a hostname this // app ALREADY holds the claim on stays attachable, because a first-route // attach makes the host a real route and the fold would otherwise report the // caller's own route back at them forever. func TestAppDomainAttachStillConvergesTheCallersOwnDeploymentRoute(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) if _, err := fx.rel.Put(ctx, edgesfile.Key("annie"), []byte(`{"v":1,"box":"annie","eip":"198.51.100.9","hosts":{"mine.example.com":{"role":"home"}}}`), ""); err != nil { t.Fatal(err) } if err := status.WriteBox(ctx, fx.rel, status.Box{Label: "annie", GeneratedAt: time.Now()}); err != nil { t.Fatal(err) } if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ Host: "mine.example.com", Account: fx.sqid, App: "kass", CreatedAt: time.Now().UTC().Format(time.RFC3339), }); err != nil { t.Fatal(err) } st, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), url.Values{"csrf": {csrf}, "host": {"mine.example.com"}}) if st != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") { t.Fatalf("re-attach of the caller's own hostname = %d -> %q, want 303 -> settings:\n%s", st, loc, body) } }