| new file mode 100644 |
| index 000000000..702937843 |
| --- /dev/null |
| +++ b/internal/console/appdomains_test.go |
| @@ -0,0 +1,165 @@ |
| +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/domainclaim" |
| +) |
| + |
| +// 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") { |
| + t.Fatalf("attach = %d -> %q, want 303 -> settings:\n%s", status, loc, body) |
| + } |
| + claim, _, err := domainclaim.Get(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) |
| +} |
| + |
| +// 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 := domainclaim.Get(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") { |
| + t.Fatalf("detach = %d -> %q, want 303 -> settings:\n%s", status, loc, body) |
| + } |
| + 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 := domainclaim.Get(context.Background(), fx.rel, host); err != nil { |
| + t.Errorf("claim after a member's refused detach: %v, want it untouched", err) |
| + } |
| +} |