| 1 | package movementtest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | movement "amadan.net/carlos/movement/internal/movement" |
| 11 | ) |
| 12 | |
| 13 | const tickToken = "test-tick-token" |
| 14 | |
| 15 | // tick posts the way the platform's scheduler does: a bearer token, and none |
| 16 | // of the three headers a browser would send. |
| 17 | func tick(t *testing.T, h *testApp) *httptest.ResponseRecorder { |
| 18 | t.Helper() |
| 19 | req := httptest.NewRequest(http.MethodPost, "/tick/sweep", strings.NewReader("")) |
| 20 | req.Header.Set("Authorization", "Bearer "+tickToken) |
| 21 | rec := httptest.NewRecorder() |
| 22 | h.ServeHTTP(rec, req) |
| 23 | return rec |
| 24 | } |
| 25 | |
| 26 | // The gate this whole route exists behind. |
| 27 | // |
| 28 | // csrf.SameOrigin refuses any POST carrying no Sec-Fetch-Site, Origin or |
| 29 | // Referer, and the scheduler sends none of them. Mounted inside the protected |
| 30 | // router the tick would answer 403 on every delivery, forever, and the |
| 31 | // platform reads 4xx as "do not retry" — so the sweep would simply never run |
| 32 | // and nothing would say so. |
| 33 | func TestTickReachesTheSweepWithoutBrowserHeaders(t *testing.T) { |
| 34 | t.Setenv("CARLOS_ADMIN_TOKEN", tickToken) |
| 35 | h := newApp(t) |
| 36 | |
| 37 | rec := tick(t, h) |
| 38 | |
| 39 | if rec.Code == http.StatusForbidden { |
| 40 | t.Fatal("the tick was refused by CSRF; it is mounted inside the protected router") |
| 41 | } |
| 42 | if rec.Code != http.StatusOK { |
| 43 | t.Fatalf("POST /tick/sweep: %d, want 200", rec.Code) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | // Fails closed off-platform: with no token configured there is no public |
| 48 | // delete endpoint sitting on the internet. |
| 49 | func TestTickIsRefusedWithoutTheRightBearer(t *testing.T) { |
| 50 | t.Setenv("CARLOS_ADMIN_TOKEN", tickToken) |
| 51 | h := newApp(t) |
| 52 | |
| 53 | req := httptest.NewRequest(http.MethodPost, "/tick/sweep", nil) |
| 54 | req.Header.Set("Authorization", "Bearer wrong") |
| 55 | rec := httptest.NewRecorder() |
| 56 | h.ServeHTTP(rec, req) |
| 57 | |
| 58 | if rec.Code == http.StatusOK { |
| 59 | t.Error("a wrong bearer ran the sweep") |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func TestTickIsRefusedWhenNoTokenIsConfigured(t *testing.T) { |
| 64 | t.Setenv("CARLOS_ADMIN_TOKEN", "") |
| 65 | h := newApp(t) |
| 66 | |
| 67 | if rec := tick(t, h); rec.Code == http.StatusOK { |
| 68 | t.Error("the sweep ran on an instance with no admin token; it must fail closed") |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // Confirmed rows are the list. Nothing sweeps them, ever. |
| 73 | func TestSweepSparesConfirmedRowsAndFreshPendingOnes(t *testing.T) { |
| 74 | t.Setenv("CARLOS_ADMIN_TOKEN", tickToken) |
| 75 | h := newApp(t) |
| 76 | |
| 77 | postSignup(t, h, submission(t, "member@example.com", "curious")) |
| 78 | confirmSignup(t, h, "member@example.com") |
| 79 | postSignup(t, h, submission(t, "recent@example.com", "never")) |
| 80 | |
| 81 | // An old, unconfirmed row: exactly what the sweep is for. |
| 82 | postSignup(t, h, submission(t, "abandoned@example.com", "never")) |
| 83 | ageSignup(t, h, "abandoned@example.com", 40*24*time.Hour) |
| 84 | |
| 85 | if rec := tick(t, h); rec.Code != http.StatusOK { |
| 86 | t.Fatalf("sweep: %d", rec.Code) |
| 87 | } |
| 88 | |
| 89 | if got := countSignups(t, h, "member@example.com"); got != 1 { |
| 90 | t.Error("the sweep deleted a confirmed row") |
| 91 | } |
| 92 | if got := countSignups(t, h, "recent@example.com"); got != 1 { |
| 93 | t.Error("the sweep deleted a pending row that is still within its window") |
| 94 | } |
| 95 | if got := countSignups(t, h, "abandoned@example.com"); got != 0 { |
| 96 | t.Error("an abandoned row nobody confirmed survived the sweep") |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestSweepClearsExpiredNoncesOnly(t *testing.T) { |
| 101 | t.Setenv("CARLOS_ADMIN_TOKEN", tickToken) |
| 102 | h := newApp(t) |
| 103 | |
| 104 | postSignup(t, h, submission(t, "fresh@example.com", "curious")) |
| 105 | if countNonces(t, h) != 1 { |
| 106 | t.Fatal("setup: expected one spent nonce") |
| 107 | } |
| 108 | |
| 109 | if rec := tick(t, h); rec.Code != http.StatusOK { |
| 110 | t.Fatalf("sweep: %d", rec.Code) |
| 111 | } |
| 112 | if got := countNonces(t, h); got != 1 { |
| 113 | t.Errorf("the sweep dropped a nonce that can still be replayed (%d left)", got) |
| 114 | } |
| 115 | |
| 116 | // Age it past the point where a challenge would be refused on age alone. |
| 117 | if err := h.DB.G.Model(&movement.SpentNonce{}). |
| 118 | Where("1 = 1"). |
| 119 | Update("expires_at", time.Now().Add(-time.Hour)).Error; err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | |
| 123 | tick(t, h) |
| 124 | if got := countNonces(t, h); got != 0 { |
| 125 | t.Errorf("%d expired nonces survived", got) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // The sweep runs on every delivery and at-least-once delivery is the |
| 130 | // platform's contract, so running it twice must be the same as running it |
| 131 | // once. That is why it needs no occurrence-key dedupe. |
| 132 | func TestSweepIsIdempotent(t *testing.T) { |
| 133 | t.Setenv("CARLOS_ADMIN_TOKEN", tickToken) |
| 134 | h := newApp(t) |
| 135 | |
| 136 | postSignup(t, h, submission(t, "kept@example.com", "curious")) |
| 137 | confirmSignup(t, h, "kept@example.com") |
| 138 | |
| 139 | for i := 0; i < 3; i++ { |
| 140 | if rec := tick(t, h); rec.Code != http.StatusOK { |
| 141 | t.Fatalf("sweep %d: %d", i, rec.Code) |
| 142 | } |
| 143 | } |
| 144 | if got := countSignups(t, h, "kept@example.com"); got != 1 { |
| 145 | t.Error("repeated sweeps changed the result") |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func ageSignup(t *testing.T, h *testApp, email string, by time.Duration) { |
| 150 | t.Helper() |
| 151 | err := h.DB.G.Model(&movement.Signup{}). |
| 152 | Where("email = ?", email). |
| 153 | Update("created_at", time.Now().Add(-by)).Error |
| 154 | if err != nil { |
| 155 | t.Fatalf("ageing %s: %v", email, err) |
| 156 | } |
| 157 | } |
| 158 | |