| 1 | package docs_test |
| 2 | |
| 3 | import ( |
| 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). |
| 24 | func 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. |
| 52 | func 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". |
| 75 | func 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. |
| 106 | func 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. |
| 131 | func 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. |
| 176 | func 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. |
| 189 | func 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. |
| 213 | func 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. |
| 269 | func 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 | |