From f52ef240b4e29c6cd91b24473b27ad85599dbefc Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 6 Oct 2026 09:23:31 +0000 Subject: [PATCH] feat: add audited repeat-safe sample data provisioning for active dedicated Test workspaces in Admin Testers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- admin/src/app/dashboard/TestersPage.test.tsx | 53 +++++ admin/src/app/dashboard/TestersPage.tsx | 79 ++++++- admin/src/lib/api/client/admin/testers.ts | 4 + admin/src/lib/api/models/admin.ts | 2 + docs/content/docs/api/endpoints.mdx | 2 + .../docs/development/accounts-and-access.mdx | 17 ++ docs/content/docs/development/admin-panel.mdx | 4 +- .../docs/guides/workspace-export-import.mdx | 2 + internal/api/handler/admin_tester.go | 23 ++ internal/api/handler/admin_tester_test.go | 62 +++++ internal/api/routes.go | 1 + internal/app/organization/service.go | 14 ++ .../organization/tester_sample_data_test.go | 54 +++++ internal/models/user.go | 10 +- internal/repository/pg_organization.go | 1 + internal/repository/pg_tester_sample_data.go | 140 +++++++++++ internal/repository/pg_user.go | 22 +- .../tester_sample_data_live_test.go | 219 ++++++++++++++++++ qa/README.md | 6 + qa/flows/tester-sample-data.flow.ts | 61 +++++ 20 files changed, 759 insertions(+), 17 deletions(-) create mode 100644 admin/src/app/dashboard/TestersPage.test.tsx create mode 100644 internal/app/organization/tester_sample_data_test.go create mode 100644 internal/repository/pg_tester_sample_data.go create mode 100644 internal/repository/tester_sample_data_live_test.go create mode 100644 qa/flows/tester-sample-data.flow.ts diff --git a/admin/src/app/dashboard/TestersPage.test.tsx b/admin/src/app/dashboard/TestersPage.test.tsx new file mode 100644 index 000000000..69e468a3d --- /dev/null +++ b/admin/src/app/dashboard/TestersPage.test.tsx @@ -0,0 +1,53 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { LoginCodeExemption } from "@/lib/api/models/admin"; +import TestersPage from "./TestersPage"; + +const access = vi.hoisted(() => ({ canManage: true })); +vi.mock("@/hooks/useAdminPerm", () => ({ + useAdminPerm: () => access.canManage, +})); + +function render(tester: Partial) { + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }); + client.setQueryData(["admin", "testers"], { + data: [{ user_id: "reviewer", email: "reviewer@example.test", password_expires_at: "2100-01-01T00:00:00Z", ...tester }], + }); + const html = renderToStaticMarkup(); + client.clear(); + const document = new DOMParser().parseFromString(html, "text/html"); + return document; +} + +describe("tester sample-data controls", () => { + beforeEach(() => { access.canManage = true; }); + + it("offers sample data for an active owned Test workspace", () => { + const document = render({ test_workspace_id: "test-workspace" }); + const button = document.querySelector('button[aria-label="Add sample data for reviewer@example.test"]'); + expect(button?.disabled).toBe(false); + expect(button?.textContent).toBe("Add sample data"); + expect(document.body.textContent).toContain("Mailboxes must be connected separately"); + }); + + it("does not offer sample data to attached customer-workspace testers or read-only admins", () => { + expect(render({}).querySelector('button[aria-label^="Add sample data for"]')).toBeNull(); + access.canManage = false; + expect(render({ test_workspace_id: "test-workspace" }).querySelector('button[aria-label^="Add sample data for"]')).toBeNull(); + }); + + it("disables populated, expired and passwordless Test workspaces", () => { + for (const tester of [ + { sample_data_seeded_at: "2026-01-01T00:00:00Z" }, + { password_expires_at: "2020-01-01T00:00:00Z" }, + { password_expires_at: null }, + ]) { + const document = render({ test_workspace_id: "test-workspace", ...tester }); + const button = document.querySelector('button[aria-label^="Add sample data for"]'); + expect(button?.disabled).toBe(true); + if (tester.sample_data_seeded_at) expect(document.body.textContent).toContain("Sample data added Jan 1, 2026"); + } + }); +}); diff --git a/admin/src/app/dashboard/TestersPage.tsx b/admin/src/app/dashboard/TestersPage.tsx index d530757e7..1cd46568b 100644 --- a/admin/src/app/dashboard/TestersPage.tsx +++ b/admin/src/app/dashboard/TestersPage.tsx @@ -24,11 +24,12 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Callout, EmptyState, Panel, Segmented, StatusBadge } from "@/components/ui/kit"; import { cn } from "@/lib/utils"; import { useAdminPerm } from "@/hooks/useAdminPerm"; import { AdminPerm } from "@/lib/auth/permissions"; -import { createTester, listTesters, listOrganizationRoles, revokeTester } from "@/lib/api/client/admin/testers"; +import { createTester, listTesters, listOrganizationRoles, revokeTester, seedTesterWorkspace } from "@/lib/api/client/admin/testers"; import { listOrganizations } from "@/lib/api/client/admin/organizations"; import { DASHBOARD_URL } from "@/lib/env"; import type { AdminOrgListItem, CreatedTester } from "@/lib/api/models/admin"; @@ -53,6 +54,7 @@ export default function TestersPage() { // Held in state, never refetched: the server returns it once and cannot // produce it again. const [created, setCreated] = useState(null); + const [sampleTarget, setSampleTarget] = useState<{ organizationID: string; email: string } | null>(null); const testers = useQuery({ queryKey: ["admin", "testers"], @@ -114,6 +116,16 @@ export default function TestersPage() { onError: (e: Error) => toast.error(e.message || "Could not revoke the exemption"), }); + const seed = useMutation({ + mutationFn: (orgID: string) => seedTesterWorkspace(orgID), + onSuccess: (result) => { + qc.invalidateQueries({ queryKey: ["admin", "testers"] }); + setSampleTarget(null); + toast.success(result.created ? "Sample data added. Connect a real test mailbox separately." : "Sample data was already added; nothing was changed."); + }, + onError: (e: Error) => toast.error(e.message || "Could not add sample data"), + }); + function copy(text: string) { navigator.clipboard?.writeText(text).then( () => toast.success("Copied"), @@ -146,6 +158,8 @@ export default function TestersPage() { or joins one that already exists. A new Test workspace includes paid-feature access and 100 test credits until the password expires. An existing workspace keeps its plan and the role you select. Its password stops working on the date you choose, and revoking it ends the password and signs out every session at once. + Use Add sample data on an active dedicated Test workspace to populate contacts, a draft campaign, templates and CRM examples. + Mailboxes must be connected separately. } /> @@ -266,6 +280,11 @@ export default function TestersPage() {
{t.reason || "no reason recorded"} · since {fmt(t.granted_at)}
+ {t.test_workspace_id && ( +
+ Test workspace{t.sample_data_seeded_at ? ` · Sample data added ${fmt(t.sample_data_seeded_at)}` : " · No sample data added"} +
+ )} {t.password_expires_at && ( )} {canManage && ( - + <> + {t.test_workspace_id && ( + + )} + + )} ); @@ -440,6 +472,33 @@ export default function TestersPage() { )} + + { if (!open && !seed.isPending) setSampleTarget(null); }}> + + + Add sample data? + + Populate the dedicated Test workspace for {sampleTarget?.email}. Existing data is left untouched. + + +
    +
  • Six fictional, unsubscribed contacts in a sample contact list
  • +
  • One draft campaign with two connected sequence steps
  • +
  • Two reusable templates
  • +
  • A sample CRM pipeline, two deals, a task and a contact note
  • +
+ + No mailbox is created and no email is sent. Connect a real test mailbox separately to review OAuth and inbox features. + Sample data is added once; repeating this action does not restore deleted examples or overwrite edits. + + + + + +
+
); } diff --git a/admin/src/lib/api/client/admin/testers.ts b/admin/src/lib/api/client/admin/testers.ts index 9ccd5a7d9..007ac598e 100644 --- a/admin/src/lib/api/client/admin/testers.ts +++ b/admin/src/lib/api/client/admin/testers.ts @@ -34,3 +34,7 @@ export function listOrganizationRoles(orgID: string): Promise<{ data: AdminOrgRo export function revokeTester(id: string): Promise<{ revoked: boolean; password_cleared: boolean }> { return Request({ method: "DELETE", url: `/admin/testers/${id}`, authorization: true }); } + +export function seedTesterWorkspace(orgID: string): Promise<{ organization_id: string; created: boolean; seeded_at: string }> { + return Request({ method: "POST", url: `/admin/organizations/${orgID}/sample-data`, authorization: true }); +} diff --git a/admin/src/lib/api/models/admin.ts b/admin/src/lib/api/models/admin.ts index afe527c6d..9c3033f3f 100644 --- a/admin/src/lib/api/models/admin.ts +++ b/admin/src/lib/api/models/admin.ts @@ -965,6 +965,8 @@ export interface LoginCodeExemption { /** When the handed-out password stops working; absent for an account * exempted without one. */ password_expires_at?: string | null; + test_workspace_id?: string | null; + sample_data_seeded_at?: string | null; } /** The one time the password is readable. It is not stored in a form anyone diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 907df4d83..959b41dc7 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -602,6 +602,8 @@ Alongside them, `api_url` is this API's own public base (for a copyable example - All of `/auth/cli/*` except the two handshake routes below (`GET /auth/cli/codes/:code`, `POST /auth/cli/codes/:code/approve`, `POST /auth/cli/codes/:code/deny`: the browser half of `warmbly auth login`, where a signed-in member reviews the code a CLI is showing and authorizes it. Approving mints an ordinary API key, so it requires the `MANAGE_API_KEYS` organization permission and a fresh authentication, is session-only (an API key must not be able to mint another one this way), and caps the key to the scopes the approver's role allows. `GET /auth/cli/codes/:code?organization_id=` adds `granted_scopes` and `granted_scope_names`: what approving into that workspace would grant) - All of `/pool-link/*` and `/cloud-link/*` (the self-hosted warmup pool link: approving an instance's code, listing and unlinking instances, reading the pool plan's price and opening its checkout, and on a self-hosted instance the connect flow and mailbox enrollment). `/cloud-link/*` exists only on a self-hosted instance. The link belongs to the whole instance, so linking and unlinking it (`/connect`, `/connect/poll`, `DELETE /cloud-link`) and the linked cloud workspace's mailboxes (`/workspace-mailboxes`, `.../adopt`) take a platform admin holding `manage_settings` whose session presented a second factor (`admin_mfa_required` otherwise). Reading the link and putting a workspace's own mailboxes on it (`GET /cloud-link`, `/mailboxes`, enroll, pause, resume and the Google and Microsoft sign-in under `/oauth/*`) take the workspace's `MANAGE_EMAILS`. `POST /pool-link/codes` and `POST /pool-link/poll` are public and per-IP rate limited: they are the device-code handshake an instance uses before it has a token, and `/pool-link/instance/*` accepts only an instance token. `/pool-link/instance/oauth/*`, `/pool-link/instance/mailboxes/:id/token`, `/pool-link/instance/workspace-mailboxes` and `/pool-link/instance/mailboxes/adopt` are the cloud-managed mailbox surface (Google and Microsoft sign-in on Warmbly's OAuth apps, brokered access tokens); their instance-side counterparts are `/cloud-link/oauth/*` and `/cloud-link/workspace-mailboxes/*`. `/pool-link/instance/redirects` (list), and `GET`, `PUT`, `DELETE` and `POST .../verify` on `/pool-link/instance/redirects/:domain`, are the root redirects Warmbly Cloud serves for the calling instance; the instance drives them through `PUT /emails/domains/:domain/redirect` with `served_by: "cloud"`, and `GET /pool-link/instance` carries `redirects` (`available`, `host`, `limit`, `used`) - All of `/admin/*`. `DELETE /admin/instance/invitations/expired` requires platform admin `manage_organizations` and an MFA-verified session. It removes only expired invitation records across all workspaces, leaves active invitations and membership unchanged, records an admin audit entry, and returns `{ "cleaned": true }`. The admin panel confirms before calling it. + - `POST /admin/organizations/:id/sample-data`: `manage_testers` and an MFA-verified admin session. Adds the [reviewer sample dataset](/development/accounts-and-access/#adding-sample-data) only to an active dedicated Test workspace. No request body. Returns `200` with `organization_id`, `created` (false when already seeded) and `seeded_at`; retries preserve edits and deletions. An ordinary workspace, revoked or expired tester, or missing/expired managed Test grant returns `400`. Inserts and the admin audit marker commit together. No mailbox connection, email delivery, external request or credit spend occurs. + - `GET /admin/testers`: `view_users` and an MFA-verified admin session. Each tester in `data` may include `test_workspace_id` for its owned dedicated Test workspace and `sample_data_seeded_at` when populated. These fields are absent for a tester merely attached to an ordinary workspace. `POST /subscription/checkout`, `POST /subscription/portal`, `POST /subscription/cancel`, `POST /subscription/change-plan` and `GET /subscription/preview-change` require the `MANAGE_BILLING` organization permission; reading the subscription, its limits, trial and features needs only membership. `POST /subscription/portal` requires an existing Stripe billing customer. A workspace without one receives `400` with code `bad_request` and a message to complete checkout first; free and operator-granted plans alone do not create a billing customer. diff --git a/docs/content/docs/development/accounts-and-access.mdx b/docs/content/docs/development/accounts-and-access.mdx index 234ea5d2a..d8fb92d1b 100644 --- a/docs/content/docs/development/accounts-and-access.mdx +++ b/docs/content/docs/development/accounts-and-access.mdx @@ -123,6 +123,23 @@ Its own is the default and the safer one. The reviewer connects their own mailbo The managed plan expires when the handed-out password expires, and revoking the tester ends that grant immediately. Credits do not bypass feature gating after the grant ends. Existing dedicated reviewer workspaces recorded by tester creation are categorized on upgrade; still-active reviewers receive the grant and credits unless the workspace already has a Stripe subscription or an operator-managed plan. Legacy admin-created reviewers without a password expiry get the default 30-day lifetime measured from their original creation, not from the upgrade. Expired or revoked reviewers receive no renewed access. Existing memberships and data are not deleted. +### Adding sample data + +In **Admins > Testers**, select **Add sample data** beside an active tester who owns a dedicated **Test** workspace, then confirm. This works for both newly created workspaces and existing dedicated Test workspaces after upgrading. Nothing is added automatically during deployment or account creation. The action requires the platform `manage_testers` permission and an MFA-verified admin session. The backend checks the Test category, the owner's current exemption and password expiry, and an unexpired managed Test grant. Testers attached to ordinary customer workspaces cannot use it. + +The dataset contains: + +- Six fictional, unsubscribed contacts with non-deliverable `@review.invalid` addresses, in one sample contact list. +- One **draft** campaign with two connected email sequence steps, no sender mailbox and open/link tracking off. +- Two reusable reply templates. +- One native CRM pipeline with three stages, two open example deals, one unscheduled task and one contact note. + +Names clearly identify the examples as samples. The reviewer can open **Contacts**, **Campaigns**, **Templates** and **CRM** to inspect or edit them. Existing workspace records and the Test credit balance stay unchanged. The action adds everything in one transaction and records `seed_tester_workspace` in the admin audit log. Repeating it, including simultaneous requests, returns the original seed timestamp without duplicating data, overwriting edits or restoring deleted examples. The tester list shows when the sample data was added. + +No mailbox, provider credentials, inbox history, warmup activity, delivery events or sending tasks are created. No email is sent and no external integration is called. Connect a dedicated test mailbox manually for OAuth and inbox review; real inbox content must come from that mailbox. An empty mailbox or an unconnected one remains empty. Sample data does not isolate the backend or guarantee complete security-scan coverage. + +The examples are ordinary workspace records and follow the selected export groups. The one-time admin audit marker belongs to this instance and is not part of a workspace archive. Imported workspaces do not acquire a new managed Test grant automatically. + Connecting Gmail still needs configured `BOX_GOOGLE_CLIENT_ID` and `BOX_GOOGLE_CLIENT_SECRET`, and the backend and frontend mailbox OAuth flags must permit new connections. A Test plan does not supply provider credentials or change those flags. Dashboard Google sign-in uses the separate `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` configuration. See [configuration](/development/configuration/). Joining an existing workspace is for a review judged on the app doing real work, where an empty workspace shows none of it. The tester becomes an ordinary member of that workspace and gets none of its own, so signing in lands straight in it: the dashboard skips its workspace picker only when someone belongs to exactly one. Two things follow from that, and both are the point rather than a side effect: diff --git a/docs/content/docs/development/admin-panel.mdx b/docs/content/docs/development/admin-panel.mdx index c6f959d3b..0ba699b9b 100644 --- a/docs/content/docs/development/admin-panel.mdx +++ b/docs/content/docs/development/admin-panel.mdx @@ -131,7 +131,7 @@ Reading the tabs needs `view_organizations`; every action needs `manage_organiza |---|---| | Overview, Live events | none beyond signing in as an admin | | Workers, Fleet | `view_workers` | -| Mailboxes, Sync, Users | `view_users` | +| Mailboxes, Sync, Users, Testers | `view_users` | | Warmup, Warmup content, Seed panel | `view_warmup_pool` (changing seeds or running a test needs `manage_warmup_bans`) | | Warmup appeals | `review_appeals` | | Campaigns, Sends | `view_campaigns` | @@ -143,6 +143,8 @@ Reading the tabs needs `view_organizations`; every action needs `manage_organiza Detail pages need the same bit as the list they open from. Actions inside a page can need more than the page itself: reading an organization's abuse posture or the Outreach list needs `view_organizations`, while changing a posture or sending outreach needs `manage_organizations`, as [accounts and access](/development/accounts-and-access/#workspace-abuse-posture) lists. +On **Admins > Testers**, `manage_testers` allows creating or revoking reviewer accounts and adding [sample data](/development/accounts-and-access/#adding-sample-data) to active dedicated Test workspaces. The confirmation lists what will be added; a seeded row shows its timestamp. It never creates a mailbox or sends email, and it cannot populate a customer workspace that a tester joined. + ## See also - [Accounts and access](/development/accounts-and-access/) for the presets and the shell commands diff --git a/docs/content/docs/guides/workspace-export-import.mdx b/docs/content/docs/guides/workspace-export-import.mdx index d02dc23f5..57a2bb739 100644 --- a/docs/content/docs/guides/workspace-export-import.mdx +++ b/docs/content/docs/guides/workspace-export-import.mdx @@ -33,6 +33,8 @@ The data is split into groups. Every export includes **Workspace**; the rest are Inbox, send history, delivery events, and logs are the ones that grow without limit. Turn them off and you get a small archive that still rebuilds a working workspace; leave them on for a faithful copy. +[Test workspace samples](/development/accounts-and-access/#adding-sample-data) move as ordinary contacts, campaigns and CRM records in their corresponding groups; reply templates are in **Assistant**. Their instance-wide admin audit marker does not travel, and import does not grant managed Test access. + The suppression list is part of the Contacts group and is never optional within it. An import that dropped it would start mailing people who already opted out. diff --git a/internal/api/handler/admin_tester.go b/internal/api/handler/admin_tester.go index 0b2c44510..7896f556c 100644 --- a/internal/api/handler/admin_tester.go +++ b/internal/api/handler/admin_tester.go @@ -237,6 +237,29 @@ func (h *Handler) AdminListTesters(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": list}) } +func (h *Handler) AdminSeedTesterWorkspace(c *gin.Context) { + adminID := middleware.GetAdminUserID(c) + if adminID == nil { + errx.JSON(c, errx.ErrUnauthorized) + return + } + orgID, err := uuid.Parse(c.Param("id")) + if err != nil || orgID == uuid.Nil { + errx.JSON(c, errx.New(errx.BadRequest, "that is not a workspace id")) + return + } + if h.OrganizationService == nil { + errx.JSON(c, errx.New(errx.ServiceUnavailable, "sample data is not available on this instance")) + return + } + result, xerr := h.OrganizationService.SeedTesterWorkspace(c.Request.Context(), orgID, *adminID, c.ClientIP(), c.Request.UserAgent()) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, result) +} + // AdminRevokeTester drops the exemption, the handed-out password and every // session the account holds. The account stays, so anything it created is // still attributable. diff --git a/internal/api/handler/admin_tester_test.go b/internal/api/handler/admin_tester_test.go index 08db99ed9..b651977e2 100644 --- a/internal/api/handler/admin_tester_test.go +++ b/internal/api/handler/admin_tester_test.go @@ -90,3 +90,65 @@ func TestAdminCreateTesterOnlyGrantsDedicatedWorkspaces(t *testing.T) { } } } + +type testerSampleService struct { + organization.OrganizationService + orgID, actor uuid.UUID + ip, agent string + called bool + err *errx.Error +} + +func (s *testerSampleService) SeedTesterWorkspace(_ context.Context, orgID, actor uuid.UUID, ip, agent string) (*models.TesterSampleData, *errx.Error) { + s.called, s.orgID, s.actor, s.ip, s.agent = true, orgID, actor, ip, agent + return &models.TesterSampleData{OrganizationID: orgID, Created: true, SeededAt: time.Now()}, s.err +} + +func TestAdminSeedTesterWorkspaceRequiresAuthorityAndValidTarget(t *testing.T) { + gin.SetMode(gin.TestMode) + actor, orgID := uuid.New(), uuid.New() + for _, tc := range []struct { + name string + perms models.AdminPermission + actor bool + id string + missing bool + err *errx.Error + status int + called bool + }{ + {name: "allowed", perms: models.AdminPermManageTesters, actor: true, id: orgID.String(), status: http.StatusOK, called: true}, + {name: "view only", perms: models.AdminPermViewUsers, actor: true, id: orgID.String(), status: http.StatusForbidden}, + {name: "no actor", perms: models.AdminPermManageTesters, id: orgID.String(), status: http.StatusUnauthorized}, + {name: "invalid target", perms: models.AdminPermManageTesters, actor: true, id: "not-a-uuid", status: http.StatusBadRequest}, + {name: "nil target", perms: models.AdminPermManageTesters, actor: true, id: uuid.Nil.String(), status: http.StatusBadRequest}, + {name: "inactive workspace", perms: models.AdminPermManageTesters, actor: true, id: orgID.String(), err: errx.New(errx.BadRequest, "inactive"), status: http.StatusBadRequest, called: true}, + {name: "unavailable", perms: models.AdminPermManageTesters, actor: true, id: orgID.String(), missing: true, status: http.StatusServiceUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + s := &testerSampleService{err: tc.err} + h := &Handler{} + if !tc.missing { + h.OrganizationService = s + } + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set(middleware.AdminPermissionsKey, tc.perms) + if tc.actor { + c.Set(middleware.AdminUserIDKey, actor) + } + }) + r.POST("/organizations/:id/sample-data", middleware.RequireAdminPermission(models.AdminPermManageTesters), h.AdminSeedTesterWorkspace) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/organizations/"+tc.id+"/sample-data", nil) + req.Header.Set("User-Agent", "sample-review") + r.ServeHTTP(w, req) + if w.Code != tc.status || s.called != tc.called { + t.Fatalf("HTTP %d, called=%v: %s", w.Code, s.called, w.Body.String()) + } + if s.called && (s.orgID != orgID || s.actor != actor || s.agent != "sample-review" || s.ip == "") { + t.Fatal("target or audit attribution changed") + } + }) + } +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 570930bdc..af5c7448b 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -1774,6 +1774,7 @@ func Run( // other user routes can do. adminRoutes.GET("/testers", middleware.RequireAdminPermission(models.AdminPermViewUsers), h.AdminListTesters) adminRoutes.POST("/testers", middleware.RequireAdminPermission(models.AdminPermManageTesters), h.AdminCreateTester) + adminRoutes.POST("/organizations/:id/sample-data", middleware.RequireAdminPermission(models.AdminPermManageTesters), h.AdminSeedTesterWorkspace) adminRoutes.DELETE("/testers/:id", middleware.RequireAdminPermission(models.AdminPermManageTesters), h.AdminRevokeTester) adminRoutes.POST("/users/:id/ban", middleware.RequireAdminPermission(models.AdminPermBanUsers), h.AdminBanUser) adminRoutes.POST("/users/:id/unban", middleware.RequireAdminPermission(models.AdminPermBanUsers), h.AdminUnbanUser) diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index 91f81d411..79e13c75f 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/hex" + "errors" "regexp" "strconv" "strings" @@ -49,6 +50,7 @@ type OperatorNotifier interface { // OrganizationService defines the interface for organization management type OrganizationService interface { CreateTesterWorkspace(ctx context.Context, userID, adminID uuid.UUID, name, reason string, until time.Time) (*models.Organization, *errx.Error) + SeedTesterWorkspace(ctx context.Context, orgID, adminID uuid.UUID, ip, userAgent string) (*models.TesterSampleData, *errx.Error) // WireAuthPolicy attaches the deployment auth policy after construction. WireAuthPolicy(p *config.AuthPolicy) @@ -415,6 +417,18 @@ func (s *organizationService) CreateTesterWorkspace(ctx context.Context, userID, return org, nil } +func (s *organizationService) SeedTesterWorkspace(ctx context.Context, orgID, adminID uuid.UUID, ip, userAgent string) (*models.TesterSampleData, *errx.Error) { + result, err := s.orgRepo.SeedTesterWorkspace(ctx, orgID, adminID, ip, userAgent) + if errors.Is(err, repository.ErrTesterWorkspaceInactive) { + return nil, errx.New(errx.BadRequest, "sample data requires an active dedicated Test workspace") + } + if err != nil { + errs.CaptureException(err) + return nil, errx.New(errx.Internal, "could not add sample data; retrying is safe") + } + return result, nil +} + // Get retrieves an organization by ID func (s *organizationService) Get(ctx context.Context, orgID uuid.UUID) (*models.Organization, *errx.Error) { org, err := s.orgRepo.GetByID(ctx, orgID) diff --git a/internal/app/organization/tester_sample_data_test.go b/internal/app/organization/tester_sample_data_test.go new file mode 100644 index 000000000..f223c0a56 --- /dev/null +++ b/internal/app/organization/tester_sample_data_test.go @@ -0,0 +1,54 @@ +package organization + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +type testerSampleRepo struct { + repository.OrganizationRepository + orgID, actor uuid.UUID + ip, agent string + result *models.TesterSampleData + err error +} + +func (r *testerSampleRepo) SeedTesterWorkspace(_ context.Context, orgID, actor uuid.UUID, ip, agent string) (*models.TesterSampleData, error) { + r.orgID, r.actor, r.ip, r.agent = orgID, actor, ip, agent + return r.result, r.err +} + +func TestSeedTesterWorkspacePreservesResultAndAuditActor(t *testing.T) { + orgID, actor := uuid.New(), uuid.New() + for _, created := range []bool{true, false} { + r := &testerSampleRepo{result: &models.TesterSampleData{OrganizationID: orgID, Created: created, SeededAt: time.Now()}} + s := &organizationService{orgRepo: r} + result, xerr := s.SeedTesterWorkspace(context.Background(), orgID, actor, "127.0.0.1", "reviewer") + if xerr != nil || result != r.result || r.orgID != orgID || r.actor != actor || r.ip != "127.0.0.1" || r.agent != "reviewer" { + t.Fatalf("sample result or attribution changed: %+v, %v", result, xerr) + } + } +} + +func TestSeedTesterWorkspaceMapsRepositoryErrors(t *testing.T) { + for _, tc := range []struct { + err error + code errx.Code + }{ + {repository.ErrTesterWorkspaceInactive, errx.BadRequest}, + {errors.New("database unavailable"), errx.Internal}, + } { + s := &organizationService{orgRepo: &testerSampleRepo{err: tc.err}} + result, xerr := s.SeedTesterWorkspace(context.Background(), uuid.New(), uuid.New(), "", "") + if result != nil || xerr == nil || xerr.Code != tc.code { + t.Fatalf("unexpected sample error: %+v, %v", result, xerr) + } + } +} diff --git a/internal/models/user.go b/internal/models/user.go index c773b9c54..de42261cc 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -69,5 +69,13 @@ type LoginCodeExemption struct { Reason *string `json:"reason,omitempty"` GrantedAt *time.Time `json:"granted_at,omitempty"` // PasswordExpiresAt is set on a tester whose password was handed out. - PasswordExpiresAt *time.Time `json:"password_expires_at,omitempty"` + PasswordExpiresAt *time.Time `json:"password_expires_at,omitempty"` + TestWorkspaceID *uuid.UUID `json:"test_workspace_id,omitempty"` + SampleDataSeededAt *time.Time `json:"sample_data_seeded_at,omitempty"` +} + +type TesterSampleData struct { + OrganizationID uuid.UUID `json:"organization_id"` + Created bool `json:"created"` + SeededAt time.Time `json:"seeded_at"` } diff --git a/internal/repository/pg_organization.go b/internal/repository/pg_organization.go index fbf27c023..52098f7d4 100644 --- a/internal/repository/pg_organization.go +++ b/internal/repository/pg_organization.go @@ -15,6 +15,7 @@ import ( // OrganizationRepository defines the interface for organization data access type OrganizationRepository interface { ProvisionTesterWorkspace(ctx context.Context, orgID, ownerID, adminID uuid.UUID, reason string, until time.Time) error + SeedTesterWorkspace(ctx context.Context, orgID, adminID uuid.UUID, ip, userAgent string) (*models.TesterSampleData, error) // Organization CRUD Create(ctx context.Context, org *models.Organization) error GetByID(ctx context.Context, id uuid.UUID) (*models.Organization, error) diff --git a/internal/repository/pg_tester_sample_data.go b/internal/repository/pg_tester_sample_data.go new file mode 100644 index 000000000..8598e3fce --- /dev/null +++ b/internal/repository/pg_tester_sample_data.go @@ -0,0 +1,140 @@ +package repository + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/warmbly/warmbly/internal/models" +) + +var ErrTesterWorkspaceInactive = errors.New("sample data requires an active dedicated Test workspace") + +func (r *organizationRepository) SeedTesterWorkspace(ctx context.Context, orgID, adminID uuid.UUID, ip, userAgent string) (*models.TesterSampleData, error) { + tx, err := r.db.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + + var ownerID uuid.UUID + err = tx.QueryRow(ctx, ` + SELECT o.owner_user_id FROM organizations o JOIN users u ON u.id = o.owner_user_id + WHERE o.id = $1 AND o.category = 'test' AND u.login_code_exempt + AND u.password_expires_at > now() + AND EXISTS ( + SELECT 1 FROM subscriptions s WHERE s.organization_id = o.id + AND s.managed_plan_id = $2 AND s.managed_at IS NOT NULL AND s.managed_until > now() + ) + FOR UPDATE OF o, u`, orgID, models.TestPlanID).Scan(&ownerID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrTesterWorkspaceInactive + } + if err != nil { + return nil, err + } + + result := &models.TesterSampleData{OrganizationID: orgID} + // The workspace lock serializes retries; the audit marker preserves edits and deletions. + err = tx.QueryRow(ctx, `SELECT created_at FROM admin_audit_logs + WHERE target_type = 'organization' AND target_id = $1 AND action = 'seed_tester_workspace' + ORDER BY created_at LIMIT 1`, orgID).Scan(&result.SeededAt) + if err == nil { + return result, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + if err := seedTesterSampleData(ctx, tx, orgID, ownerID); err != nil { + return nil, err + } + err = tx.QueryRow(ctx, ` + INSERT INTO admin_audit_logs (admin_user_id, action, target_type, target_id, details, ip_address, user_agent) + VALUES ($1, 'seed_tester_workspace', 'organization', $2, + '{"dataset_version":1,"contacts":6,"contact_lists":1,"campaigns":1,"sequence_steps":2,"templates":2,"pipelines":1,"deals":2,"tasks":1,"notes":1}', $3, $4) + RETURNING created_at`, adminID, orgID, ip, userAgent).Scan(&result.SeededAt) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + result.Created = true + return result, nil +} + +func seedTesterSampleData(ctx context.Context, tx pgx.Tx, orgID, ownerID uuid.UUID) error { + batch := &pgx.Batch{} + listID, campaignID, pipelineID := uuid.New(), uuid.New(), uuid.New() + batch.Queue(`INSERT INTO categories (id, organization_id, user_id, title, color, position) + VALUES ($1, $2, $3, 'Sample: reviewer contacts', '#38bdf8', + (SELECT COALESCE(MAX(position), -1) + 1 FROM categories WHERE organization_id = $2))`, listID, orgID, ownerID) + + contacts := []struct{ first, last, company, email string }{ + {"Avery", "Sample", "Sample North Studio", "avery@review.invalid"}, + {"Jordan", "Sample", "Sample Meadow Labs", "jordan@review.invalid"}, + {"Morgan", "Sample", "Sample Harbor Works", "morgan@review.invalid"}, + {"Casey", "Sample", "Sample Cedar Team", "casey@review.invalid"}, + {"Riley", "Sample", "Sample Valley Studio", "riley@review.invalid"}, + {"Taylor", "Sample", "Sample Brook Labs", "taylor@review.invalid"}, + } + contactIDs := make([]uuid.UUID, len(contacts)) + for i, contact := range contacts { + id := uuid.New() + contactIDs[i] = id + batch.Queue(`INSERT INTO contacts (id, organization_id, user_id, first_name, last_name, email, company, phone, custom_fields, subscribed) + VALUES ($1, $2, $3, $4, $5, $6, $7, '', '{"sample_data":"true"}', false)`, + id, orgID, ownerID, contact.first, contact.last, contact.email, contact.company) + batch.Queue(`INSERT INTO contact_categories (contact_id, category_id) VALUES ($1, $2)`, id, listID) + } + + batch.Queue(`INSERT INTO campaigns (id, organization_id, user_id, name, description, status, + stop_on_reply, open_tracking, link_tracking, risky_emails, days, timezone, updated_at, created_at) + VALUES ($1, $2, $3, 'Sample: reviewer outreach', + 'Fictional sample data. Contacts are unsubscribed and use non-deliverable .invalid addresses. No mailbox is assigned.', + 'draft', true, false, false, false, 31, 'UTC', now(), now())`, campaignID, orgID, ownerID) + for i, id := range contactIDs { + batch.Queue(`INSERT INTO campaign_leads (campaign_id, contact_id, position) VALUES ($1, $2, $3)`, campaignID, id, i) + } + steps := []struct{ name, subject, plain, html string }{ + {"Sample introduction", "Sample introduction for {{.Company}}", "Hi {{.FirstName}},\n\nThis is fictional content for reviewing the editor. No email has been sent.", "

Hi {{.FirstName}},

This is fictional content for reviewing the editor. No email has been sent.

"}, + {"Sample follow-up", "Sample follow-up", "Hi {{.FirstName}},\n\nThis is a second draft step for reviewing the sequence.", "

Hi {{.FirstName}},

This is a second draft step for reviewing the sequence.

"}, + } + stepIDs := []uuid.UUID{uuid.New(), uuid.New()} + for i, step := range steps { + batch.Queue(`INSERT INTO sequences (id, campaign_id, organization_id, name, subject, body_plain, body_html, wait_after, position, x, y) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10)`, stepIDs[i], campaignID, orgID, + step.name, step.subject, step.plain, step.html, i*10, i, i*240) + batch.Queue(`INSERT INTO reply_templates (organization_id, user_id, name, subject, body_plain, body_html, position) + VALUES ($1, $2, $3, $4, $5, $6, + (SELECT COALESCE(MAX(position), -1) + 1 FROM reply_templates WHERE organization_id = $1))`, + orgID, ownerID, "Sample: "+step.name, step.subject, step.plain, step.html) + } + + batch.Queue(`INSERT INTO pipelines (id, organization_id, name, position) + VALUES ($1, $2, 'Sample: review pipeline', + (SELECT COALESCE(MAX(position), -1) + 1 FROM pipelines WHERE organization_id = $2))`, pipelineID, orgID) + stageIDs := []uuid.UUID{uuid.New(), uuid.New(), uuid.New()} + for i, stage := range []struct{ name, color string }{{"New", "#38bdf8"}, {"Qualified", "#a855f7"}, {"Won", "#10b981"}} { + batch.Queue(`INSERT INTO pipeline_stages (id, pipeline_id, name, color, position) VALUES ($1, $2, $3, $4, $5)`, + stageIDs[i], pipelineID, stage.name, stage.color, i) + } + dealIDs := []uuid.UUID{uuid.New(), uuid.New()} + for i, id := range dealIDs { + batch.Queue(`INSERT INTO deals (id, organization_id, pipeline_id, stage_id, contact_id, name, value, currency, assigned_to) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'USD', $8)`, id, orgID, pipelineID, stageIDs[i], + contactIDs[i], "Sample: "+contacts[i].company+" review", (i+1)*1000, ownerID) + } + batch.Queue(`INSERT INTO crm_tasks (organization_id, contact_id, deal_id, assigned_to, created_by, title, description) + VALUES ($1, $2, $3, $4, $4, 'Sample: inspect the deal', 'Fictional review task. No email or external action is scheduled.')`, + orgID, contactIDs[0], dealIDs[0], ownerID) + batch.Queue(`INSERT INTO contact_notes (contact_id, organization_id, user_id, content) + VALUES ($1, $2, $3, 'Fictional sample contact for product review. Do not replace this with customer data.')`, + contactIDs[0], orgID, ownerID) + + if err := tx.SendBatch(ctx, batch).Close(); err != nil { + return err + } + return connectLinearSequenceTx(ctx, tx, stepIDs) +} diff --git a/internal/repository/pg_user.go b/internal/repository/pg_user.go index 1a136dbb1..084d65d17 100644 --- a/internal/repository/pg_user.go +++ b/internal/repository/pg_user.go @@ -355,10 +355,22 @@ func (r *userRepository) SetLoginCodeExempt(ctx context.Context, id uuid.UUID, e // likely to have been forgotten is the one that has been there longest. func (r *userRepository) ListLoginCodeExempt(ctx context.Context) ([]models.LoginCodeExemption, error) { rows, err := r.DB.Query(ctx, ` - SELECT id, email, login_code_exempt_reason, login_code_exempt_at, password_expires_at - FROM users - WHERE login_code_exempt - ORDER BY login_code_exempt_at NULLS FIRST`) + SELECT u.id, u.email, u.login_code_exempt_reason, u.login_code_exempt_at, u.password_expires_at, + workspace.id, seeded.created_at + FROM users u + LEFT JOIN LATERAL ( + SELECT o.id FROM organizations o + WHERE o.owner_user_id = u.id AND o.category = 'test' + ORDER BY o.created_at, o.id LIMIT 1 + ) workspace ON true + LEFT JOIN LATERAL ( + SELECT a.created_at FROM admin_audit_logs a + WHERE a.target_type = 'organization' AND a.target_id = workspace.id + AND a.action = 'seed_tester_workspace' + ORDER BY a.created_at LIMIT 1 + ) seeded ON true + WHERE u.login_code_exempt + ORDER BY u.login_code_exempt_at NULLS FIRST`) if err != nil { return nil, err } @@ -367,7 +379,7 @@ func (r *userRepository) ListLoginCodeExempt(ctx context.Context) ([]models.Logi out := []models.LoginCodeExemption{} for rows.Next() { var e models.LoginCodeExemption - if err := rows.Scan(&e.UserID, &e.Email, &e.Reason, &e.GrantedAt, &e.PasswordExpiresAt); err != nil { + if err := rows.Scan(&e.UserID, &e.Email, &e.Reason, &e.GrantedAt, &e.PasswordExpiresAt, &e.TestWorkspaceID, &e.SampleDataSeededAt); err != nil { return nil, err } out = append(out, e) diff --git a/internal/repository/tester_sample_data_live_test.go b/internal/repository/tester_sample_data_live_test.go new file mode 100644 index 000000000..33c704b10 --- /dev/null +++ b/internal/repository/tester_sample_data_live_test.go @@ -0,0 +1,219 @@ +package repository + +import ( + "context" + "errors" + "net/mail" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/warmbly/warmbly/internal/models" +) + +type testerSampleFixture struct { + orgID, userID uuid.UUID + orgs OrganizationRepository + users UserRepository +} + +func newTesterSampleFixture(t *testing.T, pool *pgxpool.Pool, users UserRepository) testerSampleFixture { + t.Helper() + ctx := context.Background() + until := time.Now().Add(24 * time.Hour).Truncate(time.Microsecond) + u, err := users.CreateExemptUser(ctx, &mail.Address{Address: uuid.NewString() + "@example.test"}, "hash", "sample review", nil, until) + if err != nil { + t.Fatal(err) + } + orgs := NewOrganizationRepository(pool) + slug := uuid.NewString() + org := &models.Organization{ID: uuid.New(), OwnerUserID: u.ID, Name: "Sample review workspace", Slug: &slug} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM admin_audit_logs WHERE target_id = $1`, org.ID) + _, _ = pool.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, org.ID) + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, u.ID) + }) + if err := orgs.Create(ctx, org); err != nil { + t.Fatal(err) + } + if err := orgs.ProvisionTesterWorkspace(ctx, org.ID, u.ID, u.ID, "sample review", until); err != nil { + t.Fatal(err) + } + return testerSampleFixture{org.ID, u.ID, orgs, users} +} + +func sampleCount(t *testing.T, pool *pgxpool.Pool, query string, args ...any) int { + t.Helper() + var count int + if err := pool.QueryRow(context.Background(), query, args...).Scan(&count); err != nil { + t.Fatal(err) + } + return count +} + +func assertTesterSampleCounts(t *testing.T, pool *pgxpool.Pool, orgID uuid.UUID, multiplier int) { + t.Helper() + for _, tc := range []struct { + query string + count int + }{ + {`SELECT count(*) FROM contacts WHERE organization_id = $1`, 6}, + {`SELECT count(*) FROM categories WHERE organization_id = $1`, 1}, + {`SELECT count(*) FROM campaigns WHERE organization_id = $1`, 1}, + {`SELECT count(*) FROM sequences WHERE organization_id = $1`, 2}, + {`SELECT count(*) FROM reply_templates WHERE organization_id = $1`, 2}, + {`SELECT count(*) FROM pipelines WHERE organization_id = $1`, 1}, + {`SELECT count(*) FROM deals WHERE organization_id = $1`, 2}, + {`SELECT count(*) FROM crm_tasks WHERE organization_id = $1`, 1}, + {`SELECT count(*) FROM contact_notes WHERE organization_id = $1`, 1}, + {`SELECT count(*) FROM admin_audit_logs WHERE target_id = $1 AND action = 'seed_tester_workspace'`, 1}, + } { + if got := sampleCount(t, pool, tc.query, orgID); got != tc.count*multiplier { + t.Errorf("%s: got %d, want %d", tc.query, got, tc.count*multiplier) + } + } +} + +func TestLiveTesterSampleDataIsSafeAndPreservesEdits(t *testing.T) { + db, pool := liveContactDB(t) + requireSchemaVersion(t, pool, 264) + ctx := context.Background() + f := newTesterSampleFixture(t, pool, NewUserRepostory(db, nil)) + other := newTesterSampleFixture(t, pool, f.users) + result, err := f.orgs.SeedTesterWorkspace(ctx, f.orgID, f.userID, "127.0.0.1", "sample-review") + if err != nil || result == nil || !result.Created || result.SeededAt.IsZero() { + t.Fatalf("seed = %+v, %v", result, err) + } + assertTesterSampleCounts(t, pool, f.orgID, 1) + assertTesterSampleCounts(t, pool, other.orgID, 0) + contact, xerr := NewContactRepostory(db).GetByEmailAndOrganization(ctx, f.orgID, "avery@review.invalid") + if xerr != nil || contact == nil || contact.CustomFields["sample_data"] != "true" { + t.Fatalf("sample contact cannot be read: %+v, %v", contact, xerr) + } + for _, query := range []string{ + `SELECT count(*) FROM contacts WHERE organization_id = $1 AND (subscribed OR email NOT LIKE '%@review.invalid')`, + `SELECT count(*) FROM campaigns WHERE organization_id = $1 AND (status <> 'draft' OR open_tracking OR link_tracking)`, + `SELECT count(*) FROM email_accounts WHERE organization_id = $1`, + `SELECT count(*) FROM campaign_tasks t JOIN campaigns c ON c.id = t.campaign_id WHERE c.organization_id = $1`, + `SELECT count(*) FROM campaign_contact_progress p JOIN campaigns c ON c.id = p.campaign_id WHERE c.organization_id = $1`, + } { + if got := sampleCount(t, pool, query, f.orgID); got != 0 { + t.Errorf("unexpected side effect: %s = %d", query, got) + } + } + var conditions models.BranchConditions + var next uuid.UUID + if err := pool.QueryRow(ctx, `SELECT conditions FROM sequences WHERE organization_id = $1 AND position = 0`, f.orgID).Scan(&conditions); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT id FROM sequences WHERE organization_id = $1 AND position = 1`, f.orgID).Scan(&next); err != nil { + t.Fatal(err) + } + if len(conditions.Branches) != 1 || conditions.Branches[0].TargetSequenceID == nil || *conditions.Branches[0].TargetSequenceID != next { + t.Fatal("sample campaign steps are not connected") + } + var ip, agent string + if err := pool.QueryRow(ctx, `SELECT ip_address, user_agent FROM admin_audit_logs WHERE target_id = $1 AND action = 'seed_tester_workspace'`, f.orgID).Scan(&ip, &agent); err != nil || ip != "127.0.0.1" || agent != "sample-review" { + t.Fatalf("audit attribution: %s, %s, %v", ip, agent, err) + } + if got := sampleCount(t, pool, `SELECT balance FROM credit_ledger WHERE org_id = $1`, f.orgID); got != 100 { + t.Fatalf("seeding changed test credits: %d", got) + } + list, err := f.users.ListLoginCodeExempt(ctx) + if err != nil { + t.Fatal(err) + } + found := false + for _, tester := range list { + if tester.UserID == f.userID { + found = tester.TestWorkspaceID != nil && *tester.TestWorkspaceID == f.orgID && tester.SampleDataSeededAt != nil && tester.SampleDataSeededAt.Equal(result.SeededAt) + } + } + if !found { + t.Fatal("tester list does not expose the seeded workspace") + } + if _, err := pool.Exec(ctx, `UPDATE campaigns SET name = 'Edited sample' WHERE organization_id = $1`, f.orgID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `DELETE FROM contacts WHERE organization_id = $1 AND email = 'taylor@review.invalid'`, f.orgID); err != nil { + t.Fatal(err) + } + retry, err := f.orgs.SeedTesterWorkspace(ctx, f.orgID, f.userID, "", "") + if err != nil || retry == nil || retry.Created || !retry.SeededAt.Equal(result.SeededAt) { + t.Fatalf("retry = %+v, %v", retry, err) + } + if got := sampleCount(t, pool, `SELECT count(*) FROM campaigns WHERE organization_id = $1 AND name = 'Edited sample'`, f.orgID); got != 1 { + t.Fatal("retry overwrote edited sample data") + } + if got := sampleCount(t, pool, `SELECT count(*) FROM contacts WHERE organization_id = $1`, f.orgID); got != 5 { + t.Fatal("retry restored deleted sample data") + } +} + +func TestLiveTesterSampleDataRollsBackAndSerializesRetries(t *testing.T) { + db, pool := liveContactDB(t) + requireSchemaVersion(t, pool, 264) + ctx := context.Background() + f := newTesterSampleFixture(t, pool, NewUserRepostory(db, nil)) + if _, err := f.orgs.SeedTesterWorkspace(ctx, f.orgID, uuid.New(), "", ""); err == nil { + t.Fatal("invalid audit actor must fail the entire transaction") + } + assertTesterSampleCounts(t, pool, f.orgID, 0) + + results := make(chan *models.TesterSampleData, 8) + errors := make(chan error, 8) + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + result, err := f.orgs.SeedTesterWorkspace(ctx, f.orgID, f.userID, "", "") + results <- result + errors <- err + }) + } + wg.Wait() + close(results) + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + created := 0 + for result := range results { + if result.Created { + created++ + } + } + if created != 1 { + t.Fatalf("concurrent actions created %d datasets", created) + } + assertTesterSampleCounts(t, pool, f.orgID, 1) +} + +func TestLiveTesterSampleDataRejectsNonTestAndInactiveWorkspaces(t *testing.T) { + db, pool := liveContactDB(t) + requireSchemaVersion(t, pool, 264) + ctx := context.Background() + for _, tc := range []struct { + name, query string + }{ + {"standard workspace", `UPDATE organizations SET category = 'standard' WHERE id = $1`}, + {"expired password", `UPDATE users SET password_expires_at = now() - interval '1 hour' WHERE id = (SELECT owner_user_id FROM organizations WHERE id = $1)`}, + {"revoked exemption", `UPDATE users SET login_code_exempt = false WHERE id = (SELECT owner_user_id FROM organizations WHERE id = $1)`}, + {"expired grant", `UPDATE subscriptions SET managed_until = now() - interval '1 hour' WHERE organization_id = $1`}, + {"different managed plan", `UPDATE subscriptions SET managed_plan_id = '00000000-0000-0000-0000-000000000001' WHERE organization_id = $1`}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newTesterSampleFixture(t, pool, NewUserRepostory(db, nil)) + if _, err := pool.Exec(ctx, tc.query, f.orgID); err != nil { + t.Fatal(err) + } + if _, err := f.orgs.SeedTesterWorkspace(ctx, f.orgID, f.userID, "", ""); !errors.Is(err, ErrTesterWorkspaceInactive) { + t.Fatalf("ineligible workspace returned %v", err) + } + assertTesterSampleCounts(t, pool, f.orgID, 0) + }) + } +} diff --git a/qa/README.md b/qa/README.md index 78f1a5c75..dbb5b4b44 100644 --- a/qa/README.md +++ b/qa/README.md @@ -161,6 +161,12 @@ Run `QA_FLEET_FIXTURES=1 pnpm proof fleet-telemetry` in `qa/`. Stop the addition admin server as well as the stack afterward. This proof does not validate real worker sampling, network egress, mail delivery or inbox placement. +## Tester sample data proof + +The `tester-sample-data` flow uses the real local tester-provisioning and sample-data endpoints. Start the worktree's lite stack, then serve `admin/` separately against its API. Set `QA_WEB_URL` to that localhost admin origin. Prepare a seeded admin holding `view_users` and `manage_testers`, with a locally MFA-verified session; clear only that user's and session's isolated Redis entries after SQL fixture changes. These authentication fixtures are not proof of real MFA. Run `QA_TESTER_SAMPLE_FIXTURES=1 pnpm proof tester-sample-data`. + +The flow creates a disposable dedicated reviewer workspace without displaying its password, confirms the sample-data dialog, checks persisted status and a no-op retry, and revokes the reviewer afterward. It does not connect a mailbox, send email, launch a scan or populate a customer workspace. Stop the additional admin server and the QA stack when finished. + ## Writing a flow Flows live in `flows/.flow.ts`, one `test` per walkthrough. Extend the diff --git a/qa/flows/tester-sample-data.flow.ts b/qa/flows/tester-sample-data.flow.ts new file mode 100644 index 000000000..c8708bedd --- /dev/null +++ b/qa/flows/tester-sample-data.flow.ts @@ -0,0 +1,61 @@ +import { readFileSync } from "node:fs"; +import { authFile, env } from "../lib/env.ts"; +import { expect, test } from "../lib/proof.ts"; + +// Use the isolated admin origin with locally provisioned admin permissions and MFA session fixtures. +test("add sample data to a dedicated Test workspace once", async ({ page, proof }) => { + test.skip(process.env.QA_TESTER_SAMPLE_FIXTURES !== "1", "Prepare the localhost admin auth fixtures and set QA_TESTER_SAMPLE_FIXTURES=1."); + const state = JSON.parse(readFileSync(authFile(), "utf8")) as { + origins: { origin: string; localStorage: { name: string; value: string }[] }[]; + }; + const stored = state.origins.find((origin) => origin.origin === env.webURL)?.localStorage.find((item) => item.name === "auth_token"); + if (!stored) throw new Error("No local admin session prepared"); + const token = JSON.parse(stored.value) as { access_token: string }; + const headers = { Authorization: `Bearer ${token.access_token}` }; + await page.addInitScript(() => { + const token = localStorage.getItem("auth_token"); + if (token) localStorage.setItem("warmbly_admin_token", token); + }); + + const email = `sample-proof-${Date.now()}@example.test`; + const created = await page.request.post(`${env.apiURL}/v1/admin/testers`, { + headers, + data: { email, org_name: "Sample review proof", reason: "Local sample-data proof", password_days: 30 }, + }); + expect(created.status()).toBe(201); + const { organization_id: orgID, user_id: userID } = await created.json() as { organization_id: string; user_id: string }; + try { + await page.goto("/testers"); + const row = page.getByRole("listitem").filter({ has: page.getByText(email, { exact: true }) }); + await expect(row).toContainText("No sample data added"); + const add = row.getByRole("button", { name: `Add sample data for ${email}`, exact: true }); + await expect(add).toBeEnabled(); + await proof.chapter("Dedicated Test workspace", "Real localhost API. Admin permissions and verified session are local auth fixtures, not MFA proof."); + await proof.shot("tester-sample-data-before", { caption: "A dedicated Test workspace can be populated without connecting a mailbox." }); + + await add.click(); + const dialog = page.getByRole("dialog", { name: "Add sample data?", exact: true }); + await expect(dialog).toContainText("Six fictional, unsubscribed contacts"); + await expect(dialog).toContainText("One draft campaign"); + await expect(dialog).toContainText("No mailbox is created and no email is sent"); + await proof.shot("tester-sample-data-confirm", { caption: "Confirmation names the synthetic dataset, draft campaign and manual mailbox requirement." }); + const seeded = page.waitForResponse((response) => response.url().endsWith(`/admin/organizations/${orgID}/sample-data`) && response.request().method() === "POST"); + await dialog.getByRole("button", { name: "Add sample data", exact: true }).click(); + const response = await seeded; + expect(response.status()).toBe(200); + expect((await response.json()).created).toBe(true); + await expect(dialog).toBeHidden(); + await expect(row).toContainText("Sample data added"); + await expect(add).toBeDisabled(); + await proof.shot("tester-sample-data-added", { caption: "The real API persists the dataset and audit timestamp; repeat addition is disabled." }); + + const repeat = await page.request.post(`${env.apiURL}/v1/admin/organizations/${orgID}/sample-data`, { headers }); + expect(repeat.status()).toBe(200); + expect((await repeat.json()).created).toBe(false); + await page.reload(); + await expect(row).toContainText("Sample data added"); + await expect(add).toBeDisabled(); + } finally { + await page.request.delete(`${env.apiURL}/v1/admin/testers/${userID}`, { headers }); + } +});