//go:build integration package integration_test import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/google/uuid" "github.com/redis/go-redis/v9" "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/storage" ) // recordingRSVPSender captures the most recent RSVP edit-link send so the // test can pull the magic URL out of "the email" without standing up an // SMTP server. type recordingRSVPSender struct { lastEditLink string } func (s *recordingRSVPSender) SendVerification(context.Context, string, string, string) error { return nil } func (s *recordingRSVPSender) SendPasswordReset(context.Context, string, string, string) error { return nil } func (s *recordingRSVPSender) SendCollaboratorInvite(context.Context, string, string, string, string, string) error { return nil } func (s *recordingRSVPSender) SendRSVPEditLink(_ context.Context, _, _, _, link string) error { s.lastEditLink = link return nil } // TestForwardedInvitationLinkDefence covers the Block G follow-up: // - A guest submits their RSVP from device A. // - A different device opens the same invitation link. // - The /access response no longer surfaces the RSVP details. // - A PATCH from the foreign device is refused with 403. // - The original guest can request an edit link to their email, follow // the magic URL, and successfully edit from the new device. func TestForwardedInvitationLinkDefence(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") // Redis: miniredis is enough — the edit-nonce store only does // SET / GET with a TTL. No need for a real Redis container here. mr, err := miniredis.Run() must(t, err, "miniredis") t.Cleanup(mr.Close) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) // In-process stub fraud scorer so PATCH/POST score paths don't reach // for the Python service. We make it return LOW for every call. stub := startStubFraudGRPCLocal(t) fraudClient, err := fraud.Dial(ctx, stub.Addr, 2*time.Second, logger) must(t, err, "dial fraud") t.Cleanup(func() { _ = fraudClient.Close() }) // Capture-the-link email sender so the test can pull the magic // edit URL out of "the inbox" without standing up SMTP. emails := &recordingRSVPSender{} apiSrv, err := api.NewServer(api.ServerDeps{ Logger: logger, DB: db, FraudScorer: fraudClient, 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, EmailSender: emails, }) must(t, err, "build api server") srv := httptest.NewServer(apiSrv.Handler()) t.Cleanup(srv.Close) // Set up host, event, guest with email on file. host := insertHost(t, ctx, db.Pool) hostToken := issueHostToken(t, host) eventID := createEvent(t, srv.URL, hostToken, "Forwarded Link", "forwarded-link") guestEmail := fmt.Sprintf("guest+%d@guestguard.test", time.Now().UnixNano()) guestID := createGuestWithFields(t, srv.URL, hostToken, eventID, "Sam", guestEmail) token := issueToken(t, srv.URL, hostToken, eventID, guestID) const uaA = "Mozilla/5.0 (Macintosh) AppleWebKit/605 Browser-A" const uaB = "Mozilla/5.0 (X11; Linux) Firefox/120 Browser-B" // 1. Submit from device A. stub.SetNext(5, "low", nil) submitFromUA(t, srv.URL, token, uaA, "attending", 0, http.StatusCreated) // 2. Open the same invitation from device B — RSVP is hidden, // rsvp_submitted_elsewhere=true, can_request_edit_link=true. access := accessFromUA(t, srv.URL, token, uaB) if access.RSVP != nil { t.Fatalf("expected hidden rsvp on different device; got %+v", access.RSVP) } if !access.RSVPSubmittedElsewhere { t.Fatalf("expected rsvp_submitted_elsewhere=true; got false") } if !access.CanRequestEditLink { t.Fatalf("expected can_request_edit_link=true (email on file)") } // 3. Device A still sees its own RSVP (sanity). accessA := accessFromUA(t, srv.URL, token, uaA) if accessA.RSVP == nil { t.Fatalf("device A should still see its own RSVP") } // 4. PATCH from device B without a nonce — refused. patchFromUA(t, srv.URL, token, uaB, map[string]any{ "response": "declined", "plus_ones": 0, }, http.StatusForbidden) // 5. Request an edit link from device B. resp := postRequestEditLink(t, srv.URL, token, http.StatusAccepted) if resp.Channel != "email" { t.Errorf("expected channel=email, got %q", resp.Channel) } if emails.lastEditLink == "" { t.Fatal("expected email sender to have captured a link") } // Extract nonce from the magic URL the email contained. nonce := extractEditNonce(emails.lastEditLink) if nonce == "" { t.Fatalf("could not parse nonce from %q", emails.lastEditLink) } // 6. GET /access?edit= from device B unhides the RSVP. accessWithNonce := accessFromUAWithNonce(t, srv.URL, token, uaB, nonce) if accessWithNonce.RSVP == nil { t.Fatalf("nonce should have unhidden the RSVP") } // 7. PATCH from device B carrying the nonce — accepted. stub.SetNext(5, "low", nil) patchFromUAWithNonce(t, srv.URL, token, uaB, nonce, map[string]any{ "response": "declined", "plus_ones": 0, }, http.StatusOK) // 8. A third device (UA-C) with a made-up nonce stays refused. // At this point the stored fingerprint is UA-B (step 7 legitimately // updated it), so UA-C is again a stranger and the gate fires. const uaC = "Mozilla/5.0 (iPhone) Safari/17 Browser-C" patchFromUAWithNonce(t, srv.URL, token, uaC, "deadbeefdeadbeef", map[string]any{ "response": "attending", "plus_ones": 0, }, http.StatusForbidden) } // --- helpers (kept local to this file so we don't bloat the shared scaffold) --- func submitFromUA(t *testing.T, base, token, ua, response string, plusOnes int, want int) { t.Helper() body, _ := json.Marshal(map[string]any{ "response": response, "plus_ones": plusOnes, }) req, _ := http.NewRequest(http.MethodPost, base+"/rsvp/"+token, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", ua) resp, err := http.DefaultClient.Do(req) must(t, err, "submit") defer resp.Body.Close() if resp.StatusCode != want { b, _ := io.ReadAll(resp.Body) t.Fatalf("submit status=%d want=%d body=%s", resp.StatusCode, want, b) } } func patchFromUA(t *testing.T, base, token, ua string, body map[string]any, want int) { t.Helper() b, _ := json.Marshal(body) req, _ := http.NewRequest(http.MethodPatch, base+"/rsvp/"+token, bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", ua) resp, err := http.DefaultClient.Do(req) must(t, err, "patch") defer resp.Body.Close() if resp.StatusCode != want { body, _ := io.ReadAll(resp.Body) t.Fatalf("patch status=%d want=%d body=%s", resp.StatusCode, want, body) } } func patchFromUAWithNonce(t *testing.T, base, token, ua, nonce string, body map[string]any, want int) { t.Helper() body["edit_nonce"] = nonce patchFromUA(t, base, token, ua, body, want) } type accessLite struct { RSVP *struct{ Response string `json:"response"` } `json:"rsvp"` RSVPSubmittedElsewhere bool `json:"rsvp_submitted_elsewhere"` CanRequestEditLink bool `json:"can_request_edit_link"` } func accessFromUA(t *testing.T, base, token, ua string) accessLite { t.Helper() return accessFromUAWithNonce(t, base, token, ua, "") } func accessFromUAWithNonce(t *testing.T, base, token, ua, nonce string) accessLite { t.Helper() url := base + "/access/" + token if nonce != "" { url += "?edit=" + nonce } req, _ := http.NewRequest(http.MethodGet, url, nil) req.Header.Set("User-Agent", ua) resp, err := http.DefaultClient.Do(req) must(t, err, "access") defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) t.Fatalf("access status=%d body=%s", resp.StatusCode, b) } var out accessLite must(t, json.NewDecoder(resp.Body).Decode(&out), "decode access") return out } type editLinkResp struct { Channel string `json:"channel"` } func postRequestEditLink(t *testing.T, base, token string, want int) editLinkResp { t.Helper() req, _ := http.NewRequest(http.MethodPost, base+"/access/"+token+"/request-edit-link", nil) resp, err := http.DefaultClient.Do(req) must(t, err, "request edit link") defer resp.Body.Close() if resp.StatusCode != want { b, _ := io.ReadAll(resp.Body) t.Fatalf("request-edit-link status=%d want=%d body=%s", resp.StatusCode, want, b) } var out editLinkResp if resp.ContentLength != 0 { _ = json.NewDecoder(resp.Body).Decode(&out) } return out } func extractEditNonce(link string) string { i := strings.Index(link, "edit=") if i < 0 { return "" } rest := link[i+len("edit="):] // Strip any trailing fragment / extra query separators. if j := strings.IndexAny(rest, "&#"); j > 0 { return rest[:j] } return rest } // createGuestWithFields lets us pass an email so the request-edit-link // path has somewhere to deliver to. The shared createGuest helper doesn't // accept extras. func createGuestWithFields(t *testing.T, base, hostToken string, eventID uuid.UUID, name, email string) uuid.UUID { t.Helper() var out struct{ ID uuid.UUID `json:"id"` } postJSONAuthed(t, fmt.Sprintf("%s/events/%s/guests", base, eventID), hostToken, map[string]any{"name": name, "email": email}, http.StatusCreated, &out) return out.ID } // startStubFraudGRPCLocal is a copy of the e2e_test scaffold's stub // scorer. Replicated here so this file is self-contained when run on // its own (go test -run TestForwarded...). func startStubFraudGRPCLocal(t *testing.T) *stubFraud { t.Helper() lis, err := net.Listen("tcp", "127.0.0.1:0") must(t, err, "listen for stub fraud") s := &stubFraud{Addr: lis.Addr().String()} s.risk.Store("low") s.reasons.Store([]string(nil)) s.server = grpc.NewServer() pb.RegisterFraudServiceServer(s.server, s) go func() { _ = s.server.Serve(lis) }() t.Cleanup(s.server.Stop) return s } // satisfy the EmailSender interface check at compile time. var _ auth.EmailSender = (*recordingRSVPSender)(nil)