//go:build integration package integration_test import ( "context" "fmt" "net/http" "testing" "time" "github.com/google/uuid" ) // Tier 2 Block G — per-event thresholds, allowlists, feedback. type thresholdsResp struct { Medium int `json:"medium"` High int `json:"high"` Block int `json:"block"` Defaults struct { Medium int `json:"medium"` High int `json:"high"` Block int `json:"block"` } `json:"defaults"` } // TestSecurityThresholdsRoundTrip confirms a fresh event has the default // 30/60/85 thresholds, a PUT persists, and an invalid ordering is rejected // at the API rather than reaching the DB. func TestSecurityThresholdsRoundTrip(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) eventID := createEvent(t, srv.URL, token, "Thresholds", "thresholds-test") var got thresholdsResp getJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID), token, http.StatusOK, &got) if got.Medium != 30 || got.High != 60 || got.Block != 85 { t.Fatalf("defaults: got %+v", got) } if got.Defaults.Medium != 30 { t.Errorf("defaults echo missing: %+v", got) } // Tighten the thresholds — a strict event blocks at 70 rather than 85. putJSON(t, fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID), token, map[string]any{"medium": 20, "high": 50, "block": 70}, http.StatusOK, nil) getJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID), token, http.StatusOK, &got) if got.Medium != 20 || got.High != 50 || got.Block != 70 { t.Errorf("after PUT: got %+v, want 20/50/70", got) } // Invalid ordering: medium > high — rejected. assertStatus(t, http.MethodPut, fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID), token, map[string]any{"medium": 80, "high": 50, "block": 90}, http.StatusBadRequest) } // TestSecurityAllowlistCRUD covers add → list → remove + duplicate // rejection + invalid CIDR rejection. func TestSecurityAllowlistCRUD(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) eventID := createEvent(t, srv.URL, token, "Allowlist", "allowlist-test") // Add a CIDR + a single IP (which we auto-widen to /32). postJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID), token, map[string]any{"cidr": "203.0.113.0/24", "label": "Office"}, http.StatusCreated, nil) postJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID), token, map[string]any{"cidr": "10.0.0.42", "label": "Family router"}, http.StatusCreated, nil) // List. var list struct { Entries []struct { CIDR string `json:"cidr"` Label string `json:"label"` } `json:"entries"` } getJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID), token, http.StatusOK, &list) if len(list.Entries) != 2 { t.Fatalf("expected 2 entries, got %d", len(list.Entries)) } foundOffice := false foundFamily := false for _, e := range list.Entries { if e.CIDR == "203.0.113.0/24" { foundOffice = true } if e.CIDR == "10.0.0.42/32" { // auto-widened foundFamily = true } } if !foundOffice || !foundFamily { t.Errorf("entries: %+v", list.Entries) } // Duplicate → 409. assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID), token, map[string]any{"cidr": "203.0.113.0/24"}, http.StatusConflict) // Invalid CIDR → 400. assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID), token, map[string]any{"cidr": "not-an-ip"}, http.StatusBadRequest) // Remove the office one. assertStatus(t, http.MethodDelete, fmt.Sprintf("%s/events/%s/security/allowlist?cidr=%s", srv.URL, eventID, "203.0.113.0%2F24"), token, nil, http.StatusNoContent) getJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID), token, http.StatusOK, &list) if len(list.Entries) != 1 { t.Errorf("after delete: expected 1 entry, got %d", len(list.Entries)) } } // TestSecurityFeedbackRecord exercises POST feedback + list. The access // log id is forged via the DB so we don't need to drive a full RSVP flow // — only the feedback path is under test here. func TestSecurityFeedbackRecord(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, "Feedback", "feedback-test") // Insert a guest + access log directly so we have an ID to feed back on. var guestID, accessLogID uuid.UUID must(t, db.Pool.QueryRow(ctx, ` INSERT INTO guests (event_id, name) VALUES ($1, 'Test') RETURNING id `, eventID).Scan(&guestID), "insert guest") must(t, db.Pool.QueryRow(ctx, ` INSERT INTO access_logs (guest_id) VALUES ($1) RETURNING id `, guestID).Scan(&accessLogID), "insert access_log") // Record a verdict. postJSONAuthed(t, fmt.Sprintf("%s/events/%s/access-logs/%s/feedback", srv.URL, eventID, accessLogID), token, map[string]any{"verdict": "legitimate", "note": "Aunty's actually fine"}, http.StatusOK, nil) // Upsert: change verdict on second call. postJSONAuthed(t, fmt.Sprintf("%s/events/%s/access-logs/%s/feedback", srv.URL, eventID, accessLogID), token, map[string]any{"verdict": "suspicious"}, http.StatusOK, nil) // List. var list struct { Feedback []struct { AccessLogID uuid.UUID `json:"access_log_id"` Verdict string `json:"verdict"` } `json:"feedback"` } getJSONAuthed(t, fmt.Sprintf("%s/events/%s/security/feedback", srv.URL, eventID), token, http.StatusOK, &list) if len(list.Feedback) != 1 { t.Fatalf("expected 1 feedback row after upsert, got %d", len(list.Feedback)) } if list.Feedback[0].Verdict != "suspicious" { t.Errorf("verdict not upserted: %+v", list.Feedback) } // Cross-tenant: another event's editor can't mark this log. otherEventID := createEvent(t, srv.URL, token, "Other", "other-feedback") assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/access-logs/%s/feedback", srv.URL, otherEventID, accessLogID), token, map[string]any{"verdict": "legitimate"}, http.StatusNotFound) // Invalid verdict → 400. assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/access-logs/%s/feedback", srv.URL, eventID, accessLogID), token, map[string]any{"verdict": "kinda-sus"}, http.StatusBadRequest) } // TestSecurityAuthzMatrix confirms viewers can read but not write, and // non-members can't even see the endpoints exist. func TestSecurityAuthzMatrix(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, ownerID, ownerToken := setupAuthedAPI(t, ctx) eventID := createEvent(t, srv.URL, ownerToken, "Authz Sec", "authz-sec") viewer, viewerToken := makeAuthedUser(t, ctx, db.Pool) directlyInsertCollaborator(t, ctx, db.Pool, eventID, viewer, "viewer", uuid.UUID(ownerID)) // Viewer can read. assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID), viewerToken, nil, http.StatusOK) assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID), viewerToken, nil, http.StatusOK) // Viewer can't write. assertStatus(t, http.MethodPut, fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID), viewerToken, map[string]any{"medium": 25, "high": 55, "block": 80}, http.StatusForbidden) assertStatus(t, http.MethodPost, fmt.Sprintf("%s/events/%s/security/allowlist", srv.URL, eventID), viewerToken, map[string]any{"cidr": "10.0.0.0/24"}, http.StatusForbidden) // Outsider: 404 everywhere. _, outsiderToken := makeAuthedUser(t, ctx, db.Pool) assertStatus(t, http.MethodGet, fmt.Sprintf("%s/events/%s/security/thresholds", srv.URL, eventID), outsiderToken, nil, http.StatusNotFound) }