| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "net/url" |
| 9 | "os" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/mail" |
| 15 | "github.com/carlosframework/platform/internal/provision" |
| 16 | "github.com/carlosframework/platform/internal/relay" |
| 17 | ) |
| 18 | |
| 19 | const keepHint = "carlos apps keep" |
| 20 | |
| 21 | // refusalFixture is a console with one live temporary app, tmp-site, and a |
| 22 | // person token and browser for paul, who owns the account. |
| 23 | func refusalFixture(t *testing.T) (*consoleFixture, string, *http.Client, string) { |
| 24 | t.Helper() |
| 25 | fx := newConsoleFixture(t) |
| 26 | fx.srv.cfg.AppsDomain = "carlosapp.test" |
| 27 | tempApp(t, fx, "tmp-site", time.Now().Add(24*time.Hour)) |
| 28 | token := cliToken(t, fx, "paul@keymail.dev", "code-refuse-cli") |
| 29 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-refuse-web") |
| 30 | return fx, token, c, csrf |
| 31 | } |
| 32 | |
| 33 | func keep(t *testing.T, fx *consoleFixture) { |
| 34 | t.Helper() |
| 35 | if ok, err := fx.st.KeepApp(OpsAccountID, "tmp-site"); err != nil || !ok { |
| 36 | t.Fatalf("KeepApp = %v %v", ok, err) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | // flashAfter follows a browser door's 303 and returns the page it lands on. |
| 41 | func flashAfter(t *testing.T, fx *consoleFixture, c *http.Client, status int, loc, body string) string { |
| 42 | t.Helper() |
| 43 | if status != http.StatusSeeOther { |
| 44 | return body |
| 45 | } |
| 46 | _, _, page := get(t, c, fx.ts.URL+loc) |
| 47 | return page |
| 48 | } |
| 49 | |
| 50 | // Spec §9: the server paths in §4 each refuse on a temporary row and pass |
| 51 | // after keep. "Pass" means the temporary refusal no longer fires; the door |
| 52 | // may still refuse for its own reasons (no mail backend, no store backend). |
| 53 | func TestTemporaryRefusalsAtTheAPIDoors(t *testing.T) { |
| 54 | fx, token, _, _ := refusalFixture(t) |
| 55 | base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site" |
| 56 | doors := []struct { |
| 57 | name string |
| 58 | call func() (int, string) |
| 59 | }{ |
| 60 | {"ship a binary", func() (int, string) { |
| 61 | st, out := postShip(t, base+"/releases", token, []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}}) |
| 62 | return st, str(t, out, "error") |
| 63 | }}, |
| 64 | {"store create", func() (int, string) { |
| 65 | st, out := postJSON(t, base+"/store", map[string]string{"env_prefix": "S3"}, token) |
| 66 | return st, str(t, out, "error") |
| 67 | }}, |
| 68 | {"store provision", func() (int, string) { |
| 69 | st, out := postJSON(t, base+"/store/provision", map[string]string{}, token) |
| 70 | return st, str(t, out, "error") |
| 71 | }}, |
| 72 | {"domain attach", func() (int, string) { |
| 73 | st, out := postJSON(t, base+"/domains", map[string]string{"host": "www.example.com"}, token) |
| 74 | return st, str(t, out, "error") |
| 75 | }}, |
| 76 | {"email domains add", func() (int, string) { |
| 77 | st, out := postJSON(t, base+"/email/domains", map[string]string{"domain": "example.com"}, token) |
| 78 | return st, str(t, out, "error") |
| 79 | }}, |
| 80 | {"email enable", func() (int, string) { |
| 81 | st, out := postJSON(t, base+"/email/enable", map[string]string{}, token) |
| 82 | return st, str(t, out, "error") |
| 83 | }}, |
| 84 | {"email credentials", func() (int, string) { |
| 85 | st, out := postJSON(t, base+"/email/credentials", map[string]string{"name": "ci"}, token) |
| 86 | return st, str(t, out, "error") |
| 87 | }}, |
| 88 | {"private storage", func() (int, string) { |
| 89 | st, out := putJSON(t, base+"/instances-manifest", map[string]any{"private_storage": true}, token) |
| 90 | return st, str(t, out, "error") |
| 91 | }}, |
| 92 | } |
| 93 | for _, d := range doors { |
| 94 | if st, msg := d.call(); st != http.StatusConflict || !strings.Contains(msg, keepHint) { |
| 95 | t.Errorf("%s on a temporary app = %d %q, want 409 naming %s", d.name, st, msg, keepHint) |
| 96 | } |
| 97 | } |
| 98 | keep(t, fx) |
| 99 | for _, d := range doors { |
| 100 | if _, msg := d.call(); strings.Contains(msg, keepHint) { |
| 101 | t.Errorf("%s after keep still refuses as temporary: %q", d.name, msg) |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | // Instance create: static at the platform host only. |
| 107 | func TestTemporaryInstanceCreateIsStaticAtThePlatformHost(t *testing.T) { |
| 108 | fx, token, _, _ := refusalFixture(t) |
| 109 | base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site" |
| 110 | if st, out := putJSON(t, base+"/instances-manifest", map[string]any{}, token); st != http.StatusOK { |
| 111 | t.Fatalf("enable = %d %v", st, out) |
| 112 | } |
| 113 | apex := "tmp-site." + fx.sqid + ".carlosapp.test" |
| 114 | for _, body := range []map[string]any{ |
| 115 | {"host": apex, "kind": "instance"}, |
| 116 | {"host": apex}, |
| 117 | {"host": "www." + fx.sqid + ".carlosapp.test", "kind": "static"}, |
| 118 | } { |
| 119 | if st, out := postJSON(t, base+"/instances", body, token); st != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { |
| 120 | t.Errorf("instance %v on a temporary app = %d %v, want 409 naming %s", body, st, out, keepHint) |
| 121 | } |
| 122 | } |
| 123 | // The one shape a temporary app may have must actually be created, not |
| 124 | // merely escape the temporary refusal: 201, and a record in the bucket at |
| 125 | // the platform host, for this account and app, kind static. The shape is |
| 126 | // TestAPIInstanceCreateStaticAtOwnApex's (apiinstances_test.go:2044). |
| 127 | status, _, out := rawReq(t, http.MethodPost, base+"/instances", token, |
| 128 | `{"host":"`+apex+`","kind":"static","channel":"edge"}`) |
| 129 | if status != http.StatusCreated { |
| 130 | t.Fatalf("static instance at the platform host = %d %s, want 201", status, out) |
| 131 | } |
| 132 | stored, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "tmp-site", apex) |
| 133 | if err != nil { |
| 134 | t.Fatalf("read back the record: %v", err) |
| 135 | } |
| 136 | if stored.Account != fx.sqid || stored.App != "tmp-site" || stored.Host != apex || stored.Kind != "static" { |
| 137 | t.Fatalf("stored record = %+v, want account %s, app tmp-site, host %s, kind static", stored, fx.sqid, apex) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | func TestTemporaryRefusalsAtTheBrowserDoors(t *testing.T) { |
| 142 | fx, _, c, csrf := refusalFixture(t) |
| 143 | doors := map[string]url.Values{ |
| 144 | "/apps/tmp-site/production": {"csrf": {csrf}, "production": {"on"}}, |
| 145 | "/apps/tmp-site/rename": {"csrf": {csrf}, "to": {"site"}}, |
| 146 | "/apps/tmp-site/move": {"csrf": {csrf}}, |
| 147 | "/apps/tmp-site/control-plane/set": {"csrf": {csrf}}, |
| 148 | "/apps/tmp-site/domains/attach": {"csrf": {csrf}, "host": {"www.example.com"}}, |
| 149 | } |
| 150 | for path, form := range doors { |
| 151 | st, loc, body := postForm(t, c, fx.u(path), form) |
| 152 | if page := flashAfter(t, fx, c, st, loc, body); !strings.Contains(page, keepHint) { |
| 153 | t.Errorf("%s on a temporary app: the page does not name %s", path, keepHint) |
| 154 | } |
| 155 | } |
| 156 | keep(t, fx) |
| 157 | for path, form := range doors { |
| 158 | st, loc, body := postForm(t, c, fx.u(path), form) |
| 159 | if page := flashAfter(t, fx, c, st, loc, body); strings.Contains(page, keepHint) { |
| 160 | t.Errorf("%s after keep still refuses as temporary", path) |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // Spec §4: enforced at the store operation as well as the handler. |
| 166 | func TestTemporaryRefusalsAtTheStore(t *testing.T) { |
| 167 | fx, _, _, _ := refusalFixture(t) |
| 168 | if _, err := fx.st.EnsureAccount(2000, "bdz", "Other", time.Now()); err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | if err := fx.st.RenameAppIn(OpsAccountID, "tmp-site", "site"); !errors.Is(err, ErrTemporaryApp) { |
| 172 | t.Errorf("RenameAppIn = %v, want ErrTemporaryApp", err) |
| 173 | } |
| 174 | if err := fx.st.MoveApp("tmp-site", OpsAccountID, 2000); !errors.Is(err, ErrTemporaryApp) { |
| 175 | t.Errorf("MoveApp = %v, want ErrTemporaryApp", err) |
| 176 | } |
| 177 | if err := fx.st.SetAppControlPlane(OpsAccountID, "tmp-site", true); !errors.Is(err, ErrTemporaryApp) { |
| 178 | t.Errorf("SetAppControlPlane(true) = %v, want ErrTemporaryApp", err) |
| 179 | } |
| 180 | if l := lifeOf(t, fx, "tmp-site"); l.ControlPlane || l.AccountID != OpsAccountID { |
| 181 | t.Fatalf("a refused store operation changed the row: %+v", l) |
| 182 | } |
| 183 | keep(t, fx) |
| 184 | if err := fx.st.SetAppControlPlane(OpsAccountID, "tmp-site", true); err != nil { |
| 185 | t.Errorf("SetAppControlPlane after keep = %v", err) |
| 186 | } |
| 187 | if err := fx.st.SetAppControlPlane(OpsAccountID, "tmp-site", false); err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | if err := fx.st.MoveApp("tmp-site", OpsAccountID, 2000); err != nil { |
| 191 | t.Errorf("MoveApp after keep = %v", err) |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | // The shared provisioning path instance create reaches refuses on its own. |
| 196 | func TestInstanceStoreProvisionRefusesATemporaryApp(t *testing.T) { |
| 197 | fx, _, _, _ := refusalFixture(t) |
| 198 | acct := opsAccount(t, fx) |
| 199 | rec := httptest.NewRecorder() |
| 200 | if _, ok := fx.srv.instanceStoreProvision(context.Background(), rec, acct, "tmp-site", "", "paul@keymail.dev"); ok { |
| 201 | t.Fatal("instanceStoreProvision provisioned for a temporary app") |
| 202 | } |
| 203 | if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), keepHint) { |
| 204 | t.Fatalf("refusal = %d %q", rec.Code, rec.Body.String()) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | // --- Fix round 1 (Task 6 review): four doors outside the brief's §4 table --- |
| 209 | // |
| 210 | // placeAppTarget (fleet placement, shared by the CLI API, the app page and |
| 211 | // the fleet page), relay enable, both sidecar doors and email declare each |
| 212 | // let a temporary app take on estate the expiry would then have to tear |
| 213 | // down. All four use the same temporaryRefusal/refuseTemporary helper as |
| 214 | // the rest of the table, after their own membership/owner gate, so a |
| 215 | // stranger learns nothing a different door wouldn't already tell it. |
| 216 | |
| 217 | // TestTemporaryRefusalsAtFleetPlacement covers all three doors onto |
| 218 | // placeAppTarget through the CLI API, which is the one every one of them |
| 219 | // calls — see placeAppTarget's own doc comment. |
| 220 | func TestTemporaryRefusalsAtFleetPlacement(t *testing.T) { |
| 221 | fx, token, _, _ := refusalFixture(t) |
| 222 | if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| 223 | map[string]string{"name": "pike", "bucket": "pike-fleet-data"}, token); status != http.StatusOK { |
| 224 | t.Fatalf("create fleet = %d %v", status, body) |
| 225 | } |
| 226 | placeU := placeURL(fx, "tmp-site") |
| 227 | status, out := postJSON(t, placeU, map[string]string{"target": "fleet/pike"}, token) |
| 228 | if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { |
| 229 | t.Fatalf("place on a temporary app = %d %v, want 409 naming %s", status, out, keepHint) |
| 230 | } |
| 231 | if p, err := fx.st.AppPlacement(OpsAccountID, "tmp-site"); err != nil || p != "" { |
| 232 | t.Fatalf("a refused place wrote placement: %q, %v", p, err) |
| 233 | } |
| 234 | keep(t, fx) |
| 235 | status, out = postJSON(t, placeU, map[string]string{"target": "fleet/pike"}, token) |
| 236 | if strings.Contains(str(t, out, "error"), keepHint) { |
| 237 | t.Errorf("place after keep still refuses as temporary: %v", out) |
| 238 | } |
| 239 | if status != http.StatusOK { |
| 240 | t.Fatalf("place after keep = %d %v, want 200", status, out) |
| 241 | } |
| 242 | if p, err := fx.st.AppPlacement(OpsAccountID, "tmp-site"); err != nil || p != "fleet/pike" { |
| 243 | t.Fatalf("place after keep did not land: %q, %v", p, err) |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | func TestTemporaryRefusalsAtRelayEnable(t *testing.T) { |
| 248 | fx, token, _, _ := refusalFixture(t) |
| 249 | u := relayURL(fx, "tmp-site") |
| 250 | status, out := postJSON(t, u, map[string]any{}, token) |
| 251 | if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { |
| 252 | t.Fatalf("relay enable on a temporary app = %d %v, want 409 naming %s", status, out, keepHint) |
| 253 | } |
| 254 | if rec, _, err := relay.GetRecord(context.Background(), fx.rel, fx.sqid, "tmp-site"); err != nil { |
| 255 | t.Fatalf("read relay record: %v", err) |
| 256 | } else if rec != nil { |
| 257 | t.Fatalf("a refused relay enable wrote a record: %+v", rec) |
| 258 | } |
| 259 | keep(t, fx) |
| 260 | _, out = postJSON(t, u, map[string]any{}, token) |
| 261 | if strings.Contains(str(t, out, "error"), keepHint) { |
| 262 | t.Errorf("relay enable after keep still refuses as temporary: %v", out) |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | func TestTemporaryRefusalsAtSidecarEnable(t *testing.T) { |
| 267 | fx, token, _, _ := refusalFixture(t) |
| 268 | u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site/sidecars-manifest" |
| 269 | status, out := putJSON(t, u, map[string]any{}, token) |
| 270 | if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { |
| 271 | t.Fatalf("sidecars enable on a temporary app = %d %v, want 409 naming %s", status, out, keepHint) |
| 272 | } |
| 273 | if _, _, err := provision.GetSidecarManifestETag(context.Background(), fx.rel, fx.sqid, "tmp-site"); !errors.Is(err, os.ErrNotExist) { |
| 274 | t.Fatalf("a refused sidecars enable wrote a manifest: %v", err) |
| 275 | } |
| 276 | keep(t, fx) |
| 277 | _, out = putJSON(t, u, map[string]any{}, token) |
| 278 | if strings.Contains(str(t, out, "error"), keepHint) { |
| 279 | t.Errorf("sidecars enable after keep still refuses as temporary: %v", out) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | func TestTemporaryRefusalsAtSidecarCreate(t *testing.T) { |
| 284 | fx, token, _, _ := refusalFixture(t) |
| 285 | u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site/sidecars" |
| 286 | status, out := postJSON(t, u, map[string]any{"name": "worker"}, token) |
| 287 | if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { |
| 288 | t.Fatalf("sidecar create on a temporary app = %d %v, want 409 naming %s", status, out, keepHint) |
| 289 | } |
| 290 | keys, err := fx.rel.List(context.Background(), provision.RecordsPrefix(fx.sqid, "tmp-site")) |
| 291 | if err != nil { |
| 292 | t.Fatalf("list records: %v", err) |
| 293 | } |
| 294 | if len(keys) != 0 { |
| 295 | t.Fatalf("a refused sidecar create wrote a record: %v", keys) |
| 296 | } |
| 297 | keep(t, fx) |
| 298 | _, out = postJSON(t, u, map[string]any{"name": "worker"}, token) |
| 299 | if strings.Contains(str(t, out, "error"), keepHint) { |
| 300 | t.Errorf("sidecar create after keep still refuses as temporary: %v", out) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | func TestTemporaryRefusalsAtEmailDeclare(t *testing.T) { |
| 305 | fx, token, _, _ := refusalFixture(t) |
| 306 | u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site/email" |
| 307 | body := map[string]string{"env_prefix": "SVC", "from": "noreply@example.com"} |
| 308 | status, out := postJSON(t, u, body, token) |
| 309 | if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { |
| 310 | t.Fatalf("email declare on a temporary app = %d %v, want 409 naming %s", status, out, keepHint) |
| 311 | } |
| 312 | if rec, _, err := mail.Get(context.Background(), fx.rel, fx.sqid, "tmp-site"); err != nil { |
| 313 | t.Fatalf("read mail record: %v", err) |
| 314 | } else if rec != nil { |
| 315 | t.Fatalf("a refused email declare wrote a record: %+v", rec) |
| 316 | } |
| 317 | keep(t, fx) |
| 318 | _, out = postJSON(t, u, body, token) |
| 319 | if strings.Contains(str(t, out, "error"), keepHint) { |
| 320 | t.Errorf("email declare after keep still refuses as temporary: %v", out) |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | // Main's instance catch-all (domaincatchall.go) reaches the bucket without |
| 325 | // passing through attachDomain, so it carries the refusal itself: the |
| 326 | // browser door, and the core both doors share. |
| 327 | func TestTemporaryRefusalsAtInstanceCatchall(t *testing.T) { |
| 328 | fx, _, c, csrf := refusalFixture(t) |
| 329 | form := url.Values{"csrf": {csrf}, "host": {"*.example.com"}} |
| 330 | st, loc, body := postForm(t, c, fx.u("/apps/tmp-site/domains/attach"), form) |
| 331 | if page := flashAfter(t, fx, c, st, loc, body); !strings.Contains(page, keepHint) { |
| 332 | t.Errorf("catch-all on a temporary app: the page does not name %s", keepHint) |
| 333 | } |
| 334 | acct := opsAccount(t, fx) |
| 335 | if _, ref := fx.srv.attachInstanceCatchall(context.Background(), acct, "tmp-site", "example.com", "paul@keymail.dev", ""); ref == nil || !strings.Contains(ref.Msg, keepHint) { |
| 336 | t.Fatalf("attachInstanceCatchall on a temporary app = %+v, want the keep refusal", ref) |
| 337 | } |
| 338 | keep(t, fx) |
| 339 | st, loc, body = postForm(t, c, fx.u("/apps/tmp-site/domains/attach"), form) |
| 340 | if page := flashAfter(t, fx, c, st, loc, body); strings.Contains(page, keepHint) { |
| 341 | t.Error("catch-all after keep still refuses as temporary") |
| 342 | } |
| 343 | } |
| 344 | |