oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "bytes"
5 "context"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "path/filepath"
10 "strings"
11 "testing"
12)
13
14// loggingMailerStub is a minimal mail.Sender good enough to satisfy
15// Config.Mailer's own "non-nil outside development" fail-closed
16// check (buildMailer, auth.go), so
17// TestNewRefusesMissingAllowlistOutsideDevelopment reaches the
18// allowlist check it actually means to pin rather than failing
19// earlier on an unrelated one. It never actually sends anything —
20// nothing in that test signs in.
21type loggingMailerStub struct{}
22
23func (loggingMailerStub) Send(ctx context.Context, to, subject, body string) error { return nil }
24
25// newAllowlistedTestApp builds a development App exactly like
26// NewForTest (testing.go), except with AllowedAddresses set
27// explicitly. NewForTest itself cannot exercise the allowlist at all:
28// an empty AllowedAddresses in development means "admit everyone"
29// (resolveAllowlist's own doc comment, auth.go) — precisely so
30// TestSignIn and every other test built on NewForTest need no
31// allowlist setup of their own. These tests are the ones that
32// actually need a non-default allowlist configured, so they build
33// their own App via New directly, package-internal (like
34// origin_test.go and render_test.go) so they can also reach
35// app.mailRecorder — unexported, and deliberately not something
36// testing.go exposes outside this package: nothing outside auth.go's
37// own tests should need to know whether mail was sent versus merely
38// that a link came back, and TestSignIn already covers the latter.
39func newAllowlistedTestApp(t *testing.T, allowed string) *App {
40 t.Helper()
41 app, err := New(Config{
42 DBPath: filepath.Join(t.TempDir(), "docs.db"),
43 Env: "development",
44 AllowedAddresses: allowed,
45 })
46 if err != nil {
47 t.Fatalf("New: %v", err)
48 }
49 t.Cleanup(func() {
50 if cerr := app.Close(); cerr != nil {
51 t.Errorf("App.Close: %v", cerr)
52 }
53 })
54 return app
55}
56
57// postSignin POSTs address to /signin as a same-origin browser form
58// would, with force=1 — the same escape hatch TestSignIn (testing.go)
59// uses to skip signin's own keymail classification (which would
60// otherwise make a real outbound DNS/HTTPS call per address, per
61// signin's own README, entirely beside the point of what these tests
62// check). force=1 selects email but does not bypass the mail queue's admission check.
63func postSignin(app *App, address string) *httptest.ResponseRecorder {
64 form := url.Values{"address": {address}, "force": {"1"}}
65 req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
66 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
67 req.Header.Set("Sec-Fetch-Site", "same-origin")
68 rec := httptest.NewRecorder()
69 app.Handler().ServeHTTP(rec, req)
70 return rec
71}
72
73// lastMailBody reads back app.mailRecorder's most recently recorded
74// send, "" if nothing has been sent yet (or since the caller last
75// cleared it) — the direct way to observe whether Begin actually ran,
76// as opposed to TestSignIn's own success-shaped check (a link came
77// back), which a test proving the ABSENCE of a send cannot use: there
78// is no link to find in an empty body.
79func lastMailBody(app *App) string {
80 app.signinMail.wait() // links are sent after the response
81 app.mailRecorder.mu.Lock()
82 defer app.mailRecorder.mu.Unlock()
83 return app.mailRecorder.lastBody
84}
85
86// TestNonPermittedAddressSendsNoMail is this task's whole point,
87// stated as literally as possible: the defect (Config.AllowedAddresses'
88// own doc comment on Config has the full story — credited to the
89// CARLOS Sheets session's finding on their own deployed instance) was
90// never that a non-permitted address could sign in; rauth.Config.
91// Authorize already stopped that, once wired. It was that Begin sends
92// mail to any address at all, regardless of Authorize, because
93// Authorize is only ever consulted afterward, at the callback. This
94// is the test whose absence let that ship: every existing test in
95// this package (before this task) proved something about admission,
96// and none of them ever looked at whether a Send happened. Before
97// the delivery gate existed, this test failed exactly this way — see this
98// file's own commit message for how that was confirmed live, not
99// assumed.
100func TestNonPermittedAddressSendsNoMail(t *testing.T) {
101 app := newAllowlistedTestApp(t, "permitted@example.com")
102
103 rec := postSignin(app, "stranger@evil.example")
104
105 if rec.Code != http.StatusSeeOther {
106 t.Fatalf("POST /signin for a non-permitted address: status %d, want %d", rec.Code, http.StatusSeeOther)
107 }
108 if loc := rec.Header().Get("Location"); !strings.Contains(loc, "sent=1") {
109 t.Fatalf("POST /signin for a non-permitted address redirected to %q, want a %q outcome — the refusal must be invisible, not merely non-fatal", loc, "?sent=1")
110 }
111 if body := lastMailBody(app); body != "" {
112 t.Fatalf("POST /signin for a non-permitted address sent mail (recorded body: %q) — Begin must never run for an address the allowlist refuses", body)
113 }
114}
115
116// TestSigninResponseIdenticalForNonPermittedAddress is fix
117// requirement 3, checked the way its own reasoning demands: not
118// "both responses are a 303" but the actual bytes — status, Location
119// and body — compared directly. Any observable difference here would
120// turn POST /signin into a membership oracle: an attacker who can
121// tell "sent" from "not sent" has learned whether a given address is
122// on this instance's allowlist, which is exactly the kind of leak an
123// admission check alone would not create (it would 403 visibly) but
124// a badly-built send-time gate could.
125func TestSigninResponseIdenticalForNonPermittedAddress(t *testing.T) {
126 app := newAllowlistedTestApp(t, "permitted@example.com")
127
128 permitted := postSignin(app, "permitted@example.com")
129 nonPermitted := postSignin(app, "stranger@evil.example")
130
131 if permitted.Code != http.StatusSeeOther {
132 t.Fatalf("POST /signin for the permitted address: status %d, want %d", permitted.Code, http.StatusSeeOther)
133 }
134 if got, want := nonPermitted.Code, permitted.Code; got != want {
135 t.Errorf("status differs: non-permitted=%d permitted=%d", got, want)
136 }
137 if got, want := nonPermitted.Header().Get("Location"), permitted.Header().Get("Location"); got != want {
138 t.Errorf("Location differs: non-permitted=%q permitted=%q", got, want)
139 }
140 if got, want := nonPermitted.Header().Get("Content-Type"), permitted.Header().Get("Content-Type"); got != want {
141 t.Errorf("Content-Type differs: non-permitted=%q permitted=%q", got, want)
142 }
143 if got, want := nonPermitted.Body.Bytes(), permitted.Body.Bytes(); !bytes.Equal(got, want) {
144 t.Errorf("body differs: non-permitted=%q permitted=%q", got, want)
145 }
146}
147
148// TestPermittedAddressStillSignsIn proves the fix does not break the
149// working path: a permitted address must still complete the full
150// magic-link ceremony end to end, mirroring
151// TestMagicLinkThenSignedInRouteWorks (auth_test.go) but against an
152// App with a real, non-empty allowlist instead of NewForTest's
153// admit-everyone default.
154func TestPermittedAddressStillSignsIn(t *testing.T) {
155 app := newAllowlistedTestApp(t, "permitted@example.com")
156
157 link, err := app.TestSignIn(t, "permitted@example.com")
158 if err != nil {
159 t.Fatalf("TestSignIn: %v", err)
160 }
161
162 verifyReq := confirmRequest(link)
163 verifyRec := httptest.NewRecorder()
164 app.Handler().ServeHTTP(verifyRec, verifyReq)
165 cookies := verifyRec.Result().Cookies()
166 if len(cookies) == 0 {
167 t.Fatal("following the magic link set no session cookie")
168 }
169
170 req := httptest.NewRequest(http.MethodGet, "/", nil)
171 for _, c := range cookies {
172 req.AddCookie(c)
173 }
174 rec := httptest.NewRecorder()
175 app.Handler().ServeHTTP(rec, req)
176 if rec.Code != http.StatusOK {
177 t.Fatalf("GET / signed in as a permitted address: status %d, want 200", rec.Code)
178 }
179}
180
181// TestAllowlistIsCaseAndWhitespaceInsensitive is fix requirement 1's
182// own explicit demand: real config files have spaces after their
183// commas, and people type capitals — $DOCS_ALLOWED_ADDRESSES=
184// "Permitted@Example.com" must still admit
185// "permitted@example.com" and vice versa, in both directions
186// (the configured entry's own case/whitespace, and the submitted
187// address's).
188func TestAllowlistIsCaseAndWhitespaceInsensitive(t *testing.T) {
189 app := newAllowlistedTestApp(t, " Permitted@Example.com , other@example.com")
190
191 for _, variant := range []string{
192 "permitted@example.com",
193 "PERMITTED@EXAMPLE.COM",
194 " permitted@example.com ",
195 "Permitted@Example.com",
196 } {
197 if _, err := app.TestSignIn(t, variant); err != nil {
198 t.Errorf("TestSignIn(%q): %v, want the allowlisted address to be admitted regardless of case or surrounding whitespace", variant, err)
199 }
200 }
201}
202
203// TestResolveAllowlistParsesCommaSeparatedList pins
204// resolveAllowlist's own parsing directly: comma-separated, each
205// entry trimmed and lowercased, blank entries (a trailing comma, or
206// stray whitespace between two commas) dropped rather than becoming a
207// spurious "" that could never match a real address anyway but would
208// otherwise clutter the set.
209func TestResolveAllowlistParsesCommaSeparatedList(t *testing.T) {
210 list, err := resolveAllowlist(Config{
211 Env: "production",
212 AllowedAddresses: " Alice@Example.com, bob@example.com ,, carol@example.com ",
213 })
214 if err != nil {
215 t.Fatalf("resolveAllowlist: %v", err)
216 }
217 for _, addr := range []string{"alice@example.com", "ALICE@EXAMPLE.COM", "bob@example.com", "carol@example.com"} {
218 if !list.admits(addr) {
219 t.Errorf("list.admits(%q) = false, want true", addr)
220 }
221 }
222 if list.admits("mallory@example.com") {
223 t.Error("list.admits(\"mallory@example.com\") = true, want false — not on the configured list")
224 }
225}
226
227// TestResolveAllowlistEmptyOutsideDevelopmentRefuses is fix
228// requirement 4's decision, pinned directly: an unset
229// $DOCS_ALLOWED_ADDRESSES must never mean "allow everyone" outside
230// development. resolveAllowlist's own doc comment has the full
231// reasoning for refusing at startup rather than the alternative
232// (silently admitting nobody) — this test only pins the outcome:
233// New must refuse to start, the same fail-closed shape
234// TestNewRefusesMissingInstanceKeyOutsideDevelopment and
235// TestNewRefusesMissingMailOutsideDevelopment (auth_test.go) already
236// pin for InstanceKey and Mailer.
237func TestResolveAllowlistEmptyOutsideDevelopmentRefuses(t *testing.T) {
238 for _, env := range []string{"production", "staging", ""} {
239 if _, err := resolveAllowlist(Config{Env: env}); err == nil {
240 t.Errorf("resolveAllowlist with Env %q and no AllowedAddresses: err = nil, want a refusal", env)
241 }
242 }
243}
244
245// TestResolveAllowlistEmptyInDevelopmentAdmitsEveryone pins the other
246// half of the same decision: development's own convenience default
247// (TestSignIn, scripts/run-dev.sh) needs no allowlist configured at
248// all, exactly as it needed no InstanceKey or Mailer configured
249// before this task.
250func TestResolveAllowlistEmptyInDevelopmentAdmitsEveryone(t *testing.T) {
251 list, err := resolveAllowlist(Config{Env: "development"})
252 if err != nil {
253 t.Fatalf("resolveAllowlist: %v", err)
254 }
255 if !list.admits("anyone@example.com") {
256 t.Error("an empty AllowedAddresses in development must admit everyone")
257 }
258}
259
260// TestNewRefusesMissingAllowlistOutsideDevelopment is the New-level
261// version of TestResolveAllowlistEmptyOutsideDevelopmentRefuses,
262// exercised the way a real misconfigured deployment would actually
263// hit it — through New itself, alongside its own InstanceKey/Mailer
264// siblings in auth_test.go.
265func TestNewRefusesMissingAllowlistOutsideDevelopment(t *testing.T) {
266 _, err := New(Config{
267 Env: "production",
268 Origin: "https://docs.example.com",
269 InstanceKey: "some-real-key",
270 Mailer: loggingMailerStub{},
271 })
272 if err == nil {
273 t.Fatal("New must refuse to start outside development with no allowlist configured")
274 }
275}
276