| 1 | package console |
| 2 | |
| 3 | import ( |
| 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 | |
| 18 | func 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 | |
| 26 | func 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). |
| 30 | func 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 | |
| 43 | func errOf(res map[string]any) string { s, _ := res["error"].(string); return s } |
| 44 | |
| 45 | var ukPlace = geo.PlaceName("eu-west-2", "uk") |
| 46 | |
| 47 | func 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). |
| 70 | func 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 | |
| 84 | func 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 | |
| 119 | func 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 | |
| 136 | func 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). |
| 156 | func 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. |
| 187 | func 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. |
| 211 | func 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. |
| 233 | func 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. |
| 256 | func 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. |
| 274 | func 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 | |