| 1 | package slidestest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/url" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | slides "amadan.net/oficina/slides/internal/slides" |
| 10 | ) |
| 11 | |
| 12 | // TestSignedOutIsRedirectedToSignin: "/" is behind the session gate, |
| 13 | // and a signed-out page request lands on the sign-in page rather than |
| 14 | // on an empty screen or a bare 403. |
| 15 | func TestSignedOutIsRedirectedToSignin(t *testing.T) { |
| 16 | rec := get(t, newApp(t), "/") |
| 17 | if rec.Code != http.StatusSeeOther { |
| 18 | t.Fatalf("GET / signed out: status %d, want %d", rec.Code, http.StatusSeeOther) |
| 19 | } |
| 20 | if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/signin") { |
| 21 | t.Errorf("GET / signed out: Location = %q, want a redirect to /signin", loc) |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | // TestSigninPageIsPublic: the front door has to be reachable signed |
| 26 | // out, or nobody could ever get in. |
| 27 | func TestSigninPageIsPublic(t *testing.T) { |
| 28 | rec := get(t, newApp(t), "/signin") |
| 29 | if rec.Code != http.StatusOK { |
| 30 | t.Fatalf("GET /signin signed out: status %d, want 200", rec.Code) |
| 31 | } |
| 32 | body := rec.Body.String() |
| 33 | if !strings.Contains(body, `name="address"`) { |
| 34 | t.Errorf("GET /signin: no address field in the form:\n%s", body) |
| 35 | } |
| 36 | // A real <label> bound to the input, not a placeholder standing in |
| 37 | // for one — the guarantee the vendored field-text partial carries, |
| 38 | // pinned here so a future hand-rolled input cannot quietly drop it. |
| 39 | if !strings.Contains(body, `for="estilo-signin-address"`) { |
| 40 | t.Errorf("GET /signin: the address field has no bound <label>:\n%s", body) |
| 41 | } |
| 42 | // The catalog resolved, rather than the key leaking onto the page. |
| 43 | if !strings.Contains(body, "Sign in") { |
| 44 | t.Errorf("GET /signin: page does not carry its translated title:\n%s", body) |
| 45 | } |
| 46 | if strings.Contains(body, "slides.signin.") { |
| 47 | t.Errorf("GET /signin: a catalog key leaked onto the page unresolved:\n%s", body) |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | // TestSigninMailsAllowedAddressOnly is the allowlist's whole point: an |
| 52 | // address nobody put on the list never causes this instance's sending |
| 53 | // identity to mail anything at all — and it cannot tell that it did |
| 54 | // not, because the two responses are identical (anything else would be |
| 55 | // a membership oracle on an unauthenticated endpoint). |
| 56 | func TestSigninMailsAllowedAddressOnly(t *testing.T) { |
| 57 | app := newApp(t) |
| 58 | |
| 59 | allowed := post(t, app, "/signin", url.Values{"address": {allowedAddress}}) |
| 60 | if allowed.Code != http.StatusSeeOther { |
| 61 | t.Fatalf("POST /signin (allowed): status %d, want %d", allowed.Code, http.StatusSeeOther) |
| 62 | } |
| 63 | if loc := allowed.Header().Get("Location"); loc != "/signin?sent=1" { |
| 64 | t.Errorf("POST /signin (allowed): Location = %q, want %q", loc, "/signin?sent=1") |
| 65 | } |
| 66 | if n := len(app.mailer.messages()); n != 1 { |
| 67 | t.Fatalf("POST /signin (allowed): mailer captured %d messages, want 1", n) |
| 68 | } |
| 69 | if to := app.mailer.messages()[0].To; to != allowedAddress { |
| 70 | t.Errorf("mailed to %q, want %q", to, allowedAddress) |
| 71 | } |
| 72 | |
| 73 | denied := post(t, app, "/signin", url.Values{"address": {deniedAddress}}) |
| 74 | if denied.Code != allowed.Code { |
| 75 | t.Errorf("POST /signin (denied): status %d, want %d — identical to the permitted answer", denied.Code, allowed.Code) |
| 76 | } |
| 77 | if got, want := denied.Header().Get("Location"), allowed.Header().Get("Location"); got != want { |
| 78 | t.Errorf("POST /signin (denied): Location = %q, want %q — identical to the permitted answer", got, want) |
| 79 | } |
| 80 | // Byte-for-byte, not merely "both are 303": any observable |
| 81 | // difference at all would let someone enumerate the allowlist. |
| 82 | if got, want := denied.Body.String(), allowed.Body.String(); got != want { |
| 83 | t.Errorf("POST /signin (denied): body %q, want %q — identical to the permitted answer", got, want) |
| 84 | } |
| 85 | if n := len(app.mailer.messages()); n != 1 { |
| 86 | t.Errorf("POST /signin (denied): mailer captured %d messages, want the 1 from the permitted address alone", n) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // TestSigninAnswerIsIdenticalOverTheRateLimitBudget is the sharp end of |
| 91 | // the no-oracle guarantee, and the case the first version of gateBegin |
| 92 | // got wrong. |
| 93 | // |
| 94 | // That version short-circuited a denied address before rastrillo/auth's |
| 95 | // Begin, so it answered ?sent=1 forever, while a permitted address |
| 96 | // reached Begin's per-address budget of 5 and got ?err=rate: six POSTs |
| 97 | // of one candidate address separated "on the list" from "not on it". |
| 98 | // Every address reaches Begin now (gateBegin), so the budgets are the |
| 99 | // same for both; sameAnswer still answers ?err=rate as sent, and this |
| 100 | // pins that. |
| 101 | // |
| 102 | // Seven POSTs, every one byte-identical to what a denied address gets. |
| 103 | // The mailer stops at 5, which is the other half of the proof: the |
| 104 | // answers are the same because the failure is hidden, not because the |
| 105 | // budget quietly stopped being enforced. |
| 106 | func TestSigninAnswerIsIdenticalOverTheRateLimitBudget(t *testing.T) { |
| 107 | app := newApp(t) |
| 108 | |
| 109 | denied := post(t, app, "/signin", url.Values{"address": {deniedAddress}}) |
| 110 | if denied.Code != http.StatusSeeOther { |
| 111 | t.Fatalf("POST /signin (denied): status %d, want %d", denied.Code, http.StatusSeeOther) |
| 112 | } |
| 113 | wantLoc := denied.Header().Get("Location") |
| 114 | wantBody := denied.Body.String() |
| 115 | if wantLoc != "/signin?sent=1" { |
| 116 | t.Fatalf("POST /signin (denied): Location = %q, want %q", wantLoc, "/signin?sent=1") |
| 117 | } |
| 118 | |
| 119 | const attempts = 7 |
| 120 | for i := 1; i <= attempts; i++ { |
| 121 | rec := post(t, app, "/signin", url.Values{"address": {allowedAddress}}) |
| 122 | if rec.Code != denied.Code { |
| 123 | t.Errorf("POST /signin (allowed, attempt %d): status %d, want %d — identical to a denied address", i, rec.Code, denied.Code) |
| 124 | } |
| 125 | if got := rec.Header().Get("Location"); got != wantLoc { |
| 126 | t.Errorf("POST /signin (allowed, attempt %d): Location = %q, want %q — identical to a denied address", i, got, wantLoc) |
| 127 | } |
| 128 | if got := rec.Body.String(); got != wantBody { |
| 129 | t.Errorf("POST /signin (allowed, attempt %d): body %q, want %q — identical to a denied address", i, got, wantBody) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | // signin's per-address budget is 5; the two attempts past it are |
| 134 | // the ones whose ?err=rate the guard rewrote. |
| 135 | if n := len(app.mailer.messages()); n != 5 { |
| 136 | t.Errorf("mailer captured %d messages over %d attempts, want the 5 the per-address budget allows", n, attempts) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // TestSigninAcceptsALegacyOriginOnlyPost covers the path a |
| 141 | // Sec-Fetch-Site header hides: csrf.Protect checks that header first |
| 142 | // when a browser sends it, and only falls back to comparing Origin |
| 143 | // against the app's configured origin when it is absent entirely — a |
| 144 | // non-browser client, or an old one. The post helper sends both, so |
| 145 | // without this test the Origin half of the app's config would never be |
| 146 | // exercised at all, and a wrong resolved origin would pass the suite. |
| 147 | // |
| 148 | // It is also the one test that observes the RESOLVED origin from |
| 149 | // outside the package: the app was configured with testOrigin, and this |
| 150 | // is what proves that string is what csrf.Protect is holding requests |
| 151 | // to. |
| 152 | func TestSigninAcceptsALegacyOriginOnlyPost(t *testing.T) { |
| 153 | app := newApp(t) |
| 154 | |
| 155 | ok := postLegacy(t, app, "/signin", testOrigin, url.Values{"address": {allowedAddress}}) |
| 156 | if ok.Code != http.StatusSeeOther { |
| 157 | t.Errorf("POST /signin with Origin %q and no Sec-Fetch-Site: status %d, want %d", testOrigin, ok.Code, http.StatusSeeOther) |
| 158 | } |
| 159 | if n := len(app.mailer.messages()); n != 1 { |
| 160 | t.Errorf("a same-origin legacy POST mailed %d links, want 1", n) |
| 161 | } |
| 162 | |
| 163 | refused := postLegacy(t, app, "/signin", "http://evil.test", url.Values{"address": {allowedAddress}}) |
| 164 | if refused.Code == http.StatusSeeOther { |
| 165 | t.Errorf("POST /signin with Origin %q and no Sec-Fetch-Site: status %d, want a refusal", "http://evil.test", refused.Code) |
| 166 | } |
| 167 | if n := len(app.mailer.messages()); n != 1 { |
| 168 | t.Errorf("a cross-origin POST mailed a link; mailer has %d messages, want the 1 from the same-origin post", n) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // TestVerifyMintsSessionAndUserRow: following the emailed link both |
| 173 | // establishes a usable session and creates this app's own person row — |
| 174 | // the address-to-id mapping every later owner-scoped query needs, since |
| 175 | // under rastrillo/auth the session Subject is the email, not a user id. |
| 176 | func TestVerifyMintsSessionAndUserRow(t *testing.T) { |
| 177 | app := newApp(t) |
| 178 | cookies := signedIn(t, app, allowedAddress) |
| 179 | |
| 180 | rec := get(t, app, "/", cookies...) |
| 181 | if rec.Code != http.StatusOK { |
| 182 | t.Fatalf("GET / signed in: status %d, want 200", rec.Code) |
| 183 | } |
| 184 | |
| 185 | var users []slides.User |
| 186 | if err := app.db.G.Find(&users).Error; err != nil { |
| 187 | t.Fatalf("read users: %v", err) |
| 188 | } |
| 189 | if len(users) != 1 { |
| 190 | t.Fatalf("users table has %d rows, want 1: %+v", len(users), users) |
| 191 | } |
| 192 | if users[0].Email != allowedAddress { |
| 193 | t.Errorf("users[0].Email = %q, want %q", users[0].Email, allowedAddress) |
| 194 | } |
| 195 | if users[0].ID == 0 { |
| 196 | t.Error("users[0].ID is 0; the row was never assigned an id") |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // TestSecondSignInReusesTheUserRow: the row is created once, on first |
| 201 | // sign-in, not on every request that reads it. |
| 202 | func TestSecondSignInReusesTheUserRow(t *testing.T) { |
| 203 | app := newApp(t) |
| 204 | |
| 205 | first := signedIn(t, app, allowedAddress) |
| 206 | if rec := get(t, app, "/", first...); rec.Code != http.StatusOK { |
| 207 | t.Fatalf("GET / after first sign-in: status %d, want 200", rec.Code) |
| 208 | } |
| 209 | second := signedIn(t, app, allowedAddress) |
| 210 | if rec := get(t, app, "/", second...); rec.Code != http.StatusOK { |
| 211 | t.Fatalf("GET / after second sign-in: status %d, want 200", rec.Code) |
| 212 | } |
| 213 | |
| 214 | var users []slides.User |
| 215 | if err := app.db.G.Find(&users).Error; err != nil { |
| 216 | t.Fatalf("read users: %v", err) |
| 217 | } |
| 218 | if len(users) != 1 { |
| 219 | t.Fatalf("users table has %d rows after two sign-ins, want 1: %+v", len(users), users) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | // TestSignoutEndsTheSession: sign-out is real revocation, so the |
| 224 | // signed-in screen goes back to being a redirect. |
| 225 | func TestSignoutEndsTheSession(t *testing.T) { |
| 226 | app := newApp(t) |
| 227 | cookies := signedIn(t, app, allowedAddress) |
| 228 | |
| 229 | if rec := post(t, app, "/signout", url.Values{}, cookies...); rec.Code != http.StatusSeeOther { |
| 230 | t.Fatalf("POST /signout: status %d, want %d", rec.Code, http.StatusSeeOther) |
| 231 | } |
| 232 | rec := get(t, app, "/", cookies...) |
| 233 | if rec.Code != http.StatusSeeOther { |
| 234 | t.Errorf("GET / after sign-out: status %d, want %d", rec.Code, http.StatusSeeOther) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | // TestDevelopmentDefaultsRefusedInProduction: each of the four |
| 239 | // settings that decide whether sign-in is safe refuses to start when it |
| 240 | // is missing and Env is not "development". A missing one must never |
| 241 | // silently take a development fallback in a deployment — the whole |
| 242 | // fail-closed story, one subtest per setting so a regression names |
| 243 | // which one. |
| 244 | func TestDevelopmentDefaultsRefusedInProduction(t *testing.T) { |
| 245 | for _, tc := range []struct { |
| 246 | name string |
| 247 | bend func(cfg *slides.Config) |
| 248 | }{ |
| 249 | {"InstanceKey", func(cfg *slides.Config) { cfg.InstanceKey = "" }}, |
| 250 | {"Origin", func(cfg *slides.Config) { cfg.Origin = "" }}, |
| 251 | {"Mailer", func(cfg *slides.Config) { cfg.Mailer = nil }}, |
| 252 | {"AllowedAddresses", func(cfg *slides.Config) { cfg.AllowedAddresses = nil }}, |
| 253 | } { |
| 254 | t.Run(tc.name, func(t *testing.T) { |
| 255 | cfg, _ := newConfig(t) |
| 256 | cfg.Env = "production" |
| 257 | tc.bend(&cfg) |
| 258 | if _, _, err := slides.New(openTestDB(t, cfg), cfg); err == nil { |
| 259 | t.Fatalf("slides.New with an empty %s and Env %q returned no error; want a startup refusal", tc.name, cfg.Env) |
| 260 | } |
| 261 | }) |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | // TestDevelopmentDefaultsAllowedInDevelopment is the other half of the |
| 266 | // same rule: the one Env that may default does, so scripts/run-dev.sh |
| 267 | // needs no setup at all. |
| 268 | func TestDevelopmentDefaultsAllowedInDevelopment(t *testing.T) { |
| 269 | cfg, _ := newConfig(t) |
| 270 | cfg.Env = "development" |
| 271 | cfg.InstanceKey = "" |
| 272 | cfg.Origin = "" |
| 273 | cfg.Mailer = nil |
| 274 | cfg.AllowedAddresses = nil |
| 275 | _, a, err := slides.New(openTestDB(t, cfg), cfg) |
| 276 | if err != nil { |
| 277 | t.Fatalf("slides.New with an empty config and Env %q: %v", cfg.Env, err) |
| 278 | } |
| 279 | t.Cleanup(func() { a.Close() }) |
| 280 | } |
| 281 | |