mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(analytics): build-time bake + telemetry depth (#336)
Bake PostHog + Sentry into the published Docker image (SNAPOTTER_ANALYTICS build arg, codegen script). Delete entire consent system. Move event emission to BullMQ worker. Add cross-tier identity stitching, Sentry performance tracing on both tiers, frontend funnel events. Fix stateful regex bug. 86 files changed, 1593 insertions(+), 3747 deletions(-)
This commit is contained in:
@@ -1,22 +1,13 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// ─── Analytics Privacy / No Data Leak ───────────────────────────────
|
||||
// CRITICAL privacy tests: verify that disabling analytics means
|
||||
// absolutely zero data is sent to PostHog, Sentry, or any external
|
||||
// analytics domain. Also verifies that tool functionality is not
|
||||
// degraded when analytics are disabled.
|
||||
//
|
||||
// NOTE: The auth setup project already accepted analytics consent for
|
||||
// the admin user, so the consent page won't appear on login. Instead,
|
||||
// these tests login, then explicitly disable analytics via the API,
|
||||
// then reload the page so the store picks up the new state.
|
||||
// Verifies that when analytics is disabled at build time, zero data
|
||||
// is sent to PostHog or Sentry, and tool functionality is unaffected.
|
||||
|
||||
const SAMPLES_DIR = path.join(process.env.HOME ?? "/Users/sidd", "Downloads", "sample");
|
||||
const FIXTURES_DIR = path.join(process.cwd(), "tests", "fixtures");
|
||||
// biome-ignore lint/suspicious/noUndeclaredEnvVars: e2e test env var
|
||||
const BASE_URL = process.env.API_URL ?? "http://localhost:1349";
|
||||
|
||||
/** Analytics-related domains to watch for in network traffic. */
|
||||
const ANALYTICS_DOMAINS = [
|
||||
"posthog.com",
|
||||
"us.i.posthog.com",
|
||||
@@ -35,60 +26,7 @@ async function loginFresh(page: import("@playwright/test").Page) {
|
||||
await page.getByLabel("Username").fill("admin");
|
||||
await page.getByLabel("Password").fill("admin");
|
||||
await page.getByRole("button", { name: /login/i }).click();
|
||||
// May land on "/" or "/analytics-consent" depending on user state
|
||||
try {
|
||||
const acceptBtn = page.getByRole("button", { name: /sure, sounds good/i });
|
||||
await acceptBtn.waitFor({ state: "visible", timeout: 5_000 });
|
||||
await acceptBtn.click();
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
} catch {
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable analytics for the admin user via the browser's existing auth token,
|
||||
* then reload so the frontend store picks up analyticsEnabled=false.
|
||||
* This avoids an extra /api/auth/login call (which counts toward rate limits).
|
||||
*/
|
||||
async function disableAnalytics(page: import("@playwright/test").Page): Promise<void> {
|
||||
const ok = await page.evaluate(async () => {
|
||||
const token = localStorage.getItem("snapotter-token") ?? "";
|
||||
const res = await fetch("/api/v1/user/analytics", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
return res.ok;
|
||||
});
|
||||
expect(ok, "Failed to disable analytics via in-browser API call").toBe(true);
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enable analytics for the admin user via the browser's existing auth token.
|
||||
* Called in afterEach so subsequent tests/runs start with analytics enabled.
|
||||
*/
|
||||
async function enableAnalytics(page: import("@playwright/test").Page): Promise<void> {
|
||||
try {
|
||||
await page.evaluate(async () => {
|
||||
const token = localStorage.getItem("snapotter-token") ?? "";
|
||||
await fetch("/api/v1/user/analytics", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup — page may already be closed
|
||||
}
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
function getFixture(name: string): string {
|
||||
@@ -117,30 +55,26 @@ async function waitForProcessingDone(
|
||||
await spinner.waitFor({ state: "hidden", timeout: timeoutMs });
|
||||
}
|
||||
} catch {
|
||||
// No spinner — processing may have been instant
|
||||
// No spinner
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe("No data leak after disabling analytics", () => {
|
||||
// Use fresh browser context — no saved auth state
|
||||
test.describe("No data leak when analytics disabled", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
// Re-enable analytics after each test so subsequent tests/runs start clean
|
||||
test.afterEach(async ({ page }) => {
|
||||
await enableAnalytics(page);
|
||||
test.beforeEach(async ({ request: _request }, testInfo) => {
|
||||
// Skip if this build has analytics enabled
|
||||
const res = await fetch(`${BASE_URL}/api/v1/config/analytics`);
|
||||
const config = await res.json();
|
||||
if (config.enabled) {
|
||||
testInfo.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test("zero PostHog/Sentry traffic after explicitly disabling analytics", async ({ page }) => {
|
||||
test("zero PostHog/Sentry traffic when analytics disabled", async ({ page }) => {
|
||||
const analyticsRequests: string[] = [];
|
||||
|
||||
// Login first (consent was already accepted by auth setup)
|
||||
await loginFresh(page);
|
||||
|
||||
// Disable analytics via API and reload
|
||||
await disableAnalytics(page);
|
||||
|
||||
// Set up network interception AFTER disabling analytics
|
||||
await page.route("**/*", (route) => {
|
||||
const url = route.request().url();
|
||||
if (isAnalyticsRequest(url)) {
|
||||
@@ -149,7 +83,8 @@ test.describe("No data leak after disabling analytics", () => {
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
// Navigate to several pages
|
||||
await loginFresh(page);
|
||||
|
||||
await page.goto("/resize");
|
||||
await page.waitForTimeout(2_000);
|
||||
await page.goto("/compress");
|
||||
@@ -159,115 +94,32 @@ test.describe("No data leak after disabling analytics", () => {
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
// Assert ZERO analytics requests were made
|
||||
expect(
|
||||
analyticsRequests,
|
||||
`Expected zero analytics requests, but found: ${analyticsRequests.join(", ")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("zero PostHog/Sentry traffic after toggling analytics off", async ({ page }) => {
|
||||
const analyticsRequests: string[] = [];
|
||||
|
||||
// Login (consent was already accepted by auth setup)
|
||||
test("tool processing works when analytics disabled", async ({ page }) => {
|
||||
await loginFresh(page);
|
||||
|
||||
// Disable analytics via API and reload
|
||||
await disableAnalytics(page);
|
||||
|
||||
// Set up network interception
|
||||
await page.route("**/*", (route) => {
|
||||
const url = route.request().url();
|
||||
if (isAnalyticsRequest(url)) {
|
||||
analyticsRequests.push(url);
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
// Browse around
|
||||
await page.goto("/crop");
|
||||
await page.waitForTimeout(2_000);
|
||||
await page.goto("/convert");
|
||||
await page.waitForTimeout(2_000);
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
expect(
|
||||
analyticsRequests,
|
||||
`Expected zero analytics requests, but found: ${analyticsRequests.join(", ")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("tool processing works normally after disabling analytics", async ({ page }) => {
|
||||
// Login and disable analytics
|
||||
await loginFresh(page);
|
||||
await disableAnalytics(page);
|
||||
|
||||
// Navigate to resize tool
|
||||
await page.goto("/resize");
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
// Upload a test image
|
||||
const testImage = getFixture("test-200x150.png");
|
||||
await uploadFiles(page, [testImage]);
|
||||
|
||||
// Set resize parameters
|
||||
const widthInput = page.getByLabel("Width (px)");
|
||||
await widthInput.fill("100");
|
||||
|
||||
// Process the image
|
||||
const processBtn = page.getByTestId("resize-submit");
|
||||
await expect(processBtn).toBeEnabled({ timeout: 15_000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page);
|
||||
|
||||
// Verify no errors
|
||||
const error = page.locator(".text-red-500");
|
||||
expect(await error.isVisible({ timeout: 2_000 }).catch(() => false)).toBe(false);
|
||||
|
||||
// Verify a download link or result appeared
|
||||
const downloadLink = page.locator(
|
||||
"a[download], a[href*='download'], button:has-text('Download')",
|
||||
);
|
||||
await expect(downloadLink.first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("tool processing works with sample portrait after disabling analytics", async ({ page }) => {
|
||||
const portraitPath = path.join(
|
||||
SAMPLES_DIR,
|
||||
"portrait-of-a-smiling-man-with-glasses-and-a-beard-isolated.png",
|
||||
);
|
||||
if (!fs.existsSync(portraitPath)) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Login and disable analytics
|
||||
await loginFresh(page);
|
||||
await disableAnalytics(page);
|
||||
|
||||
// Navigate to resize tool
|
||||
await page.goto("/resize");
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
// Upload sample portrait
|
||||
await uploadFiles(page, [portraitPath]);
|
||||
|
||||
// Set resize width
|
||||
const widthInput = page.getByLabel("Width (px)");
|
||||
await widthInput.fill("200");
|
||||
|
||||
// Process
|
||||
const processBtn = page.getByTestId("resize-submit");
|
||||
await expect(processBtn).toBeEnabled({ timeout: 15_000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page);
|
||||
|
||||
// Verify no errors
|
||||
const error = page.locator(".text-red-500");
|
||||
expect(await error.isVisible({ timeout: 2_000 }).catch(() => false)).toBe(false);
|
||||
|
||||
// Verify download appeared — proves functionality is not degraded
|
||||
const downloadLink = page.locator(
|
||||
"a[download], a[href*='download'], button:has-text('Download')",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user