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:
@@ -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",
|
||||
}})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user