carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1package movementtest
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "testing"
8 "time"
9
10 movement "amadan.net/carlos/movement/internal/movement"
11)
12
13const 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.
17func 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.
33func 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.
49func 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
63func 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.
73func 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
100func 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.
132func 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
149func 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