oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs_test
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "testing"
8
9 "github.com/carlosframework/docs/internal/docs"
10)
11
12// TestSignInRequiredForEverything is the brief's own literal test,
13// extended per fix round 1: a create endpoint reachable without a
14// session is the entire problem this task exists to solve, and a
15// "does the list require sign-in?" GET-only check reads straight
16// past it — a route moved out of the RequireSession group by a
17// future refactor would go undetected here otherwise. POST
18// /documents is sent with Sec-Fetch-Site: same-origin so csrf.Protect
19// (mounted above RequireSession in the middleware chain) lets it
20// through, isolating what actually refuses it: RequireSession itself,
21// not the CSRF check. A signed-out GET/HEAD redirects to sign-in;
22// anything else — POST included — 403s (rastrillo/auth's own
23// refuseSession, docs/site's own contract).
24func TestSignInRequiredForEverything(t *testing.T) {
25 app, err := docs.NewForTest(t)
26 if err != nil {
27 t.Fatal(err)
28 }
29 for _, path := range []string{"/", "/d/anything"} {
30 req := httptest.NewRequest(http.MethodGet, path, nil)
31 rec := httptest.NewRecorder()
32 app.Handler().ServeHTTP(rec, req)
33 if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound {
34 t.Errorf("GET %s signed out: status %d, want a redirect to sign-in", path, rec.Code)
35 }
36 }
37
38 form := strings.NewReader("title=Should+not+be+created")
39 req := httptest.NewRequest(http.MethodPost, "/documents", form)
40 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
41 req.Header.Set("Sec-Fetch-Site", "same-origin")
42 rec := httptest.NewRecorder()
43 app.Handler().ServeHTTP(rec, req)
44 if rec.Code != http.StatusForbidden {
45 t.Errorf("POST /documents signed out: status %d, want %d (RequireSession's own refusal, not a redirect)", rec.Code, http.StatusForbidden)
46 }
47}
48
49// TestMagicLinkSignsSomebodyIn is the brief's own literal test:
50// TestSignIn mints a link without sending real mail, and following it
51// must establish a real session (a cookie), not merely 200.
52func TestMagicLinkSignsSomebodyIn(t *testing.T) {
53 app, err := docs.NewForTest(t)
54 if err != nil {
55 t.Fatal(err)
56 }
57 link, err := app.TestSignIn(t, "reader@example.com")
58 if err != nil {
59 t.Fatalf("TestSignIn: %v", err)
60 }
61 // Following the link must establish a session.
62 req := app.TestConfirmRequest(link)
63 rec := httptest.NewRecorder()
64 app.Handler().ServeHTTP(rec, req)
65 if len(rec.Result().Cookies()) == 0 {
66 t.Fatal("following the magic link set no session cookie")
67 }
68}
69
70// TestMagicLinkThenSignedInRouteWorks proves the session the previous
71// test only checked for the shape of is actually usable: the cookie
72// following the link sets must let a subsequent request reach a
73// RequireSession route — the whole point of Task 1, not just "a
74// cookie was set".
75func TestMagicLinkThenSignedInRouteWorks(t *testing.T) {
76 app, err := docs.NewForTest(t)
77 if err != nil {
78 t.Fatal(err)
79 }
80 link, err := app.TestSignIn(t, "reader@example.com")
81 if err != nil {
82 t.Fatalf("TestSignIn: %v", err)
83 }
84
85 verifyReq := app.TestConfirmRequest(link)
86 verifyRec := httptest.NewRecorder()
87 app.Handler().ServeHTTP(verifyRec, verifyReq)
88 cookies := verifyRec.Result().Cookies()
89 if len(cookies) == 0 {
90 t.Fatal("following the magic link set no session cookie")
91 }
92
93 req := httptest.NewRequest(http.MethodGet, "/", nil)
94 for _, c := range cookies {
95 req.AddCookie(c)
96 }
97 rec := httptest.NewRecorder()
98 app.Handler().ServeHTTP(rec, req)
99 if rec.Code != http.StatusOK {
100 t.Fatalf("GET / signed in: status %d, want 200", rec.Code)
101 }
102}
103
104// TestSignInPageRendersSignedOut proves the sign-in form itself is
105// reachable signed out — it has to be, or nobody could ever sign in.
106func TestSignInPageRendersSignedOut(t *testing.T) {
107 app, err := docs.NewForTest(t)
108 if err != nil {
109 t.Fatal(err)
110 }
111 req := httptest.NewRequest(http.MethodGet, "/signin", nil)
112 rec := httptest.NewRecorder()
113 app.Handler().ServeHTTP(rec, req)
114 if rec.Code != http.StatusOK {
115 t.Fatalf("GET /signin: status %d, want 200", rec.Code)
116 }
117 body := rec.Body.String()
118 if !strings.Contains(body, `<label rst-field-label for="estilo-signin-address">Email address</label>`) {
119 t.Error("email input has no persistent associated translated label")
120 }
121 if !strings.Contains(body, `type="email" id="estilo-signin-address" name="address" required autofocus autocomplete="email"`) {
122 t.Error("label target must remain the required, focused email input with email autocomplete")
123 }
124
125}
126
127// TestSignOutEndsTheSession proves POST /signout is real revocation,
128// not merely clearing a cookie the client could ignore: the session
129// must stop working for a RequireSession route even if a client
130// somehow kept presenting the old cookie value.
131func TestSignOutEndsTheSession(t *testing.T) {
132 app, err := docs.NewForTest(t)
133 if err != nil {
134 t.Fatal(err)
135 }
136 link, err := app.TestSignIn(t, "reader@example.com")
137 if err != nil {
138 t.Fatalf("TestSignIn: %v", err)
139 }
140 verifyReq := app.TestConfirmRequest(link)
141 verifyRec := httptest.NewRecorder()
142 app.Handler().ServeHTTP(verifyRec, verifyReq)
143 cookies := verifyRec.Result().Cookies()
144 if len(cookies) == 0 {
145 t.Fatal("following the magic link set no session cookie")
146 }
147
148 signoutReq := httptest.NewRequest(http.MethodPost, "/signout", nil)
149 signoutReq.Header.Set("Sec-Fetch-Site", "same-origin")
150 for _, c := range cookies {
151 signoutReq.AddCookie(c)
152 }
153 signoutRec := httptest.NewRecorder()
154 app.Handler().ServeHTTP(signoutRec, signoutReq)
155 if signoutRec.Code != http.StatusSeeOther {
156 t.Fatalf("POST /signout: status %d, want %d", signoutRec.Code, http.StatusSeeOther)
157 }
158
159 req := httptest.NewRequest(http.MethodGet, "/", nil)
160 for _, c := range cookies {
161 req.AddCookie(c)
162 }
163 rec := httptest.NewRecorder()
164 app.Handler().ServeHTTP(rec, req)
165 if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound {
166 t.Errorf("GET / with a signed-out session's old cookie: status %d, want a redirect to sign-in", rec.Code)
167 }
168}
169
170// TestNewRefusesMissingInstanceKeyOutsideDevelopment is the same
171// fail-closed shape as Origin's own startup refusal (app_test.go):
172// InstanceKey seals auth's pending-link blob, and an empty one is
173// dangerous, not merely inconvenient (rauth.Config.InstanceKey's own
174// doc comment) — so it must refuse to start rather than default
175// outside development.
176func TestNewRefusesMissingInstanceKeyOutsideDevelopment(t *testing.T) {
177 _, err := docs.New(docs.Config{
178 Env: "production",
179 Origin: "https://docs.example.com",
180 })
181 if err == nil {
182 t.Fatal("New must refuse a missing InstanceKey outside development")
183 }
184}
185
186// TestNewRefusesMissingMailOutsideDevelopment: a sign-in that cannot
187// send its link is not sign-in — the same fail-closed shape as the
188// origin check, per the brief.
189func TestNewRefusesMissingMailOutsideDevelopment(t *testing.T) {
190 _, err := docs.New(docs.Config{
191 Env: "production",
192 Origin: "https://docs.example.com",
193 InstanceKey: "some-real-key",
194 })
195 if err == nil {
196 t.Fatal("New must refuse to start outside development with no mail configured")
197 }
198}
199
200// TestSecondClickOfTheSameMagicLinkOffersRecovery is fix round 1 on
201// Task 3's own regression test: rauth.Auth's Verify handler
202// deliberately gives one error for "unknown, used and expired alike"
203// (its own doc comment), which means a magic link clicked a second
204// time — the ordinary case of someone opening an old sign-in email —
205// lands on the exact same ?err=expired outcome as a link that was
206// never valid at all. Confirmed directly against this app's own
207// router, not assumed: the first click consumes the link and sets a
208// session; the second click, with no cookie attached (a fresh tab, or
209// the same old email opened again after the first session already
210// expired), must redirect back to /signin with the specific "expired
211// or already used... enter your email for a new one" copy — never a
212// bare, uninformative dead end.
213func TestSecondClickOfTheSameMagicLinkOffersRecovery(t *testing.T) {
214 app, err := docs.NewForTest(t)
215 if err != nil {
216 t.Fatal(err)
217 }
218 link, err := app.TestSignIn(t, "clicktwice@example.com")
219 if err != nil {
220 t.Fatalf("TestSignIn: %v", err)
221 }
222
223 firstReq := app.TestConfirmRequest(link)
224 firstRec := httptest.NewRecorder()
225 app.Handler().ServeHTTP(firstRec, firstReq)
226 if len(firstRec.Result().Cookies()) == 0 {
227 t.Fatal("first click set no session cookie")
228 }
229
230 secondReq := app.TestConfirmRequest(link)
231 secondRec := httptest.NewRecorder()
232 app.Handler().ServeHTTP(secondRec, secondReq)
233 loc := secondRec.Header().Get("Location")
234 if !strings.Contains(loc, "err=expired") {
235 t.Fatalf("second click redirected to %q, want an err=expired outcome", loc)
236 }
237
238 signinReq := httptest.NewRequest(http.MethodGet, loc, nil)
239 signinRec := httptest.NewRecorder()
240 app.Handler().ServeHTTP(signinRec, signinReq)
241 // NewForTest's app.Handler() never wires rastrillo.Options.BaseCatalog/
242 // Locales — only cmd/docs/main.go does, for real serving — so
243 // rastrillo.T falls back to returning its key verbatim here
244 // (render.go's own doc comment on Locales: "documented, harmless").
245 // That makes the raw catalog key the correct, precise thing to
246 // assert on in this handler-level test: it pins exactly which
247 // branch of signin's switch ran, without depending on the English
248 // copy's exact wording.
249 body := signinRec.Body.String()
250 if !strings.Contains(body, "docs.signin.error.expired") {
251 t.Errorf("signin page after a second click did not resolve docs.signin.error.expired; body: %s", body)
252 }
253 if strings.Contains(body, "docs.error.generic") {
254 t.Errorf("signin page after a second click fell back to the app-wide docs.error.generic instead of the sign-in-specific expired copy")
255 }
256}
257
258// TestSigninUnrecognizedErrorUsesSigninSpecificFallback is fix round
259// 1 on Task 3's own finding: GET /signin's switch on ?err= had a
260// default: branch falling back to docs.error.generic ("Something went
261// wrong.") — the app-wide string written for a render failure with
262// nowhere styled to land, not for this page's own ordinary outcomes.
263// ?err=keymail (a failed keymail callback, rastrillo/auth's own
264// Callback handler) and ?err=1 (a raw Begin failure, logged
265// server-side) both reach that default branch in real use — this
266// pins both directly against the handler, without depending on
267// rastrillo internals staying exactly as they are today, and checks
268// the fallback is sign-in-specific rather than the app-wide string.
269func TestSigninUnrecognizedErrorUsesSigninSpecificFallback(t *testing.T) {
270 app, err := docs.NewForTest(t)
271 if err != nil {
272 t.Fatal(err)
273 }
274 // See TestSecondClickOfTheSameMagicLinkOffersRecovery's own comment
275 // on why the raw catalog key, not the resolved English copy, is
276 // what this handler-level test can observe and correctly should
277 // assert on.
278 for _, errParam := range []string{"keymail", "1", "some-future-value-nobody-has-written-a-case-for-yet"} {
279 req := httptest.NewRequest(http.MethodGet, "/signin?err="+errParam, nil)
280 rec := httptest.NewRecorder()
281 app.Handler().ServeHTTP(rec, req)
282 body := rec.Body.String()
283 if !strings.Contains(body, "docs.signin.error.unknown") {
284 t.Errorf("err=%q: signin page did not resolve docs.signin.error.unknown; body: %s", errParam, body)
285 }
286 if strings.Contains(body, "docs.error.generic") {
287 t.Errorf("err=%q: signin page fell back to the app-wide docs.error.generic instead of the sign-in-specific fallback", errParam)
288 }
289 }
290}
291