package console import ( "context" "errors" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "time" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/relay" ) const keepHint = "carlos apps keep" // refusalFixture is a console with one live temporary app, tmp-site, and a // person token and browser for paul, who owns the account. func refusalFixture(t *testing.T) (*consoleFixture, string, *http.Client, string) { t.Helper() fx := newConsoleFixture(t) fx.srv.cfg.AppsDomain = "carlosapp.test" tempApp(t, fx, "tmp-site", time.Now().Add(24*time.Hour)) token := cliToken(t, fx, "paul@keymail.dev", "code-refuse-cli") c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-refuse-web") return fx, token, c, csrf } func keep(t *testing.T, fx *consoleFixture) { t.Helper() if ok, err := fx.st.KeepApp(OpsAccountID, "tmp-site"); err != nil || !ok { t.Fatalf("KeepApp = %v %v", ok, err) } } // flashAfter follows a browser door's 303 and returns the page it lands on. func flashAfter(t *testing.T, fx *consoleFixture, c *http.Client, status int, loc, body string) string { t.Helper() if status != http.StatusSeeOther { return body } _, _, page := get(t, c, fx.ts.URL+loc) return page } // Spec §9: the server paths in §4 each refuse on a temporary row and pass // after keep. "Pass" means the temporary refusal no longer fires; the door // may still refuse for its own reasons (no mail backend, no store backend). func TestTemporaryRefusalsAtTheAPIDoors(t *testing.T) { fx, token, _, _ := refusalFixture(t) base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site" doors := []struct { name string call func() (int, string) }{ {"ship a binary", func() (int, string) { st, out := postShip(t, base+"/releases", token, []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}}) return st, str(t, out, "error") }}, {"store create", func() (int, string) { st, out := postJSON(t, base+"/store", map[string]string{"env_prefix": "S3"}, token) return st, str(t, out, "error") }}, {"store provision", func() (int, string) { st, out := postJSON(t, base+"/store/provision", map[string]string{}, token) return st, str(t, out, "error") }}, {"domain attach", func() (int, string) { st, out := postJSON(t, base+"/domains", map[string]string{"host": "www.example.com"}, token) return st, str(t, out, "error") }}, {"email domains add", func() (int, string) { st, out := postJSON(t, base+"/email/domains", map[string]string{"domain": "example.com"}, token) return st, str(t, out, "error") }}, {"email enable", func() (int, string) { st, out := postJSON(t, base+"/email/enable", map[string]string{}, token) return st, str(t, out, "error") }}, {"email credentials", func() (int, string) { st, out := postJSON(t, base+"/email/credentials", map[string]string{"name": "ci"}, token) return st, str(t, out, "error") }}, {"private storage", func() (int, string) { st, out := putJSON(t, base+"/instances-manifest", map[string]any{"private_storage": true}, token) return st, str(t, out, "error") }}, } for _, d := range doors { if st, msg := d.call(); st != http.StatusConflict || !strings.Contains(msg, keepHint) { t.Errorf("%s on a temporary app = %d %q, want 409 naming %s", d.name, st, msg, keepHint) } } keep(t, fx) for _, d := range doors { if _, msg := d.call(); strings.Contains(msg, keepHint) { t.Errorf("%s after keep still refuses as temporary: %q", d.name, msg) } } } // Instance create: static at the platform host only. func TestTemporaryInstanceCreateIsStaticAtThePlatformHost(t *testing.T) { fx, token, _, _ := refusalFixture(t) base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site" if st, out := putJSON(t, base+"/instances-manifest", map[string]any{}, token); st != http.StatusOK { t.Fatalf("enable = %d %v", st, out) } apex := "tmp-site." + fx.sqid + ".carlosapp.test" for _, body := range []map[string]any{ {"host": apex, "kind": "instance"}, {"host": apex}, {"host": "www." + fx.sqid + ".carlosapp.test", "kind": "static"}, } { if st, out := postJSON(t, base+"/instances", body, token); st != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { t.Errorf("instance %v on a temporary app = %d %v, want 409 naming %s", body, st, out, keepHint) } } // The one shape a temporary app may have must actually be created, not // merely escape the temporary refusal: 201, and a record in the bucket at // the platform host, for this account and app, kind static. The shape is // TestAPIInstanceCreateStaticAtOwnApex's (apiinstances_test.go:2044). status, _, out := rawReq(t, http.MethodPost, base+"/instances", token, `{"host":"`+apex+`","kind":"static","channel":"edge"}`) if status != http.StatusCreated { t.Fatalf("static instance at the platform host = %d %s, want 201", status, out) } stored, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "tmp-site", apex) if err != nil { t.Fatalf("read back the record: %v", err) } if stored.Account != fx.sqid || stored.App != "tmp-site" || stored.Host != apex || stored.Kind != "static" { t.Fatalf("stored record = %+v, want account %s, app tmp-site, host %s, kind static", stored, fx.sqid, apex) } } func TestTemporaryRefusalsAtTheBrowserDoors(t *testing.T) { fx, _, c, csrf := refusalFixture(t) doors := map[string]url.Values{ "/apps/tmp-site/production": {"csrf": {csrf}, "production": {"on"}}, "/apps/tmp-site/rename": {"csrf": {csrf}, "to": {"site"}}, "/apps/tmp-site/move": {"csrf": {csrf}}, "/apps/tmp-site/control-plane/set": {"csrf": {csrf}}, "/apps/tmp-site/domains/attach": {"csrf": {csrf}, "host": {"www.example.com"}}, } for path, form := range doors { st, loc, body := postForm(t, c, fx.u(path), form) if page := flashAfter(t, fx, c, st, loc, body); !strings.Contains(page, keepHint) { t.Errorf("%s on a temporary app: the page does not name %s", path, keepHint) } } keep(t, fx) for path, form := range doors { st, loc, body := postForm(t, c, fx.u(path), form) if page := flashAfter(t, fx, c, st, loc, body); strings.Contains(page, keepHint) { t.Errorf("%s after keep still refuses as temporary", path) } } } // Spec §4: enforced at the store operation as well as the handler. func TestTemporaryRefusalsAtTheStore(t *testing.T) { fx, _, _, _ := refusalFixture(t) if _, err := fx.st.EnsureAccount(2000, "bdz", "Other", time.Now()); err != nil { t.Fatal(err) } if err := fx.st.RenameAppIn(OpsAccountID, "tmp-site", "site"); !errors.Is(err, ErrTemporaryApp) { t.Errorf("RenameAppIn = %v, want ErrTemporaryApp", err) } if err := fx.st.MoveApp("tmp-site", OpsAccountID, 2000); !errors.Is(err, ErrTemporaryApp) { t.Errorf("MoveApp = %v, want ErrTemporaryApp", err) } if err := fx.st.SetAppControlPlane(OpsAccountID, "tmp-site", true); !errors.Is(err, ErrTemporaryApp) { t.Errorf("SetAppControlPlane(true) = %v, want ErrTemporaryApp", err) } if l := lifeOf(t, fx, "tmp-site"); l.ControlPlane || l.AccountID != OpsAccountID { t.Fatalf("a refused store operation changed the row: %+v", l) } keep(t, fx) if err := fx.st.SetAppControlPlane(OpsAccountID, "tmp-site", true); err != nil { t.Errorf("SetAppControlPlane after keep = %v", err) } if err := fx.st.SetAppControlPlane(OpsAccountID, "tmp-site", false); err != nil { t.Fatal(err) } if err := fx.st.MoveApp("tmp-site", OpsAccountID, 2000); err != nil { t.Errorf("MoveApp after keep = %v", err) } } // The shared provisioning path instance create reaches refuses on its own. func TestInstanceStoreProvisionRefusesATemporaryApp(t *testing.T) { fx, _, _, _ := refusalFixture(t) acct := opsAccount(t, fx) rec := httptest.NewRecorder() if _, ok := fx.srv.instanceStoreProvision(context.Background(), rec, acct, "tmp-site", "", "paul@keymail.dev"); ok { t.Fatal("instanceStoreProvision provisioned for a temporary app") } if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), keepHint) { t.Fatalf("refusal = %d %q", rec.Code, rec.Body.String()) } } // --- Fix round 1 (Task 6 review): four doors outside the brief's §4 table --- // // placeAppTarget (fleet placement, shared by the CLI API, the app page and // the fleet page), relay enable, both sidecar doors and email declare each // let a temporary app take on estate the expiry would then have to tear // down. All four use the same temporaryRefusal/refuseTemporary helper as // the rest of the table, after their own membership/owner gate, so a // stranger learns nothing a different door wouldn't already tell it. // TestTemporaryRefusalsAtFleetPlacement covers all three doors onto // placeAppTarget through the CLI API, which is the one every one of them // calls — see placeAppTarget's own doc comment. func TestTemporaryRefusalsAtFleetPlacement(t *testing.T) { fx, token, _, _ := refusalFixture(t) if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", map[string]string{"name": "pike", "bucket": "pike-fleet-data"}, token); status != http.StatusOK { t.Fatalf("create fleet = %d %v", status, body) } placeU := placeURL(fx, "tmp-site") status, out := postJSON(t, placeU, map[string]string{"target": "fleet/pike"}, token) if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { t.Fatalf("place on a temporary app = %d %v, want 409 naming %s", status, out, keepHint) } if p, err := fx.st.AppPlacement(OpsAccountID, "tmp-site"); err != nil || p != "" { t.Fatalf("a refused place wrote placement: %q, %v", p, err) } keep(t, fx) status, out = postJSON(t, placeU, map[string]string{"target": "fleet/pike"}, token) if strings.Contains(str(t, out, "error"), keepHint) { t.Errorf("place after keep still refuses as temporary: %v", out) } if status != http.StatusOK { t.Fatalf("place after keep = %d %v, want 200", status, out) } if p, err := fx.st.AppPlacement(OpsAccountID, "tmp-site"); err != nil || p != "fleet/pike" { t.Fatalf("place after keep did not land: %q, %v", p, err) } } func TestTemporaryRefusalsAtRelayEnable(t *testing.T) { fx, token, _, _ := refusalFixture(t) u := relayURL(fx, "tmp-site") status, out := postJSON(t, u, map[string]any{}, token) if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { t.Fatalf("relay enable on a temporary app = %d %v, want 409 naming %s", status, out, keepHint) } if rec, _, err := relay.GetRecord(context.Background(), fx.rel, fx.sqid, "tmp-site"); err != nil { t.Fatalf("read relay record: %v", err) } else if rec != nil { t.Fatalf("a refused relay enable wrote a record: %+v", rec) } keep(t, fx) _, out = postJSON(t, u, map[string]any{}, token) if strings.Contains(str(t, out, "error"), keepHint) { t.Errorf("relay enable after keep still refuses as temporary: %v", out) } } func TestTemporaryRefusalsAtSidecarEnable(t *testing.T) { fx, token, _, _ := refusalFixture(t) u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site/sidecars-manifest" status, out := putJSON(t, u, map[string]any{}, token) if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { t.Fatalf("sidecars enable on a temporary app = %d %v, want 409 naming %s", status, out, keepHint) } if _, _, err := provision.GetSidecarManifestETag(context.Background(), fx.rel, fx.sqid, "tmp-site"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("a refused sidecars enable wrote a manifest: %v", err) } keep(t, fx) _, out = putJSON(t, u, map[string]any{}, token) if strings.Contains(str(t, out, "error"), keepHint) { t.Errorf("sidecars enable after keep still refuses as temporary: %v", out) } } func TestTemporaryRefusalsAtSidecarCreate(t *testing.T) { fx, token, _, _ := refusalFixture(t) u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site/sidecars" status, out := postJSON(t, u, map[string]any{"name": "worker"}, token) if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { t.Fatalf("sidecar create on a temporary app = %d %v, want 409 naming %s", status, out, keepHint) } keys, err := fx.rel.List(context.Background(), provision.RecordsPrefix(fx.sqid, "tmp-site")) if err != nil { t.Fatalf("list records: %v", err) } if len(keys) != 0 { t.Fatalf("a refused sidecar create wrote a record: %v", keys) } keep(t, fx) _, out = postJSON(t, u, map[string]any{"name": "worker"}, token) if strings.Contains(str(t, out, "error"), keepHint) { t.Errorf("sidecar create after keep still refuses as temporary: %v", out) } } func TestTemporaryRefusalsAtEmailDeclare(t *testing.T) { fx, token, _, _ := refusalFixture(t) u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site/email" body := map[string]string{"env_prefix": "SVC", "from": "noreply@example.com"} status, out := postJSON(t, u, body, token) if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) { t.Fatalf("email declare on a temporary app = %d %v, want 409 naming %s", status, out, keepHint) } if rec, _, err := mail.Get(context.Background(), fx.rel, fx.sqid, "tmp-site"); err != nil { t.Fatalf("read mail record: %v", err) } else if rec != nil { t.Fatalf("a refused email declare wrote a record: %+v", rec) } keep(t, fx) _, out = postJSON(t, u, body, token) if strings.Contains(str(t, out, "error"), keepHint) { t.Errorf("email declare after keep still refuses as temporary: %v", out) } } // Main's instance catch-all (domaincatchall.go) reaches the bucket without // passing through attachDomain, so it carries the refusal itself: the // browser door, and the core both doors share. func TestTemporaryRefusalsAtInstanceCatchall(t *testing.T) { fx, _, c, csrf := refusalFixture(t) form := url.Values{"csrf": {csrf}, "host": {"*.example.com"}} st, loc, body := postForm(t, c, fx.u("/apps/tmp-site/domains/attach"), form) if page := flashAfter(t, fx, c, st, loc, body); !strings.Contains(page, keepHint) { t.Errorf("catch-all on a temporary app: the page does not name %s", keepHint) } acct := opsAccount(t, fx) if _, ref := fx.srv.attachInstanceCatchall(context.Background(), acct, "tmp-site", "example.com", "paul@keymail.dev", ""); ref == nil || !strings.Contains(ref.Msg, keepHint) { t.Fatalf("attachInstanceCatchall on a temporary app = %+v, want the keep refusal", ref) } keep(t, fx) st, loc, body = postForm(t, c, fx.u("/apps/tmp-site/domains/attach"), form) if page := flashAfter(t, fx, c, st, loc, body); strings.Contains(page, keepHint) { t.Error("catch-all after keep still refuses as temporary") } }