carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
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
19const 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.
23func 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
33func 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.
41func 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).
53func 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.
107func 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
141func 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.
166func 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.
196func 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.
220func 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
247func 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
266func 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
283func 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
304func 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.
327func 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