oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1package slidestest
2
3import (
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.
15func 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.
27func 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).
56func 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.
106func 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.
152func 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.
176func 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.
202func 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.
225func 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.
244func 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.
268func 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