| 1 | package console |
| 2 | |
| 3 | // The browser Settings door for domain attach/detach (appdomains.go, #82 |
| 4 | // Task 4): thin handlers over the attachDomain/detachDomain core proven in |
| 5 | // domainattach_test.go. What these tests own is the door's OWN shape — |
| 6 | // session, CSRF, the owner-only gate matching handleAppDelete's, and |
| 7 | // detach's typed-host confirm — not the attach/detach policy itself. |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "net/http" |
| 12 | "net/url" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/domains" |
| 18 | "github.com/carlosframework/platform/internal/edgesfile" |
| 19 | "github.com/carlosframework/platform/internal/status" |
| 20 | ) |
| 21 | |
| 22 | // TestAppDomainAttachHappyPathAliases exercises the default fixture's kass |
| 23 | // app, which already carries one ready route (kass.carlosapp.dev, view_test's |
| 24 | // buildFixtures) — the alias branch needs nothing more set up than that. |
| 25 | func TestAppDomainAttachHappyPathAliases(t *testing.T) { |
| 26 | fx := newConsoleFixture(t) |
| 27 | c := signInPaul(t, fx) |
| 28 | csrf := sessionToken(t, fx, c) |
| 29 | const host = "docs.example.com" |
| 30 | |
| 31 | status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| 32 | url.Values{"csrf": {csrf}, "host": {host}}) |
| 33 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") { |
| 34 | t.Fatalf("attach = %d -> %q, want 303 -> settings:\n%s", status, loc, body) |
| 35 | } |
| 36 | claim, err := domains.GetClaim(context.Background(), fx.rel, host) |
| 37 | if err != nil { |
| 38 | t.Fatalf("claim: %v", err) |
| 39 | } |
| 40 | if claim.Account != fx.sqid || claim.App != "kass" || claim.Route == "" { |
| 41 | t.Errorf("claim = %+v, want an alias claim naming kass's existing route", claim) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // TestAppDomainAttachNeedsCSRF matches TestRenameHandlerNeedsCSRF's shape: a |
| 46 | // cross-site request is refused before the host is ever looked at, and |
| 47 | // nothing is claimed. |
| 48 | func TestAppDomainAttachNeedsCSRF(t *testing.T) { |
| 49 | fx := newConsoleFixture(t) |
| 50 | c := signInPaul(t, fx) |
| 51 | const host = "docs.example.com" |
| 52 | |
| 53 | status, _, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| 54 | url.Values{"csrf": {"not-the-token"}, "host": {host}}) |
| 55 | if status != http.StatusForbidden { |
| 56 | t.Fatalf("attach with a bad csrf = %d, want 403:\n%s", status, body) |
| 57 | } |
| 58 | if !strings.Contains(body, "Cross-site request blocked.") { |
| 59 | t.Fatalf("403 body does not name the reason:\n%s", body) |
| 60 | } |
| 61 | claimAbsent(t, fx, host) |
| 62 | } |
| 63 | |
| 64 | // TestAppDomainAttachLowercasesHost: a member typing Docs.Example.com must |
| 65 | // not be refused by ValidHostShape's lowercase-only label regex — the door |
| 66 | // normalizes case before the host ever reaches the core, and the claim |
| 67 | // lands at the normalized key, not the one typed. |
| 68 | func TestAppDomainAttachLowercasesHost(t *testing.T) { |
| 69 | fx := newConsoleFixture(t) |
| 70 | c := signInPaul(t, fx) |
| 71 | csrf := sessionToken(t, fx, c) |
| 72 | const mixed = "Docs.Example.com" |
| 73 | const lower = "docs.example.com" |
| 74 | |
| 75 | status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| 76 | url.Values{"csrf": {csrf}, "host": {mixed}}) |
| 77 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") { |
| 78 | t.Fatalf("attach with a mixed-case host = %d -> %q, want 303 -> settings:\n%s", status, loc, body) |
| 79 | } |
| 80 | if _, err := domains.GetClaim(context.Background(), fx.rel, lower); err != nil { |
| 81 | t.Errorf("claim at the lowercase key: %v, want it there", err) |
| 82 | } |
| 83 | if _, err := domains.GetClaim(context.Background(), fx.rel, mixed); err == nil { |
| 84 | t.Error("a claim also landed at the mixed-case key — the door did not normalize") |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // TestAppDomainAttachIsOwnerOnly matches TestAppDeleteIsOwnerOnly's shape: |
| 89 | // a member gets the same byte-identical 404 a total stranger would, and |
| 90 | // nothing is claimed. |
| 91 | func TestAppDomainAttachIsOwnerOnly(t *testing.T) { |
| 92 | fx := newConsoleFixture(t) |
| 93 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | fx.setUser("sam@keymail.dev") |
| 97 | c := newBrowser(t) |
| 98 | signUp(t, fx, c, "code-sam-appdomainattach") |
| 99 | csrf := sessionToken(t, fx, c) |
| 100 | const host = "docs.example.com" |
| 101 | |
| 102 | status, _, memberBody := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| 103 | url.Values{"csrf": {csrf}, "host": {host}}) |
| 104 | if status != http.StatusNotFound { |
| 105 | t.Fatalf("member attaching a domain = %d, want 404", status) |
| 106 | } |
| 107 | strangerBody := strangerRefusal(t, fx, "code-zoe-appdomainattach", fx.u("/apps/kass/domains/attach"), |
| 108 | url.Values{"host": {host}}) |
| 109 | if memberBody != strangerBody { |
| 110 | t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| 111 | } |
| 112 | claimAbsent(t, fx, host) |
| 113 | } |
| 114 | |
| 115 | // domainAttachedFixture attaches host to app via the Task 3 core directly |
| 116 | // (the doors' shared, already-tested path) and returns the fixture and |
| 117 | // signed-in owner client — the setup every detach test needs and none of |
| 118 | // them is testing. |
| 119 | func domainAttachedFixture(t *testing.T, app, host string) (*consoleFixture, *http.Client, string) { |
| 120 | t.Helper() |
| 121 | fx := newConsoleFixture(t) |
| 122 | acct, err := fx.st.AccountBySqid(fx.sqid) |
| 123 | if err != nil || acct == nil { |
| 124 | t.Fatalf("fixture account %s: %v", fx.sqid, err) |
| 125 | } |
| 126 | if _, ref := fx.srv.attachDomain(context.Background(), acct, app, host, "", "", "paul@keymail.dev"); ref != nil { |
| 127 | t.Fatalf("attach setup: refused %+v", ref) |
| 128 | } |
| 129 | c := signInPaul(t, fx) |
| 130 | csrf := sessionToken(t, fx, c) |
| 131 | return fx, c, csrf |
| 132 | } |
| 133 | |
| 134 | // TestAppDomainDetachNeedsMatchingConfirm is the binding detail from the |
| 135 | // brief: a confirm that does not equal the host is refused 422 BEFORE the |
| 136 | // core is ever called, so the claim must survive untouched. |
| 137 | func TestAppDomainDetachNeedsMatchingConfirm(t *testing.T) { |
| 138 | const host = "docs.example.com" |
| 139 | fx, c, csrf := domainAttachedFixture(t, "kass", host) |
| 140 | |
| 141 | status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"), |
| 142 | url.Values{"csrf": {csrf}, "host": {host}, "confirm": {"not-" + host}}) |
| 143 | if status != http.StatusUnprocessableEntity { |
| 144 | t.Fatalf("detach with a wrong confirm = %d, want 422:\n%s", status, body) |
| 145 | } |
| 146 | if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil { |
| 147 | t.Errorf("claim after a refused detach: %v, want it untouched", err) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // TestAppDomainDetachHappyPath: the typed confirm matches, the claim goes, |
| 152 | // and the member lands back on Settings. |
| 153 | func TestAppDomainDetachHappyPath(t *testing.T) { |
| 154 | const host = "docs.example.com" |
| 155 | fx, c, csrf := domainAttachedFixture(t, "kass", host) |
| 156 | |
| 157 | status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/detach"), |
| 158 | url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}) |
| 159 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") { |
| 160 | t.Fatalf("detach = %d -> %q, want 303 -> settings:\n%s", status, loc, body) |
| 161 | } |
| 162 | claimAbsent(t, fx, host) |
| 163 | } |
| 164 | |
| 165 | // TestAppDomainDetachUnattachedHostIs404 exercises renderDoorRefusal's 404 |
| 166 | // branch: the owner's own gates all pass (CSRF, RoleOwner, confirm == host), |
| 167 | // but the core has nothing to release, so its doorRefusal{404,...} must |
| 168 | // render as the stranger's own byte-identical 404 — never a message that |
| 169 | // would tell a prober "an app exists here, but this host does not". |
| 170 | func TestAppDomainDetachUnattachedHostIs404(t *testing.T) { |
| 171 | fx := newConsoleFixture(t) |
| 172 | c := signInPaul(t, fx) |
| 173 | csrf := sessionToken(t, fx, c) |
| 174 | const host = "never-attached.example.com" |
| 175 | |
| 176 | status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"), |
| 177 | url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}) |
| 178 | if status != http.StatusNotFound { |
| 179 | t.Fatalf("detach of an unattached host = %d, want 404:\n%s", status, body) |
| 180 | } |
| 181 | strangerBody := strangerRefusal(t, fx, "code-zoe-appdomaindetach-unattached", fx.u("/apps/kass/domains/detach"), |
| 182 | url.Values{"host": {host}, "confirm": {host}}) |
| 183 | if body != strangerBody { |
| 184 | t.Fatalf("owner's unattached-host 404 is not byte-identical to the stranger's:\n(owner) %q\n(stranger) %q", body, strangerBody) |
| 185 | } |
| 186 | claimAbsent(t, fx, host) |
| 187 | } |
| 188 | |
| 189 | // TestAppDomainDetachIsOwnerOnly mirrors the attach case: a member gets the |
| 190 | // same byte-identical 404 a stranger would. |
| 191 | func TestAppDomainDetachIsOwnerOnly(t *testing.T) { |
| 192 | const host = "docs.example.com" |
| 193 | fx, _, _ := domainAttachedFixture(t, "kass", host) |
| 194 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 195 | t.Fatal(err) |
| 196 | } |
| 197 | fx.setUser("sam@keymail.dev") |
| 198 | sc := newBrowser(t) |
| 199 | signUp(t, fx, sc, "code-sam-appdomaindetach") |
| 200 | scsrf := sessionToken(t, fx, sc) |
| 201 | |
| 202 | status, _, memberBody := postForm(t, sc, fx.u("/apps/kass/domains/detach"), |
| 203 | url.Values{"csrf": {scsrf}, "host": {host}, "confirm": {host}}) |
| 204 | if status != http.StatusNotFound { |
| 205 | t.Fatalf("member detaching a domain = %d, want 404", status) |
| 206 | } |
| 207 | strangerBody := strangerRefusal(t, fx, "code-zoe-appdomaindetach", fx.u("/apps/kass/domains/detach"), |
| 208 | url.Values{"host": {host}, "confirm": {host}}) |
| 209 | if memberBody != strangerBody { |
| 210 | t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| 211 | } |
| 212 | if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil { |
| 213 | t.Errorf("claim after a member's refused detach: %v, want it untouched", err) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // TestAppDomainAttachRefusesADeploymentRouteNeutrally: the BROWSER door's |
| 218 | // half of the fleet-wide route collision (Task 5 review round 2). Both doors |
| 219 | // share attachDomain, which is where the guard now lives — immediately |
| 220 | // before each of its two AttachClaim calls — so this door is covered by the |
| 221 | // same move that closed the CLI's kind=static branch. This test is what |
| 222 | // makes that coverage a fact rather than an inference. |
| 223 | // |
| 224 | // The property is the one the CLI door keeps: a hostname a live box already |
| 225 | // serves as a REAL route is refused with the refusal a hostname held by |
| 226 | // another account gets, and the two are indistinguishable. Compared page |
| 227 | // against page, not against a literal, so they cannot drift apart. |
| 228 | func TestAppDomainAttachRefusesADeploymentRouteNeutrally(t *testing.T) { |
| 229 | ctx := context.Background() |
| 230 | fx := newConsoleFixture(t) |
| 231 | c := signInPaul(t, fx) |
| 232 | csrf := sessionToken(t, fx, c) |
| 233 | |
| 234 | // go.tito.io is a real, NON-ALIAS route published by a live box. Both |
| 235 | // halves are required: DeploymentRoutes drops any edges file whose box |
| 236 | // has no fresh status snapshot. |
| 237 | if _, err := fx.rel.Put(ctx, edgesfile.Key("annie"), |
| 238 | []byte(`{"v":1,"box":"annie","eip":"198.51.100.9","hosts":{"go.tito.io":{"role":"home"}}}`), ""); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | if err := status.WriteBox(ctx, fx.rel, status.Box{Label: "annie", GeneratedAt: time.Now()}); err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | // The yardstick: a hostname held by a different account entirely. |
| 245 | if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ |
| 246 | Host: "claimed-by-someone-else.com", Account: "cdef", App: "widgets", |
| 247 | Route: "widgets.example.net", CreatedAt: time.Now().UTC().Format(time.RFC3339), |
| 248 | }); err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | |
| 252 | routeStatus, _, routeBody := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| 253 | url.Values{"csrf": {csrf}, "host": {"go.tito.io"}}) |
| 254 | heldStatus, _, heldBody := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| 255 | url.Values{"csrf": {csrf}, "host": {"claimed-by-someone-else.com"}}) |
| 256 | |
| 257 | if routeStatus != http.StatusConflict { |
| 258 | t.Fatalf("browser attach of another box's route = %d, want 409:\n%s", routeStatus, routeBody) |
| 259 | } |
| 260 | if routeStatus != heldStatus || routeBody != heldBody { |
| 261 | 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", |
| 262 | routeStatus, heldStatus, routeBody, heldBody) |
| 263 | } |
| 264 | claimAbsent(t, fx, "go.tito.io") |
| 265 | } |
| 266 | |
| 267 | // The browser door's own version of the ownership exemption: a hostname this |
| 268 | // app ALREADY holds the claim on stays attachable, because a first-route |
| 269 | // attach makes the host a real route and the fold would otherwise report the |
| 270 | // caller's own route back at them forever. |
| 271 | func TestAppDomainAttachStillConvergesTheCallersOwnDeploymentRoute(t *testing.T) { |
| 272 | ctx := context.Background() |
| 273 | fx := newConsoleFixture(t) |
| 274 | c := signInPaul(t, fx) |
| 275 | csrf := sessionToken(t, fx, c) |
| 276 | |
| 277 | if _, err := fx.rel.Put(ctx, edgesfile.Key("annie"), |
| 278 | []byte(`{"v":1,"box":"annie","eip":"198.51.100.9","hosts":{"mine.example.com":{"role":"home"}}}`), ""); err != nil { |
| 279 | t.Fatal(err) |
| 280 | } |
| 281 | if err := status.WriteBox(ctx, fx.rel, status.Box{Label: "annie", GeneratedAt: time.Now()}); err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ |
| 285 | Host: "mine.example.com", Account: fx.sqid, App: "kass", |
| 286 | CreatedAt: time.Now().UTC().Format(time.RFC3339), |
| 287 | }); err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | |
| 291 | st, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"), |
| 292 | url.Values{"csrf": {csrf}, "host": {"mine.example.com"}}) |
| 293 | if st != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") { |
| 294 | t.Fatalf("re-attach of the caller's own hostname = %d -> %q, want 303 -> settings:\n%s", st, loc, body) |
| 295 | } |
| 296 | } |
| 297 | |