| new file mode 100644 |
| index 000000000..ef45cb603 |
| --- /dev/null |
| +++ b/internal/console/regiondefault_test.go |
| @@ -0,0 +1,348 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func regionsURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/titogo/regions" } |
| +func guessURL(fx *consoleFixture) string { return regionsURL(fx) + "/guess" } |
| + |
| +// corruptManifest replaces the manifest with unparsable bytes, under its |
| +// current etag (Put with "" is create-only and would not replace it). |
| +func corruptManifest(t *testing.T, fx *consoleFixture) { |
| + t.Helper() |
| + key := provision.ManifestKey("bab", "titogo") |
| + _, etag, err := fx.rel.Get(context.Background(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.rel.Put(context.Background(), key, []byte("{not json"), etag); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestRegionsKeepsLegacyFieldsAndAddsAppDefault(t *testing.T) { |
| + fx, token, _ := fillFixture(t, "uk") |
| + status, _, out := rawReq(t, http.MethodGet, regionsURL(fx), token, "") |
| + var res struct { |
| + Default string `json:"default"` |
| + AppDefault appDefaultView `json:"app_default"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &res); err != nil || status != http.StatusOK || res.Default != "ie" { |
| + t.Fatalf("legacy default = %d %s", status, out) |
| + } |
| + if d := res.AppDefault; d.Region != "uk" || d.Effective != "uk" || d.Available == nil || !*d.Available || d.Name != ukPlace { |
| + t.Fatalf("app_default = %+v", d) |
| + } |
| + if !strings.Contains(out, `"aws_region":"eu-west-2"`) || !strings.Contains(out, ukPlace) { |
| + t.Fatalf("entries lack names: %s", out) |
| + } |
| +} |
| + |
| +// Every reason §9.3 names, each from its own condition (codex plan-R1 12). |
| +func TestAppDefaultReasons(t *testing.T) { |
| + acct := &Account{ID: OpsAccountID, Sqid: "bab"} |
| + cases := []struct { |
| + name string |
| + setup func(t *testing.T, fx *consoleFixture) |
| + reason string |
| + available *bool |
| + effective string |
| + }{ |
| + {"available", func(*testing.T, *consoleFixture) {}, "", bp(true), "uk"}, |
| + {"absent", func(t *testing.T, fx *consoleFixture) { fx.rel.Delete(t.Context(), "control/edges/uk-box.json") }, "absent", bp(false), "uk"}, |
| + {"not-hosting", func(t *testing.T, fx *consoleFixture) { |
| + setEdges(t, fx, map[string]string{"uk-box": edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(false), shared: bp(true), inbound: bp(true), fp: testKeyFP})}) |
| + }, "not-hosting", bp(false), "uk"}, |
| + {"no-secrets-key", func(t *testing.T, fx *consoleFixture) { setEdges(t, fx, map[string]string{"uk-box": ukNoKey}) }, "no-secrets-key", bp(false), "uk"}, |
| + {"not-eligible", func(t *testing.T, fx *consoleFixture) { |
| + setEdges(t, fx, map[string]string{"uk-box": edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(false), inbound: bp(true), fp: testKeyFP})}) |
| + }, "not-eligible", bp(false), "uk"}, |
| + {"not-allowed", func(t *testing.T, fx *consoleFixture) { |
| + m := titoManifest() |
| + m.Pool, m.Regions = "", []string{"au"} |
| + enableInstances(t, fx, "titogo", m) |
| + }, "not-allowed", bp(false), "uk"}, |
| + {"pooled", func(t *testing.T, fx *consoleFixture) { |
| + m := titoManifest() |
| + m.Pool, m.Regions = "tito", nil |
| + enableInstances(t, fx, "titogo", m) |
| + }, "pooled", bp(true), "ie"}, |
| + {"fleet", func(t *testing.T, fx *consoleFixture) { fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme") }, "fleet", nil, "fleet/acme"}, |
| + {"policy-unreadable", corruptManifest, "policy-unreadable", nil, "uk"}, |
| + {"edges-unreadable", func(t *testing.T, fx *consoleFixture) { fx.srv.cfg.Releases = failingList{fx.rel} }, "edges-unreadable", nil, "uk"}, |
| + } |
| + for _, c := range cases { |
| + fx, _, _ := fillFixture(t, "uk") |
| + c.setup(t, fx) |
| + d := fx.srv.appDefault(t.Context(), acct, "titogo") |
| + okAvail := (c.available == nil && d.Available == nil) || (c.available != nil && d.Available != nil && *c.available == *d.Available) |
| + if d.Reason != c.reason || !okAvail || d.Effective != c.effective || d.Region != "uk" { |
| + t.Errorf("%s: %+v, want reason %q effective %q available %v", c.name, d, c.reason, c.effective, c.available) |
| + } |
| + } |
| +} |
| + |
| +// codex plan-R2 3: a fact the console could not read is never presented as |
| +// known: no home-valued region, no non-fleet effective. |
| +func TestAppDefaultKnownFlags(t *testing.T) { |
| + acct := &Account{ID: OpsAccountID, Sqid: "bab"} |
| + fx, _, _ := fillFixture(t, "uk") |
| + if d := fx.srv.appDefault(t.Context(), acct, "titogo"); !d.RegionKnown || !d.PlacementKnown || d.Region != "uk" { |
| + t.Fatalf("readable = %+v", d) |
| + } |
| + fx.srv.appRegionOf = func(int64, string) (string, string, error) { return "", "", errors.New("db gone") } |
| + if d := fx.srv.appDefault(t.Context(), acct, "titogo"); d.RegionKnown || !d.PlacementKnown || d.Region != "" || d.Effective != "" || d.Available != nil { |
| + t.Fatalf("unreadable region = %+v, want region unknown and nothing presented as fact", d) |
| + } |
| + fx2, _, _ := fillFixture(t, "uk") |
| + corruptManifest(t, fx2) |
| + if d := fx2.srv.appDefault(t.Context(), acct, "titogo"); !d.RegionKnown || d.PlacementKnown || d.Reason != "policy-unreadable" { |
| + t.Fatalf("unreadable policy = %+v, want placement unknown", d) |
| + } |
| +} |
| + |
| +// codex plan-R3 2: a pooled default is available only when the create door |
| +// would take it. A no-region pooled create lands on the home, so the home |
| +// must pass the manifest's allow-list. |
| +func TestPooledAvailabilityMatchesTheCreateDoor(t *testing.T) { |
| + acct := &Account{ID: OpsAccountID, Sqid: "bab"} |
| + for _, c := range []struct { |
| + name string |
| + regions []string |
| + avail bool |
| + }{ |
| + {"no allow-list", nil, true}, |
| + {"allow-list without the home", []string{"uk"}, false}, |
| + } { |
| + fx, token, u := fillFixture(t, "uk") |
| + m := titoManifest() |
| + m.Pool, m.Regions = "tito", c.regions |
| + enableInstances(t, fx, "titogo", m) |
| + d := fx.srv.appDefault(t.Context(), acct, "titogo") |
| + if d.Reason != "pooled" || d.Effective != "ie" || d.Available == nil || *d.Available != c.avail { |
| + t.Fatalf("%s: app_default = %+v, want pooled, ie, available %v", c.name, d, c.avail) |
| + } |
| + status, _, out := create(t, u, token, `{"host":"p.go.tito.io"}`) |
| + if created := status == http.StatusCreated; created != c.avail { |
| + t.Fatalf("%s: create = %d %s, but app_default said available %v", c.name, status, out, c.avail) |
| + } |
| + } |
| +} |
| + |
| +// codex plan-R3 1: instance_domain is the domain the instances-manifest PUT |
| +// stamps on hosts, so the CLI can name a canary's host before any write. |
| +func TestAppDefaultInstanceDomain(t *testing.T) { |
| + acct := &Account{ID: OpsAccountID, Sqid: "bab"} |
| + fx, _, _ := fillFixture(t, "uk") |
| + if d := fx.srv.appDefault(t.Context(), acct, "titogo"); d.InstanceDomain != "go.tito.io" { |
| + t.Fatalf("with a manifest = %q, want the manifest's go.tito.io", d.InstanceDomain) |
| + } |
| + corruptManifest(t, fx) |
| + if d := fx.srv.appDefault(t.Context(), acct, "titogo"); d.InstanceDomain != "" { |
| + t.Fatalf("unreadable manifest = %q, want none", d.InstanceDomain) |
| + } |
| + fx2, token := instanceFixture(t, "code-inst-domain") |
| + fx2.srv.cfg.AppsDomain = "" |
| + if d := fx2.srv.appDefault(t.Context(), acct, "titogo"); d.InstanceDomain != "" { |
| + t.Fatalf("no apps domain = %q, want none (the PUT refuses)", d.InstanceDomain) |
| + } |
| + fx2.srv.cfg.AppsDomain = "oncarlos.test" |
| + before := fx2.srv.appDefault(t.Context(), acct, "titogo").InstanceDomain |
| + status, _, out := rawReq(t, http.MethodPut, fx2.ts.URL+"/api/cli/apps/bab/titogo/instances-manifest", token, `{}`) |
| + var m struct { |
| + Domain string `json:"domain"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &m); err != nil || status != http.StatusOK { |
| + t.Fatalf("enable = %d %s", status, out) |
| + } |
| + if before == "" || before != m.Domain { |
| + t.Fatalf("before the PUT instance_domain = %q, the PUT stamped %q", before, m.Domain) |
| + } |
| +} |
| + |
| +// codex plan-R4 1, spec R9a: an unread stored region leaves region, name, |
| +// source and effective empty and available omitted, even when a fleet or a |
| +// pooled manifest would otherwise decide them. The placement the console |
| +// did read stays visible in reason. |
| +func TestAppDefaultUnreadRegionHidesEffective(t *testing.T) { |
| + acct := &Account{ID: OpsAccountID, Sqid: "bab"} |
| + for _, c := range []struct { |
| + name string |
| + setup func(t *testing.T, fx *consoleFixture) |
| + reason string |
| + }{ |
| + {"pooled", func(t *testing.T, fx *consoleFixture) { |
| + m := titoManifest() |
| + m.Pool, m.Regions = "tito", nil |
| + enableInstances(t, fx, "titogo", m) |
| + }, "pooled"}, |
| + {"fleet", func(t *testing.T, fx *consoleFixture) { |
| + if err := fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme"); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "fleet"}, |
| + } { |
| + fx, _, _ := fillFixture(t, "uk") |
| + c.setup(t, fx) |
| + fx.srv.appRegionOf = func(int64, string) (string, string, error) { return "", "", errors.New("db gone") } |
| + d := fx.srv.appDefault(t.Context(), acct, "titogo") |
| + if d.RegionKnown || !d.PlacementKnown || d.Region != "" || d.Name != "" || d.Source != "" || |
| + d.Effective != "" || d.Available != nil || d.Reason != c.reason { |
| + t.Errorf("%s with an unread region = %+v, want only placement_known and reason %q", c.name, d, c.reason) |
| + } |
| + } |
| +} |
| + |
| +func TestRegionGuessEndpoint(t *testing.T) { |
| + fx, token, _ := fillFixture(t, "") |
| + status, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`) |
| + if status != http.StatusOK || !strings.Contains(out, `"code":"uk"`) || !strings.Contains(out, `"source":"timezone"`) { |
| + t.Fatalf("guess = %d %s", status, out) |
| + } |
| + // Review Focus 4: garbage hints are absent, never an error. |
| + status, _, out = rawReq(t, http.MethodPost, guessURL(fx), token, `{"ip":"not-an-ip","tz":"`+strings.Repeat("a", 10<<10)+`"}`) |
| + if status != http.StatusOK || !strings.Contains(out, `"source":"default"`) { |
| + t.Fatalf("garbage = %d %s", status, out) |
| + } |
| + status, _, out = rawReq(t, http.MethodPost, guessURL(fx), token, `{not json`) |
| + if status != http.StatusOK || !strings.Contains(out, `"source":"default"`) { |
| + t.Fatalf("malformed body = %d %s", status, out) |
| + } |
| + fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme") |
| + if _, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); !strings.Contains(out, `"guess":null`) { |
| + t.Fatalf("fleet guess = %s (codex R4-1)", out) |
| + } |
| +} |
| + |
| +func TestRegionGuessPooledNullAndUnreadable502(t *testing.T) { |
| + fx, token, _ := fillFixture(t, "") |
| + m := titoManifest() |
| + m.Pool = "tito" |
| + enableInstances(t, fx, "titogo", m) |
| + if _, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); !strings.Contains(out, `"guess":null`) { |
| + t.Fatalf("pooled guess = %s", out) |
| + } |
| + corruptManifest(t, fx) |
| + if status, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); status != http.StatusBadGateway || !strings.Contains(out, msgPolicyUnreadable) { |
| + t.Fatalf("unreadable manifest = %d %s, want 502 C27", status, out) |
| + } |
| + fx2, token2, _ := fillFixture(t, "") |
| + fx2.srv.cfg.Registry = t.TempDir() // a directory: the registry will not open |
| + if status, _, out := rawReq(t, http.MethodPost, guessURL(fx2), token2, `{"tz":"Europe/London"}`); status != http.StatusBadGateway || !strings.Contains(out, msgPolicyUnreadable) { |
| + t.Fatalf("unreadable placement = %d %s, want 502 C27", status, out) |
| + } |
| +} |
| + |
| +// codex 8: with a canonical-host redirect configured, a guess POST's hints |
| +// appear in no Location, no body, no log and no audit row. PF9: the endpoint |
| +// must first answer 2xx (a 4xx would leak nothing for the wrong reason), and |
| +// the no-timezone run drives the GeoIP locator path with the IP hint. |
| +func TestRegionGuessAPIHintsNeverLeak(t *testing.T) { |
| + for _, c := range []struct { |
| + name, hints string |
| + secrets []string |
| + wantSource string |
| + }{ |
| + {"timezone and ip", `{"tz":"Pacific/Chatham","ip":"203.0.113.77"}`, []string{"Chatham", "203.0.113.77"}, "timezone"}, |
| + {"ip only, no timezone", `{"ip":"203.0.113.77"}`, []string{"203.0.113.77"}, "ip"}, |
| + } { |
| + fx, token, _ := fillFixture(t, "") |
| + fx.srv.cfg.GeoIP = fakeLocator{"203.0.113.77": geo.Point{Lat: 51.5, Lon: -0.12}} |
| + fx.srv.redirects = map[string]bool{"old.example": true} |
| + var logs strings.Builder |
| + restore := captureLog(&logs) |
| + req, _ := http.NewRequest(http.MethodPost, guessURL(fx), strings.NewReader(c.hints)) |
| + req.Host = "old.example" |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + res, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req) |
| + if err != nil { |
| + restore() |
| + t.Fatal(err) |
| + } |
| + body, _ := io.ReadAll(res.Body) |
| + res.Body.Close() |
| + everything := res.Header.Get("Location") + string(body) |
| + status, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, c.hints) |
| + restore() |
| + if status/100 != 2 || !strings.Contains(out, `"source":"`+c.wantSource+`"`) { |
| + t.Fatalf("%s: guess = %d %s, want 2xx from source %q", c.name, status, out, c.wantSource) |
| + } |
| + everything += out + logs.String() |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 50) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range events { |
| + everything += e.Detail + e.Message |
| + } |
| + for _, secret := range c.secrets { |
| + if strings.Contains(everything, secret) { |
| + t.Fatalf("%s: hint %q leaked: %s", c.name, secret, everything) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// PF7: when ONLY the stored region is unread, reason stays empty. |
| +// policy-unreadable is reserved for placement_known being false (spec §9.3). |
| +func TestAppDefaultUnreadRegionAloneLeavesReasonEmpty(t *testing.T) { |
| + acct := &Account{ID: OpsAccountID, Sqid: "bab"} |
| + fx, _, _ := fillFixture(t, "uk") |
| + fx.srv.appRegionOf = func(int64, string) (string, string, error) { return "", "", errors.New("db gone") } |
| + d := fx.srv.appDefault(t.Context(), acct, "titogo") |
| + if d.RegionKnown || !d.PlacementKnown || d.Reason != "" || d.Effective != "" || d.Available != nil || d.Region != "" { |
| + t.Fatalf("unread region alone = %+v, want empty reason, effective and available", d) |
| + } |
| +} |
| + |
| +// PF10: the legacy fields older clients and titogo read are unchanged for an |
| +// app with no stored region. The base answer was |
| +// |
| +// {"default":"ie","regions":[{"code":"ie","armed":true},{"code":"uk","armed":true}]} |
| +// |
| +// Entries gain aws_region and name (the brief's additive fields), so each |
| +// entry is compared on the two keys it had, in the order it had them. |
| +func TestRegionsLegacyFieldsGolden(t *testing.T) { |
| + fx, token, _ := fillFixture(t, "") |
| + status, _, out := rawReq(t, http.MethodGet, regionsURL(fx), token, "") |
| + var res struct { |
| + Default json.RawMessage `json:"default"` |
| + Regions []json.RawMessage `json:"regions"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &res); err != nil || status != http.StatusOK { |
| + t.Fatalf("regions = %d %s", status, out) |
| + } |
| + var legacy []struct { |
| + Code string `json:"code"` |
| + Armed bool `json:"armed"` |
| + } |
| + for _, raw := range res.Regions { |
| + var e struct { |
| + Code string `json:"code"` |
| + Armed bool `json:"armed"` |
| + } |
| + if err := json.Unmarshal(raw, &e); err != nil { |
| + t.Fatal(err) |
| + } |
| + legacy = append(legacy, e) |
| + } |
| + got, _ := json.Marshal(legacy) |
| + if string(got) != `[{"code":"ie","armed":true},{"code":"uk","armed":true}]` || string(res.Default) != `"ie"` { |
| + t.Fatalf("legacy fields drifted: regions %s default %s", got, res.Default) |
| + } |
| + for _, raw := range res.Regions { |
| + if !strings.HasPrefix(string(raw), `{"code":"`) || !strings.Contains(string(raw), `","armed":true`) { |
| + t.Fatalf("entry %s lost its legacy key order", raw) |
| + } |
| + } |
| +} |