| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "html" |
| 7 | "log" |
| 8 | "net/http" |
| 9 | "net/url" |
| 10 | "os" |
| 11 | "regexp" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/geo" |
| 16 | ) |
| 17 | |
| 18 | func has(body, plain string) bool { return strings.Contains(body, html.EscapeString(plain)) } |
| 19 | |
| 20 | // newAppFormGolden is the base tree's own rendering of the create-app form |
| 21 | // (the branch base, switch off), with the session's CSRF value blanked. |
| 22 | const newAppFormGolden = `<form class="signin" method="post" action="/a/bab/apps/new"> |
| 23 | <input type="hidden" name="csrf" value="CSRF"> |
| 24 | <label class="visually-hidden" for="app-name">App name</label> |
| 25 | <input id="app-name" type="text" name="name" placeholder="my-app" autofocus |
| 26 | required pattern="[A-Za-z][A-Za-z0-9-]{0,31}" maxlength="32"> |
| 27 | <button type="submit">Create app</button> |
| 28 | </form>` |
| 29 | |
| 30 | // newAppForm is the rendered <form>…</form>, CSRF value blanked. |
| 31 | func newAppForm(t *testing.T, body string) string { |
| 32 | t.Helper() |
| 33 | i := strings.Index(body, `<form class="signin"`) |
| 34 | if i < 0 { |
| 35 | t.Fatalf("no create-app form in %s", body) |
| 36 | } |
| 37 | j := strings.Index(body[i:], "</form>") + i + len("</form>") |
| 38 | return regexp.MustCompile(`name="csrf" value="[^"]*"`).ReplaceAllString(body[i:j], `name="csrf" value="CSRF"`) |
| 39 | } |
| 40 | |
| 41 | // codex plan-R4 3, spec §10 and §16: whenever there is no picker, the form |
| 42 | // is today's form byte for byte: with the switch off, and with the switch |
| 43 | // on but only one eligible region. |
| 44 | func TestNewAppPageWithoutPickerIsUnchanged(t *testing.T) { |
| 45 | off := newConsoleFixture(t) |
| 46 | one, _ := regionFixture(t) |
| 47 | setEdges(t, one, map[string]string{"uk-box": ukNoKey}) // the home alone is eligible |
| 48 | for name, fx := range map[string]*consoleFixture{"switch off": off, "one eligible region": one} { |
| 49 | _, _, body := get(t, signInPaul(t, fx), fx.u("/apps/new")) |
| 50 | if got := newAppForm(t, body); got != newAppFormGolden { |
| 51 | t.Errorf("%s: form =\n%q\nwant\n%q", name, got, newAppFormGolden) |
| 52 | } |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestNewAppPickerAndAutoChoice(t *testing.T) { |
| 57 | fx, _ := regionFixture(t) |
| 58 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-picker") |
| 59 | _, _, body := get(t, c, fx.u("/apps/new")) |
| 60 | for _, want := range []string{`name="region"`, `value="@auto"`, `value="uk"`, `value="ie"`} { |
| 61 | if !strings.Contains(body, want) { |
| 62 | t.Fatalf("picker missing %q", want) |
| 63 | } |
| 64 | } |
| 65 | if !has(body, ukPlace) || !has(body, regionCopy.PickerLabel) || !has(body, regionCopy.PickerHelp) { |
| 66 | t.Fatal("picker missing its copy") |
| 67 | } |
| 68 | if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"auto1"}, "region": {"@auto"}, "tz": {"Europe/London"}}); status != http.StatusSeeOther { |
| 69 | t.Fatalf("@auto post = %d", status) |
| 70 | } |
| 71 | if r, src, _ := fx.st.AppRegion(OpsAccountID, "auto1"); r != "uk" || src != "timezone" { |
| 72 | t.Fatalf("@auto = %q %q", r, src) |
| 73 | } |
| 74 | postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"chose"}, "region": {"ie"}, "tz": {"Europe/London"}}) |
| 75 | if r, src, _ := fx.st.AppRegion(OpsAccountID, "chose"); r != "" || src != "chosen" { |
| 76 | t.Fatalf("explicit ie = %q %q", r, src) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // codex R2-3: a home NAMED "auto" is a region, not the sentinel. |
| 81 | func TestRegionNamedAutoIsNotTheSentinel(t *testing.T) { |
| 82 | fx, _ := regionFixture(t) |
| 83 | fx.srv.cfg.HomeRegion = "auto" |
| 84 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-auto-name") |
| 85 | postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"homeauto"}, "region": {"auto"}}) |
| 86 | if r, src, _ := fx.st.AppRegion(OpsAccountID, "homeauto"); r != "" || src != "chosen" { |
| 87 | t.Fatalf("explicit home named auto = %q %q", r, src) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // Review Focus 3: uk's box loses the secrets key between render and POST. |
| 92 | func TestNewAppPostWithANoLongerEligibleRegion(t *testing.T) { |
| 93 | fx, _ := regionFixture(t) |
| 94 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-stale") |
| 95 | if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, `value="uk"`) { |
| 96 | t.Fatal("precondition: uk offered") |
| 97 | } |
| 98 | setEdges(t, fx, map[string]string{"uk-box": ukNoKey}) |
| 99 | status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"stale"}, "region": {"uk"}}) |
| 100 | if status != http.StatusSeeOther || loc != fx.path("/apps/new") { |
| 101 | t.Fatalf("stale region = %d %q, want back to the form", status, loc) |
| 102 | } |
| 103 | if _, _, body := get(t, c, fx.u("/apps/new")); !has(body, regionCopy.Unavailable) { |
| 104 | t.Fatal("no C5 flash") |
| 105 | } |
| 106 | if exists, _ := fx.st.HasAppIn(OpsAccountID, "stale"); exists { |
| 107 | t.Fatal("a refused region still created the app") |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func TestNewAppRegionRouteNeedsSessionAndCSRF(t *testing.T) { |
| 112 | fx, _ := regionFixture(t) |
| 113 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-route") |
| 114 | status, _, body := postForm(t, c, fx.u("/apps/new/region"), url.Values{"csrf": {csrf}, "tz": {"Europe/London"}}) |
| 115 | var res map[string]string |
| 116 | if err := json.Unmarshal([]byte(body), &res); err != nil { |
| 117 | t.Fatalf("guess route body %q: %v", body, err) |
| 118 | } |
| 119 | if status != http.StatusOK || res["code"] != "uk" || res["source"] != "timezone" || res["name"] != ukPlace { |
| 120 | t.Fatalf("guess route = %d %v", status, res) |
| 121 | } |
| 122 | if status, _, _ := postForm(t, c, fx.u("/apps/new/region"), url.Values{"csrf": {"wrong"}, "tz": {"Europe/London"}}); status != http.StatusForbidden { |
| 123 | t.Fatalf("bad csrf = %d, want 403", status) |
| 124 | } |
| 125 | if status, _, _ := postForm(t, newBrowser(t), fx.u("/apps/new/region"), url.Values{"tz": {"Europe/London"}}); status == http.StatusOK { |
| 126 | t.Fatal("no session got a guess") |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // §9.6 as 69a84224 made it: with the switch off the browser door neither |
| 131 | // validates nor refuses an explicit region (resolveNewAppRegion stores the |
| 132 | // home as "default"), exactly as the API door does. |
| 133 | func TestNewAppExplicitRegionSkippedWithSwitchOff(t *testing.T) { |
| 134 | fx, _ := regionFixture(t) |
| 135 | fx.srv.cfg.AppRegions = false |
| 136 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-off") |
| 137 | for _, region := range []string{"uk", "mars"} { |
| 138 | name := "off-" + region |
| 139 | status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {name}, "region": {region}}) |
| 140 | if status != http.StatusSeeOther || loc == fx.path("/apps/new") { |
| 141 | t.Fatalf("switch off, region %q: %d %q, want the app created", region, status, loc) |
| 142 | } |
| 143 | if r, src, _ := fx.st.AppRegion(OpsAccountID, name); r != "" || src != "default" { |
| 144 | t.Fatalf("switch off, region %q stored %q %q, want \"\" default", region, r, src) |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | // With the switch on, a code that is not a region at all is refused with |
| 150 | // C5 too, and nothing is created. |
| 151 | func TestNewAppUnknownRegionIsRefused(t *testing.T) { |
| 152 | fx, _ := regionFixture(t) |
| 153 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-unknown") |
| 154 | status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"nowhere"}, "region": {"mars!"}}) |
| 155 | if status != http.StatusSeeOther || loc != fx.path("/apps/new") { |
| 156 | t.Fatalf("unknown region = %d %q", status, loc) |
| 157 | } |
| 158 | if _, _, body := get(t, c, fx.u("/apps/new")); !has(body, regionCopy.Unavailable) { |
| 159 | t.Fatal("no C5 flash") |
| 160 | } |
| 161 | if exists, _ := fx.st.HasAppIn(OpsAccountID, "nowhere"); exists { |
| 162 | t.Fatal("an unknown region still created the app") |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // The picker's preselected option is "@auto", never a region code, so an |
| 167 | // untouched form is not an explicit choice (adoption must never move). |
| 168 | func TestNewAppPickerPreselectsAuto(t *testing.T) { |
| 169 | fx, _ := regionFixture(t) |
| 170 | status, _, body := get(t, signInPaul(t, fx), fx.u("/apps/new")) |
| 171 | if status != http.StatusOK { |
| 172 | t.Fatalf("form = %d", status) |
| 173 | } |
| 174 | if !strings.Contains(body, `<option value="@auto" selected>`) { |
| 175 | t.Fatalf("@auto is not the preselected option: %s", body) |
| 176 | } |
| 177 | if n := strings.Count(body, " selected>"); n != 1 { |
| 178 | t.Fatalf("%d selected options, want exactly 1", n) |
| 179 | } |
| 180 | if !strings.Contains(body, `id="app-region"`) || !strings.Contains(body, `name="tz"`) { |
| 181 | t.Fatal("picker has no region select or tz hint") |
| 182 | } |
| 183 | // The hints travel in POST bodies, never in a URL. |
| 184 | if !strings.Contains(body, `method: "POST"`) { |
| 185 | t.Fatal("the guess script does not POST") |
| 186 | } |
| 187 | if strings.Contains(body, "?tz=") || strings.Contains(body, "&tz=") { |
| 188 | t.Fatal("tz in a URL") |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // C24 (Paul's ruling for DB-IP's licence): a link to db-ip.com carrying the |
| 193 | // approved text, shown only when GeoIP is configured. |
| 194 | func TestNewAppAttributionOnlyWithGeoIP(t *testing.T) { |
| 195 | fx, _ := regionFixture(t) |
| 196 | c := signInPaul(t, fx) |
| 197 | status, _, body := get(t, c, fx.u("/apps/new")) |
| 198 | if status != http.StatusOK || !strings.Contains(body, `id="app-region"`) { |
| 199 | t.Fatalf("precondition: picker page = %d", status) |
| 200 | } |
| 201 | if strings.Contains(body, "db-ip.com") || has(body, regionCopy.Attribution) { |
| 202 | t.Fatal("attribution shown with no GeoIP") |
| 203 | } |
| 204 | fx.srv.cfg.GeoIP = fakeLocator{"81.2.69.142": geo.Point{Lat: 51.5, Lon: -0.12}} |
| 205 | _, _, body = get(t, c, fx.u("/apps/new")) |
| 206 | want := `<a href="https://db-ip.com">` + html.EscapeString("IP Geolocation by DB-IP") + `</a>` |
| 207 | if !strings.Contains(body, want) { |
| 208 | t.Fatalf("attribution link %q not in page", want) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | // §6.5: the timezone and the address are never echoed, stored or logged. |
| 213 | // PF9: assert the route answers 2xx before any negative check, and key the |
| 214 | // page checks on an element id. |
| 215 | func TestRegionGuessHintsNeverLeak(t *testing.T) { |
| 216 | fx, _ := regionFixture(t) |
| 217 | fx.srv.cfg.GeoIP = fakeLocator{"81.2.69.142": geo.Point{Lat: 51.5, Lon: -0.12}} |
| 218 | var logged bytes.Buffer |
| 219 | log.SetOutput(&logged) |
| 220 | t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| 221 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-leak") |
| 222 | |
| 223 | const tz, ip = "Europe/London", "81.2.69.142" |
| 224 | req, err := http.NewRequest(http.MethodPost, fx.u("/apps/new/region"), strings.NewReader(url.Values{"csrf": {csrf}, "tz": {tz}}.Encode())) |
| 225 | if err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 229 | req.Header.Set("X-Forwarded-For", ip) |
| 230 | res, err := c.Do(req) |
| 231 | if err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | var buf bytes.Buffer |
| 235 | buf.ReadFrom(res.Body) |
| 236 | res.Body.Close() |
| 237 | if res.StatusCode < 200 || res.StatusCode > 299 { |
| 238 | t.Fatalf("guess route = %d %s", res.StatusCode, buf.String()) |
| 239 | } |
| 240 | if strings.Contains(buf.String(), tz) || strings.Contains(buf.String(), ip) { |
| 241 | t.Fatalf("guess answer echoes a hint: %s", buf.String()) |
| 242 | } |
| 243 | if strings.Contains(logged.String(), tz) || strings.Contains(logged.String(), ip) { |
| 244 | t.Fatalf("a hint was logged: %s", logged.String()) |
| 245 | } |
| 246 | // The form page carries no hint, and the tz field starts empty (the |
| 247 | // script fills it in the browser). |
| 248 | status, _, body := get(t, c, fx.u("/apps/new")) |
| 249 | if status != http.StatusOK { |
| 250 | t.Fatalf("form = %d", status) |
| 251 | } |
| 252 | if !strings.Contains(body, `id="app-region"`) || !strings.Contains(body, `<input type="hidden" name="tz" value="">`) { |
| 253 | t.Fatal("picker page without its empty tz field") |
| 254 | } |
| 255 | if strings.Contains(body, tz) || strings.Contains(body, ip) { |
| 256 | t.Fatal("form page echoes a hint") |
| 257 | } |
| 258 | } |
| 259 | |