| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | rdb "amadan.net/rastrillo/rastrillo/db" |
| 12 | "amadan.net/rastrillo/rastrillo/migrate" |
| 13 | ) |
| 14 | |
| 15 | // TestSigninErrorNeverFallsThroughSilently is the reason signinError |
| 16 | // exists. Every outcome on this page means one operational thing, so an |
| 17 | // unrecognised code must still tell the person what to do. |
| 18 | func TestSigninErrorNeverFallsThroughSilently(t *testing.T) { |
| 19 | for _, code := range []string{"1", "keymail", "expired", "rate", "address", "wat", "0"} { |
| 20 | msg, _ := signinError(code) |
| 21 | if msg == "" { |
| 22 | t.Errorf("err=%s produced no message", code) |
| 23 | continue |
| 24 | } |
| 25 | // Every error path has to leave a way forward. |
| 26 | low := strings.ToLower(msg) |
| 27 | if !strings.Contains(low, "new link") && !strings.Contains(low, "new one") && |
| 28 | !strings.Contains(low, "a link instead") && !strings.Contains(low, "try again") && |
| 29 | !strings.Contains(low, "email address") { |
| 30 | t.Errorf("err=%s says %q, with no next step", code, msg) |
| 31 | } |
| 32 | } |
| 33 | if msg, _ := signinError(""); msg != "" { |
| 34 | t.Errorf("bare page gave %q, want no message", msg) |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | // The sent state replaces the form (spec §9.2), rather than a notice |
| 39 | // above a still-primary "Email me a link" that invites a second send. |
| 40 | func TestSigninSentReplacesTheForm(t *testing.T) { |
| 41 | rec := httptest.NewRecorder() |
| 42 | New(Config{}).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/signin?sent=1", nil)) |
| 43 | body := rec.Body.String() |
| 44 | if !strings.Contains(body, "<h1>Check your email</h1>") || !strings.Contains(body, "<title>Check your email · Sheets</title>") { |
| 45 | t.Fatal("the sent state is not the Check your email page") |
| 46 | } |
| 47 | if strings.Contains(body, `name="address"`) || strings.Contains(body, "Email me a link") { |
| 48 | t.Fatal("the sent state still shows the form") |
| 49 | } |
| 50 | if !strings.Contains(body, `<a href="/signin">Use a different address</a>`) { |
| 51 | t.Fatal("the sent state has no way back to the form") |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // TestSigninPageDoesNotEchoQuery: signinError selects from a fixed set |
| 56 | // rather than echoing, so there is no path from the URL into the HTML. |
| 57 | func TestSigninPageDoesNotEchoQuery(t *testing.T) { |
| 58 | rec := httptest.NewRecorder() |
| 59 | req := httptest.NewRequest(http.MethodGet, "/signin?err=%3Cscript%3Ealert(1)%3C/script%3E", nil) |
| 60 | New(Config{}).Router().ServeHTTP(rec, req) |
| 61 | if rec.Code != http.StatusOK { |
| 62 | t.Fatalf("GET /signin = %d", rec.Code) |
| 63 | } |
| 64 | if strings.Contains(rec.Body.String(), "<script>") { |
| 65 | t.Error("query value reached the page") |
| 66 | } |
| 67 | if !strings.Contains(rec.Body.String(), "couldn’t sign you in") { |
| 68 | t.Error("unknown err code did not get the fallback message") |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // testAuth builds a real *auth.Auth over a temp database, so route |
| 73 | // mounting is proven against the actual handlers rather than a stub. |
| 74 | func testAuth(t *testing.T) *App { |
| 75 | t.Helper() |
| 76 | d, err := rdb.Open(filepath.Join(t.TempDir(), "test.db"), quietLogger()) |
| 77 | if err != nil { |
| 78 | t.Fatalf("open db: %v", err) |
| 79 | } |
| 80 | t.Cleanup(func() { d.Close() }) |
| 81 | if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { |
| 82 | t.Fatalf("migrate: %v", err) |
| 83 | } |
| 84 | app := New(Config{DB: d.G, Admit: Admits([]string{"paul@example.com"})}) |
| 85 | a, err := NewAuth(d.Writer(), AuthEnv{ |
| 86 | Origin: "https://sheets.example.com", |
| 87 | InstanceKey: "test-instance-key", |
| 88 | Owners: []string{"paul@example.com"}, |
| 89 | Mailer: nil, // auth falls back to a logging sender; no mail is sent in this test |
| 90 | }, quietLogger(), app) |
| 91 | if err != nil { |
| 92 | t.Fatalf("NewAuth: %v", err) |
| 93 | } |
| 94 | t.Cleanup(func() { CloseSigninMail(a) }) |
| 95 | app.UseAuth(a) |
| 96 | return app |
| 97 | } |
| 98 | |
| 99 | // TestAuthRoutesAreMounted proves auth's handlers are reachable — Verify |
| 100 | // on POST as well as GET, or the confirm door's button gets a 405. It |
| 101 | // exists because Router mounts them behind `if a.auth != nil`, and every |
| 102 | // other test in this package passes nil — so without this, the whole |
| 103 | // sign-in surface could be unmounted and the suite would stay green. |
| 104 | func TestAuthRoutesAreMounted(t *testing.T) { |
| 105 | app := testAuth(t) |
| 106 | for _, tc := range []struct{ method, path string }{ |
| 107 | {http.MethodPost, "/signin"}, |
| 108 | {http.MethodGet, "/auth/callback"}, |
| 109 | {http.MethodGet, "/auth/verify"}, |
| 110 | {http.MethodPost, "/auth/verify"}, |
| 111 | {http.MethodPost, "/signout"}, |
| 112 | } { |
| 113 | rec := httptest.NewRecorder() |
| 114 | app.Router().ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil)) |
| 115 | if rec.Code == http.StatusNotFound || rec.Code == http.StatusMethodNotAllowed { |
| 116 | t.Errorf("%s %s = %d, handler not mounted", tc.method, tc.path, rec.Code) |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // TestAuthRoutesAbsentWithoutAuth is the control for the test above: it |
| 122 | // pins that those codes really do mean "not mounted", so the assertion |
| 123 | // there is measuring what it claims to. |
| 124 | func TestAuthRoutesAbsentWithoutAuth(t *testing.T) { |
| 125 | app := New(Config{}) |
| 126 | for _, tc := range []struct{ method, path string }{ |
| 127 | {http.MethodPost, "/signin"}, |
| 128 | {http.MethodGet, "/auth/verify"}, |
| 129 | } { |
| 130 | rec := httptest.NewRecorder() |
| 131 | app.Router().ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil)) |
| 132 | if rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed { |
| 133 | t.Errorf("%s %s = %d with nil auth, want 404/405", tc.method, tc.path, rec.Code) |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // TestSigninCarriesForce: when auth redirects a failed keymail sign-in |
| 139 | // to ?force=1&err=keymail, the page must post force back, or Begin |
| 140 | // bounces the user to keymail again and the loop has no exit. |
| 141 | func TestSigninCarriesForce(t *testing.T) { |
| 142 | body := func(url string) string { |
| 143 | rec := httptest.NewRecorder() |
| 144 | New(Config{}).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil)) |
| 145 | return rec.Body.String() |
| 146 | } |
| 147 | with := body("/signin?force=1&err=keymail") |
| 148 | if !strings.Contains(with, `name="force"`) { |
| 149 | t.Error("force=1 page does not carry the force field") |
| 150 | } |
| 151 | if without := body("/signin"); strings.Contains(without, `name="force"`) { |
| 152 | t.Error("ordinary page carries a force field it was not given") |
| 153 | } |
| 154 | } |
| 155 | |