package movementtest import ( "net/http" "net/http/httptest" "strings" "testing" "time" movement "amadan.net/carlos/movement/internal/movement" ) const tickToken = "test-tick-token" // tick posts the way the platform's scheduler does: a bearer token, and none // of the three headers a browser would send. func tick(t *testing.T, h *testApp) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, "/tick/sweep", strings.NewReader("")) req.Header.Set("Authorization", "Bearer "+tickToken) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // The gate this whole route exists behind. // // csrf.SameOrigin refuses any POST carrying no Sec-Fetch-Site, Origin or // Referer, and the scheduler sends none of them. Mounted inside the protected // router the tick would answer 403 on every delivery, forever, and the // platform reads 4xx as "do not retry" — so the sweep would simply never run // and nothing would say so. func TestTickReachesTheSweepWithoutBrowserHeaders(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", tickToken) h := newApp(t) rec := tick(t, h) if rec.Code == http.StatusForbidden { t.Fatal("the tick was refused by CSRF; it is mounted inside the protected router") } if rec.Code != http.StatusOK { t.Fatalf("POST /tick/sweep: %d, want 200", rec.Code) } } // Fails closed off-platform: with no token configured there is no public // delete endpoint sitting on the internet. func TestTickIsRefusedWithoutTheRightBearer(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", tickToken) h := newApp(t) req := httptest.NewRequest(http.MethodPost, "/tick/sweep", nil) req.Header.Set("Authorization", "Bearer wrong") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusOK { t.Error("a wrong bearer ran the sweep") } } func TestTickIsRefusedWhenNoTokenIsConfigured(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", "") h := newApp(t) if rec := tick(t, h); rec.Code == http.StatusOK { t.Error("the sweep ran on an instance with no admin token; it must fail closed") } } // Confirmed rows are the list. Nothing sweeps them, ever. func TestSweepSparesConfirmedRowsAndFreshPendingOnes(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", tickToken) h := newApp(t) postSignup(t, h, submission(t, "member@example.com", "curious")) confirmSignup(t, h, "member@example.com") postSignup(t, h, submission(t, "recent@example.com", "never")) // An old, unconfirmed row: exactly what the sweep is for. postSignup(t, h, submission(t, "abandoned@example.com", "never")) ageSignup(t, h, "abandoned@example.com", 40*24*time.Hour) if rec := tick(t, h); rec.Code != http.StatusOK { t.Fatalf("sweep: %d", rec.Code) } if got := countSignups(t, h, "member@example.com"); got != 1 { t.Error("the sweep deleted a confirmed row") } if got := countSignups(t, h, "recent@example.com"); got != 1 { t.Error("the sweep deleted a pending row that is still within its window") } if got := countSignups(t, h, "abandoned@example.com"); got != 0 { t.Error("an abandoned row nobody confirmed survived the sweep") } } func TestSweepClearsExpiredNoncesOnly(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", tickToken) h := newApp(t) postSignup(t, h, submission(t, "fresh@example.com", "curious")) if countNonces(t, h) != 1 { t.Fatal("setup: expected one spent nonce") } if rec := tick(t, h); rec.Code != http.StatusOK { t.Fatalf("sweep: %d", rec.Code) } if got := countNonces(t, h); got != 1 { t.Errorf("the sweep dropped a nonce that can still be replayed (%d left)", got) } // Age it past the point where a challenge would be refused on age alone. if err := h.DB.G.Model(&movement.SpentNonce{}). Where("1 = 1"). Update("expires_at", time.Now().Add(-time.Hour)).Error; err != nil { t.Fatal(err) } tick(t, h) if got := countNonces(t, h); got != 0 { t.Errorf("%d expired nonces survived", got) } } // The sweep runs on every delivery and at-least-once delivery is the // platform's contract, so running it twice must be the same as running it // once. That is why it needs no occurrence-key dedupe. func TestSweepIsIdempotent(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", tickToken) h := newApp(t) postSignup(t, h, submission(t, "kept@example.com", "curious")) confirmSignup(t, h, "kept@example.com") for i := 0; i < 3; i++ { if rec := tick(t, h); rec.Code != http.StatusOK { t.Fatalf("sweep %d: %d", i, rec.Code) } } if got := countSignups(t, h, "kept@example.com"); got != 1 { t.Error("repeated sweeps changed the result") } } func ageSignup(t *testing.T, h *testApp, email string, by time.Duration) { t.Helper() err := h.DB.G.Model(&movement.Signup{}). Where("email = ?", email). Update("created_at", time.Now().Add(-by)).Error if err != nil { t.Fatalf("ageing %s: %v", email, err) } }