| new file mode 100644 |
| index 000000000..2a36efc90 |
| --- /dev/null |
| +++ b/internal/console/regionpicker_test.go |
| @@ -0,0 +1,258 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "html" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| +) |
| + |
| +func has(body, plain string) bool { return strings.Contains(body, html.EscapeString(plain)) } |
| + |
| +// newAppFormGolden is the base tree's own rendering of the create-app form |
| +// (the branch base, switch off), with the session's CSRF value blanked. |
| +const newAppFormGolden = `<form class="signin" method="post" action="/a/bab/apps/new"> |
| + <input type="hidden" name="csrf" value="CSRF"> |
| + <label class="visually-hidden" for="app-name">App name</label> |
| + <input id="app-name" type="text" name="name" placeholder="my-app" autofocus |
| + required pattern="[A-Za-z][A-Za-z0-9-]{0,31}" maxlength="32"> |
| + <button type="submit">Create app</button> |
| +</form>` |
| + |
| +// newAppForm is the rendered <form>…</form>, CSRF value blanked. |
| +func newAppForm(t *testing.T, body string) string { |
| + t.Helper() |
| + i := strings.Index(body, `<form class="signin"`) |
| + if i < 0 { |
| + t.Fatalf("no create-app form in %s", body) |
| + } |
| + j := strings.Index(body[i:], "</form>") + i + len("</form>") |
| + return regexp.MustCompile(`name="csrf" value="[^"]*"`).ReplaceAllString(body[i:j], `name="csrf" value="CSRF"`) |
| +} |
| + |
| +// codex plan-R4 3, spec §10 and §16: whenever there is no picker, the form |
| +// is today's form byte for byte: with the switch off, and with the switch |
| +// on but only one eligible region. |
| +func TestNewAppPageWithoutPickerIsUnchanged(t *testing.T) { |
| + off := newConsoleFixture(t) |
| + one, _ := regionFixture(t) |
| + setEdges(t, one, map[string]string{"uk-box": ukNoKey}) // the home alone is eligible |
| + for name, fx := range map[string]*consoleFixture{"switch off": off, "one eligible region": one} { |
| + _, _, body := get(t, signInPaul(t, fx), fx.u("/apps/new")) |
| + if got := newAppForm(t, body); got != newAppFormGolden { |
| + t.Errorf("%s: form =\n%q\nwant\n%q", name, got, newAppFormGolden) |
| + } |
| + } |
| +} |
| + |
| +func TestNewAppPickerAndAutoChoice(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-picker") |
| + _, _, body := get(t, c, fx.u("/apps/new")) |
| + for _, want := range []string{`name="region"`, `value="@auto"`, `value="uk"`, `value="ie"`} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("picker missing %q", want) |
| + } |
| + } |
| + if !has(body, ukPlace) || !has(body, regionCopy.PickerLabel) || !has(body, regionCopy.PickerHelp) { |
| + t.Fatal("picker missing its copy") |
| + } |
| + if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"auto1"}, "region": {"@auto"}, "tz": {"Europe/London"}}); status != http.StatusSeeOther { |
| + t.Fatalf("@auto post = %d", status) |
| + } |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "auto1"); r != "uk" || src != "timezone" { |
| + t.Fatalf("@auto = %q %q", r, src) |
| + } |
| + postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"chose"}, "region": {"ie"}, "tz": {"Europe/London"}}) |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "chose"); r != "" || src != "chosen" { |
| + t.Fatalf("explicit ie = %q %q", r, src) |
| + } |
| +} |
| + |
| +// codex R2-3: a home NAMED "auto" is a region, not the sentinel. |
| +func TestRegionNamedAutoIsNotTheSentinel(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + fx.srv.cfg.HomeRegion = "auto" |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-auto-name") |
| + postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"homeauto"}, "region": {"auto"}}) |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "homeauto"); r != "" || src != "chosen" { |
| + t.Fatalf("explicit home named auto = %q %q", r, src) |
| + } |
| +} |
| + |
| +// Review Focus 3: uk's box loses the secrets key between render and POST. |
| +func TestNewAppPostWithANoLongerEligibleRegion(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-stale") |
| + if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, `value="uk"`) { |
| + t.Fatal("precondition: uk offered") |
| + } |
| + setEdges(t, fx, map[string]string{"uk-box": ukNoKey}) |
| + status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"stale"}, "region": {"uk"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/new") { |
| + t.Fatalf("stale region = %d %q, want back to the form", status, loc) |
| + } |
| + if _, _, body := get(t, c, fx.u("/apps/new")); !has(body, regionCopy.Unavailable) { |
| + t.Fatal("no C5 flash") |
| + } |
| + if exists, _ := fx.st.HasAppIn(OpsAccountID, "stale"); exists { |
| + t.Fatal("a refused region still created the app") |
| + } |
| +} |
| + |
| +func TestNewAppRegionRouteNeedsSessionAndCSRF(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-route") |
| + status, _, body := postForm(t, c, fx.u("/apps/new/region"), url.Values{"csrf": {csrf}, "tz": {"Europe/London"}}) |
| + var res map[string]string |
| + if err := json.Unmarshal([]byte(body), &res); err != nil { |
| + t.Fatalf("guess route body %q: %v", body, err) |
| + } |
| + if status != http.StatusOK || res["code"] != "uk" || res["source"] != "timezone" || res["name"] != ukPlace { |
| + t.Fatalf("guess route = %d %v", status, res) |
| + } |
| + if status, _, _ := postForm(t, c, fx.u("/apps/new/region"), url.Values{"csrf": {"wrong"}, "tz": {"Europe/London"}}); status != http.StatusForbidden { |
| + t.Fatalf("bad csrf = %d, want 403", status) |
| + } |
| + if status, _, _ := postForm(t, newBrowser(t), fx.u("/apps/new/region"), url.Values{"tz": {"Europe/London"}}); status == http.StatusOK { |
| + t.Fatal("no session got a guess") |
| + } |
| +} |
| + |
| +// §9.6 as 69a84224 made it: with the switch off the browser door neither |
| +// validates nor refuses an explicit region (resolveNewAppRegion stores the |
| +// home as "default"), exactly as the API door does. |
| +func TestNewAppExplicitRegionSkippedWithSwitchOff(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + fx.srv.cfg.AppRegions = false |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-off") |
| + for _, region := range []string{"uk", "mars"} { |
| + name := "off-" + region |
| + status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {name}, "region": {region}}) |
| + if status != http.StatusSeeOther || loc == fx.path("/apps/new") { |
| + t.Fatalf("switch off, region %q: %d %q, want the app created", region, status, loc) |
| + } |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, name); r != "" || src != "default" { |
| + t.Fatalf("switch off, region %q stored %q %q, want \"\" default", region, r, src) |
| + } |
| + } |
| +} |
| + |
| +// With the switch on, a code that is not a region at all is refused with |
| +// C5 too, and nothing is created. |
| +func TestNewAppUnknownRegionIsRefused(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-unknown") |
| + status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"nowhere"}, "region": {"mars!"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/new") { |
| + t.Fatalf("unknown region = %d %q", status, loc) |
| + } |
| + if _, _, body := get(t, c, fx.u("/apps/new")); !has(body, regionCopy.Unavailable) { |
| + t.Fatal("no C5 flash") |
| + } |
| + if exists, _ := fx.st.HasAppIn(OpsAccountID, "nowhere"); exists { |
| + t.Fatal("an unknown region still created the app") |
| + } |
| +} |
| + |
| +// The picker's preselected option is "@auto", never a region code, so an |
| +// untouched form is not an explicit choice (adoption must never move). |
| +func TestNewAppPickerPreselectsAuto(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + status, _, body := get(t, signInPaul(t, fx), fx.u("/apps/new")) |
| + if status != http.StatusOK { |
| + t.Fatalf("form = %d", status) |
| + } |
| + if !strings.Contains(body, `<option value="@auto" selected>`) { |
| + t.Fatalf("@auto is not the preselected option: %s", body) |
| + } |
| + if n := strings.Count(body, " selected>"); n != 1 { |
| + t.Fatalf("%d selected options, want exactly 1", n) |
| + } |
| + if !strings.Contains(body, `id="app-region"`) || !strings.Contains(body, `name="tz"`) { |
| + t.Fatal("picker has no region select or tz hint") |
| + } |
| + // The hints travel in POST bodies, never in a URL. |
| + if !strings.Contains(body, `method: "POST"`) { |
| + t.Fatal("the guess script does not POST") |
| + } |
| + if strings.Contains(body, "?tz=") || strings.Contains(body, "&tz=") { |
| + t.Fatal("tz in a URL") |
| + } |
| +} |
| + |
| +// C24 (Paul's ruling for DB-IP's licence): a link to db-ip.com carrying the |
| +// approved text, shown only when GeoIP is configured. |
| +func TestNewAppAttributionOnlyWithGeoIP(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/apps/new")) |
| + if status != http.StatusOK || !strings.Contains(body, `id="app-region"`) { |
| + t.Fatalf("precondition: picker page = %d", status) |
| + } |
| + if strings.Contains(body, "db-ip.com") || has(body, regionCopy.Attribution) { |
| + t.Fatal("attribution shown with no GeoIP") |
| + } |
| + fx.srv.cfg.GeoIP = fakeLocator{"81.2.69.142": geo.Point{Lat: 51.5, Lon: -0.12}} |
| + _, _, body = get(t, c, fx.u("/apps/new")) |
| + want := `<a href="https://db-ip.com">` + html.EscapeString("IP Geolocation by DB-IP") + `</a>` |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("attribution link %q not in page", want) |
| + } |
| +} |
| + |
| +// §6.5: the timezone and the address are never echoed, stored or logged. |
| +// PF9: assert the route answers 2xx before any negative check, and key the |
| +// page checks on an element id. |
| +func TestRegionGuessHintsNeverLeak(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + fx.srv.cfg.GeoIP = fakeLocator{"81.2.69.142": geo.Point{Lat: 51.5, Lon: -0.12}} |
| + var logged bytes.Buffer |
| + log.SetOutput(&logged) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-leak") |
| + |
| + const tz, ip = "Europe/London", "81.2.69.142" |
| + req, err := http.NewRequest(http.MethodPost, fx.u("/apps/new/region"), strings.NewReader(url.Values{"csrf": {csrf}, "tz": {tz}}.Encode())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("X-Forwarded-For", ip) |
| + res, err := c.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var buf bytes.Buffer |
| + buf.ReadFrom(res.Body) |
| + res.Body.Close() |
| + if res.StatusCode < 200 || res.StatusCode > 299 { |
| + t.Fatalf("guess route = %d %s", res.StatusCode, buf.String()) |
| + } |
| + if strings.Contains(buf.String(), tz) || strings.Contains(buf.String(), ip) { |
| + t.Fatalf("guess answer echoes a hint: %s", buf.String()) |
| + } |
| + if strings.Contains(logged.String(), tz) || strings.Contains(logged.String(), ip) { |
| + t.Fatalf("a hint was logged: %s", logged.String()) |
| + } |
| + // The form page carries no hint, and the tz field starts empty (the |
| + // script fills it in the browser). |
| + status, _, body := get(t, c, fx.u("/apps/new")) |
| + if status != http.StatusOK { |
| + t.Fatalf("form = %d", status) |
| + } |
| + if !strings.Contains(body, `id="app-region"`) || !strings.Contains(body, `<input type="hidden" name="tz" value="">`) { |
| + t.Fatal("picker page without its empty tz field") |
| + } |
| + if strings.Contains(body, tz) || strings.Contains(body, ip) { |
| + t.Fatal("form page echoes a hint") |
| + } |
| +} |