feat: ship Tier 1 — auth, authz, rate limits, real notifications, CSV import, billing, backups/DR, privacy

Closes every block in docs/TIER1_PLAN.md from the Claude-scope side. The
homelab / cloud setup steps (SES verification, restore drill, lawyer-
drafted ToS) remain operator-owned but are unblocked.

Block A — Authentication
- Migration 0003: password_hash, email_verified, email_verification_tokens,
  password_reset_tokens, refresh_tokens (with replaced_by family chain).
- Bcrypt hasher, HS256 JWT signer, single-use refresh tokens with rotation
  + replay-detection (revokes the family on reuse).
- /auth/signup, /login, /refresh, /logout, /verify-email,
  /forgot-password, /reset-password — enumeration-safe.
- requireAuth middleware + GET /me.
- Frontend useAuth/useApi with auto-refresh-on-401, login/signup/verify/
  forgot/reset pages, route-guard middleware.

Block B — Authorisation
- EventRepo.GetForHost; Update/Delete scoped by host_id.
- All host routes behind requireAuth + ownership; cross-tenant returns
  404 (no enumeration). ?host_id removed.
- WS auth via short-lived single-use tickets (POST /auth/ws-ticket).
- Tests: TestCrossTenantIsolation — 9 probes.

Block C — Rate limiting
- Redis sliding-window via Lua (atomic ZADD+ZCARD+PEXPIRE).
- Per-route limits matching the plan (signup IP, login IP+email, RSVP/
  access by token, events/guests/tokens by user_id).
- 429 with Retry-After header and JSON body.
- Auth lockout: 5 failed logins → account locked, only password reset
  clears it.
- Frontend: useErrMessage normalises 429 + locked messaging.

Block D — Real notifications
- Migration 0004: provider_message_id, bounce_type, complained columns
  + unsubscribes (CITEXT) suppression table.
- Branded HTML + plaintext templates for verification, reset, invitation,
  confirmation, reminder. Per-page templates avoid html/template's
  contextual-escape collisions.
- Senders: SESv2, Twilio (SMS), SMTP (Mailpit-friendly), Resend HTTP.
- PickEmailSender priority Resend > SMTP > SES > Log — system boots
  cleanly in dev with Mailpit; production flips one env var.
- Webhook endpoints (Twilio status + SES SNS) — bounces add to suppression;
  signature verification stubbed pending creds.
- Auto-send: POST /tokens publishes invitation.send; notifier renders +
  delivers via the configured backend; suppression list honoured.
- Bulk + per-row invitation flow: POST /events/{id}/guests/invitations/bulk
  returns per-guest tokens so phone-only guests can be SMS'd manually.
- Unsubscribe: signed HMAC token (no TTL) + /unsubscribe/[token] page.
- WhatsApp Option A+: wa.me click-to-chat wizard with per-guest progress
  tracking, isLikelyE164 validation, edit-from-wizard.
- Token rotate (POST /tokens/rotate) invalidates the old URL — used by
  the regenerate-link flow.
- Mailpit added to docker-compose for dev inbox.

Block E — CSV import
- Streaming parser: tolerant header detection, UTF-8 BOM + UTF-16 LE/BE
  decoding, row-level validation, 5,000-row cap.
- Strict E.164 phone validation with helpful error message.
- POST /preview + /import + GET /template; preview UI on event page;
  atomic per-batch with dedup on existing emails.

Phone capture across UI
- PhoneInput component: country picker (~50 ISO codes) + national input +
  live E.164 preview + inline length validation.
- Used in Add Guest and Edit Guest modals. Smart paste-handling extracts
  country code from full E.164 strings.

Block F — Billing (Stripe)
- Migration 0005: subscriptions table (user_id → tier/status/period_end +
  Stripe customer/sub ids). Partial unique index keeps one granting sub
  per user.
- internal/billing: Tier + Limits model (Free 1/50, Pro 10/1000, Business
  ∞/5000), Stripe SDK wrapper with IgnoreAPIVersionMismatch for newer
  account API versions.
- /billing/checkout-session, /billing/portal, /billing/status,
  /webhooks/stripe (signature-verified, lifecycle events).
- Tier enforcement: 402 on POST /events, /guests, /import with
  {error, reason, tier, used, limit, upgrade_url} body.
- Frontend: useBilling composable, /dashboard/billing page (current plan,
  usage bars, tier cards), global UpgradeModal triggered by useApi's
  402 interceptor.
- Customer portal kept for self-service cancel/payment-method changes.

Block G — Backups & DR (application side)
- Every migration has a tested .down.sql.
- TestMigrationRoundtrip applies all ups → all downs → all ups against a
  fresh container; catches asymmetric down migrations.
- cmd/restore-verify: 28-check post-restore invariant tool (schema
  presence, no orphans across 10 FK relationships, email uniqueness,
  single-active subscription, row-count snapshot).
- docs/RUNBOOK_RESTORE.md: 9-step restore procedure with RTO/RPO
  targets, drill instructions, rollback path.

Block H — Privacy compliance (application side)
- Migration 0006: deleted_at + terms_accepted_at + privacy_policy_accepted_at
  on users. Partial index on email for live-only uniqueness.
- GET /me/data-export — synchronous JSON dump (user, events, guests,
  tokens, rsvps, access_logs, notifications).
- DELETE /me — soft-delete with PII scrub + refresh-token revocation;
  re-signup with same email works.
- POST /me/accept-terms — idempotent consent recording.
- Frontend /privacy + /terms placeholder pages with substantive (pending
  legal review) copy; footer links; signup terms checkbox; TermsGateModal
  for accounts created before the rollout; export + delete buttons on
  /dashboard/billing.

Tests
- All migrations verified up/down/up.
- Integration suite: TestE2EHappyPath, TestAuthFlow, TestCrossTenantIsolation,
  TestRateLimitSignup, TestLoginLockout, TestUnsubscribeFlow,
  TestSESBounceWebhook, TestTwilioStatusWebhook, TestCsvImportFlow,
  TestCsvImportAtomicRollback, TestBulkIssueInvitations, TestBulkIssueExplicitSubset,
  TestTokenIssuePublishesInvitation, TestTokenIssueWithoutGuestEmailSkipsInvitation,
  TestGuestUpdate, TestGuestDelete, TestTokenRotate, TestSMTPSenderAgainstMailpit,
  TestFreeTierEventLimit, TestFreeTierGuestLimit, TestBusinessTierBypassesLimits,
  TestDataExport, TestDeleteMe, TestAcceptTerms, TestMigrationRoundtrip.
  Full suite runs in ~120s against real Postgres + NATS + Redis + Mailpit.
- Unit suite green across internal/auth, internal/csvimport,
  internal/notification, internal/ratelimit, internal/domain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-16 23:54:22 +01:00
parent a0ed34f860
commit 59b8781659
124 changed files with 13702 additions and 445 deletions
+378
View File
@@ -0,0 +1,378 @@
//go:build integration
package integration_test
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/api"
"github.com/alchemistkay/guestguard/internal/storage"
)
const authTestPassword = "correct-horse-battery-staple"
// recordingEmailSender captures the most recent verification / reset link so
// tests can finish the signup flow without a real inbox.
type recordingEmailSender struct {
verifyLink string
resetLink string
}
func (s *recordingEmailSender) SendVerification(_ context.Context, _, _, link string) error {
s.verifyLink = link
return nil
}
func (s *recordingEmailSender) SendPasswordReset(_ context.Context, _, _, link string) error {
s.resetLink = link
return nil
}
func TestAuthFlow(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
db, err := storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
emails := &recordingEmailSender{}
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
TokenTTL: 24 * time.Hour,
JWTSecret: "test-secret-must-be-at-least-32-bytes-long-xx",
JWTIssuer: "guestguard-test",
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "http://localhost",
EmailSender: emails,
})
must(t, err, "build api server")
srv := httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
email := uniqueEmail(t)
t.Run("signup", func(t *testing.T) {
resp := post(t, client, srv.URL+"/auth/signup", map[string]string{
"email": email,
"name": "Auth Test",
"password": authTestPassword,
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("signup status: %d", resp.StatusCode)
}
resp.Body.Close()
if emails.verifyLink == "" {
t.Fatal("verification email not captured")
}
})
t.Run("login before verify is forbidden", func(t *testing.T) {
resp := post(t, client, srv.URL+"/auth/login", map[string]string{
"email": email,
"password": authTestPassword,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 403, got %d: %s", resp.StatusCode, body)
}
})
t.Run("verify email", func(t *testing.T) {
token := tokenFromQuery(t, emails.verifyLink, "token")
resp := post(t, client, srv.URL+"/auth/verify-email", map[string]string{"token": token})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("verify status: %d %s", resp.StatusCode, body)
}
})
t.Run("verify token replay rejected", func(t *testing.T) {
token := tokenFromQuery(t, emails.verifyLink, "token")
resp := post(t, client, srv.URL+"/auth/verify-email", map[string]string{"token": token})
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("replay should be 400, got %d", resp.StatusCode)
}
})
var firstAccess string
t.Run("login returns access + refresh cookie", func(t *testing.T) {
resp := post(t, client, srv.URL+"/auth/login", map[string]string{
"email": email,
"password": authTestPassword,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("login status: %d %s", resp.StatusCode, body)
}
var body struct {
AccessToken string `json:"access_token"`
}
must(t, json.NewDecoder(resp.Body).Decode(&body), "decode login")
if body.AccessToken == "" {
t.Fatal("missing access token")
}
firstAccess = body.AccessToken
assertRefreshCookieSet(t, srv.URL, jar)
})
t.Run("access token authorises /me", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/me", nil)
req.Header.Set("Authorization", "Bearer "+firstAccess)
resp, err := client.Do(req)
must(t, err, "GET /me")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("/me status: %d %s", resp.StatusCode, body)
}
})
t.Run("refresh rotates tokens", func(t *testing.T) {
oldCookie := refreshCookieValue(t, srv.URL, jar)
resp := post(t, client, srv.URL+"/auth/refresh", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("refresh status: %d %s", resp.StatusCode, body)
}
var body struct {
AccessToken string `json:"access_token"`
}
must(t, json.NewDecoder(resp.Body).Decode(&body), "decode refresh")
if body.AccessToken == "" {
t.Fatal("missing new access token")
}
newCookie := refreshCookieValue(t, srv.URL, jar)
if newCookie == oldCookie {
t.Fatal("refresh did not rotate cookie")
}
// Replay of the old refresh token must be rejected and revoke the family.
jar2, _ := cookiejar.New(nil)
client2 := &http.Client{Jar: jar2}
setRefreshCookie(t, srv.URL, jar2, oldCookie)
replay := post(t, client2, srv.URL+"/auth/refresh", nil)
replay.Body.Close()
if replay.StatusCode != http.StatusUnauthorized {
t.Fatalf("old refresh replay should be 401, got %d", replay.StatusCode)
}
// And the family should be revoked: even the new (just-rotated) cookie
// no longer works.
familyReplay := post(t, client, srv.URL+"/auth/refresh", nil)
familyReplay.Body.Close()
if familyReplay.StatusCode != http.StatusUnauthorized {
t.Fatalf("family-revoked refresh should be 401, got %d", familyReplay.StatusCode)
}
})
// After family revocation, log back in to keep going.
resp := post(t, client, srv.URL+"/auth/login", map[string]string{
"email": email,
"password": authTestPassword,
})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("second login: %d", resp.StatusCode)
}
t.Run("forgot-password emits link without leaking existence", func(t *testing.T) {
// Unknown email — still 202, no link sent.
emails.resetLink = ""
unknown := post(t, client, srv.URL+"/auth/forgot-password", map[string]string{
"email": "nobody-" + uuid.NewString() + "@guestguard.test",
})
unknown.Body.Close()
if unknown.StatusCode != http.StatusAccepted {
t.Fatalf("unknown forgot-password: %d", unknown.StatusCode)
}
if emails.resetLink != "" {
t.Fatal("reset link sent for unknown email")
}
known := post(t, client, srv.URL+"/auth/forgot-password", map[string]string{
"email": email,
})
known.Body.Close()
if known.StatusCode != http.StatusAccepted {
t.Fatalf("known forgot-password: %d", known.StatusCode)
}
if emails.resetLink == "" {
t.Fatal("reset link not captured")
}
})
t.Run("reset password invalidates sessions", func(t *testing.T) {
token := tokenFromPath(t, emails.resetLink, "/reset-password/")
newPw := "new-correct-horse-battery-staple"
resp := post(t, client, srv.URL+"/auth/reset-password", map[string]string{
"token": token,
"new_password": newPw,
})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("reset status: %d", resp.StatusCode)
}
// Old password fails.
bad := post(t, client, srv.URL+"/auth/login", map[string]string{
"email": email,
"password": authTestPassword,
})
bad.Body.Close()
if bad.StatusCode != http.StatusUnauthorized {
t.Fatalf("old password should 401, got %d", bad.StatusCode)
}
// Existing refresh cookie should no longer work.
refresh := post(t, client, srv.URL+"/auth/refresh", nil)
refresh.Body.Close()
if refresh.StatusCode != http.StatusUnauthorized {
t.Fatalf("refresh after reset should 401, got %d", refresh.StatusCode)
}
// New password works.
ok := post(t, client, srv.URL+"/auth/login", map[string]string{
"email": email,
"password": newPw,
})
ok.Body.Close()
if ok.StatusCode != http.StatusOK {
t.Fatalf("new password login: %d", ok.StatusCode)
}
})
t.Run("logout revokes refresh", func(t *testing.T) {
resp := post(t, client, srv.URL+"/auth/logout", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("logout status: %d", resp.StatusCode)
}
refresh := post(t, client, srv.URL+"/auth/refresh", nil)
refresh.Body.Close()
if refresh.StatusCode != http.StatusUnauthorized {
t.Fatalf("refresh after logout should 401, got %d", refresh.StatusCode)
}
})
t.Run("requireAuth rejects invalid bearer", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/me", nil)
req.Header.Set("Authorization", "Bearer not-a-real-jwt")
resp, err := client.Do(req)
must(t, err, "GET /me bad token")
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("bad bearer should 401, got %d", resp.StatusCode)
}
})
}
// --- helpers ---
func uniqueEmail(t *testing.T) string {
t.Helper()
return "auth-" + uuid.NewString() + "@guestguard.test"
}
func post(t *testing.T, client *http.Client, url string, body any) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
b, err := json.Marshal(body)
must(t, err, "marshal post body")
r = bytes.NewReader(b)
}
req, err := http.NewRequest(http.MethodPost, url, r)
must(t, err, "build post request")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
must(t, err, "do post "+url)
return resp
}
func tokenFromQuery(t *testing.T, link, key string) string {
t.Helper()
idx := strings.Index(link, key+"=")
if idx < 0 {
t.Fatalf("link missing %s: %s", key, link)
}
return link[idx+len(key)+1:]
}
func tokenFromPath(t *testing.T, link, prefix string) string {
t.Helper()
idx := strings.LastIndex(link, prefix)
if idx < 0 {
t.Fatalf("link missing prefix %s: %s", prefix, link)
}
return link[idx+len(prefix):]
}
func assertRefreshCookieSet(t *testing.T, baseURL string, jar http.CookieJar) {
t.Helper()
if refreshCookieValue(t, baseURL, jar) == "" {
t.Fatal("refresh cookie not set")
}
}
func refreshCookieValue(t *testing.T, baseURL string, jar http.CookieJar) string {
t.Helper()
// jar.Cookies needs a URL whose path matches the cookie's Path (/auth).
u := baseURL + "/auth/refresh"
parsed, err := url.Parse(u)
must(t, err, "parse url")
for _, c := range jar.Cookies(parsed) {
if c.Name == "gg_refresh" {
return c.Value
}
}
return ""
}
func setRefreshCookie(t *testing.T, baseURL string, jar http.CookieJar, value string) {
t.Helper()
parsed, err := url.Parse(baseURL + "/auth/refresh")
must(t, err, "parse url")
jar.SetCookies(parsed, []*http.Cookie{{
Name: "gg_refresh",
Value: value,
Path: "/auth",
}})
}
+220
View File
@@ -0,0 +1,220 @@
//go:build integration
package integration_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/api"
"github.com/alchemistkay/guestguard/internal/storage"
)
// TestCrossTenantIsolation confirms that one authenticated host cannot
// read, modify, or extend another host's event. All cross-tenant attempts
// should return 404 — never 403 — so a probe can't tell whether a given
// UUID exists at all.
func TestCrossTenantIsolation(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
db, err := storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "http://localhost",
})
must(t, err, "build api server")
srv := httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
hostA := insertHost(t, ctx, db.Pool)
hostB := insertHost(t, ctx, db.Pool)
tokenA := issueHostToken(t, hostA)
tokenB := issueHostToken(t, hostB)
eventA := createEvent(t, srv.URL, tokenA, "Host A's Event", "host-a-event")
t.Run("list returns only own events", func(t *testing.T) {
out := struct {
Events []struct {
ID uuid.UUID `json:"id"`
} `json:"events"`
}{}
getJSONAuthed(t, srv.URL+"/events", tokenB, http.StatusOK, &out)
for _, e := range out.Events {
if e.ID == eventA {
t.Fatalf("host B saw host A's event in /events list")
}
}
})
t.Run("GET other host's event is 404", func(t *testing.T) {
assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s", srv.URL, eventA),
tokenB, nil, http.StatusNotFound)
})
t.Run("PATCH other host's event is 404", func(t *testing.T) {
body := map[string]any{"name": "hijacked"}
assertStatus(t, http.MethodPatch, fmt.Sprintf("%s/events/%s", srv.URL, eventA),
tokenB, body, http.StatusNotFound)
})
t.Run("DELETE other host's event is 404", func(t *testing.T) {
assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s", srv.URL, eventA),
tokenB, nil, http.StatusNotFound)
})
t.Run("POST guest on other host's event is 404", func(t *testing.T) {
body := map[string]any{"name": "Mallory"}
assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventA),
tokenB, body, http.StatusNotFound)
})
t.Run("GET guests on other host's event is 404", func(t *testing.T) {
assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/guests", srv.URL, eventA),
tokenB, nil, http.StatusNotFound)
})
t.Run("GET activity on other host's event is 404", func(t *testing.T) {
assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/activity", srv.URL, eventA),
tokenB, nil, http.StatusNotFound)
})
t.Run("no bearer is 401", func(t *testing.T) {
assertStatus(t, http.MethodGet, srv.URL+"/events", "", nil, http.StatusUnauthorized)
})
t.Run("ws-ticket for other host's event is 404", func(t *testing.T) {
body := map[string]any{"event_id": eventA.String()}
assertStatus(t, http.MethodPost, srv.URL+"/auth/ws-ticket",
tokenB, body, http.StatusNotFound)
})
t.Run("ws-ticket then WS handshake requires matching path", func(t *testing.T) {
// Host A mints a ticket for their own event.
var ticketResp struct {
Ticket string `json:"ticket"`
}
postJSONAuthed(t, srv.URL+"/auth/ws-ticket", tokenA,
map[string]any{"event_id": eventA.String()},
http.StatusOK, &ticketResp)
if ticketResp.Ticket == "" {
t.Fatal("empty ticket")
}
// Trying to use that ticket on a *different* event id must fail.
bogus := uuid.New()
req, _ := http.NewRequest(http.MethodGet,
fmt.Sprintf("%s/ws/events/%s?ticket=%s", srv.URL, bogus, ticketResp.Ticket), nil)
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
resp, err := http.DefaultClient.Do(req)
must(t, err, "WS handshake")
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 403 or 401, got %d", resp.StatusCode)
}
})
t.Run("replaying a consumed ticket fails", func(t *testing.T) {
var ticketResp struct {
Ticket string `json:"ticket"`
}
postJSONAuthed(t, srv.URL+"/auth/ws-ticket", tokenA,
map[string]any{"event_id": eventA.String()},
http.StatusOK, &ticketResp)
// First handshake against the correct event id — consumes the ticket.
req1, _ := http.NewRequest(http.MethodGet,
fmt.Sprintf("%s/ws/events/%s?ticket=%s", srv.URL, eventA, ticketResp.Ticket), nil)
req1.Header.Set("Upgrade", "websocket")
req1.Header.Set("Connection", "Upgrade")
resp1, err := http.DefaultClient.Do(req1)
must(t, err, "WS handshake 1")
resp1.Body.Close()
// Replay — ticket is already consumed.
req2, _ := http.NewRequest(http.MethodGet,
fmt.Sprintf("%s/ws/events/%s?ticket=%s", srv.URL, eventA, ticketResp.Ticket), nil)
req2.Header.Set("Upgrade", "websocket")
req2.Header.Set("Connection", "Upgrade")
resp2, err := http.DefaultClient.Do(req2)
must(t, err, "WS handshake 2")
resp2.Body.Close()
if resp2.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 on replay, got %d", resp2.StatusCode)
}
})
}
func getJSONAuthed(t *testing.T, url, bearer string, wantStatus int, out any) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, url, nil)
must(t, err, "build get")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := http.DefaultClient.Do(req)
must(t, err, "do get "+url)
defer resp.Body.Close()
if resp.StatusCode != wantStatus {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("%s status=%d want=%d body=%s", url, resp.StatusCode, wantStatus, body)
}
if out != nil {
must(t, json.NewDecoder(resp.Body).Decode(out), "decode response from "+url)
}
}
func assertStatus(t *testing.T, method, url, bearer string, body any, wantStatus int) {
t.Helper()
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, rdr)
must(t, err, "build "+method+" "+url)
if rdr != nil {
req.Header.Set("Content-Type", "application/json")
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := http.DefaultClient.Do(req)
must(t, err, "do "+method+" "+url)
defer resp.Body.Close()
if resp.StatusCode != wantStatus {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("%s %s status=%d want=%d body=%s", method, url, resp.StatusCode, wantStatus, b)
}
}
+190
View File
@@ -0,0 +1,190 @@
//go:build integration
package integration_test
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/alchemistkay/guestguard/internal/api"
"github.com/alchemistkay/guestguard/internal/storage"
)
// TestFreeTierEventLimit confirms free-tier hosts are capped at one
// event per calendar month with a 402 response carrying the upgrade
// payload the frontend uses to render the modal.
func TestFreeTierEventLimit(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
db, err := storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "http://localhost:3000",
})
must(t, err, "api server")
srv := httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
// Bypass insertHost (which auto-grants Business) so this host stays free.
hostID := insertFreeTierHost(t, ctx, db.Pool)
token := issueHostToken(t, hostID)
// First event under the limit — should succeed.
_ = createEvent(t, srv.URL, token, "First", "free-first")
// Second event must be 402 with the upgrade payload.
body, _ := json.Marshal(map[string]any{
"name": "Second",
"slug": "free-second",
"event_date": time.Now().Add(30 * 24 * time.Hour).UTC().Format(time.RFC3339),
"venue": "Hall",
})
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/events", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
must(t, err, "POST /events second")
defer resp.Body.Close()
if resp.StatusCode != http.StatusPaymentRequired {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 402 on 2nd event, got %d body=%s", resp.StatusCode, b)
}
var rb struct {
Error string `json:"error"`
Tier string `json:"tier"`
Used int `json:"used"`
Limit int `json:"limit"`
UpgradeURL string `json:"upgrade_url"`
}
must(t, json.NewDecoder(resp.Body).Decode(&rb), "decode 402 body")
if rb.Tier != "free" || rb.Used != 1 || rb.Limit != 1 {
t.Errorf("402 payload: %+v", rb)
}
if !strings.Contains(rb.UpgradeURL, "/dashboard/billing") {
t.Errorf("expected upgrade_url to point at /dashboard/billing, got %q", rb.UpgradeURL)
}
}
// TestFreeTierGuestLimit confirms the per-event guest cap kicks in at
// the right number, again with a 402 payload.
func TestFreeTierGuestLimit(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
db, err := storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "http://localhost:3000",
})
must(t, err, "api server")
srv := httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
hostID := insertFreeTierHost(t, ctx, db.Pool)
token := issueHostToken(t, hostID)
eventID := createEvent(t, srv.URL, token, "Free Event", "free-guests-event")
// Free tier allows 50 guests per event. Seed 50 directly so we don't
// pay 50 HTTP round-trips, then attempt one more via the API.
for i := 0; i < 50; i++ {
_, err := db.Pool.Exec(ctx,
`INSERT INTO guests (event_id, name) VALUES ($1, $2)`,
eventID, fmt.Sprintf("Seeded %d", i),
)
must(t, err, "seed guest")
}
// 51st guest must be 402.
body, _ := json.Marshal(map[string]any{"name": "Overflow"})
req, _ := http.NewRequest(http.MethodPost,
fmt.Sprintf("%s/events/%s/guests", srv.URL, eventID),
strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
must(t, err, "POST /guests overflow")
defer resp.Body.Close()
if resp.StatusCode != http.StatusPaymentRequired {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 402 on 51st guest, got %d body=%s", resp.StatusCode, b)
}
}
// TestBusinessTierBypassesLimits sanity-checks that a host with an
// active Business subscription can create more than the free-tier
// allowance — the enforcer code path that returns "unlimited".
func TestBusinessTierBypassesLimits(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, _, _, token := setupAuthedAPI(t, ctx)
// setupAuthedAPI already grants Business via insertHost.
for i := 0; i < 3; i++ {
_ = createEvent(t, srv.URL, token, fmt.Sprintf("Biz Event %d", i),
fmt.Sprintf("biz-event-%d", i))
}
}
// insertFreeTierHost mints a verified user WITHOUT granting any
// subscription — opposite of the default test helper. Used to exercise
// the free-tier enforcement path.
func insertFreeTierHost(t *testing.T, ctx context.Context, pool *pgxpool.Pool) uuid.UUID {
t.Helper()
var id uuid.UUID
err := pool.QueryRow(ctx,
`INSERT INTO users (email, name, email_verified, email_verified_at)
VALUES ($1, 'Free Tier', TRUE, now()) RETURNING id`,
fmt.Sprintf("free-%d@guestguard.test", time.Now().UnixNano()),
).Scan(&id)
must(t, err, "insert free-tier host")
return id
}
+234
View File
@@ -0,0 +1,234 @@
//go:build integration
package integration_test
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/api"
"github.com/alchemistkay/guestguard/internal/natspub"
"github.com/alchemistkay/guestguard/internal/storage"
)
// TestBulkIssueInvitations confirms the bulk endpoint:
// - mints tokens for guests without one
// - skips guests that already have a token
// - publishes invitation.send only for guests with an email
// - returns an accurate per-bucket summary
func TestBulkIssueInvitations(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
natsURL := startNATS(t, ctx)
db, err := storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
natsClient, err := natspub.Connect(ctx, natsURL, logger)
must(t, err, "connect nats")
t.Cleanup(natsClient.Close)
var pubCount atomic.Int32
emails := make(chan string, 32)
sub, err := natspub.NewInvitationSendSubscriber(ctx, natsClient, "test-bulk-invitation",
func(_ context.Context, evt natspub.InvitationSend) error {
pubCount.Add(1)
select {
case emails <- evt.GuestEmail:
default:
}
return nil
}, logger)
must(t, err, "build subscriber")
cc, err := sub.Start(ctx)
must(t, err, "start subscriber")
t.Cleanup(cc.Stop)
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
AccessPublisher: natsClient,
RSVPPublisher: natsClient,
InvitationPublisher: natsClient,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "https://gg.example.test",
})
must(t, err, "build api")
srv := httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
hostID := insertHost(t, ctx, db.Pool)
hostToken := issueHostToken(t, hostID)
eventID := createEvent(t, srv.URL, hostToken, "Bulk Event", "bulk-event")
// Three guests with emails, two without.
var withEmail []uuid.UUID
for i := 0; i < 3; i++ {
id, _ := createGuestWithEmail(t, srv.URL, hostToken, eventID, fmt.Sprintf("Email-%d", i))
withEmail = append(withEmail, id)
}
var noEmail []uuid.UUID
for i := 0; i < 2; i++ {
noEmail = append(noEmail, createGuest(t, srv.URL, hostToken, eventID, fmt.Sprintf("Phone-%d", i)))
}
// Pre-issue one token for the first email guest to test the
// skipped_existing path.
issueToken(t, srv.URL, hostToken, eventID, withEmail[0])
// Call bulk endpoint (empty body = "all eligible").
var result struct {
Issued int `json:"issued"`
Queued int `json:"queued"`
SkippedExisting int `json:"skipped_existing"`
SkippedNoEmail int `json:"skipped_no_email"`
Errors []struct {
GuestID string `json:"guest_id"`
Reason string `json:"reason"`
} `json:"errors"`
}
postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests/invitations/bulk", srv.URL, eventID),
hostToken, map[string]any{}, http.StatusOK, &result)
if result.Issued != 4 {
t.Errorf("issued: got %d want 4 (2 emails + 2 no-email; the third email guest was pre-issued)", result.Issued)
}
if result.Queued != 2 {
t.Errorf("queued: got %d want 2", result.Queued)
}
if result.SkippedExisting != 1 {
t.Errorf("skipped_existing: got %d want 1", result.SkippedExisting)
}
if result.SkippedNoEmail != 2 {
t.Errorf("skipped_no_email: got %d want 2", result.SkippedNoEmail)
}
if len(result.Errors) != 0 {
t.Errorf("unexpected errors: %+v", result.Errors)
}
// Wait for the two NATS messages.
deadline := time.After(10 * time.Second)
received := 0
loop:
for received < 2 {
select {
case <-emails:
received++
case <-deadline:
break loop
}
}
if received != 2 {
t.Fatalf("expected 2 invitation.send messages, got %d", received)
}
// Re-running bulk should be a no-op (everyone now has a token).
var second struct {
Issued int `json:"issued"`
Queued int `json:"queued"`
SkippedExisting int `json:"skipped_existing"`
}
postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests/invitations/bulk", srv.URL, eventID),
hostToken, map[string]any{}, http.StatusOK, &second)
if second.Issued != 0 || second.Queued != 0 || second.SkippedExisting != 5 {
t.Errorf("re-run: got issued=%d queued=%d skipped_existing=%d (want 0/0/5)",
second.Issued, second.Queued, second.SkippedExisting)
}
}
// TestBulkIssueExplicitSubset confirms guest_ids is honoured.
func TestBulkIssueExplicitSubset(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
natsURL := startNATS(t, ctx)
db, err := storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
natsClient, err := natspub.Connect(ctx, natsURL, logger)
must(t, err, "connect nats")
t.Cleanup(natsClient.Close)
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
AccessPublisher: natsClient,
RSVPPublisher: natsClient,
InvitationPublisher: natsClient,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "https://gg.example.test",
})
must(t, err, "build api")
srv := httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
hostID := insertHost(t, ctx, db.Pool)
hostToken := issueHostToken(t, hostID)
eventID := createEvent(t, srv.URL, hostToken, "Subset Event", "subset-event")
var ids []uuid.UUID
for i := 0; i < 3; i++ {
id, _ := createGuestWithEmail(t, srv.URL, hostToken, eventID, fmt.Sprintf("G-%d", i))
ids = append(ids, id)
}
// Send to only the middle guest.
var result struct {
Issued int `json:"issued"`
Queued int `json:"queued"`
}
postJSONAuthed(t,
fmt.Sprintf("%s/events/%s/guests/invitations/bulk", srv.URL, eventID),
hostToken,
map[string]any{"guest_ids": []string{ids[1].String()}},
http.StatusOK, &result)
if result.Issued != 1 || result.Queued != 1 {
t.Fatalf("subset send: got issued=%d queued=%d, want 1/1", result.Issued, result.Queued)
}
// The other two should still be tokenless.
var hasToken int
must(t, db.Pool.QueryRow(ctx,
"SELECT COUNT(*) FROM tokens WHERE guest_id = ANY($1)", []uuid.UUID{ids[0], ids[2]},
).Scan(&hasToken), "count tokens for non-targets")
if hasToken != 0 {
t.Fatalf("expected 0 tokens for non-targets, got %d", hasToken)
}
}
+186
View File
@@ -0,0 +1,186 @@
//go:build integration
package integration_test
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/alchemistkay/guestguard/internal/api"
"github.com/alchemistkay/guestguard/internal/storage"
)
// TestCsvImportFlow walks the happy path: preview, then commit, then a
// re-import to confirm dedup is honoured.
func TestCsvImportFlow(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, host, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Import Event", "import-event")
_ = host
const csvOne = `name,email,phone,plus_ones
Alex,alex@example.test,+447700900111,1
Sam,sam@example.test,,0
Jordan,,+15551234567,2
,,,
Mira,malformed-email,,0
`
// Preview.
var preview struct {
Rows []map[string]any `json:"rows"`
Errors []map[string]any `json:"errors"`
TotalCount int `json:"total_count"`
}
resp := postMultipart(t, srv.URL+"/events/"+eventID.String()+"/guests/import/preview", token, csvOne)
must(t, json.NewDecoder(resp.Body).Decode(&preview), "decode preview")
resp.Body.Close()
if len(preview.Rows) != 3 {
t.Fatalf("preview rows: got %d want 3 (errors=%+v)", len(preview.Rows), preview.Errors)
}
if len(preview.Errors) != 1 {
t.Fatalf("preview errors: got %d want 1: %+v", len(preview.Errors), preview.Errors)
}
// Commit.
var commit struct {
Added int `json:"added"`
Skipped int `json:"skipped"`
SkippedEmails []string `json:"skipped_emails"`
}
resp = postMultipart(t, srv.URL+"/events/"+eventID.String()+"/guests/import", token, csvOne)
must(t, json.NewDecoder(resp.Body).Decode(&commit), "decode commit")
resp.Body.Close()
if commit.Added != 3 || commit.Skipped != 0 {
t.Fatalf("commit: added=%d skipped=%d (want 3/0)", commit.Added, commit.Skipped)
}
// Re-import the same file — emails should dedup.
var commit2 struct {
Added int `json:"added"`
Skipped int `json:"skipped"`
SkippedEmails []string `json:"skipped_emails"`
}
resp = postMultipart(t, srv.URL+"/events/"+eventID.String()+"/guests/import", token, csvOne)
must(t, json.NewDecoder(resp.Body).Decode(&commit2), "decode commit2")
resp.Body.Close()
// Jordan has no email, so they re-import as a new row each time.
// Alex + Sam have emails and should be skipped.
if commit2.Skipped != 2 {
t.Fatalf("re-import dedup: skipped=%d want 2 (added=%d)", commit2.Skipped, commit2.Added)
}
// Verify the row count in the DB matches expectations: 3 from first
// commit + 1 (Jordan again) from second.
var count int
must(t, db.Pool.QueryRow(ctx,
"SELECT count(*) FROM guests WHERE event_id = $1", eventID,
).Scan(&count), "count guests")
if count != 4 {
t.Fatalf("guest count: got %d want 4", count)
}
}
// TestCsvImportAtomicRollback verifies that a runtime error mid-batch
// leaves NO partial rows. We trigger this by injecting a name longer than
// the column allows (VARCHAR(255)) on row 3 of 4.
func TestCsvImportAtomicRollback(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Atomic Event", "atomic-event")
longName := bytes.Repeat([]byte("Aa"), 200) // 400 chars > VARCHAR(255)
csv := "name,email\nAlice,a@example.test\nBob,b@example.test\n" +
string(longName) + ",c@example.test\nDave,d@example.test\n"
resp := postMultipart(t, srv.URL+"/events/"+eventID.String()+"/guests/import", token, csv)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("expected 500 on row insert error, got %d body=%s", resp.StatusCode, body)
}
var count int
must(t, db.Pool.QueryRow(ctx,
"SELECT count(*) FROM guests WHERE event_id = $1", eventID,
).Scan(&count), "count after rollback")
if count != 0 {
t.Fatalf("expected 0 guests after rollback, got %d", count)
}
}
// --- helpers shared with other tests in this dir ---
// setupAuthedAPI builds a fresh API server + a verified host + bearer
// token. Tests that just need a logged-in host can use this directly.
func setupAuthedAPI(t *testing.T, ctx context.Context) (srv *httptest.Server, db *storage.DB, hostID [16]byte, bearer string) {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
var err error
db, err = storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "http://localhost",
})
must(t, err, "build api server")
srv = httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
host := insertHost(t, ctx, db.Pool)
hostID = host
bearer = issueHostToken(t, host)
return
}
func postMultipart(t *testing.T, url, bearer, body string) *http.Response {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", "guests.csv")
must(t, err, "create form file")
_, err = fw.Write([]byte(body))
must(t, err, "write csv")
must(t, mw.Close(), "close mw")
req, err := http.NewRequest(http.MethodPost, url, &buf)
must(t, err, "build req")
req.Header.Set("Content-Type", mw.FormDataContentType())
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := http.DefaultClient.Do(req)
must(t, err, "do multipart")
return resp
}
+78 -26
View File
@@ -25,6 +25,7 @@ import (
"google.golang.org/grpc"
"github.com/alchemistkay/guestguard/internal/api"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/fraud"
pb "github.com/alchemistkay/guestguard/internal/fraudpb"
"github.com/alchemistkay/guestguard/internal/natspub"
@@ -79,22 +80,32 @@ func TestE2EHappyPath(t *testing.T) {
rsvpCounter := subscribeRSVPConfirmed(t, ctx, natsClient)
srv := httptest.NewServer(api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
AccessPublisher: natsClient,
RSVPPublisher: natsClient,
FraudScorer: fraudClient,
TokenTTL: 24 * time.Hour,
}).Handler())
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
AccessPublisher: natsClient,
RSVPPublisher: natsClient,
FraudScorer: fraudClient,
TokenTTL: 24 * time.Hour,
JWTSecret: "test-secret-must-be-at-least-32-bytes-long-xx",
JWTIssuer: "guestguard-test",
AccessTokenTTL: 15 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "http://localhost",
})
must(t, err, "build api server")
srv := httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
hostID := insertHost(t, ctx, db.Pool)
hostToken := issueHostToken(t, hostID)
t.Run("async access flow flags access_logs", func(t *testing.T) {
eventID := createEvent(t, srv.URL, hostID, "Async Test", "async-test")
guestID := createGuest(t, srv.URL, eventID, "Async Guest")
token := issueToken(t, srv.URL, eventID, guestID)
eventID := createEvent(t, srv.URL, hostToken, "Async Test", "async-test")
guestID := createGuest(t, srv.URL, hostToken, eventID, "Async Guest")
token := issueToken(t, srv.URL, hostToken, eventID, guestID)
accessResp := getAccess(t, srv.URL, token)
@@ -119,9 +130,9 @@ func TestE2EHappyPath(t *testing.T) {
})
t.Run("sync rsvp flow records rsvp and marks token used", func(t *testing.T) {
eventID := createEvent(t, srv.URL, hostID, "Sync Test", "sync-test")
guestID := createGuest(t, srv.URL, eventID, "Sync Guest")
token := issueToken(t, srv.URL, eventID, guestID)
eventID := createEvent(t, srv.URL, hostToken, "Sync Test", "sync-test")
guestID := createGuest(t, srv.URL, hostToken, eventID, "Sync Guest")
token := issueToken(t, srv.URL, hostToken, eventID, guestID)
stub.SetNext(15, "low", nil)
@@ -145,9 +156,9 @@ func TestE2EHappyPath(t *testing.T) {
})
t.Run("sync rsvp flow blocks when fraud score is BLOCK", func(t *testing.T) {
eventID := createEvent(t, srv.URL, hostID, "Block Test", "block-test")
guestID := createGuest(t, srv.URL, eventID, "Block Guest")
token := issueToken(t, srv.URL, eventID, guestID)
eventID := createEvent(t, srv.URL, hostToken, "Block Test", "block-test")
guestID := createGuest(t, srv.URL, hostToken, eventID, "Block Guest")
token := issueToken(t, srv.URL, hostToken, eventID, guestID)
stub.SetNext(95, "block", []string{"fingerprint differs from baseline", "ip address changed"})
@@ -274,33 +285,32 @@ func startStubFraudGRPC(t *testing.T) *stubFraud {
// --- HTTP helpers ---
func createEvent(t *testing.T, base string, hostID uuid.UUID, name, slug string) uuid.UUID {
func createEvent(t *testing.T, base, accessToken string, name, slug string) uuid.UUID {
t.Helper()
body := map[string]any{
"host_id": hostID.String(),
"name": name,
"slug": slug,
"event_date": time.Now().Add(30 * 24 * time.Hour).UTC().Format(time.RFC3339),
"venue": "Integration Hall",
}
var out struct{ ID uuid.UUID `json:"id"` }
postJSON(t, base+"/events", body, http.StatusCreated, &out)
postJSONAuthed(t, base+"/events", accessToken, body, http.StatusCreated, &out)
return out.ID
}
func createGuest(t *testing.T, base string, eventID uuid.UUID, name string) uuid.UUID {
func createGuest(t *testing.T, base, accessToken string, eventID uuid.UUID, name string) uuid.UUID {
t.Helper()
var out struct{ ID uuid.UUID `json:"id"` }
postJSON(t, fmt.Sprintf("%s/events/%s/guests", base, eventID),
postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests", base, eventID), accessToken,
map[string]any{"name": name}, http.StatusCreated, &out)
return out.ID
}
func issueToken(t *testing.T, base string, eventID, guestID uuid.UUID) string {
func issueToken(t *testing.T, base, accessToken string, eventID, guestID uuid.UUID) string {
t.Helper()
var out struct{ Token string `json:"token"` }
postJSON(t, fmt.Sprintf("%s/events/%s/guests/%s/tokens", base, eventID, guestID),
nil, http.StatusCreated, &out)
postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests/%s/tokens", base, eventID, guestID),
accessToken, nil, http.StatusCreated, &out)
return out.Token
}
@@ -340,6 +350,11 @@ func submitRSVP(t *testing.T, base, token string, body map[string]any) submitRSV
}
func postJSON(t *testing.T, url string, body any, wantStatus int, out any) {
t.Helper()
postJSONAuthed(t, url, "", body, wantStatus, out)
}
func postJSONAuthed(t *testing.T, url, bearer string, body any, wantStatus int, out any) {
t.Helper()
var rdr io.Reader
if body != nil {
@@ -351,6 +366,9 @@ func postJSON(t *testing.T, url string, body any, wantStatus int, out any) {
if rdr != nil {
req.Header.Set("Content-Type", "application/json")
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := http.DefaultClient.Do(req)
must(t, err, "do request "+url)
defer resp.Body.Close()
@@ -370,14 +388,48 @@ func insertHost(t *testing.T, ctx context.Context, pool *pgxpool.Pool) uuid.UUID
t.Helper()
var id uuid.UUID
err := pool.QueryRow(ctx,
`INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id`,
`INSERT INTO users (email, name, email_verified, email_verified_at)
VALUES ($1, $2, TRUE, now()) RETURNING id`,
fmt.Sprintf("test-%d@guestguard.test", time.Now().UnixNano()),
"Integration Host",
).Scan(&id)
must(t, err, "insert host")
// Default test hosts to the Business tier so existing tests that
// create multiple events for one host aren't tripped up by the
// free-tier limit (1 event / month). Tests that specifically exercise
// the free-tier path skip this helper.
grantBusinessTier(t, ctx, pool, id)
return id
}
// grantBusinessTier inserts an active Business subscription row for the
// given user so tier-enforcement middleware grants unlimited events.
func grantBusinessTier(t *testing.T, ctx context.Context, pool *pgxpool.Pool, userID uuid.UUID) {
t.Helper()
_, err := pool.Exec(ctx, `
INSERT INTO subscriptions (user_id, stripe_customer_id, tier, status)
VALUES ($1::uuid, 'cus_test_' || replace($1::uuid::text, '-', ''), 'business', 'active')
`, userID.String())
must(t, err, "grant business tier")
}
// issueHostToken mints a Bearer access token for an existing host using the
// same JWT secret/issuer the test API server was constructed with. This
// lets integration tests skip the signup/verify/login dance.
func issueHostToken(t *testing.T, hostID uuid.UUID) string {
t.Helper()
signer, err := auth.NewJWTSigner(testJWTSecret, 5*time.Minute, testJWTIssuer)
must(t, err, "build jwt signer")
tok, _, err := signer.Issue(hostID, time.Now())
must(t, err, "issue jwt")
return tok
}
const (
testJWTSecret = "test-secret-must-be-at-least-32-bytes-long-xx"
testJWTIssuer = "guestguard-test"
)
func waitForFlagged(t *testing.T, ctx context.Context, pool *pgxpool.Pool, accessLogID uuid.UUID, wantScore int, wantFlagged bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
+281
View File
@@ -0,0 +1,281 @@
//go:build integration
package integration_test
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/api"
"github.com/alchemistkay/guestguard/internal/natspub"
"github.com/alchemistkay/guestguard/internal/storage"
)
// TestGuestUpdate confirms PATCH semantics: partial fields update, empty
// strings clear nullable columns, missing fields are left untouched.
func TestGuestUpdate(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Update Event", "update-event")
guestID, originalEmail := createGuestWithEmail(t, srv.URL, token, eventID, "Original Name")
// Patch name + email together.
var updated struct {
Name string `json:"name"`
Email *string `json:"email"`
}
patchJSON(t, fmt.Sprintf("%s/events/%s/guests/%s", srv.URL, eventID, guestID), token,
map[string]any{"name": "Renamed", "email": "new-" + originalEmail},
http.StatusOK, &updated)
if updated.Name != "Renamed" {
t.Errorf("name: got %q want Renamed", updated.Name)
}
if updated.Email == nil || !strings.HasPrefix(*updated.Email, "new-") {
t.Errorf("email: got %v", updated.Email)
}
// Clear the email by sending empty string. domain.Guest tags Email
// as omitempty so a nil pointer doesn't serialize; check DB state
// directly instead of relying on the response shape.
patchJSON(t, fmt.Sprintf("%s/events/%s/guests/%s", srv.URL, eventID, guestID), token,
map[string]any{"email": ""}, http.StatusOK, nil)
var dbEmail *string
must(t, db.Pool.QueryRow(ctx, "SELECT email FROM guests WHERE id = $1", guestID).Scan(&dbEmail),
"fetch email after clear")
if dbEmail != nil {
t.Errorf("expected DB email cleared (NULL), got %q", *dbEmail)
}
// Empty name is rejected.
assertStatus(t, http.MethodPatch, fmt.Sprintf("%s/events/%s/guests/%s", srv.URL, eventID, guestID),
token, map[string]any{"name": ""}, http.StatusBadRequest)
}
// TestGuestDelete confirms the row goes away + cascade-deletes the token.
func TestGuestDelete(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Delete Event", "delete-event")
guestID, _ := createGuestWithEmail(t, srv.URL, token, eventID, "Bye")
// Give the guest a token first so we can prove the cascade.
issueToken(t, srv.URL, token, eventID, guestID)
var hasToken int
must(t, db.Pool.QueryRow(ctx, "SELECT count(*) FROM tokens WHERE guest_id = $1", guestID).Scan(&hasToken),
"count tokens before delete")
if hasToken != 1 {
t.Fatalf("setup: expected 1 token, got %d", hasToken)
}
assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s/guests/%s", srv.URL, eventID, guestID),
token, nil, http.StatusNoContent)
var remaining int
must(t, db.Pool.QueryRow(ctx, "SELECT count(*) FROM guests WHERE id = $1", guestID).Scan(&remaining),
"count after delete")
if remaining != 0 {
t.Errorf("guest still exists after delete")
}
must(t, db.Pool.QueryRow(ctx, "SELECT count(*) FROM tokens WHERE guest_id = $1", guestID).Scan(&hasToken),
"count tokens after delete")
if hasToken != 0 {
t.Errorf("expected cascade to delete the token, %d still exist", hasToken)
}
// Re-deleting → 404.
assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s/guests/%s", srv.URL, eventID, guestID),
token, nil, http.StatusNotFound)
}
// TestTokenRotate confirms the old token stops working and the new one
// is recognised. Optionally re-publishes invitation.send when send_email
// is true.
func TestTokenRotate(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
natsURL := startNATS(t, ctx)
db, err := storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
natsClient, err := natspub.Connect(ctx, natsURL, logger)
must(t, err, "connect nats")
t.Cleanup(natsClient.Close)
var invitationCount atomic.Int32
sub, err := natspub.NewInvitationSendSubscriber(ctx, natsClient, "test-rotate",
func(_ context.Context, _ natspub.InvitationSend) error {
invitationCount.Add(1)
return nil
}, logger)
must(t, err, "subscriber")
cc, err := sub.Start(ctx)
must(t, err, "start subscriber")
t.Cleanup(cc.Stop)
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
AccessPublisher: natsClient,
RSVPPublisher: natsClient,
InvitationPublisher: natsClient,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "https://gg.example.test",
})
must(t, err, "api server")
srv := httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
hostID := insertHost(t, ctx, db.Pool)
hostToken := issueHostToken(t, hostID)
eventID := createEvent(t, srv.URL, hostToken, "Rotate Event", "rotate-event")
guestID, _ := createGuestWithEmail(t, srv.URL, hostToken, eventID, "Mira")
// Initial issue — captures the original token + 1 invitation publish.
originalToken := issueToken(t, srv.URL, hostToken, eventID, guestID)
// The /access endpoint accepts the original token before rotation.
resp, err := http.Get(srv.URL + "/access/" + originalToken)
must(t, err, "GET original access")
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("original access pre-rotate: got %d want 200", resp.StatusCode)
}
// Rotate WITHOUT email — just want a fresh link.
var rotated struct {
Token string `json:"token"`
InvitationQueued bool `json:"invitation_queued"`
}
postJSONAuthed(t,
fmt.Sprintf("%s/events/%s/guests/%s/tokens/rotate", srv.URL, eventID, guestID),
hostToken,
map[string]any{"send_email": false},
http.StatusOK, &rotated)
if rotated.Token == "" || rotated.Token == originalToken {
t.Fatalf("rotated token should be fresh and non-empty (was %q, original %q)", rotated.Token, originalToken)
}
if rotated.InvitationQueued {
t.Error("expected invitation_queued=false when send_email=false")
}
// Old token no longer works.
resp, err = http.Get(srv.URL + "/access/" + originalToken)
must(t, err, "GET original after rotate")
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
t.Errorf("old token should not authenticate after rotation, got 200")
}
// New token works.
resp, err = http.Get(srv.URL + "/access/" + rotated.Token)
must(t, err, "GET new access")
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("new token should authenticate: got %d", resp.StatusCode)
}
// Rotate again WITH email — should bump the invitation count.
preCount := invitationCount.Load()
var rotated2 struct {
Token string `json:"token"`
InvitationQueued bool `json:"invitation_queued"`
}
postJSONAuthed(t,
fmt.Sprintf("%s/events/%s/guests/%s/tokens/rotate", srv.URL, eventID, guestID),
hostToken,
map[string]any{"send_email": true},
http.StatusOK, &rotated2)
if !rotated2.InvitationQueued {
t.Error("expected invitation_queued=true when send_email=true")
}
// Wait briefly for the NATS round-trip.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if invitationCount.Load() > preCount {
break
}
time.Sleep(100 * time.Millisecond)
}
if invitationCount.Load() <= preCount {
t.Fatalf("expected invitation publish after rotate-with-send, count %d -> %d", preCount, invitationCount.Load())
}
}
func patchJSONRaw(t *testing.T, url, bearer string, body any, wantStatus int) []byte {
t.Helper()
b, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPatch, url, strings.NewReader(string(b)))
must(t, err, "build patch")
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := http.DefaultClient.Do(req)
must(t, err, "do patch")
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != wantStatus {
t.Fatalf("%s status=%d want=%d body=%s", url, resp.StatusCode, wantStatus, respBody)
}
return respBody
}
func patchJSON(t *testing.T, url, bearer string, body any, wantStatus int, out any) {
t.Helper()
b, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPatch, url, strings.NewReader(string(b)))
must(t, err, "build patch")
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := http.DefaultClient.Do(req)
must(t, err, "do patch")
defer resp.Body.Close()
if resp.StatusCode != wantStatus {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("%s status=%d want=%d body=%s", url, resp.StatusCode, wantStatus, body)
}
if out != nil {
must(t, json.NewDecoder(resp.Body).Decode(out), "decode patch response")
}
}
// Silence unused import warning if the future drops something.
var _ uuid.UUID
+218
View File
@@ -0,0 +1,218 @@
//go:build integration
package integration_test
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/api"
"github.com/alchemistkay/guestguard/internal/natspub"
"github.com/alchemistkay/guestguard/internal/storage"
)
// TestTokenIssuePublishesInvitation walks the new wire-up: issuing a
// token for a guest with an email-on-file should publish an
// invitation.send event over NATS, and the API response should reflect
// that the invitation was queued.
func TestTokenIssuePublishesInvitation(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
natsURL := startNATS(t, ctx)
db, err := storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
natsClient, err := natspub.Connect(ctx, natsURL, logger)
must(t, err, "connect nats")
t.Cleanup(natsClient.Close)
// Subscribe before issuing — JetStream replay is on by default but
// this guarantees we don't race the consumer setup.
var seen atomic.Int32
captured := make(chan natspub.InvitationSend, 1)
sub, err := natspub.NewInvitationSendSubscriber(ctx, natsClient, "test-invitation-send",
func(ctx context.Context, evt natspub.InvitationSend) error {
seen.Add(1)
select {
case captured <- evt:
default:
}
return nil
}, logger)
must(t, err, "build subscriber")
cc, err := sub.Start(ctx)
must(t, err, "start subscriber")
t.Cleanup(cc.Stop)
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
AccessPublisher: natsClient,
RSVPPublisher: natsClient,
InvitationPublisher: natsClient,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "https://gg.example.test",
})
must(t, err, "build api server")
srv := httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
hostID := insertHost(t, ctx, db.Pool)
token := issueHostToken(t, hostID)
eventID := createEvent(t, srv.URL, token, "Invitation Test", "invitation-test")
guestID, guestEmail := createGuestWithEmail(t, srv.URL, token, eventID, "Mira")
var issued struct {
Token string `json:"token"`
InvitationQueued bool `json:"invitation_queued"`
InvitationLink string `json:"invitation_link"`
}
postJSONAuthed(t,
fmt.Sprintf("%s/events/%s/guests/%s/tokens", srv.URL, eventID, guestID),
token, nil, http.StatusCreated, &issued)
if !issued.InvitationQueued {
t.Fatalf("expected invitation_queued=true (response: %+v)", issued)
}
if !strings.HasPrefix(issued.InvitationLink, "https://gg.example.test/rsvp/tk_") {
t.Fatalf("invitation_link should use publicBaseURL: got %q", issued.InvitationLink)
}
select {
case evt := <-captured:
if evt.GuestID.String() != guestID.String() {
t.Errorf("guest id: got %s want %s", evt.GuestID, guestID)
}
if evt.GuestEmail != guestEmail {
t.Errorf("guest email: got %s want %s", evt.GuestEmail, guestEmail)
}
if evt.EventName != "Invitation Test" {
t.Errorf("event name: got %s", evt.EventName)
}
if !strings.HasPrefix(evt.Link, "https://gg.example.test/rsvp/tk_") {
t.Errorf("link: got %s", evt.Link)
}
case <-time.After(10 * time.Second):
t.Fatalf("did not see invitation.send within 10s (seen=%d)", seen.Load())
}
}
// TestTokenIssueWithoutGuestEmailSkipsInvitation confirms that a guest
// with no email on file does NOT trigger a publish — the host still gets
// a copy-pasteable link.
func TestTokenIssueWithoutGuestEmailSkipsInvitation(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
natsURL := startNATS(t, ctx)
db, err := storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
natsClient, err := natspub.Connect(ctx, natsURL, logger)
must(t, err, "connect nats")
t.Cleanup(natsClient.Close)
var invitations atomic.Int32
sub, err := natspub.NewInvitationSendSubscriber(ctx, natsClient, "test-no-email-invitation",
func(ctx context.Context, evt natspub.InvitationSend) error {
invitations.Add(1)
return nil
}, logger)
must(t, err, "build subscriber")
cc, err := sub.Start(ctx)
must(t, err, "start subscriber")
t.Cleanup(cc.Stop)
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
AccessPublisher: natsClient,
RSVPPublisher: natsClient,
InvitationPublisher: natsClient,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "http://localhost",
})
must(t, err, "build api server")
srv := httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
hostID := insertHost(t, ctx, db.Pool)
hostToken := issueHostToken(t, hostID)
eventID := createEvent(t, srv.URL, hostToken, "No Email Event", "no-email-event")
// createGuest helper produces a guest with no email field set.
guestID := createGuest(t, srv.URL, hostToken, eventID, "Phone-only Guest")
var issued struct {
InvitationQueued bool `json:"invitation_queued"`
}
postJSONAuthed(t,
fmt.Sprintf("%s/events/%s/guests/%s/tokens", srv.URL, eventID, guestID),
hostToken, nil, http.StatusCreated, &issued)
if issued.InvitationQueued {
t.Fatalf("expected invitation_queued=false for emailless guest")
}
// Give NATS a moment to surface any (unwanted) message.
time.Sleep(500 * time.Millisecond)
if invitations.Load() != 0 {
t.Fatalf("expected 0 invitation.send messages, got %d", invitations.Load())
}
}
// createGuestWithEmail is a thin wrapper that adds an email field, since
// the existing helper omits it.
func createGuestWithEmail(t *testing.T, base, accessToken string, eventID uuid.UUID, name string) (uuid.UUID, string) {
t.Helper()
email := fmt.Sprintf("guest-%d@example.test", time.Now().UnixNano())
body := map[string]any{"name": name, "email": email}
var out struct{ ID uuid.UUID `json:"id"` }
postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests", base, eventID), accessToken,
body, http.StatusCreated, &out)
return out.ID, email
}
// Avoid the import being marked unused if a future refactor drops it.
var _ = json.Marshal
+122
View File
@@ -0,0 +1,122 @@
//go:build integration
package integration_test
import (
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"github.com/alchemistkay/guestguard/internal/notification"
)
// startMailpit launches a Mailpit container and returns (smtpHost, smtpPort,
// httpBaseURL). The HTTP API is what we query to assert delivery.
func startMailpit(t *testing.T, ctx context.Context) (string, int, string) {
t.Helper()
req := testcontainers.ContainerRequest{
Image: "axllent/mailpit:latest",
ExposedPorts: []string{"1025/tcp", "8025/tcp"},
WaitingFor: wait.ForListeningPort("8025/tcp").WithStartupTimeout(45 * time.Second),
}
c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
must(t, err, "start mailpit container")
t.Cleanup(func() { _ = c.Terminate(context.Background()) })
host, err := c.Host(ctx)
must(t, err, "mailpit host")
smtpMP, err := c.MappedPort(ctx, "1025/tcp")
must(t, err, "mailpit smtp port")
httpMP, err := c.MappedPort(ctx, "8025/tcp")
must(t, err, "mailpit http port")
port, _ := strconv.Atoi(smtpMP.Port())
return host, port, "http://" + host + ":" + httpMP.Port()
}
// TestSMTPSenderAgainstMailpit sends a real verification email via the
// SMTP adapter and asserts the Mailpit HTTP API saw it land in the inbox.
// This is the closest thing to "did a real email arrive" we can run in CI.
func TestSMTPSenderAgainstMailpit(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
smtpHost, smtpPort, httpBase := startMailpit(t, ctx)
tpls, err := notification.NewTemplates()
must(t, err, "templates")
sender, err := notification.NewSMTPEmailSender(notification.SMTPConfig{
Host: smtpHost,
Port: smtpPort,
FromEmail: "noreply@guestguard.local",
FromName: "GuestGuard (dev)",
TLS: "none", // mailpit accepts plain SMTP
}, tpls)
must(t, err, "smtp sender")
must(t, sender.SendVerification(ctx, "kay@example.test", "Kay",
"http://localhost:3000/verify-email?token=demo"), "send")
// Mailpit exposes a /api/v1/messages list endpoint. Poll briefly since
// SMTP delivery is async.
var found bool
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
resp, err := http.Get(httpBase + "/api/v1/messages")
must(t, err, "mailpit list")
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var list struct {
Messages []struct {
Subject string `json:"Subject"`
To []struct {
Address string `json:"Address"`
} `json:"To"`
ID string `json:"ID"`
} `json:"messages"`
}
if err := json.Unmarshal(body, &list); err != nil {
t.Fatalf("decode mailpit list: %v body=%s", err, body)
}
for _, m := range list.Messages {
if m.Subject == "Verify your GuestGuard email" {
for _, to := range m.To {
if to.Address == "kay@example.test" {
found = true
// Fetch the full message and confirm the verification
// link survived through MIME encoding.
full, err := http.Get(httpBase + "/api/v1/message/" + m.ID)
must(t, err, "fetch message")
b, _ := io.ReadAll(full.Body)
full.Body.Close()
if !strings.Contains(string(b), "verify-email?token=demo") {
t.Errorf("verification link missing from body: %s", b)
}
break
}
}
}
}
if found {
break
}
time.Sleep(200 * time.Millisecond)
}
if !found {
t.Fatalf("did not see verification email in mailpit within 10s")
}
}
@@ -0,0 +1,128 @@
//go:build integration
package integration_test
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
const migrationsDir = "../../internal/storage/migrations"
// TestMigrationRoundtrip applies every up migration, runs every down in
// reverse, then applies the ups again, against a fresh Postgres
// container. Catches any down.sql that's missing, broken, or asymmetric
// with its up — Block G's "every migration has a tested down" check.
func TestMigrationRoundtrip(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
dsn := startPostgres(t, ctx)
pool, err := pgxpool.New(ctx, dsn)
must(t, err, "connect")
t.Cleanup(pool.Close)
ups, downs := loadMigrations(t)
// Phase 1: apply all ups in order. Mirrors what the API does at boot.
for _, m := range ups {
t.Logf("up: %s", m.version)
execAll(t, ctx, pool, m.sql)
}
// Sanity: the latest table from each migration exists.
for _, expected := range []string{
"users", "rsvps", "refresh_tokens", "unsubscribes", "subscriptions",
} {
var exists bool
must(t, pool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name=$1)`,
expected,
).Scan(&exists), "check "+expected)
if !exists {
t.Fatalf("after ups: table %q is missing", expected)
}
}
// Phase 2: apply downs in REVERSE order. Each must execute without
// error (even though the result is allowed to be lossy — down
// migrations are not required to preserve data).
for i := len(downs) - 1; i >= 0; i-- {
m := downs[i]
t.Logf("down: %s", m.version)
execAll(t, ctx, pool, m.sql)
}
// All app tables should be gone now.
for _, gone := range []string{
"users", "events", "guests", "tokens", "rsvps",
"access_logs", "notifications", "subscriptions",
} {
var exists bool
must(t, pool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name=$1)`,
gone,
).Scan(&exists), "check "+gone)
if exists {
t.Errorf("after downs: %q still exists — incomplete down migration", gone)
}
}
// Phase 3: re-apply all ups. This catches down migrations that
// leave hidden state (types, sequences, indexes, extensions) which
// would clash on the second up.
for _, m := range ups {
t.Logf("up2: %s", m.version)
execAll(t, ctx, pool, m.sql)
}
}
type migration struct {
version string
sql string
}
func loadMigrations(t *testing.T) (ups, downs []migration) {
t.Helper()
entries, err := os.ReadDir(migrationsDir)
must(t, err, "read migrations dir")
for _, e := range entries {
name := e.Name()
b, err := os.ReadFile(filepath.Join(migrationsDir, name))
must(t, err, "read "+name)
m := migration{sql: string(b)}
switch {
case strings.HasSuffix(name, ".up.sql"):
m.version = strings.TrimSuffix(name, ".up.sql")
ups = append(ups, m)
case strings.HasSuffix(name, ".down.sql"):
m.version = strings.TrimSuffix(name, ".down.sql")
downs = append(downs, m)
}
}
sort.Slice(ups, func(i, j int) bool { return ups[i].version < ups[j].version })
sort.Slice(downs, func(i, j int) bool { return downs[i].version < downs[j].version })
if len(ups) != len(downs) {
t.Fatalf("up/down count mismatch: %d ups, %d downs — every migration needs a .down.sql", len(ups), len(downs))
}
for i := range ups {
if ups[i].version != downs[i].version {
t.Fatalf("migration %s has no matching down (or vice versa)", ups[i].version)
}
}
return ups, downs
}
func execAll(t *testing.T, ctx context.Context, pool *pgxpool.Pool, sql string) {
t.Helper()
_, err := pool.Exec(ctx, sql)
must(t, err, "exec migration")
}
+230
View File
@@ -0,0 +1,230 @@
//go:build integration
package integration_test
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/api"
"github.com/alchemistkay/guestguard/internal/notification"
"github.com/alchemistkay/guestguard/internal/storage"
)
// mustInsertEventAndGuest seeds the bare minimum rows the notifications
// webhook tests need to attach a notification to a real guest.
func mustInsertEventAndGuest(t *testing.T, ctx context.Context, db *storage.DB, hostID uuid.UUID) (uuid.UUID, uuid.UUID) {
t.Helper()
var eventID uuid.UUID
must(t, db.Pool.QueryRow(ctx, `
INSERT INTO events (host_id, name, slug, event_date)
VALUES ($1, 'Notif Test', $2, now() + interval '30 day')
RETURNING id
`, hostID, fmt.Sprintf("notif-%d", time.Now().UnixNano())).Scan(&eventID),
"insert event")
var guestID uuid.UUID
must(t, db.Pool.QueryRow(ctx, `
INSERT INTO guests (event_id, name, email)
VALUES ($1, 'Notif Guest', $2)
RETURNING id
`, eventID, fmt.Sprintf("notif-%d@example.test", time.Now().UnixNano())).Scan(&guestID),
"insert guest")
return eventID, guestID
}
func setupNotificationsAPI(t *testing.T, ctx context.Context) (*httptest.Server, *storage.DB, *notification.UnsubscribeSigner) {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
db, err := storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
suppressions := notification.NewSuppressionRepo(db)
notifRepo := notification.NewRepo(db)
const secret = "test-unsubscribe-secret-at-least-32-bytes-long"
signer := notification.NewUnsubscribeSigner(secret)
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "http://localhost",
NotificationRepo: notifRepo,
SuppressionRepo: suppressions,
UnsubscribeSigner: signer,
})
must(t, err, "build api server")
srv := httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
return srv, db, signer
}
// TestUnsubscribeFlow exercises the signed-link end-to-end: preview surfaces
// the email, confirm writes the suppression row, and a tampered token is
// rejected.
func TestUnsubscribeFlow(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, signer := setupNotificationsAPI(t, ctx)
email := "mira@example.test"
token := signer.Sign(email)
// Preview returns the bound email.
var preview struct{ Email string }
getJSONAuthed(t, srv.URL+"/unsubscribe/"+token, "", http.StatusOK, &preview)
if preview.Email != email {
t.Fatalf("preview email: got %q want %q", preview.Email, email)
}
// Confirm writes the row.
assertStatus(t, http.MethodPost, srv.URL+"/unsubscribe/"+token, "", nil, http.StatusOK)
yep, err := notification.NewSuppressionRepo(db).IsSuppressed(ctx, email)
must(t, err, "check suppression")
if !yep {
t.Fatalf("expected email %s suppressed", email)
}
// Tampered token is rejected.
tampered := token[:len(token)-2] + "xx"
assertStatus(t, http.MethodGet, srv.URL+"/unsubscribe/"+tampered, "", nil, http.StatusBadRequest)
}
// TestSESBounceWebhook walks the inbound bounce → suppression chain. We
// build a notification row first (so MarkBounce has something to update),
// then post a Bounce envelope, then verify both the status flip and the
// suppression entry.
func TestSESBounceWebhook(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _ := setupNotificationsAPI(t, ctx)
// Insert a fake guest + notification with a known provider_message_id.
hostID := insertHost(t, ctx, db.Pool)
var eventID, guestID = mustInsertEventAndGuest(t, ctx, db, hostID)
const msgID = "ses-fake-message-id-1234"
notifRepo := notification.NewRepo(db)
_, err := notifRepo.Record(ctx, notification.RecordParams{
GuestID: guestID,
Channel: notification.ChannelEmail,
Type: notification.TypeInvitation,
Status: notification.StatusSent,
ProviderMessageID: msgID,
})
must(t, err, "seed notification")
_ = eventID
// SES → SNS envelope: outer "Notification" carries inner JSON as a string.
innerJSON, _ := json.Marshal(map[string]any{
"notificationType": "Bounce",
"mail": map[string]any{"messageId": msgID},
"bounce": map[string]any{
"bounceType": "Permanent",
"bouncedRecipients": []map[string]any{
{"emailAddress": "bouncer@example.test"},
},
},
})
envelope, _ := json.Marshal(map[string]any{
"Type": "Notification",
"Message": string(innerJSON),
})
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/webhooks/ses/notifications",
strings.NewReader(string(envelope)))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
must(t, err, "post ses webhook")
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("expected 204, got %d", resp.StatusCode)
}
// Notification row marked as bounced/permanent.
var status, bounceType string
must(t, db.Pool.QueryRow(ctx,
"SELECT status, bounce_type FROM notifications WHERE provider_message_id = $1",
msgID,
).Scan(&status, &bounceType), "fetch notification")
if status != "bounced" || bounceType != "permanent" {
t.Fatalf("bad row: status=%s bounce_type=%s", status, bounceType)
}
// Suppression row populated.
yep, err := notification.NewSuppressionRepo(db).IsSuppressed(ctx, "bouncer@example.test")
must(t, err, "check suppression")
if !yep {
t.Fatal("expected bouncer email suppressed")
}
}
// TestTwilioStatusWebhook flips a row's status to delivered.
func TestTwilioStatusWebhook(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _ := setupNotificationsAPI(t, ctx)
hostID := insertHost(t, ctx, db.Pool)
_, guestID := mustInsertEventAndGuest(t, ctx, db, hostID)
const sid = "SMfake0123456789"
_, err := notification.NewRepo(db).Record(ctx, notification.RecordParams{
GuestID: guestID,
Channel: notification.ChannelSMS,
Type: notification.TypeInvitation,
Status: notification.StatusSent,
ProviderMessageID: sid,
})
must(t, err, "seed notification")
form := url.Values{}
form.Set("MessageSid", sid)
form.Set("MessageStatus", "delivered")
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/webhooks/twilio/status",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
must(t, err, "post twilio webhook")
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("expected 204, got %d", resp.StatusCode)
}
var status string
must(t, db.Pool.QueryRow(ctx,
"SELECT status FROM notifications WHERE provider_message_id = $1", sid,
).Scan(&status), "fetch status")
if status != "delivered" {
t.Fatalf("expected delivered, got %s", status)
}
}
+213
View File
@@ -0,0 +1,213 @@
//go:build integration
package integration_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
"github.com/google/uuid"
)
// TestDataExport confirms GET /me/data-export returns a JSON payload
// containing the user + their events + nested records, and rejects
// unauthenticated callers.
func TestDataExport(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, _, hostID, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Export Event", "export-event")
guestID, _ := createGuestWithEmail(t, srv.URL, token, eventID, "Export Guest")
_ = issueToken(t, srv.URL, token, eventID, guestID)
// Unauthenticated → 401.
resp, err := http.Get(srv.URL + "/me/data-export")
must(t, err, "GET unauthed")
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("unauthed export should 401, got %d", resp.StatusCode)
}
// Authed → JSON dump with the expected shape.
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/me/data-export", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = http.DefaultClient.Do(req)
must(t, err, "GET authed")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("authed export status=%d body=%s", resp.StatusCode, body)
}
if cd := resp.Header.Get("Content-Disposition"); cd == "" {
t.Errorf("missing Content-Disposition header — browser won't offer download")
}
body, _ := io.ReadAll(resp.Body)
var out struct {
Format string `json:"format"`
User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
} `json:"user"`
Events []struct {
ID uuid.UUID `json:"id"`
} `json:"events"`
Guests []struct {
ID uuid.UUID `json:"id"`
} `json:"guests"`
Tokens []struct {
ID uuid.UUID `json:"id"`
} `json:"tokens"`
}
must(t, json.Unmarshal(body, &out), "decode export")
if out.Format != "guestguard.v1" {
t.Errorf("format: got %q want guestguard.v1", out.Format)
}
if out.User.ID.String() != uuid.UUID(hostID).String() {
t.Errorf("user id mismatch: got %s want %s", out.User.ID, uuid.UUID(hostID))
}
if len(out.Events) != 1 || out.Events[0].ID != eventID {
t.Errorf("events: got %d entries, want 1 for the seeded event", len(out.Events))
}
if len(out.Guests) != 1 || out.Guests[0].ID != guestID {
t.Errorf("guests: got %d entries, want 1", len(out.Guests))
}
if len(out.Tokens) != 1 {
t.Errorf("tokens: got %d entries, want 1 (issued one above)", len(out.Tokens))
}
}
// TestDeleteMe walks the soft-delete flow: account row gets tombstoned,
// subsequent /me requests fail because the user is no longer findable,
// and re-signup with the same email succeeds (proves the unique index
// only constrains live users).
func TestDeleteMe(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, hostID, token := setupAuthedAPI(t, ctx)
// Capture the original email so we can prove the tombstone scrubbed it.
var originalEmail string
must(t, db.Pool.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, uuid.UUID(hostID)).Scan(&originalEmail),
"fetch original email")
// Hit DELETE /me.
req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
must(t, err, "DELETE /me")
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("DELETE /me status=%d want 204", resp.StatusCode)
}
// DB: row is soft-deleted with PII scrubbed.
var deletedAt *time.Time
var emailAfter, nameAfter string
must(t, db.Pool.QueryRow(ctx,
`SELECT deleted_at, email, name FROM users WHERE id = $1`, uuid.UUID(hostID),
).Scan(&deletedAt, &emailAfter, &nameAfter), "fetch after delete")
if deletedAt == nil {
t.Fatal("expected deleted_at set")
}
if emailAfter == originalEmail {
t.Errorf("expected email scrubbed, got %q", emailAfter)
}
if nameAfter != "Deleted user" {
t.Errorf("expected name='Deleted user', got %q", nameAfter)
}
// API: subsequent /me with the same JWT returns 401 (user not found
// by GetByID since it filters on deleted_at).
req, _ = http.NewRequest(http.MethodGet, srv.URL+"/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = http.DefaultClient.Do(req)
must(t, err, "GET /me after delete")
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("post-delete /me should 401, got %d", resp.StatusCode)
}
// Re-signup with the ORIGINAL email succeeds because the unique
// index is partial — soft-deleted rows don't block new accounts.
body := fmt.Sprintf(`{"email":%q,"name":"New owner","password":"correct-horse","accept_terms":true}`, originalEmail)
resp, err = http.Post(srv.URL+"/auth/signup", "application/json", stringReader(body))
must(t, err, "POST /auth/signup after delete")
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("re-signup status=%d (expected 201 — soft-deleted shouldn't block new signups)", resp.StatusCode)
}
}
// TestAcceptTerms confirms a user created without terms acceptance can
// record it post-hoc via POST /me/accept-terms.
func TestAcceptTerms(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, hostID, token := setupAuthedAPI(t, ctx)
// insertHost (in e2e_test.go) doesn't set the terms columns, so a
// fresh host starts with NULLs. Confirm.
var acceptedAt *time.Time
must(t, db.Pool.QueryRow(ctx,
`SELECT terms_accepted_at FROM users WHERE id = $1`, uuid.UUID(hostID),
).Scan(&acceptedAt), "fetch pre")
if acceptedAt != nil {
t.Fatal("setup: expected fresh host to have no terms_accepted_at")
}
// Hit the endpoint.
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/me/accept-terms", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
must(t, err, "POST /me/accept-terms")
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("accept-terms status=%d", resp.StatusCode)
}
// DB: timestamps now set on both columns.
var terms, privacy *time.Time
must(t, db.Pool.QueryRow(ctx,
`SELECT terms_accepted_at, privacy_policy_accepted_at FROM users WHERE id = $1`, uuid.UUID(hostID),
).Scan(&terms, &privacy), "fetch post")
if terms == nil || privacy == nil {
t.Errorf("expected both timestamps set: terms=%v privacy=%v", terms, privacy)
}
}
func stringReader(s string) *stringReadCloser {
return &stringReadCloser{s: s}
}
type stringReadCloser struct {
s string
pos int
}
func (r *stringReadCloser) Read(p []byte) (int, error) {
if r.pos >= len(r.s) {
return 0, io.EOF
}
n := copy(p, r.s[r.pos:])
r.pos += n
return n, nil
}
func (r *stringReadCloser) Close() error { return nil }
+205
View File
@@ -0,0 +1,205 @@
//go:build integration
package integration_test
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/alchemistkay/guestguard/internal/api"
"github.com/alchemistkay/guestguard/internal/storage"
)
// TestRateLimitSignup confirms the per-IP signup limit kicks in at the
// configured threshold and includes a Retry-After header.
func TestRateLimitSignup(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
db, err := storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
mr, err := miniredis.Run()
must(t, err, "miniredis")
t.Cleanup(mr.Close)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
srv := httptest.NewServer(must1(api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "http://localhost",
Redis: rdb,
}))(t).Handler())
t.Cleanup(srv.Close)
// First 5 signups from the same IP succeed (httptest reuses 127.0.0.1).
for i := 0; i < 5; i++ {
body := map[string]any{
"email": uniqueEmail(t),
"name": "Probe",
"password": "correct-horse",
}
postJSONAuthed(t, srv.URL+"/auth/signup", "", body, http.StatusCreated, nil)
}
// 6th in the same window must be 429.
req := buildJSON(t, http.MethodPost, srv.URL+"/auth/signup", "",
map[string]any{"email": uniqueEmail(t), "name": "Probe", "password": "correct-horse"})
resp, err := http.DefaultClient.Do(req)
must(t, err, "do 6th signup")
defer resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 429, got %d body=%s", resp.StatusCode, body)
}
if resp.Header.Get("Retry-After") == "" {
t.Fatal("missing Retry-After header on 429")
}
}
// TestLoginLockout confirms 5 consecutive bad-password attempts trip the
// account-lock flag, login then 403s with a "locked" message, and only a
// password reset clears it.
func TestLoginLockout(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
db, err := storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
mr, err := miniredis.Run()
must(t, err, "miniredis")
t.Cleanup(mr.Close)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
emails := &recordingEmailSender{}
srv := httptest.NewServer(must1(api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "http://localhost",
EmailSender: emails,
Redis: rdb,
}))(t).Handler())
t.Cleanup(srv.Close)
email := "lockout-" + uuid.NewString() + "@guestguard.test"
// Sign up and verify so we have a working account.
postJSONAuthed(t, srv.URL+"/auth/signup", "",
map[string]any{"email": email, "name": "Lock Probe", "password": "correct-horse"},
http.StatusCreated, nil)
token := tokenFromQuery(t, emails.verifyLink, "token")
postJSONAuthed(t, srv.URL+"/auth/verify-email", "",
map[string]any{"token": token}, http.StatusOK, nil)
// 5 bad-password attempts — the lockout middleware engages at the 5th.
for i := 0; i < 5; i++ {
req := buildJSON(t, http.MethodPost, srv.URL+"/auth/login", "",
map[string]any{"email": email, "password": "wrong"})
resp, err := http.DefaultClient.Do(req)
must(t, err, "do login")
resp.Body.Close()
}
// Even correct password is now rejected with 403 "locked".
{
req := buildJSON(t, http.MethodPost, srv.URL+"/auth/login", "",
map[string]any{"email": email, "password": "correct-horse"})
resp, err := http.DefaultClient.Do(req)
must(t, err, "do correct login")
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 403 locked, got %d body=%s", resp.StatusCode, body)
}
}
// Reset the password — should clear the lock.
emails.resetLink = ""
postJSONAuthed(t, srv.URL+"/auth/forgot-password", "",
map[string]any{"email": email}, http.StatusAccepted, nil)
if emails.resetLink == "" {
t.Fatal("reset link not captured")
}
resetToken := tokenFromPath(t, emails.resetLink, "/reset-password/")
postJSONAuthed(t, srv.URL+"/auth/reset-password", "",
map[string]any{"token": resetToken, "new_password": "new-correct-horse"},
http.StatusOK, nil)
// Now login succeeds with the new password.
postJSONAuthed(t, srv.URL+"/auth/login", "",
map[string]any{"email": email, "password": "new-correct-horse"},
http.StatusOK, nil)
}
func buildJSON(t *testing.T, method, url, bearer string, body any) *http.Request {
t.Helper()
var r io.Reader
if body != nil {
b, err := json.Marshal(body)
must(t, err, "marshal body")
r = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, r)
must(t, err, "build req")
if r != nil {
req.Header.Set("Content-Type", "application/json")
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
return req
}
func must1[T any](v T, err error) func(*testing.T) T {
return func(t *testing.T) T {
t.Helper()
must(t, err, "api server")
return v
}
}