fix(rsvp): defend the edit flow against forwarded invitation links

When a guest submitted via their invitation and then forwarded the link
(or someone copied the URL), the recipient was shown the original guest's
response and a "Change my response" button. Two real problems:
  - Privacy leak: the original guest's reply was visible
  - Integrity: the recipient could silently overwrite the response

The fix is two layered defences plus a recovery path, matching the
industry pattern used by Eventbrite / Partiful / Lu.ma:

Backend
  - GET /access/{token} now compares the device fingerprint of the
    current request to the fingerprint stored on the existing RSVP.
    When they don't match, the rsvp field is omitted from the
    response and a new rsvp_submitted_elsewhere flag is set instead.
    The original guest's reply stays private.
  - PATCH /rsvp/{token} runs the same gate before scoring. A foreign
    device gets 403 with a hint to request an edit link.
  - The fingerprint check is intentionally narrow (user_agent only),
    so a guest jumping between Wi-Fi and mobile data on the same
    phone still sails through.

Recovery path
  - New POST /access/{token}/request-edit-link mints a short-lived
    edit nonce (Redis, 30-min TTL, SHA-256-hashed), then emails it
    to the guest's address on file via the existing EmailSender.
    Rate-limited to 3 per token per hour.
  - GET /access/{token}?edit=<nonce> and PATCH /rsvp with edit_nonce
    in the body both accept the nonce as a bypass for the
    same-device check. Lets the real guest edit from a new phone
    when their original device is gone.
  - New SendRSVPEditLink method on auth.EmailSender, implemented by
    every concrete sender (log stub / Resend / SMTP / SES), with a
    branded HTML+text template that explains the "we sent this
    because we didn't recognise the device" framing.

Frontend
  - rsvp/[token].vue learns the new "responded elsewhere" state.
    Renders "This invitation has already been used" + a
    "Send me an edit link" CTA when the access response says we
    have somewhere to deliver it. Empty-state copy reads "If you
    forwarded the link, please ask the original guest to reach
    out to the host".
  - When the URL carries ?edit=<nonce>, the page passes it on the
    /access call (so the backend unhides the RSVP), opens the edit
    form pre-populated, and forwards the nonce on PATCH.
  - Removed two leftover leaks from earlier — the page no longer
    shows internal "Risk score N · band" to confirmed or blocked
    guests; the blocked-attempt copy now reads "Something about
    this attempt looked off" rather than "suspicious access
    attempt".

Defensive nil-guard
  - The access handler's NATS publish goroutine now skips when
    deps.AccessPublisher is nil (matches the rsvp publisher's
    existing guard); without it the handler nil-panicked in tests
    that don't wire NATS.

Tests
  - TestFingerprintsSimilar (unit) covers the same-UA / different-UA
    / missing-UA matrix.
  - TestForwardedInvitationLinkDefence (integration) walks the full
    flow: submit from UA-A, hide on UA-B, request link, follow nonce
    from UA-B and edit, then verify a UA-C with a forged nonce is
    still refused.
  - Full integration suite passes (183.5s).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-20 15:09:07 +01:00
parent b34715f152
commit dbddf17e3b
16 changed files with 893 additions and 25 deletions
+102 -1
View File
@@ -30,6 +30,11 @@ interface AccessResponse {
token: { id: string; status: string; expires_at: string } token: { id: string; status: string; expires_at: string }
access_log_id: string access_log_id: string
rsvp?: ExistingRSVP | null rsvp?: ExistingRSVP | null
// Forwarded-link defence (Block G follow-up). When the Gate didn't
// recognise the device, the existing RSVP is hidden from the
// response and these flags drive the alternative UX.
rsvp_submitted_elsewhere?: boolean
can_request_edit_link?: boolean
calendar?: CalendarLinks calendar?: CalendarLinks
branding?: BrandingPayload | null branding?: BrandingPayload | null
} }
@@ -63,6 +68,25 @@ const submitError = ref<string | null>(null)
const existing = ref<ExistingRSVP | null>(null) const existing = ref<ExistingRSVP | null>(null)
const editing = ref(false) const editing = ref(false)
// Forwarded-link defence state.
// When the access response sets rsvp_submitted_elsewhere=true, an RSVP
// exists but the Gate didn't recognise this device, so we don't surface
// the response details. The original guest (if it really is them on a
// new phone) can request a one-time edit link to their email.
const respondedElsewhere = ref(false)
const canRequestEditLink = ref(false)
const requestingEditLink = ref(false)
const editLinkSent = ref(false)
const editLinkError = ref<string | null>(null)
// editNonce comes from the magic edit link the guest receives by email
// (?edit=<nonce> in the URL). When present we pass it on /access and
// later on PATCH so the backend treats this device as the original.
const editNonce = computed(() => {
const q = route.query.edit
return typeof q === 'string' ? q : ''
})
const editsRemaining = computed(() => { const editsRemaining = computed(() => {
const used = existing.value?.edit_count ?? 0 const used = existing.value?.edit_count ?? 0
return Math.max(0, MAX_EDITS - used) return Math.max(0, MAX_EDITS - used)
@@ -77,12 +101,23 @@ function prefillFromRSVP(rsvp: ExistingRSVP) {
onMounted(async () => { onMounted(async () => {
try { try {
access.value = await useApi<AccessResponse>(`/access/${token}`) // Pass through the edit nonce if the guest arrived via the magic
// edit link. The backend uses it to unhide the existing RSVP.
const path = editNonce.value
? `/access/${token}?edit=${encodeURIComponent(editNonce.value)}`
: `/access/${token}`
access.value = await useApi<AccessResponse>(path)
if (!access.value) return if (!access.value) return
plusOnes.value = access.value.guest.plus_ones || 0 plusOnes.value = access.value.guest.plus_ones || 0
if (access.value.rsvp) { if (access.value.rsvp) {
existing.value = access.value.rsvp existing.value = access.value.rsvp
prefillFromRSVP(access.value.rsvp) prefillFromRSVP(access.value.rsvp)
// Magic-link arrival: open the form straight into edit mode so
// the guest doesn't have to click "Change my response" first.
if (editNonce.value) editing.value = true
} else if (access.value.rsvp_submitted_elsewhere) {
respondedElsewhere.value = true
canRequestEditLink.value = !!access.value.can_request_edit_link
} }
} catch (e: any) { } catch (e: any) {
loadError.value = e?.data?.error || e?.message || 'Invitation not found' loadError.value = e?.data?.error || e?.message || 'Invitation not found'
@@ -91,6 +126,19 @@ onMounted(async () => {
} }
}) })
async function requestEditLink() {
requestingEditLink.value = true
editLinkError.value = null
try {
await useApi(`/access/${token}/request-edit-link`, { method: 'POST' })
editLinkSent.value = true
} catch (e: any) {
editLinkError.value = e?.data?.error || e?.message || 'Could not send the edit link'
} finally {
requestingEditLink.value = false
}
}
async function submit() { async function submit() {
submitting.value = true submitting.value = true
submitError.value = null submitError.value = null
@@ -104,6 +152,11 @@ async function submit() {
plus_ones: plusOnes.value, plus_ones: plusOnes.value,
dietary_notes: dietary.value || null, dietary_notes: dietary.value || null,
fingerprint: fp, fingerprint: fp,
// Forwarded-link defence: pass the edit nonce on PATCH so the
// backend can bypass the same-device check when the guest is
// legitimately on a new device. Empty on POST and on
// same-device edits.
...(editNonce.value ? { edit_nonce: editNonce.value } : {}),
}, },
}) })
// Refresh the cached "existing" view so a back-to-summary toggle shows // Refresh the cached "existing" view so a back-to-summary toggle shows
@@ -195,6 +248,54 @@ const submitLabel = computed(() => {
</p> </p>
</div> </div>
<!-- Forwarded-link landing. There's an RSVP on file for this invitation,
but we don't recognise this device, so we don't show the response
or let it be changed here. If we have the original guest's email
on file, they can request a one-time edit link to recover. -->
<div v-else-if="respondedElsewhere" class="card">
<h1 class="mb-2 text-xl font-semibold">This invitation has already been used</h1>
<p class="mb-4 text-sm text-zinc-300">
Someone has already replied with this invitation, so the response
is private. If you forwarded the link, please ask the original guest
to reach out to the host.
</p>
<div v-if="canRequestEditLink && !editLinkSent" class="rounded-md border border-brand-900/40 bg-brand-500/[0.04] p-4">
<p class="mb-3 text-sm text-zinc-200">
<strong>Is this your invitation, just on a new device?</strong>
We can send a one-time edit link to the email we have on file
so you can review and update your reply.
</p>
<button
type="button"
class="btn-primary text-sm"
:disabled="requestingEditLink"
@click="requestEditLink"
>
{{ requestingEditLink ? 'Sending' : 'Send me an edit link' }}
</button>
<p v-if="editLinkError" class="mt-3 text-sm text-red-400">{{ editLinkError }}</p>
<p class="mt-3 text-xs text-zinc-500">
The link expires in 30 minutes. If you don't see the email shortly,
check your spam folder or ask your host to resend your invitation.
</p>
</div>
<div v-else-if="editLinkSent" class="rounded-md border border-brand-900/40 bg-brand-500/[0.04] p-4">
<p class="text-sm text-brand-200">
<strong>Edit link on its way.</strong>
</p>
<p class="mt-2 text-sm text-zinc-300">
Check your inbox for an email from us. The link expires in 30 minutes,
so do click it as soon as you can.
</p>
</div>
<p v-else class="text-xs text-zinc-500">
If you think you're the original guest, please contact your host for help.
</p>
</div>
<div v-else-if="result?.rsvp && !editing" class="card border-brand-900/60 bg-brand-950/20"> <div v-else-if="result?.rsvp && !editing" class="card border-brand-900/60 bg-brand-950/20">
<h1 class="mb-2 text-xl font-semibold text-brand-200"> <h1 class="mb-2 text-xl font-semibold text-brand-200">
{{ result.edited ? 'Your response has been updated' : "You're confirmed" }} {{ result.edited ? 'Your response has been updated' : "You're confirmed" }}
+111
View File
@@ -0,0 +1,111 @@
package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
)
// RSVP-edit nonce store. When a guest revisits their invitation from a
// device that doesn't match the one they originally responded from, we
// hide their RSVP details from the access response so a forwarded link
// can't leak (or alter) their reply. They can request a one-time edit
// link sent to their email/SMS; that link carries a short-lived nonce
// stored here.
//
// We store sha256(raw) so a database snapshot of Redis doesn't give an
// attacker working nonces; the raw value only ever lives in the email
// link the guest receives.
const (
editNonceTTL = 30 * time.Minute
editNonceKeyPrefix = "gg:rsvp_edit:"
)
type editNonceStore struct {
rdb *redis.Client
}
// newEditNonceStore returns nil when Redis isn't configured — the calling
// handler treats nil as "edit-link flow disabled" and falls back to the
// strict same-device rule.
func newEditNonceStore(rdb *redis.Client) *editNonceStore {
if rdb == nil {
return nil
}
return &editNonceStore{rdb: rdb}
}
// Mint creates a fresh nonce bound to guestID, stores its hash with a
// 30-minute TTL, and returns the raw value for embedding in the email
// link. Hex-encoded so it survives URL transit without further escaping.
func (s *editNonceStore) Mint(ctx context.Context, guestID uuid.UUID) (raw string, err error) {
if s == nil {
return "", errors.New("edit nonce store not configured")
}
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", err
}
raw = hex.EncodeToString(buf)
sum := sha256.Sum256([]byte(raw))
key := editNonceKeyPrefix + hex.EncodeToString(sum[:])
if err := s.rdb.Set(ctx, key, guestID.String(), editNonceTTL).Err(); err != nil {
return "", err
}
return raw, nil
}
// Verify reports whether raw is a still-valid nonce for guestID.
// Doesn't consume the nonce; expiry happens naturally via TTL so the
// guest can refresh the edit page or briefly close + reopen the link
// during the half-hour window without losing their session.
func (s *editNonceStore) Verify(ctx context.Context, raw string, guestID uuid.UUID) (bool, error) {
if s == nil || raw == "" {
return false, nil
}
sum := sha256.Sum256([]byte(raw))
key := editNonceKeyPrefix + hex.EncodeToString(sum[:])
val, err := s.rdb.Get(ctx, key).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return false, nil
}
return false, err
}
return val == guestID.String(), nil
}
// fingerprintsSimilar reports whether two device fingerprints look like
// the same browser. The check is intentionally narrow: just `user_agent`,
// because it's the strongest signal of "same browser" and the one least
// affected by normal day-to-day variation (changing networks, switching
// rooms, etc).
//
// Conservative-by-default: when either side lacks a user_agent string we
// return false, which causes the access handler to hide the RSVP. The
// legitimate guest in that edge case can still recover via the
// request-edit-link flow.
//
// Two fingerprints with the same user_agent but different IPs are
// considered the same device. This is on purpose: a guest jumping
// between Wi-Fi and mobile data is the same person on the same phone.
// The Gate's full scoring (run on submit/edit, not on access) catches
// the more sophisticated mismatches.
func fingerprintsSimilar(stored, current map[string]any) bool {
if stored == nil || current == nil {
return false
}
s, _ := stored["user_agent"].(string)
c, _ := current["user_agent"].(string)
if s == "" || c == "" {
return false
}
return s == c
}
+52
View File
@@ -0,0 +1,52 @@
package api
import "testing"
func TestFingerprintsSimilar(t *testing.T) {
const ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605"
other := "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537"
tests := []struct {
name string
stored, current map[string]any
want bool
}{
{
name: "identical UA, different IPs counts as same device",
stored: map[string]any{"user_agent": ua, "accept_language": "en"},
current: map[string]any{"user_agent": ua, "accept_language": "fr"},
want: true,
},
{
name: "different UA never matches",
stored: map[string]any{"user_agent": ua},
current: map[string]any{"user_agent": other},
want: false,
},
{
name: "missing UA on current side is treated as mismatch",
stored: map[string]any{"user_agent": ua},
current: map[string]any{},
want: false,
},
{
name: "missing UA on stored side is treated as mismatch",
stored: map[string]any{},
current: map[string]any{"user_agent": ua},
want: false,
},
{
name: "both nil is mismatch (conservative default)",
stored: nil,
current: nil,
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := fingerprintsSimilar(tc.stored, tc.current); got != tc.want {
t.Errorf("fingerprintsSimilar = %v, want %v", got, tc.want)
}
})
}
}
+38
View File
@@ -34,6 +34,7 @@ type rsvpHandler struct {
rsvps *storage.RSVPRepo rsvps *storage.RSVPRepo
accessLogs *storage.AccessLogRepo accessLogs *storage.AccessLogRepo
allowlist *storage.AllowlistRepo allowlist *storage.AllowlistRepo
editNonces *editNonceStore
scorer fraudScorer scorer fraudScorer
pub rsvpPublisher pub rsvpPublisher
} }
@@ -43,6 +44,12 @@ type submitRSVPRequest struct {
PlusOnes int `json:"plus_ones"` PlusOnes int `json:"plus_ones"`
DietaryNotes *string `json:"dietary_notes"` DietaryNotes *string `json:"dietary_notes"`
Fingerprint map[string]any `json:"fingerprint"` Fingerprint map[string]any `json:"fingerprint"`
// EditNonce is the short-lived value carried back from the magic
// edit link the guest received by email. It bypasses the
// same-device check on PATCH so a guest who jumped to a new phone
// can still edit their RSVP. Empty on POST and on edits from the
// original device.
EditNonce string `json:"edit_nonce,omitempty"`
} }
type submitRSVPResponse struct { type submitRSVPResponse struct {
@@ -117,6 +124,13 @@ func (h *rsvpHandler) submit(w http.ResponseWriter, r *http.Request) {
// PATCH /rsvp/{token} — revise an existing RSVP. Same fraud check as POST. // PATCH /rsvp/{token} — revise an existing RSVP. Same fraud check as POST.
// The prior state is snapshotted into rsvp_revisions inside Update; edits // The prior state is snapshotted into rsvp_revisions inside Update; edits
// past MaxRSVPEdits return 429. // past MaxRSVPEdits return 429.
//
// As of the forwarded-link defence: a PATCH is only accepted when either
// (a) the current device fingerprint matches the one stored on the
// existing RSVP, or (b) the caller presented a valid edit nonce in the
// request body. Otherwise we refuse with 403 + a hint to request an edit
// link — this stops anyone the original guest forwarded the link to from
// quietly altering the response.
func (h *rsvpHandler) edit(w http.ResponseWriter, r *http.Request) { func (h *rsvpHandler) edit(w http.ResponseWriter, r *http.Request) {
tk, ok := h.loadValidToken(w, r) tk, ok := h.loadValidToken(w, r)
if !ok { if !ok {
@@ -136,6 +150,28 @@ func (h *rsvpHandler) edit(w http.ResponseWriter, r *http.Request) {
return return
} }
// Forwarded-link gate. Load the existing RSVP (if any) so we can
// compare device fingerprints; absent RSVP means this is a quirky
// PATCH-before-POST flow that Update will 404 below anyway.
if existing, err := h.rsvps.GetByGuest(r.Context(), guest.ID); err == nil && existing != nil {
current := mergeFingerprint(req.Fingerprint, collectFingerprint(r))
if !fingerprintsSimilar(existing.DeviceFingerprint, current) {
// Bypass via edit nonce delivered to the guest's email.
bypassed := false
if h.editNonces != nil && req.EditNonce != "" {
if ok, _ := h.editNonces.Verify(r.Context(), req.EditNonce, guest.ID); ok {
bypassed = true
}
}
if !bypassed {
writeError(w, http.StatusForbidden,
"this invitation looks like it's being opened from a different device; "+
"please request an edit link from the previous screen")
return
}
}
}
decision, fingerprint, ip, ok := h.scoreAccess(w, r, event, guest, tk, req.Fingerprint) decision, fingerprint, ip, ok := h.scoreAccess(w, r, event, guest, tk, req.Fingerprint)
if !ok { if !ok {
return return
@@ -238,6 +274,7 @@ type decodedRSVPRequest struct {
PlusOnes int PlusOnes int
DietaryNotes *string DietaryNotes *string
Fingerprint map[string]any Fingerprint map[string]any
EditNonce string
} }
func decodeSubmitRSVP(w http.ResponseWriter, r *http.Request) (decodedRSVPRequest, bool) { func decodeSubmitRSVP(w http.ResponseWriter, r *http.Request) (decodedRSVPRequest, bool) {
@@ -260,6 +297,7 @@ func decodeSubmitRSVP(w http.ResponseWriter, r *http.Request) (decodedRSVPReques
PlusOnes: req.PlusOnes, PlusOnes: req.PlusOnes,
DietaryNotes: req.DietaryNotes, DietaryNotes: req.DietaryNotes,
Fingerprint: req.Fingerprint, Fingerprint: req.Fingerprint,
EditNonce: req.EditNonce,
}, true }, true
} }
+11
View File
@@ -102,6 +102,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
analyticsRepo := storage.NewAnalyticsRepo(deps.DB) analyticsRepo := storage.NewAnalyticsRepo(deps.DB)
brandingRepo := storage.NewBrandingRepo(deps.DB) brandingRepo := storage.NewBrandingRepo(deps.DB)
allowlistRepo := storage.NewAllowlistRepo(deps.DB) allowlistRepo := storage.NewAllowlistRepo(deps.DB)
editNonces := newEditNonceStore(deps.Redis)
feedbackRepo := storage.NewFeedbackRepo(deps.DB) feedbackRepo := storage.NewFeedbackRepo(deps.DB)
// Branding image store. Empty UploadsDir leaves it nil and the upload // Branding image store. Empty UploadsDir leaves it nil and the upload
@@ -194,6 +195,8 @@ func NewServer(deps ServerDeps) (*Server, error) {
rsvps: rsvpRepo, rsvps: rsvpRepo,
collabs: collabRepo, collabs: collabRepo,
branding: brandingRepo, branding: brandingRepo,
editNonces: editNonces,
emails: emails,
gen: auth.NewGenerator(), gen: auth.NewGenerator(),
ttl: deps.TokenTTL, ttl: deps.TokenTTL,
pub: deps.AccessPublisher, pub: deps.AccessPublisher,
@@ -208,6 +211,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
rsvps: rsvpRepo, rsvps: rsvpRepo,
accessLogs: accessRepo, accessLogs: accessRepo,
allowlist: allowlistRepo, allowlist: allowlistRepo,
editNonces: editNonces,
scorer: deps.FraudScorer, scorer: deps.FraudScorer,
pub: deps.RSVPPublisher, pub: deps.RSVPPublisher,
}, },
@@ -443,6 +447,13 @@ func (s *Server) Handler() http.Handler {
// Block B: .ics download. Same rate-limit class as /access since the // Block B: .ics download. Same rate-limit class as /access since the
// payload is similarly cheap and the abuse profile is identical (one // payload is similarly cheap and the abuse profile is identical (one
// token per attacker). // token per attacker).
// Forwarded-link defence: when a guest opens their invitation from a
// device the original RSVP wasn't submitted from, /access hides the
// reply. This endpoint mints a short-lived edit nonce and emails it
// so the real guest can recover from a new phone / new laptop.
mux.Handle("POST /access/{token}/request-edit-link",
rl("rsvp_edit_request", 3, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.requestEditLink)))
mux.Handle("GET /access/{token}/calendar.ics", mux.Handle("GET /access/{token}/calendar.ics",
rl("calendar_ics", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.calendar))) rl("calendar_ics", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.calendar)))
mux.Handle("POST /rsvp/{token}", mux.Handle("POST /rsvp/{token}",
+143 -1
View File
@@ -36,6 +36,8 @@ type tokenHandler struct {
rsvps *storage.RSVPRepo rsvps *storage.RSVPRepo
collabs *storage.CollaboratorRepo collabs *storage.CollaboratorRepo
branding *storage.BrandingRepo branding *storage.BrandingRepo
editNonces *editNonceStore
emails auth.EmailSender
gen *auth.Generator gen *auth.Generator
ttl time.Duration ttl time.Duration
pub accessPublisher pub accessPublisher
@@ -412,7 +414,22 @@ type accessResponse struct {
// RSVP is the guest's current submission, if any. Populated so the RSVP // RSVP is the guest's current submission, if any. Populated so the RSVP
// page can show an edit form instead of a fresh submit form when the // page can show an edit form instead of a fresh submit form when the
// guest revisits their invitation link (Tier 2 Block A). // guest revisits their invitation link (Tier 2 Block A).
//
// As of the forwarded-link defence (Tier 2 Block G follow-up): this is
// only populated when the current device looks like the device that
// originally submitted, OR when the caller presented a valid edit nonce.
// A forwarded-link recipient sees a nil RSVP + RSVPSubmittedElsewhere=true
// instead, so the original guest's response stays private and unmodifiable.
RSVP *domain.RSVP `json:"rsvp,omitempty"` RSVP *domain.RSVP `json:"rsvp,omitempty"`
// RSVPSubmittedElsewhere signals "there's an RSVP on file but we're
// hiding it because this looks like a different device". The frontend
// renders a "this invitation has already been responded to" view +
// (when CanRequestEditLink) a "send me an edit link" CTA.
RSVPSubmittedElsewhere bool `json:"rsvp_submitted_elsewhere,omitempty"`
// CanRequestEditLink reports whether we have a way to deliver an edit
// link to the guest (email or phone on file). When false the only
// path is for the guest to contact the host directly.
CanRequestEditLink bool `json:"can_request_edit_link,omitempty"`
// Calendar holds the add-to-calendar deep-links and the .ics download // Calendar holds the add-to-calendar deep-links and the .ics download
// path so the frontend renders four ready-to-click buttons after a // path so the frontend renders four ready-to-click buttons after a
// successful RSVP. (Tier 2 Block B.) // successful RSVP. (Tier 2 Block B.)
@@ -469,6 +486,7 @@ func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) {
h.logger.Error("create access log", "err", err) h.logger.Error("create access log", "err", err)
} }
if h.pub != nil {
go func(evt natspub.AccessAttempted) { go func(evt natspub.AccessAttempted) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -486,6 +504,7 @@ func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) {
Referrer: r.Referer(), Referrer: r.Referer(),
OccurredAt: time.Now().UTC(), OccurredAt: time.Now().UTC(),
}) })
}
var brandingPayload *domain.Branding var brandingPayload *domain.Branding
if h.branding != nil { if h.branding != nil {
@@ -518,17 +537,140 @@ func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) {
} }
} }
// Forwarded-link defence (Tier 2 Block G follow-up). If a previous
// submission exists, only surface its details when the current
// device looks like the one that submitted, OR when the caller
// presented a valid edit nonce in ?edit=<nonce>. Anything else gets
// the "responded elsewhere" view — no leak, no edit.
rsvpPayload := existingRSVP
var rsvpSubmittedElsewhere, canRequestEditLink bool
if existingRSVP != nil && !fingerprintsSimilar(existingRSVP.DeviceFingerprint, fingerprint) {
bypassed := false
if nonce := r.URL.Query().Get("edit"); nonce != "" && h.editNonces != nil {
if ok, _ := h.editNonces.Verify(r.Context(), nonce, guest.ID); ok {
bypassed = true
}
}
if !bypassed {
rsvpPayload = nil
rsvpSubmittedElsewhere = true
canRequestEditLink =
h.editNonces != nil &&
((guest.Email != nil && *guest.Email != "") ||
(guest.Phone != nil && *guest.Phone != ""))
}
}
writeJSON(w, http.StatusOK, accessResponse{ writeJSON(w, http.StatusOK, accessResponse{
Guest: guest, Guest: guest,
Event: event, Event: event,
Token: tk, Token: tk,
AccessLog: accessLogID, AccessLog: accessLogID,
RSVP: existingRSVP, RSVP: rsvpPayload,
RSVPSubmittedElsewhere: rsvpSubmittedElsewhere,
CanRequestEditLink: canRequestEditLink,
Calendar: h.calendarLinks(event, raw), Calendar: h.calendarLinks(event, raw),
Branding: brandingPayload, Branding: brandingPayload,
}) })
} }
// requestEditLinkResponse is the wire shape of POST /access/{token}/request-edit-link.
type requestEditLinkResponse struct {
// Channel hints at where the link went so the frontend can render
// "Sent to your email" vs. "Sent by SMS" feedback. Empty when the
// store/sender wasn't configured (dev environments without email
// wired up — the frontend should still treat that as success).
Channel string `json:"channel,omitempty"`
}
// POST /access/{token}/request-edit-link — public, token-scoped. When a
// guest opens their invitation from an unfamiliar device the regular
// access response hides their RSVP. This endpoint lets them prove email
// or phone ownership instead: we mint a short-lived edit nonce and
// deliver it to the address on file.
//
// Rate limit lives on the route registration (3 per hour per token).
// The endpoint itself stays generous about the response — we never
// reveal whether a token has an RSVP attached, just whether the request
// itself was acceptable.
func (h *tokenHandler) requestEditLink(w http.ResponseWriter, r *http.Request) {
raw := r.PathValue("token")
if err := auth.ValidateFormat(raw); err != nil {
writeError(w, http.StatusBadRequest, "malformed token")
return
}
tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw))
if err != nil {
if errors.Is(err, domain.ErrTokenNotFound) {
writeError(w, http.StatusNotFound, "token not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load token")
return
}
if err := tk.IsValid(time.Now().UTC()); err != nil {
writeError(w, http.StatusGone, err.Error())
return
}
guest, err := h.guests.Get(r.Context(), tk.GuestID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load guest")
return
}
if h.editNonces == nil {
// Redis isn't wired up; the feature is disabled. Tell the caller
// honestly rather than pretending we sent something.
writeError(w, http.StatusServiceUnavailable, "edit-link delivery isn't configured for this environment")
return
}
// Only attempt delivery when there's somewhere to deliver to. Without
// email or phone on file the request is a 404 — we don't have a
// secure channel for the nonce.
hasEmail := guest.Email != nil && *guest.Email != ""
if !hasEmail {
// Phone-only delivery is a future enhancement (Twilio path is
// wired for the broader notifier; not for synchronous edit links
// yet). For now treat as no-channel.
writeError(w, http.StatusNotFound, "no email on file for this guest")
return
}
nonce, err := h.editNonces.Mint(r.Context(), guest.ID)
if err != nil {
h.logger.Error("mint edit nonce", "err", err, "guest_id", guest.ID)
writeError(w, http.StatusInternalServerError, "failed to issue edit link")
return
}
// Resolve the event name for the email. Best-effort: if the lookup
// fails we still send a link, just with a generic fallback.
eventName := "your event"
if event, err := h.events.Get(r.Context(), guest.EventID); err == nil {
eventName = event.Name
}
link := h.editLink(raw, nonce)
if h.emails != nil {
if err := h.emails.SendRSVPEditLink(r.Context(), *guest.Email, guest.Name, eventName, link); err != nil {
h.logger.Warn("send rsvp edit link", "err", err, "guest_id", guest.ID)
// Don't 500 — the nonce already exists in Redis, and we've
// logged the link in the dev stub. A 202-ish behaviour:
// "accepted; delivery might be best-effort".
}
}
writeJSON(w, http.StatusAccepted, requestEditLinkResponse{Channel: "email"})
}
func (h *tokenHandler) editLink(rawToken, nonce string) string {
base := h.publicBaseURL
if base == "" {
base = "http://localhost:3000"
}
return base + "/rsvp/" + rawToken + "?edit=" + nonce
}
// calendarLinks renders the three provider URLs + .ics path for the event. // calendarLinks renders the three provider URLs + .ics path for the event.
// The raw access token is embedded in the .ics path so the download endpoint // The raw access token is embedded in the .ics path so the download endpoint
// stays public (no auth) while still scoped to a single invitation. // stays public (no auth) while still scoped to a single invitation.
+14
View File
@@ -13,6 +13,13 @@ type EmailSender interface {
SendVerification(ctx context.Context, to, name, link string) error SendVerification(ctx context.Context, to, name, link string) error
SendPasswordReset(ctx context.Context, to, name, link string) error SendPasswordReset(ctx context.Context, to, name, link string) error
SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error
// SendRSVPEditLink delivers a one-time edit link to a guest who's
// reopened their invitation from a device the Gate didn't recognise.
// Tier 2 Block G follow-up. The link carries a short-lived nonce
// (~30 min) that lets the guest see + change their existing RSVP
// without exposing it to anyone who got hold of the original
// invitation token.
SendRSVPEditLink(ctx context.Context, to, guestName, eventName, link string) error
} }
type LogEmailSender struct { type LogEmailSender struct {
@@ -39,3 +46,10 @@ func (l LogEmailSender) SendCollaboratorInvite(_ context.Context, to, inviterNam
) )
return nil return nil
} }
func (l LogEmailSender) SendRSVPEditLink(_ context.Context, to, guestName, eventName, link string) error {
l.Logger.Info("auth email (stub): rsvp edit link",
"to", to, "guest", guestName, "event", eventName, "link", link,
)
return nil
}
+12
View File
@@ -102,6 +102,18 @@ func (s *SESEmailSender) SendCollaboratorInvite(ctx context.Context, to, inviter
}) })
} }
// SendRSVPEditLink delivers a one-time edit link to a guest who opened
// their invitation from a device the Gate didn't recognise.
func (s *SESEmailSender) SendRSVPEditLink(ctx context.Context, to, guestName, eventName, link string) error {
return s.sendTemplated(ctx, to,
"Edit your RSVP for "+eventName,
TmplRSVPEditLink, map[string]any{
"GuestName": guestName,
"EventName": eventName,
"Link": link,
})
}
// SendGuest is used by the notifier worker for invitation / confirmation / // SendGuest is used by the notifier worker for invitation / confirmation /
// reminder emails — anything addressed at a guest. // reminder emails — anything addressed at a guest.
func (s *SESEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (providerMessageID string, err error) { func (s *SESEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (providerMessageID string, err error) {
+7
View File
@@ -34,6 +34,7 @@ type CombinedEmailSender interface {
SendVerification(ctx context.Context, to, name, link string) error SendVerification(ctx context.Context, to, name, link string) error
SendPasswordReset(ctx context.Context, to, name, link string) error SendPasswordReset(ctx context.Context, to, name, link string) error
SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error
SendRSVPEditLink(ctx context.Context, to, guestName, eventName, link string) error
SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error)
} }
@@ -88,6 +89,12 @@ func (l *logCombinedSender) SendCollaboratorInvite(_ context.Context, to, invite
return nil return nil
} }
func (l *logCombinedSender) SendRSVPEditLink(_ context.Context, to, guestName, eventName, link string) error {
l.logger.Info("auth email (stub): rsvp edit link",
"to", to, "guest", guestName, "event", eventName, "link", link)
return nil
}
func (l *logCombinedSender) SendGuest(_ context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) { func (l *logCombinedSender) SendGuest(_ context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
if data == nil { if data == nil {
data = map[string]any{} data = map[string]any{}
+11
View File
@@ -83,6 +83,17 @@ func (s *ResendEmailSender) SendCollaboratorInvite(ctx context.Context, to, invi
return err return err
} }
func (s *ResendEmailSender) SendRSVPEditLink(ctx context.Context, to, guestName, eventName, link string) error {
_, err := s.sendTemplated(ctx, to,
"Edit your RSVP for "+eventName,
TmplRSVPEditLink, map[string]any{
"GuestName": guestName,
"EventName": eventName,
"Link": link,
})
return err
}
// --- GuestEmailDispatcher --- // --- GuestEmailDispatcher ---
func (s *ResendEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) { func (s *ResendEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
+10
View File
@@ -83,6 +83,16 @@ func (s *SMTPEmailSender) SendCollaboratorInvite(ctx context.Context, to, invite
}) })
} }
func (s *SMTPEmailSender) SendRSVPEditLink(ctx context.Context, to, guestName, eventName, link string) error {
return s.sendTemplated(ctx, to,
"Edit your RSVP for "+eventName,
TmplRSVPEditLink, map[string]any{
"GuestName": guestName,
"EventName": eventName,
"Link": link,
})
}
// --- GuestEmailDispatcher --- // --- GuestEmailDispatcher ---
func (s *SMTPEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) { func (s *SMTPEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
+1
View File
@@ -25,6 +25,7 @@ const (
TmplConfirmation TemplateName = "confirmation" TmplConfirmation TemplateName = "confirmation"
TmplReminder TemplateName = "reminder" TmplReminder TemplateName = "reminder"
TmplCollaboratorInvite TemplateName = "collaborator_invite" TmplCollaboratorInvite TemplateName = "collaborator_invite"
TmplRSVPEditLink TemplateName = "rsvp_edit_link"
) )
// Templates renders branded transactional emails for both HTML and // Templates renders branded transactional emails for both HTML and
@@ -0,0 +1,19 @@
{{define "body"}}
<p style="font-size:13px;letter-spacing:0.2em;text-transform:uppercase;color:#16a34a;margin:0 0 16px;">✦ Edit your RSVP</p>
<h1 style="font-size:24px;margin:0 0 4px;color:#0a0a0a;">{{.EventName}}</h1>
<p style="margin:18px 0 18px;">
Hi {{.GuestName}}, you opened your invitation from a device we hadn't seen
before, so we've sent this link to make sure it's really you.
</p>
<p style="margin:0 0 12px;">
Use the button below to view and update your RSVP.
</p>
<p style="margin:0 0 24px;text-align:center;">
<a href="{{.Link}}" style="background:#22c55e;color:#0a0a0a;padding:12px 22px;border-radius:8px;font-weight:600;text-decoration:none;display:inline-block;">Edit my RSVP</a>
</p>
<p style="margin:0 0 8px;color:#64748b;font-size:13px;">
This link expires in 30 minutes. If you didn't request it, you can ignore this
email — your existing reply stays the way you left it.
</p>
<p style="margin:8px 0 0;word-break:break-all;font-size:12px;color:#0f172a;">{{.Link}}</p>
{{end}}
@@ -0,0 +1,10 @@
Hi {{.GuestName}},
You opened your invitation to "{{.EventName}}" from a device we hadn't seen
before, so we've sent this link to make sure it's really you.
Use this link to view and update your RSVP:
{{.Link}}
The link expires in 30 minutes. If you didn't request it, you can ignore
this email — your existing reply stays the way you left it.
+6
View File
@@ -47,6 +47,12 @@ func (s *recordingEmailSender) SendCollaboratorInvite(_ context.Context, _, _, _
return nil return nil
} }
func (s *recordingEmailSender) SendRSVPEditLink(_ context.Context, _, _, _, _ string) error {
// Block G follow-up — recording-only stub; we don't track the URL here
// because the dedicated forwarded-link test has its own capture sender.
return nil
}
func TestAuthFlow(t *testing.T) { func TestAuthFlow(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("skipping integration test in -short mode") t.Skip("skipping integration test in -short mode")
@@ -0,0 +1,323 @@
//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=<nonce> 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)