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 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/geo"
14 "github.com/carlosframework/platform/internal/registry"
15 "github.com/carlosframework/platform/internal/svcrole"
16)
17
18func regionFixture(t *testing.T) (*consoleFixture, string) {
19 t.Helper()
20 fx := newConsoleFixture(t)
21 fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion, fx.srv.cfg.SecretsPub = true, "ie", testKeyPub
22 setEdges(t, fx, map[string]string{"ie-box": homeBox, "uk-box": ukEligible})
23 return fx, cliToken(t, fx, "paul@keymail.dev", "code-region-claim")
24}
25
26func newAppURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/new" }
27
28// postJSONFrom is postJSON with an X-Forwarded-For, which the console
29// trusts from the loopback edge (authcli.go clientIP).
30func postJSONFrom(t *testing.T, u string, body any, bearer, xff string) (int, map[string]any) {
31 t.Helper()
32 raw, _ := json.Marshal(body)
33 req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw))
34 if err != nil {
35 t.Fatal(err)
36 }
37 req.Header.Set("Content-Type", "application/json")
38 req.Header.Set("Authorization", "Bearer "+bearer)
39 req.Header.Set("X-Forwarded-For", xff)
40 return doJSON(t, req)
41}
42
43func errOf(res map[string]any) string { s, _ := res["error"].(string); return s }
44
45var ukPlace = geo.PlaceName("eu-west-2", "uk")
46
47func TestAPICreateAppResolvesRegion(t *testing.T) {
48 fx, token := regionFixture(t)
49 status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "blog", "timezone": "Europe/London"}, token)
50 if status != http.StatusOK || res["region"] != "uk" || res["region_source"] != "timezone" ||
51 res["region_name"] != ukPlace || res["regions_available"] != float64(2) {
52 t.Fatalf("create = %d %v", status, res)
53 }
54 if r, src, _ := fx.st.AppRegion(OpsAccountID, "blog"); r != "uk" || src != "timezone" {
55 t.Fatalf("stored %q %q", r, src)
56 }
57 // An old CLI's body, from loopback with no forwarded address.
58 if _, res = postJSON(t, newAppURL(fx), map[string]string{"name": "old"}, token); res["region"] != "ie" || res["region_source"] != "default" {
59 t.Fatalf("old body = %v", res)
60 }
61 // An explicit home name is a choice, stored as "".
62 postJSON(t, newAppURL(fx), map[string]string{"name": "athome", "region": "ie"}, token)
63 if r, src, _ := fx.st.AppRegion(OpsAccountID, "athome"); r != "" || src != "chosen" {
64 t.Fatalf("explicit home = %q %q", r, src)
65 }
66}
67
68// The old-body path through the real door: a person's forwarded address
69// is located; a service principal's never is (spec §6.2).
70func TestAPICreateAppLocatesOnlyAPerson(t *testing.T) {
71 fx, token := regionFixture(t)
72 fx.srv.cfg.GeoIP = fakeLocator{"81.2.69.142": {Lat: 51.5, Lon: -0.12}}
73 if status, res := postJSONFrom(t, newAppURL(fx), map[string]string{"name": "person"}, token, "81.2.69.142"); status != http.StatusOK ||
74 res["region"] != "uk" || res["region_source"] != "ip" {
75 t.Fatalf("person = %d %v", status, res)
76 }
77 svc := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
78 if status, res := postJSONFrom(t, newAppURL(fx), map[string]string{"name": "hubapp"}, svc, "81.2.69.142"); status != http.StatusOK ||
79 res["region"] != "ie" || res["region_source"] != "default" {
80 t.Fatalf("service = %d %v, want no IP step", status, res)
81 }
82}
83
84func TestAPICreateAppRefusesBadExplicitRegions(t *testing.T) {
85 fx, token := regionFixture(t)
86 // nz cannot host; au's current-writer box lacks the key (R9).
87 auNoKey := edgeJSON("au-box", "9.9.9.8", "ap-southeast-2", "au", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)})
88 setEdges(t, fx, map[string]string{"nz-box": nzNoHosting, "au-box": auNoKey})
89 // codex plan-R4 2: each list names exactly what this door would take.
90 choices := []string{"ie", "uk"}
91 cases := []struct{ name, region, want string }{
92 {"xa", "Bad/Code", msgRegionUnknown("Bad/Code", choices)},
93 {"xb", "nz", msgRegionNotArmed("nz", choices)},
94 {"xc", "au", msgNoSecretsKey("au")},
95 }
96 for _, c := range cases {
97 status, res := postJSON(t, newAppURL(fx), map[string]string{"name": c.name, "region": c.region}, token)
98 if status != http.StatusBadRequest || errOf(res) != c.want {
99 t.Errorf("region %q = %d %q, want 400 %q", c.region, status, errOf(res), c.want)
100 }
101 if has, _ := fx.st.HasAppIn(OpsAccountID, c.name); has {
102 t.Errorf("a refused region still claimed %s", c.name)
103 }
104 }
105 // Every advertised choice is accepted by the same door, same fixture.
106 for i, code := range choices {
107 name := fmt.Sprintf("alt%d", i)
108 if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": name, "region": code}, token); status != http.StatusOK {
109 t.Errorf("advertised %q refused: %d %v", code, status, res)
110 }
111 }
112 // R9: a region whose current-writer boxes lack the key.
113 setEdges(t, fx, map[string]string{"uk-box": ukNoKey})
114 if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "xd", "region": "uk"}, token); status != http.StatusBadRequest || errOf(res) != msgNoSecretsKey("uk") {
115 t.Fatalf("keyless uk = %d %v, want C31", status, res)
116 }
117}
118
119func TestAPICreateAppSwitchOffStoresTheHome(t *testing.T) {
120 fx, token := regionFixture(t)
121 fx.srv.cfg.AppRegions = false
122 _, res := postJSON(t, newAppURL(fx), map[string]string{"name": "off", "region": "uk", "timezone": "Europe/London"}, token)
123 if r, src, _ := fx.st.AppRegion(OpsAccountID, "off"); r != "" || src != "default" || res["region"] != "ie" {
124 t.Fatalf("switch off = %q %q %v", r, src, res)
125 }
126 fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = true, ""
127 // PF9: the second half must not pass on a failed create.
128 if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "unnamed", "region": "uk"}, token); status != http.StatusOK {
129 t.Fatalf("unnamed-home create = %d %v", status, res)
130 }
131 if r, _, _ := fx.st.AppRegion(OpsAccountID, "unnamed"); r != "" {
132 t.Fatalf("unnamed home stored %q", r)
133 }
134}
135
136func TestClaimAdoptionDiscardsTheGuess(t *testing.T) {
137 fx, _ := regionFixture(t)
138 acct := &Account{ID: OpsAccountID, Sqid: "bab"}
139 seedBucketApp(t, fx, "bab", "oldsite")
140 if out, _, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true, regionReq{tz: "Europe/London"}); out != claimOK {
141 t.Fatalf("adoption = %v", out)
142 }
143 if r, src, _ := fx.st.AppRegion(OpsAccountID, "oldsite"); r != "" || src != "default" {
144 t.Fatalf("adoption with a guess = %q %q", r, src)
145 }
146 seedBucketApp(t, fx, "bab", "oldsite2")
147 fx.srv.claimApp(context.Background(), acct, "oldsite2", "paul@keymail.dev", "", true, regionReq{explicit: "uk", hasExplicit: true})
148 if r, src, _ := fx.st.AppRegion(OpsAccountID, "oldsite2"); r != "uk" || src != "chosen" {
149 t.Fatalf("adoption with an explicit choice = %q %q", r, src)
150 }
151}
152
153// codex plan-R6 1, spec §6.5: every successful creation logs exactly one
154// region line, naming what was stored, whatever the switch says: on, off,
155// and on with an unnamed home (which keeps assignment off).
156func TestClaimLogsOneRegionLineInEverySwitchState(t *testing.T) {
157 for _, c := range []struct {
158 name, app, home string
159 on bool
160 stored, source string
161 line string
162 }{
163 {"assignment on", "on1", "ie", true, "uk", "chosen", "console: app region bab/on1: uk (chosen)"},
164 {"assignment off", "off1", "ie", false, "", "default", "console: app region bab/off1: ie (default)"},
165 {"on, home unnamed", "unnamed1", "", true, "", "default", "console: app region bab/unnamed1: (default)"},
166 } {
167 fx, token := regionFixture(t)
168 fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = c.on, c.home
169 var logs bytes.Buffer
170 restore := captureLogTo(&logs)
171 status, res := postJSON(t, newAppURL(fx), map[string]string{"name": c.app, "region": "uk"}, token)
172 restore()
173 if status != http.StatusOK {
174 t.Fatalf("%s: create = %d %v", c.name, status, res)
175 }
176 if r, src, _ := fx.st.AppRegion(OpsAccountID, c.app); r != c.stored || src != c.source {
177 t.Fatalf("%s: stored %q %q, want %q %q", c.name, r, src, c.stored, c.source)
178 }
179 if n := strings.Count(logs.String(), "console: app region bab/"+c.app+":"); n != 1 || !strings.Contains(logs.String(), c.line+"\n") {
180 t.Errorf("%s: %d region line(s) in %q, want exactly %q", c.name, n, logs.String(), c.line)
181 }
182 }
183}
184
185// Review Focus 5: a competing insert that lands AFTER the region was
186// resolved loses the claim: no row of ours, no region log line.
187func TestClaimLostAfterResolutionWritesNothing(t *testing.T) {
188 fx, token := regionFixture(t)
189 fx.srv.beforeClaimInsert = func() {
190 if _, err := fx.st.CreateAppInRegion(OpsAccountID, "racy", "rival@k.dev", "", "chosen", time.Now()); err != nil {
191 t.Error(err)
192 }
193 }
194 var logs bytes.Buffer
195 restore := captureLogTo(&logs)
196 status, _ := postJSON(t, newAppURL(fx), map[string]string{"name": "racy", "region": "uk"}, token)
197 restore()
198 if status != http.StatusConflict {
199 t.Fatalf("lost race = %d, want 409", status)
200 }
201 if r, src, _ := fx.st.AppRegion(OpsAccountID, "racy"); r != "" || src != "chosen" {
202 t.Fatalf("the rival's row changed: %q %q", r, src)
203 }
204 if strings.Contains(logs.String(), "app region bab/racy") {
205 t.Fatalf("a lost claim logged a region line: %s", logs.String())
206 }
207}
208
209// Review Focus 5: a held apex refuses before resolution, so nothing is
210// written or logged.
211func TestClaimHeldApexWritesNothing(t *testing.T) {
212 fx, token := regionFixture(t)
213 fx.srv.cfg.AppsDomain = "oncarlos.test"
214 fx.addRoute(t, registry.Route{Host: "beta." + fx.sqid + ".oncarlos.test", App: "kass", Account: fx.sqid, Kind: "instance"})
215 var logs bytes.Buffer
216 restore := captureLogTo(&logs)
217 status, _ := postJSON(t, newAppURL(fx), map[string]string{"name": "beta", "timezone": "Europe/London"}, token)
218 restore()
219 if status != http.StatusConflict {
220 t.Fatalf("held apex = %d, want 409", status)
221 }
222 if has, _ := fx.st.HasAppIn(OpsAccountID, "beta"); has {
223 t.Fatal("beta was created")
224 }
225 if strings.Contains(logs.String(), "app region bab/beta") {
226 t.Fatalf("a refused claim logged a region line: %s", logs.String())
227 }
228}
229
230// Controller ruling: with the switch off the API door neither validates nor
231// refuses an explicit region (spec §9.6, C13b is the CLI's word for it). It
232// stores the home as "default", with no bucket reads for a refusal.
233func TestAPICreateAppSwitchOffIgnoresAnExplicitRegion(t *testing.T) {
234 fx, token := regionFixture(t)
235 fx.srv.cfg.AppRegions = false
236 for _, region := range []string{"nz", "Bad/Code"} {
237 name := "off" + strings.ToLower(strings.ReplaceAll(region, "/", ""))
238 status, res := postJSON(t, newAppURL(fx), map[string]string{"name": name, "region": region}, token)
239 if status != http.StatusOK || res["region"] != "ie" || res["region_source"] != "default" {
240 t.Fatalf("switch off, region %q = %d %v, want 200 home/default", region, status, res)
241 }
242 if e := errOf(res); e != "" {
243 t.Errorf("switch off refused: %q", e)
244 }
245 if r, src, _ := fx.st.AppRegion(OpsAccountID, name); r != "" || src != "default" {
246 t.Fatalf("switch off, region %q stored %q %q", region, r, src)
247 }
248 }
249}
250
251// Final review, C11 on a switch-off console: regions_available is what the
252// create could actually use. With the switch off every create lands on the
253// home and --region is ignored (C13b), so the answer is 1, and a CLI, old
254// or new, that prints C11 at two or more never invites a flag this console
255// ignores. The same fleet with the switch on still answers 2.
256func TestAPICreateAppSwitchOffReportsOneRegionAvailable(t *testing.T) {
257 fx, token := regionFixture(t)
258 if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "on"}, token); status != http.StatusOK || res["regions_available"] != 2.0 {
259 t.Fatalf("switch on = %d %v, want regions_available 2", status, res)
260 }
261 fx.srv.cfg.AppRegions = false
262 if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "off"}, token); status != http.StatusOK || res["regions_available"] != 1.0 {
263 t.Fatalf("switch off = %d %v, want regions_available 1", status, res)
264 }
265 // An unnamed home is the switch off too (appRegionsOn).
266 fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = true, ""
267 if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "unnamed"}, token); status != http.StatusOK || res["regions_available"] != 1.0 {
268 t.Fatalf("unnamed home = %d %v, want regions_available 1", status, res)
269 }
270}
271
272// Defence in depth for future doors: an explicit code that is not a valid
273// region never reaches the row.
274func TestResolveNewAppRegionRejectsAGarbageExplicitCode(t *testing.T) {
275 fx, _ := regionFixture(t)
276 for _, bad := range []string{"Bad/Code", "../x", "UK", "a b"} {
277 if r, src := fx.srv.resolveNewAppRegion(context.Background(), regionReq{explicit: bad, hasExplicit: true}, false); r != "" || src != "default" {
278 t.Errorf("explicit %q resolved to %q %q, want the home as default", bad, r, src)
279 }
280 }
281 // Valid choices still stand: a coded region, and the home's "".
282 if r, src := fx.srv.resolveNewAppRegion(context.Background(), regionReq{explicit: "uk", hasExplicit: true}, false); r != "uk" || src != "chosen" {
283 t.Errorf("explicit uk = %q %q", r, src)
284 }
285 if r, src := fx.srv.resolveNewAppRegion(context.Background(), regionReq{explicit: "", hasExplicit: true}, false); r != "" || src != "chosen" {
286 t.Errorf("explicit home = %q %q", r, src)
287 }
288}
289