diff --git a/.dockerignore b/.dockerignore index c1a7e94e6..fda7aa837 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,9 @@ node_modules .env.* scripts/seed scripts/seed-data.ts +tests +playwright.config.ts +playwright.api.config.ts +docker-compose.test.yml +playwright-report +test-results diff --git a/.gitignore b/.gitignore index 5085c3251..15ff7b885 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ yarn-error.log* *.env.* *.dev.yml + +# api integration test runtime state +/tests/api/.runtime diff --git a/Dockerfile b/Dockerfile index 10034b9a8..49b628c18 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,10 @@ RUN pnpm install --frozen-lockfile # Rebuild the source code only when needed FROM node:${NODE_IMAGE_VERSION} AS builder +ARG PNPM_VERSION WORKDIR /app +# build-openapi shells out to pnpm, so the builder stage needs it too +RUN npm install -g pnpm@${PNPM_VERSION} COPY --from=deps /app/node_modules ./node_modules COPY . . COPY docker/proxy.ts ./src @@ -33,6 +36,9 @@ ENV BASE_PATH=$BASE_PATH ENV NEXT_TELEMETRY_DISABLED=1 ENV DATABASE_URL="postgresql://user:pass@localhost:5432/dummy" +ARG SKIP_BUILD_GEO +ENV SKIP_BUILD_GEO=$SKIP_BUILD_GEO + RUN npm run build-docker # Production image, copy all the files and run next diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 000000000..8c68caced --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,107 @@ +# Compose stack for the API integration test suite (`pnpm test:api`). +# +# This is NOT the deployable stack — see docker-compose.yml for that. Databases +# run on tmpfs and are discarded on `down`, so every run starts from an empty +# schema (migrations + the default admin user are applied by the umami image on +# boot via scripts/start-docker.sh). +# +# Profiles: +# postgres umami + Postgres -> pnpm test:api +# clickhouse umami + Postgres + ClickHouse -> pnpm test:api:clickhouse +# +# Environment overrides: +# UMAMI_TEST_PORT host port for the app (default 3100, avoids the dev server on 3000) +# UMAMI_TEST_IMAGE image tag to run/build (default umami-test:local) +# SKIP_BUILD_GEO "1" (default) skips the MaxMind download during the image build + +x-umami-service: &umami-service + image: ${UMAMI_TEST_IMAGE:-umami-test:local} + build: + context: . + args: + SKIP_BUILD_GEO: ${SKIP_BUILD_GEO:-1} + ports: + - "${UMAMI_TEST_PORT:-3100}:3000" + init: true + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/heartbeat || exit 1"] + interval: 2s + timeout: 5s + retries: 90 + start_period: 20s + +x-umami-env: &umami-env + DATABASE_URL: postgresql://umami:umami@db:5432/umami + APP_SECRET: umami-api-test-secret + TWO_FACTOR_ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + # Playwright/curl user agents are classified as bots; without this /api/send silently drops events. + DISABLE_BOT_CHECK: "1" + DISABLE_TELEMETRY: "1" + DISABLE_UPDATES: "1" + +services: + db: + image: postgres:15-alpine + environment: + POSTGRES_DB: umami + POSTGRES_USER: umami + POSTGRES_PASSWORD: umami + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U umami -d umami"] + interval: 2s + timeout: 5s + retries: 30 + + umami: + <<: *umami-service + profiles: [postgres] + environment: *umami-env + depends_on: + db: + condition: service_healthy + + clickhouse: + # Pinned to an LTS release. 26.7+ rejects db/clickhouse/schema.sql because + # website_event_stats_hourly has dimension columns outside the sorting key + # (see allow_dimensions_outside_sorting_key). + image: clickhouse/clickhouse-server:25.8 + profiles: [clickhouse] + environment: + CLICKHOUSE_DB: umami + CLICKHOUSE_USER: umami + CLICKHOUSE_PASSWORD: umami + ulimits: + nofile: + soft: 262144 + hard: 262144 + tmpfs: + - /var/lib/clickhouse + volumes: + # The image runs /docker-entrypoint-initdb.d/*.sql on first start (empty data dir). + - ./tests/api/docker/clickhouse-init/00-create-db.sql:/docker-entrypoint-initdb.d/00-create-db.sql:ro + - ./db/clickhouse/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + healthcheck: + # Healthy only once schema.sql has been applied (session_link is the last table it creates). + test: + [ + "CMD-SHELL", + "clickhouse-client --user umami --password umami --query \"SELECT throwIf(count() = 0) FROM system.tables WHERE database = 'umami' AND name = 'session_link'\"", + ] + interval: 2s + timeout: 5s + retries: 90 + start_period: 10s + + umami-clickhouse: + <<: *umami-service + profiles: [clickhouse] + environment: + <<: *umami-env + CLICKHOUSE_URL: http://umami:umami@clickhouse:8123/umami + depends_on: + db: + condition: service_healthy + clickhouse: + condition: service_healthy diff --git a/package.json b/package.json index a8c44296c..357656312 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,12 @@ "test:watch": "vitest", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "test:api": "tsx scripts/test-api.ts", + "test:api:clickhouse": "tsx scripts/test-api.ts --clickhouse", + "test:api:run": "playwright test -c playwright.api.config.ts", + "test:api:up": "tsx scripts/test-api.ts --up-only", + "test:api:down": "tsx scripts/test-api.ts --down", + "test:api:coverage": "tsx scripts/test-api.ts --coverage-report", "seed-data": "tsx scripts/seed-data.ts", "lint": "biome lint .", "format": "biome format --write .", diff --git a/playwright.api.config.ts b/playwright.api.config.ts new file mode 100644 index 000000000..3e235de5c --- /dev/null +++ b/playwright.api.config.ts @@ -0,0 +1,38 @@ +/// +import { defineConfig, type ReporterDescription } from '@playwright/test'; +import { BASE_URL as baseURL } from './tests/api/paths'; + +/** + * Playwright config for the API integration suite (tests/api). + * + * The suite only uses the `request` fixture — no browser is launched. It expects + * a running umami server (normally started by scripts/test-api.ts via + * docker-compose.test.yml) at PLAYWRIGHT_BASE_URL or http://localhost:3100. + */ + +const reporters: ReporterDescription[] = [['list'], ['./tests/api/coverage/reporter.ts']]; + +if (process.env.CI) { + reporters.push(['html', { open: 'never', outputFolder: 'playwright-report/api' }]); +} + +export default defineConfig({ + testDir: './tests/api', + testMatch: '**/*.spec.ts', + outputDir: 'test-results/api', + // Specs share one seeded database and a few mutable globals (the shared users, + // the global 2FA requirement toggled by admin.spec.ts), so they must run serially. + fullyParallel: false, + workers: 1, + // Specs are deterministic and share coverage state; retries would only hide flakes. + retries: 0, + forbidOnly: !!process.env.CI, + timeout: 30_000, + globalSetup: './tests/api/global-setup.ts', + reporter: reporters, + use: { + baseURL, + trace: 'off', + }, + projects: [{ name: 'api' }], +}); diff --git a/scripts/test-api.ts b/scripts/test-api.ts new file mode 100644 index 000000000..690258159 --- /dev/null +++ b/scripts/test-api.ts @@ -0,0 +1,325 @@ +/* eslint-disable no-console */ +/** + * Orchestrates the API integration suite: + * + * 1. docker compose up (builds the umami image, starts Postgres [+ ClickHouse]) + * 2. waits for /api/heartbeat + * 3. runs Playwright against the container (tests/api, playwright.api.config.ts) + * 4. dumps container logs on failure + * 5. tears the stack down (unless --keep) + * + * Usage: tsx scripts/test-api.ts [options] [-- playwright args] + */ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { assertDisposableTarget } from '../tests/api/paths'; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const COMPOSE_FILE = 'docker-compose.test.yml'; +const PROJECT_NAME = process.env.COMPOSE_PROJECT_NAME || 'umami-api-test'; +const PORT = process.env.UMAMI_TEST_PORT || '3100'; +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`; +// PLAYWRIGHT_BASE_URL selects an already-running server: no Compose stack is +// built, started, or torn down for it. +const EXTERNAL_TARGET = !!process.env.PLAYWRIGHT_BASE_URL; +const HEARTBEAT_TIMEOUT_MS = 180_000; +// Mirrors RUNTIME_DIR in tests/api/paths.ts (one state dir per target host). +const LOG_FILE = path.join( + ROOT_DIR, + 'tests/api/.runtime', + new URL(BASE_URL).host.replace(/[^a-z0-9.-]+/gi, '-'), + 'docker.log', +); + +const HELP = ` +Usage: pnpm test:api [options] [-- playwright args] + +Options: + --clickhouse Run against Postgres + ClickHouse (analytics stored in ClickHouse) + --keep Leave the containers running after the tests finish + --no-build Skip the image build (use the existing UMAMI_TEST_IMAGE) + --up-only Start the stack, wait for readiness, and exit (see --keep) + --down Tear down the stack and exit + --grep Only run matching tests (implies --coverage-report) + --coverage-report Print endpoint coverage without failing the run + -h, --help Show this help + +Environment: + UMAMI_TEST_PORT Host port for the app (default 3100) + UMAMI_TEST_IMAGE Image tag to build/run (default umami-test:local) + COMPOSE_PROJECT_NAME Compose project name (default umami-api-test) + PLAYWRIGHT_BASE_URL Test an already-running server instead of starting the + Compose stack (pass --clickhouse if it stores analytics there) + API_ALLOW_DESTRUCTIVE=1 Required for non-loopback PLAYWRIGHT_BASE_URL targets: the suite + recreates websites, ingests data and toggles global 2FA + API_SKIP_SEED=1 Reuse the previous seed when re-running against a kept stack + (each run seeds additively otherwise) +`; + +interface Options { + clickhouse: boolean; + keep: boolean; + build: boolean; + upOnly: boolean; + down: boolean; + grep?: string; + coverageReport: boolean; + passthrough: string[]; +} + +function parseArgs(argv: string[]): Options { + const options: Options = { + clickhouse: false, + keep: false, + build: true, + upOnly: false, + down: false, + coverageReport: false, + passthrough: [], + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (arg === '--') { + options.passthrough.push(...argv.slice(i + 1)); + break; + } + + if (arg.startsWith('--grep=')) { + options.grep = arg.slice('--grep='.length); + continue; + } + + switch (arg) { + case '--clickhouse': + options.clickhouse = true; + break; + case '--keep': + options.keep = true; + break; + case '--no-build': + options.build = false; + break; + case '--up-only': + options.upOnly = true; + break; + case '--down': + options.down = true; + break; + case '--coverage-report': + options.coverageReport = true; + break; + case '--grep': + options.grep = argv[++i]; + break; + case '-h': + case '--help': + console.log(HELP); + process.exit(0); + break; + default: + console.error(`Unknown option: ${arg}`); + console.log(HELP); + process.exit(2); + } + } + + return options; +} + +function run(command: string, args: string[], env: Record = {}) { + const result = spawnSync(command, args, { + cwd: ROOT_DIR, + stdio: 'inherit', + env: { ...process.env, ...env }, + }); + + if (result.error) { + throw result.error; + } + + return result.status ?? 1; +} + +function capture(command: string, args: string[]) { + const result = spawnSync(command, args, { + cwd: ROOT_DIR, + encoding: 'utf8', + env: process.env, + maxBuffer: 64 * 1024 * 1024, + }); + + return `${result.stdout ?? ''}${result.stderr ?? ''}`; +} + +function composeArgs(profiles: string[], args: string[]) { + return [ + 'compose', + '-f', + COMPOSE_FILE, + '-p', + PROJECT_NAME, + ...profiles.flatMap(profile => ['--profile', profile]), + ...args, + ]; +} + +async function waitForHeartbeat(url: string, timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + let lastError = ''; + + while (Date.now() < deadline) { + try { + const response = await fetch(`${url}/api/heartbeat`); + + if (response.ok) { + return; + } + + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = (error as Error).message; + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + throw new Error(`Timed out waiting for ${url}/api/heartbeat (${lastError})`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const profile = options.clickhouse ? 'clickhouse' : 'postgres'; + const appService = options.clickhouse ? 'umami-clickhouse' : 'umami'; + const logServices = options.clickhouse ? [appService, 'db', 'clickhouse'] : [appService, 'db']; + + const teardown = () => { + console.log('\nStopping test stack...'); + run('docker', composeArgs(['postgres', 'clickhouse'], ['down', '-v', '--remove-orphans'])); + }; + + if (EXTERNAL_TARGET && (options.upOnly || options.down)) { + console.error( + 'PLAYWRIGHT_BASE_URL selects an already-running server; --up-only and --down manage ' + + 'the local Compose stack and cannot be combined with it.', + ); + return 2; + } + + if (options.down) { + teardown(); + return 0; + } + + const dumpLogs = () => { + const logs = capture('docker', composeArgs([profile], ['logs', '--no-color', ...logServices])); + + mkdirSync(path.dirname(LOG_FILE), { recursive: true }); + writeFileSync(LOG_FILE, logs); + + console.log(`\n--- docker compose logs (${logServices.join(', ')}) ---\n`); + console.log(logs); + console.log(`--- end of logs (saved to ${path.relative(ROOT_DIR, LOG_FILE)}) ---\n`); + }; + + let exitCode = 1; + let started = false; + + const onSignal = () => { + console.log('\nInterrupted.'); + + if (started && !options.keep) { + teardown(); + } + + process.exit(130); + }; + + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + + try { + if (EXTERNAL_TARGET) { + try { + assertDisposableTarget(BASE_URL); + } catch (error) { + console.error((error as Error).message); + return 2; + } + console.log(`Using the already-running server at ${BASE_URL} (PLAYWRIGHT_BASE_URL is set).`); + } else { + console.log(`Starting test stack (profile: ${profile}, port: ${PORT})...`); + + const upArgs = ['up', '-d', '--wait', '--wait-timeout', '300']; + + if (options.build) { + upArgs.push('--build'); + } + + started = true; + + if (run('docker', composeArgs([profile], upArgs)) !== 0) { + console.error('docker compose up failed.'); + dumpLogs(); + return 1; + } + } + + await waitForHeartbeat(BASE_URL, HEARTBEAT_TIMEOUT_MS); + + console.log(`umami is ready at ${BASE_URL}`); + + if (options.upOnly) { + options.keep = true; + console.log( + `\nRun tests with: PLAYWRIGHT_BASE_URL=${BASE_URL} UMAMI_TEST_DB=${profile} pnpm test:api:run` + + '\nRe-running against the same stack? Add API_SKIP_SEED=1 (seeding is additive).' + + '\nStop the stack with: pnpm test:api:down', + ); + return 0; + } + + const playwrightArgs = ['exec', 'playwright', 'test', '-c', 'playwright.api.config.ts']; + + if (options.grep) { + playwrightArgs.push('--grep', options.grep); + } + + playwrightArgs.push(...options.passthrough); + + exitCode = run(process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', playwrightArgs, { + PLAYWRIGHT_BASE_URL: BASE_URL, + PLAYWRIGHT_SKIP_WEB_SERVER: '1', + UMAMI_TEST_DB: profile, + API_COVERAGE: + process.env.API_COVERAGE ?? (options.grep || options.coverageReport ? 'report' : 'enforce'), + }); + + if (exitCode !== 0 && started) { + dumpLogs(); + } + + return exitCode; + } finally { + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + + if (started && !options.keep) { + teardown(); + } else if (started) { + console.log(`\nStack left running (${BASE_URL}). Stop it with: pnpm test:api:down`); + } + } +} + +main() + .then(code => { + process.exit(code); + }) + .catch(error => { + console.error(error); + process.exit(1); + }); diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts index 7d3331dd7..d5fdbc10f 100644 --- a/src/lib/clickhouse.ts +++ b/src/lib/clickhouse.ts @@ -62,17 +62,36 @@ function getUTCString(date?: Date | string | number) { return formatInTimeZone(date || new Date(), 'UTC', 'yyyy-MM-dd HH:mm:ss'); } +// ClickHouse time zone names are case-sensitive: several callers default to the +// lowercase "utc" that Postgres accepts but ClickHouse rejects. Intl accepts any +// casing and returns the canonical IANA name. +function normalizeTimezone(timezone?: string) { + if (!timezone) { + return timezone; + } + + try { + return Intl.DateTimeFormat(undefined, { timeZone: timezone }).resolvedOptions().timeZone; + } catch { + return timezone; + } +} + function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) { - if (timezone) { - return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${timezone}')`; + const tz = normalizeTimezone(timezone); + + if (tz) { + return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${tz}')`; } return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`; } function getDateSQL(field: string, unit: string, timezone?: string) { - if (timezone) { - return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'), '${timezone}')`; + const tz = normalizeTimezone(timezone); + + if (tz) { + return `toDateTime(date_trunc('${unit}', ${field}, '${tz}'), '${tz}')`; } return `toDateTime(date_trunc('${unit}', ${field}))`; } @@ -236,21 +255,27 @@ function getQueryParams(filters: Record) { }; } -function parseFilters(filters: Record, options?: QueryOptions) { +function parseFilters(rawFilters: Record, options?: QueryOptions) { + const filters = rawFilters.timezone + ? { ...rawFilters, timezone: normalizeTimezone(rawFilters.timezone) } + : rawFilters; const cohortFilters = Object.fromEntries( Object.entries(filters).filter(([key]) => key.startsWith('cohort_')), ); - const { - sql: eventPropertyFilterQuery, - params: eventPropertyFilterParams, - } = getEventPropertyFilterQuery((filters as QueryFilters).eventPropertyFilters, filters.timezone); - const { - sql: sessionPropertyFilterQuery, - params: sessionPropertyFilterParams, - } = getSessionPropertyFilterQuery((filters as QueryFilters).sessionPropertyFilters, filters.timezone); + const { sql: eventPropertyFilterQuery, params: eventPropertyFilterParams } = + getEventPropertyFilterQuery((filters as QueryFilters).eventPropertyFilters, filters.timezone); + const { sql: sessionPropertyFilterQuery, params: sessionPropertyFilterParams } = + getSessionPropertyFilterQuery( + (filters as QueryFilters).sessionPropertyFilters, + filters.timezone, + ); return { - filterQuery: [getFilterQuery(filters, options), eventPropertyFilterQuery, sessionPropertyFilterQuery] + filterQuery: [ + getFilterQuery(filters, options), + eventPropertyFilterQuery, + sessionPropertyFilterQuery, + ] .filter(Boolean) .join('\n'), dateQuery: getDateQuery(filters), diff --git a/src/queries/sql/reports/getRevenueChart.ts b/src/queries/sql/reports/getRevenueChart.ts index a2d8e7c27..8778b27d4 100644 --- a/src/queries/sql/reports/getRevenueChart.ts +++ b/src/queries/sql/reports/getRevenueChart.ts @@ -123,7 +123,7 @@ async function clickhouseQuery( website_revenue.created_at, website_revenue.revenue from website_revenue - any inner join filtered_sessions + inner join filtered_sessions on filtered_sessions.website_id = website_revenue.website_id and filtered_sessions.session_id = website_revenue.session_id where website_revenue.website_id = {websiteId:UUID} diff --git a/src/queries/sql/reports/getRevenueMetrics.ts b/src/queries/sql/reports/getRevenueMetrics.ts index ebb51a047..ea12b5764 100644 --- a/src/queries/sql/reports/getRevenueMetrics.ts +++ b/src/queries/sql/reports/getRevenueMetrics.ts @@ -210,8 +210,8 @@ async function relationalQuery( we.utm_medium ilike '%ppc%' OR we.utm_medium ilike '%retargeting%' OR we.utm_medium ilike '%paid%' then 'paid' else 'organic' end AS prefix, - we.referrer_domain, - we.url_query, + coalesce(we.referrer_domain, '') as referrer_domain, + coalesce(we.url_query, '') as url_query, we.utm_medium, we.utm_source, we.hostname, @@ -293,7 +293,7 @@ async function clickhouseQuery( website_revenue.created_at, website_revenue.revenue from website_revenue - any inner join filtered_sessions + inner join filtered_sessions on filtered_sessions.website_id = website_revenue.website_id and filtered_sessions.session_id = website_revenue.session_id where website_revenue.website_id = {websiteId:UUID} @@ -312,7 +312,7 @@ async function clickhouseQuery( filtered_sessions.country as "name", sum(filtered_revenue.revenue) as "value" from filtered_revenue - any inner join filtered_sessions + inner join filtered_sessions on filtered_sessions.website_id = filtered_revenue.website_id and filtered_sessions.session_id = filtered_revenue.session_id group by filtered_sessions.country @@ -333,7 +333,7 @@ async function clickhouseQuery( filtered_sessions.region as "name", sum(filtered_revenue.revenue) as "value" from filtered_revenue - any inner join filtered_sessions + inner join filtered_sessions on filtered_sessions.website_id = filtered_revenue.website_id and filtered_sessions.session_id = filtered_revenue.session_id group by 1, 2 @@ -354,7 +354,7 @@ async function clickhouseQuery( website_revenue.session_id, sum(website_revenue.revenue) as "value" from website_revenue - any inner join filtered_sessions + inner join filtered_sessions on filtered_sessions.website_id = website_revenue.website_id and filtered_sessions.session_id = website_revenue.session_id where website_revenue.website_id = {websiteId:UUID} @@ -409,7 +409,7 @@ async function clickhouseQuery( website_revenue.session_id, sum(website_revenue.revenue) as "value" from website_revenue - any inner join filtered_sessions + inner join filtered_sessions on filtered_sessions.website_id = website_revenue.website_id and filtered_sessions.session_id = website_revenue.session_id where website_revenue.website_id = {websiteId:UUID} diff --git a/src/queries/sql/reports/getRevenueStats.ts b/src/queries/sql/reports/getRevenueStats.ts index aebc70031..dfb174002 100644 --- a/src/queries/sql/reports/getRevenueStats.ts +++ b/src/queries/sql/reports/getRevenueStats.ts @@ -112,7 +112,7 @@ async function clickhouseQuery( filtered_revenue as ( select website_revenue.website_id, website_revenue.session_id, website_revenue.event_id, website_revenue.revenue from website_revenue - any inner join filtered_sessions + inner join filtered_sessions on filtered_sessions.website_id = website_revenue.website_id and filtered_sessions.session_id = website_revenue.session_id where website_revenue.website_id = {websiteId:UUID} diff --git a/src/queries/sql/sessions/getWebsiteSessions.ts b/src/queries/sql/sessions/getWebsiteSessions.ts index 9bf0a232f..67d8f7323 100644 --- a/src/queries/sql/sessions/getWebsiteSessions.ts +++ b/src/queries/sql/sessions/getWebsiteSessions.ts @@ -75,7 +75,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { session.country, session.region, session.city - order by max(website_event.created_at) desc + order by max(website_event.created_at) desc, session.session_id `, queryParams, filters, @@ -138,7 +138,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { ${normalizedFilterQuery} ${searchQuery} group by session_id - order by lastAt desc + order by lastAt desc, id `; } else { sql = ` @@ -168,7 +168,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { ${normalizedFilterQuery} ${searchQuery} group by session_id - order by lastAt desc + order by lastAt desc, id `; } diff --git a/tests/api/account.spec.ts b/tests/api/account.spec.ts new file mode 100644 index 000000000..c9f5b4181 --- /dev/null +++ b/tests/api/account.spec.ts @@ -0,0 +1,340 @@ +import { expect, test } from './fixtures'; +import { login } from './helpers/auth'; +import { UNKNOWN_UUID } from './helpers/constants'; +import { createUser, deleteUser, uniqueName } from './helpers/entities'; + +test.describe('Account', () => { + test('GET /api/me returns the session user', async ({ admin, user, seed }) => { + const response = await admin.get('/api/me'); + const asUser = await user.get('/api/me'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + token: expect.any(String), + user: { + id: seed.admin.id, + username: seed.admin.username, + role: 'admin', + createdAt: expect.any(String), + isAdmin: true, + twoFactorRequired: expect.any(Boolean), + }, + }); + expect(response.body.user).not.toHaveProperty('password'); + expect(response.body).not.toHaveProperty('apiKey'); + expect(asUser.status).toBe(200); + expect(asUser.body.user).toMatchObject({ id: seed.user.id, role: 'user', isAdmin: false }); + }); + + test('GET /api/me identifies API key callers', async ({ apiKey, tokens, seed }) => { + const response = await apiKey.get('/api/me'); + + expect(response.status).toBe(200); + expect(response.body.user.id).toBe(seed.admin.id); + expect(response.body.apiKey).toMatchObject({ id: tokens.apiKey.id, name: expect.any(String) }); + }); + + test('GET /api/me requires authentication', async ({ api }) => { + const anonymous = await api.get('/api/me'); + const garbage = await api.bearer('umami_not_a_real_key').get('/api/me'); + + expect(anonymous.status).toBe(401); + expect(garbage.status).toBe(401); + }); + + test.describe('API keys', () => { + test.describe.configure({ mode: 'serial' }); + + const name = uniqueName('key'); + let keyId = ''; + let key = ''; + + test.afterAll(async ({ user }) => { + if (keyId) { + await user.del(`/api/me/api-keys/${keyId}`); + } + }); + + test('POST /api/me/api-keys creates a key', async ({ user }) => { + const response = await user.post('/api/me/api-keys', { name: ` ${name} ` }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: expect.any(String), + // Names are trimmed. + name, + keyPrefix: expect.any(String), + createdAt: expect.any(String), + key: expect.stringMatching(/^umami_/), + }); + expect(response.body.key.startsWith(response.body.keyPrefix)).toBe(true); + expect(response.body).not.toHaveProperty('keyHash'); + + keyId = response.body.id; + key = response.body.key; + }); + + test('POST /api/me/api-keys validates the body', async ({ user }) => { + const empty = await user.post('/api/me/api-keys', { name: ' ' }); + const missing = await user.post('/api/me/api-keys', {}); + const tooLong = await user.post('/api/me/api-keys', { name: 'x'.repeat(256) }); + + expect(empty.status).toBe(400); + expect(missing.status).toBe(400); + expect(tooLong.status).toBe(400); + }); + + test('POST /api/me/api-keys rejects anonymous and API key callers', async ({ api, apiKey }) => { + const anonymous = await api.post('/api/me/api-keys', { name }); + const viaApiKey = await apiKey.post('/api/me/api-keys', { name }); + + expect(anonymous.status).toBe(401); + expect(viaApiKey.status).toBe(401); + }); + + test('GET /api/me authenticates with the new key', async ({ api, seed }) => { + const response = await api.bearer(key).get('/api/me'); + + expect(response.status).toBe(200); + expect(response.body.user.id).toBe(seed.user.id); + expect(response.body.apiKey).toEqual({ id: keyId, name }); + }); + + test('GET /api/me/api-keys lists the caller keys without secrets', async ({ + user, + admin, + tokens, + }) => { + const response = await user.get('/api/me/api-keys'); + const adminKeys = await admin.get('/api/me/api-keys'); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + + const created = response.body.find((k: any) => k.id === keyId); + + expect(created).toMatchObject({ + id: keyId, + name, + keyPrefix: expect.any(String), + createdAt: expect.any(String), + }); + expect(created).toHaveProperty('lastUsedAt'); + expect(created).not.toHaveProperty('key'); + expect(created).not.toHaveProperty('keyHash'); + + // Keys are scoped to the caller: the admin sees the worker key, not the user key. + expect(adminKeys.status).toBe(200); + expect(adminKeys.body.map((k: any) => k.id)).toContain(tokens.apiKey.id); + expect(adminKeys.body.map((k: any) => k.id)).not.toContain(keyId); + }); + + test('GET /api/me/api-keys rejects anonymous and API key callers', async ({ api, apiKey }) => { + expect((await api.get('/api/me/api-keys')).status).toBe(401); + expect((await apiKey.get('/api/me/api-keys')).status).toBe(401); + }); + + test('DELETE /api/me/api-keys/{keyId} rejects unknown and foreign keys', async ({ + admin, + user, + api, + apiKey, + }) => { + const foreign = await admin.del(`/api/me/api-keys/${keyId}`); + const unknown = await user.del(`/api/me/api-keys/${UNKNOWN_UUID}`); + const anonymous = await api.del(`/api/me/api-keys/${keyId}`); + const viaApiKey = await apiKey.del(`/api/me/api-keys/${keyId}`); + + expect(foreign.status).toBe(404); + expect(unknown.status).toBe(404); + expect(anonymous.status).toBe(401); + expect(viaApiKey.status).toBe(401); + }); + + test('DELETE /api/me/api-keys/{keyId} revokes the key', async ({ user, api }) => { + const response = await user.del(`/api/me/api-keys/${keyId}`); + const list = await user.get('/api/me/api-keys'); + const revoked = await api.bearer(key).get('/api/me'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(list.body.map((k: any) => k.id)).not.toContain(keyId); + expect(revoked.status).toBe(401); + + keyId = ''; + }); + }); + + test.describe('Password', () => { + test.describe.configure({ mode: 'serial' }); + + let userId = ''; + let username = ''; + let password = ''; + let token = ''; + const newPassword = 'changed-password-1'; + + test.beforeAll(async ({ admin, api }) => { + const created = await createUser(admin); + + userId = created.id; + username = created.username; + password = created.password; + token = await login(api, { username, password }); + }); + + test.afterAll(async ({ admin }) => { + if (userId) { + await deleteUser(admin, userId); + } + }); + + test('POST /api/me/password validates the body', async ({ api }) => { + const client = api.bearer(token); + const short = await client.post('/api/me/password', { + currentPassword: password, + newPassword: 'short', + }); + const missingCurrent = await client.post('/api/me/password', { newPassword }); + const missingNew = await client.post('/api/me/password', { currentPassword: password }); + + expect(short.status).toBe(400); + expect(missingCurrent.status).toBe(400); + expect(missingNew.status).toBe(400); + }); + + test('POST /api/me/password rejects a wrong current password', async ({ api }) => { + const response = await api + .bearer(token) + .post('/api/me/password', { currentPassword: 'wrong-password', newPassword }); + + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Current password is incorrect'); + }); + + test('POST /api/me/password rejects anonymous and API key callers', async ({ api, apiKey }) => { + const body = { currentPassword: password, newPassword }; + + expect((await api.post('/api/me/password', body)).status).toBe(401); + expect((await apiKey.post('/api/me/password', body)).status).toBe(401); + }); + + test('POST /api/me/password changes the password and invalidates the session', async ({ + api, + }) => { + const response = await api + .bearer(token) + .post('/api/me/password', { currentPassword: password, newPassword }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: userId, username, role: 'user' }); + expect(response.body).not.toHaveProperty('password'); + + // Tokens are bound to the password hash, so the old session is rejected. + const stale = await api.bearer(token).get('/api/me'); + const oldLogin = await api.post('/api/auth/login', { username, password }); + const newToken = await login(api, { username, password: newPassword }); + const fresh = await api.bearer(newToken).get('/api/me'); + + expect(stale.status).toBe(401); + expect(oldLogin.status).toBe(401); + expect(fresh.status).toBe(200); + expect(fresh.body.user.id).toBe(userId); + }); + }); + + test('GET /api/me/teams lists the caller teams', async ({ user, viewer, seed }) => { + const response = await user.get('/api/me/teams'); + const none = await viewer.get('/api/me/teams'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: expect.any(Number), + page: 1, + pageSize: expect.any(Number), + }); + expect(response.body.count).toBeGreaterThanOrEqual(1); + expect(response.body.data.map((t: any) => t.id)).toContain(seed.team.id); + expect(none.status).toBe(200); + expect(none.body.data.map((t: any) => t.id)).not.toContain(seed.team.id); + }); + + test('GET /api/me/teams supports paging and sorting', async ({ user }) => { + const paged = await user.get('/api/me/teams', { params: { page: 1, pageSize: 1 } }); + const byName = await user.get('/api/me/teams', { + params: { orderBy: 'name', sortDescending: 'true' }, + }); + const byCreated = await user.get('/api/me/teams', { + params: { orderBy: 'createdAt', sortDescending: 'false' }, + }); + const badSort = await user.get('/api/me/teams', { params: { sortDescending: 'yes' } }); + const badPage = await user.get('/api/me/teams', { params: { page: 0 } }); + + expect(paged.status).toBe(200); + expect(paged.body.pageSize).toBe(1); + expect(paged.body.data.length).toBeLessThanOrEqual(1); + expect(byName.status).toBe(200); + expect(byCreated.status).toBe(200); + expect(badSort.status).toBe(400); + expect(badPage.status).toBe(400); + }); + + test('GET /api/me/teams accepts session and API key callers only', async ({ api, apiKey }) => { + const anonymous = await api.get('/api/me/teams'); + const viaApiKey = await apiKey.get('/api/me/teams'); + + expect(anonymous.status).toBe(401); + expect(viaApiKey.status).toBe(200); + expect(viaApiKey.body).toHaveProperty('data'); + }); + + test('GET /api/me/websites lists the caller websites', async ({ user, admin, seed }) => { + const response = await user.get('/api/me/websites'); + const asAdmin = await admin.get('/api/me/websites'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: expect.any(Number), + page: 1, + pageSize: expect.any(Number), + }); + + const website2 = response.body.data.find((w: any) => w.id === seed.website2.id); + + expect(website2).toMatchObject({ name: seed.website2.name, domain: seed.website2.domain }); + expect(website2).toHaveProperty('shareId'); + // Only websites owned by the caller are listed by default. + expect(response.body.data.map((w: any) => w.id)).not.toContain(seed.website.id); + expect(asAdmin.body.data.map((w: any) => w.id)).toContain(seed.website.id); + }); + + test('GET /api/me/websites supports includeTeams, paging and sorting', async ({ user, seed }) => { + const withTeams = await user.get('/api/me/websites', { params: { includeTeams: 'true' } }); + const paged = await user.get('/api/me/websites', { + params: { page: 1, pageSize: 1, orderBy: 'name', sortDescending: 'false' }, + }); + const badSort = await user.get('/api/me/websites', { params: { sortDescending: '1' } }); + + expect(withTeams.status).toBe(200); + expect(withTeams.body.data.map((w: any) => w.id)).toContain(seed.website2.id); + expect(paged.status).toBe(200); + expect(paged.body.pageSize).toBe(1); + expect(paged.body.data.length).toBeLessThanOrEqual(1); + expect(badSort.status).toBe(400); + }); + + test('GET /api/me/websites accepts session and API key callers only', async ({ + api, + apiKey, + seed, + }) => { + const anonymous = await api.get('/api/me/websites'); + const viaApiKey = await apiKey.get('/api/me/websites'); + + expect(anonymous.status).toBe(401); + expect(viaApiKey.status).toBe(200); + expect(viaApiKey.body.data.map((w: any) => w.id)).toContain(seed.website.id); + }); +}); diff --git a/tests/api/admin.spec.ts b/tests/api/admin.spec.ts new file mode 100644 index 000000000..f580194a0 --- /dev/null +++ b/tests/api/admin.spec.ts @@ -0,0 +1,445 @@ +import { expect, test } from './fixtures'; +import { login } from './helpers/auth'; +import { UNKNOWN_UUID } from './helpers/constants'; +import { createTeam, createUser, deleteTeam, deleteUser } from './helpers/entities'; + +test.describe('Admin', () => { + test.describe('Listings', () => { + test('GET /api/admin/users lists every user', async ({ admin, seed }) => { + const response = await admin.get('/api/admin/users'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: expect.any(Number), + page: 1, + pageSize: expect.any(Number), + }); + + const ids = response.body.data.map((u: any) => u.id); + + expect(ids).toContain(seed.admin.id); + expect(ids).toContain(seed.user.id); + expect(ids).toContain(seed.viewer.id); + + const adminUser = response.body.data.find((u: any) => u.id === seed.admin.id); + + expect(adminUser).toMatchObject({ + username: seed.admin.username, + role: 'admin', + twoFactorRequired: expect.any(Boolean), + _count: { websites: expect.any(Number) }, + }); + expect(adminUser).not.toHaveProperty('password'); + }); + + test('GET /api/admin/users supports search, sorting and paging', async ({ admin, seed }) => { + const search = await admin.get('/api/admin/users', { params: { search: 'api-' } }); + const byUsername = await admin.get('/api/admin/users', { + params: { orderBy: 'username', sortDescending: 'false' }, + }); + const byRole = await admin.get('/api/admin/users', { + params: { orderBy: 'role', sortDescending: 'true' }, + }); + const byCreated = await admin.get('/api/admin/users', { params: { orderBy: 'createdAt' } }); + const paged = await admin.get('/api/admin/users', { params: { page: 1, pageSize: 2 } }); + const badSort = await admin.get('/api/admin/users', { params: { sortDescending: 'yes' } }); + const badPage = await admin.get('/api/admin/users', { params: { pageSize: 'ten' } }); + + expect(search.status).toBe(200); + expect(search.body.data.length).toBeGreaterThanOrEqual(2); + expect(search.body.data.map((u: any) => u.id)).toContain(seed.user.id); + expect(search.body.data.every((u: any) => u.username.includes('api-'))).toBe(true); + expect(search.body.data.map((u: any) => u.id)).not.toContain(seed.admin.id); + + expect(byUsername.status).toBe(200); + const usernames = byUsername.body.data.map((u: any) => u.username); + expect(usernames).toEqual([...usernames].sort()); + + expect(byRole.status).toBe(200); + expect(byCreated.status).toBe(200); + expect(paged.status).toBe(200); + expect(paged.body.pageSize).toBe(2); + expect(paged.body.data.length).toBeLessThanOrEqual(2); + expect(badSort.status).toBe(400); + expect(badPage.status).toBe(400); + }); + + test('GET /api/admin/users requires an admin session', async ({ + api, + user, + viewer, + apiKey, + }) => { + expect((await api.get('/api/admin/users')).status).toBe(401); + expect((await user.get('/api/admin/users')).status).toBe(401); + expect((await viewer.get('/api/admin/users')).status).toBe(401); + // API keys are blocked on /api/admin/* even for admins. + expect((await apiKey.get('/api/admin/users')).status).toBe(401); + }); + + test('GET /api/admin/websites lists every website with its owner', async ({ admin, seed }) => { + const response = await admin.get('/api/admin/websites'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) }); + + const ids = response.body.data.map((w: any) => w.id); + + expect(ids).toContain(seed.website.id); + expect(ids).toContain(seed.website2.id); + + const website2 = response.body.data.find((w: any) => w.id === seed.website2.id); + + expect(website2).toMatchObject({ + name: seed.website2.name, + domain: seed.website2.domain, + user: { id: seed.user.id, username: seed.user.username }, + }); + expect(website2).toHaveProperty('shareId'); + }); + + test('GET /api/admin/websites supports search, sorting and paging', async ({ admin, seed }) => { + const byDomain = await admin.get('/api/admin/websites', { + params: { search: seed.website2.domain }, + }); + const byName = await admin.get('/api/admin/websites', { + params: { search: seed.website.name }, + }); + const sortedName = await admin.get('/api/admin/websites', { + params: { orderBy: 'name', sortDescending: 'false', pageSize: 5 }, + }); + const sortedDomain = await admin.get('/api/admin/websites', { + params: { orderBy: 'domain', sortDescending: 'true' }, + }); + const sortedCreated = await admin.get('/api/admin/websites', { + params: { orderBy: 'createdAt' }, + }); + const badSort = await admin.get('/api/admin/websites', { params: { sortDescending: 'x' } }); + + expect(byDomain.status).toBe(200); + expect(byDomain.body.data.map((w: any) => w.id)).toContain(seed.website2.id); + expect(byDomain.body.data.map((w: any) => w.id)).not.toContain(seed.website.id); + expect(byName.status).toBe(200); + expect(byName.body.data.map((w: any) => w.id)).toContain(seed.website.id); + + expect(sortedName.status).toBe(200); + expect(sortedName.body.pageSize).toBe(5); + expect(sortedName.body.data.length).toBeLessThanOrEqual(5); + expect(sortedDomain.status).toBe(200); + expect(sortedCreated.status).toBe(200); + expect(badSort.status).toBe(400); + }); + + test('GET /api/admin/websites requires an admin session', async ({ api, user, apiKey }) => { + expect((await api.get('/api/admin/websites')).status).toBe(401); + expect((await user.get('/api/admin/websites')).status).toBe(401); + expect((await apiKey.get('/api/admin/websites')).status).toBe(401); + }); + + test('GET /api/admin/teams lists every team with members', async ({ admin, seed }) => { + const response = await admin.get('/api/admin/teams'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) }); + + const team = response.body.data.find((t: any) => t.id === seed.team.id); + + expect(team).toMatchObject({ + name: seed.team.name, + members: expect.any(Array), + _count: { websites: expect.any(Number), members: expect.any(Number) }, + }); + expect(team.members.map((m: any) => m.userId)).toContain(seed.user.id); + expect(team.members[0]).toMatchObject({ + role: expect.any(String), + user: { id: expect.any(String), username: expect.any(String) }, + }); + }); + + test('GET /api/admin/teams supports search, sorting and paging', async ({ admin, seed }) => { + const search = await admin.get('/api/admin/teams', { params: { search: seed.team.name } }); + const none = await admin.get('/api/admin/teams', { + params: { search: 'no-such-team-name-xyz' }, + }); + const sorted = await admin.get('/api/admin/teams', { + params: { orderBy: 'name', sortDescending: 'true', page: 1, pageSize: 1 }, + }); + const badSort = await admin.get('/api/admin/teams', { params: { sortDescending: 'nope' } }); + + expect(search.status).toBe(200); + expect(search.body.data.map((t: any) => t.id)).toContain(seed.team.id); + expect(none.status).toBe(200); + expect(none.body.data).toEqual([]); + expect(none.body.count).toBe(0); + expect(sorted.status).toBe(200); + expect(sorted.body.pageSize).toBe(1); + expect(sorted.body.data.length).toBe(1); + expect(badSort.status).toBe(400); + }); + + test('GET /api/admin/teams requires an admin session', async ({ api, user, apiKey }) => { + expect((await api.get('/api/admin/teams')).status).toBe(401); + expect((await user.get('/api/admin/teams')).status).toBe(401); + expect((await apiKey.get('/api/admin/teams')).status).toBe(401); + }); + }); + + test.describe('Global 2FA enforcement', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async ({ admin }) => { + // Never leave the global requirement enabled for other specs. + await admin.post('/api/admin/2fa/global', { required: false }); + }); + + test('POST /api/admin/2fa/global validates the body', async ({ admin }) => { + const stringValue = await admin.post('/api/admin/2fa/global', { required: 'true' }); + const missing = await admin.post('/api/admin/2fa/global', {}); + + expect(stringValue.status).toBe(400); + expect(missing.status).toBe(400); + }); + + test('POST /api/admin/2fa/global requires an admin session', async ({ api, user, apiKey }) => { + expect((await api.post('/api/admin/2fa/global', { required: false })).status).toBe(401); + expect((await user.post('/api/admin/2fa/global', { required: false })).status).toBe(401); + expect((await apiKey.post('/api/admin/2fa/global', { required: false })).status).toBe(401); + }); + + test('POST /api/admin/2fa/global toggles the global requirement', async ({ admin, user }) => { + const before = await user.get('/api/2fa/status'); + + expect(before.status).toBe(200); + expect(before.body.globalRequired).toBe(false); + + const enabled = await admin.post('/api/admin/2fa/global', { required: true }); + + try { + expect(enabled.status).toBe(200); + expect(enabled.body).toEqual({ ok: true, required: true }); + + const status = await user.get('/api/2fa/status'); + + expect(status.status).toBe(200); + expect(status.body).toMatchObject({ + isRequired: true, + requiredReason: 'global', + isConfigured: true, + globalRequired: true, + }); + } finally { + const disabled = await admin.post('/api/admin/2fa/global', { required: false }); + + expect(disabled.status).toBe(200); + expect(disabled.body).toEqual({ ok: true, required: false }); + } + + const after = await user.get('/api/2fa/status'); + + expect(after.body).toMatchObject({ globalRequired: false }); + expect(after.body.requiredReason).not.toBe('global'); + }); + }); + + test.describe('Team 2FA enforcement', () => { + test.describe.configure({ mode: 'serial' }); + + let teamId = ''; + + test.beforeAll(async ({ admin }) => { + teamId = (await createTeam(admin)).id; + }); + + test.afterAll(async ({ admin }) => { + if (teamId) { + await admin.post(`/api/admin/teams/${teamId}/2fa`, { required: false }); + await deleteTeam(admin, teamId); + } + }); + + test('POST /api/admin/teams/{teamId}/2fa validates the body', async ({ admin }) => { + const stringValue = await admin.post(`/api/admin/teams/${teamId}/2fa`, { required: 'true' }); + const missing = await admin.post(`/api/admin/teams/${teamId}/2fa`, {}); + + expect(stringValue.status).toBe(400); + expect(missing.status).toBe(400); + }); + + test('POST /api/admin/teams/{teamId}/2fa requires an admin session', async ({ + api, + user, + apiKey, + }) => { + const body = { required: false }; + + expect((await api.post(`/api/admin/teams/${teamId}/2fa`, body)).status).toBe(401); + expect((await user.post(`/api/admin/teams/${teamId}/2fa`, body)).status).toBe(401); + expect((await apiKey.post(`/api/admin/teams/${teamId}/2fa`, body)).status).toBe(401); + }); + + test('POST /api/admin/teams/{teamId}/2fa toggles the team requirement', async ({ admin }) => { + const enabled = await admin.post(`/api/admin/teams/${teamId}/2fa`, { required: true }); + + try { + expect(enabled.status).toBe(200); + expect(enabled.body).toEqual({ ok: true, teamId, twoFactorRequired: true }); + + // The admin owns the throwaway team, so their status now reports the team reason. + const status = await admin.get('/api/2fa/status'); + + expect(status.status).toBe(200); + expect(status.body).toMatchObject({ isRequired: true, requiredReason: 'team' }); + } finally { + const disabled = await admin.post(`/api/admin/teams/${teamId}/2fa`, { required: false }); + + expect(disabled.status).toBe(200); + expect(disabled.body).toEqual({ ok: true, teamId, twoFactorRequired: false }); + } + + const after = await admin.get('/api/2fa/status'); + + expect(after.body.requiredReason).not.toBe('team'); + }); + }); + + test.describe('User 2FA enforcement', () => { + test.describe.configure({ mode: 'serial' }); + + let userId = ''; + let userToken = ''; + + test.beforeAll(async ({ admin, api }) => { + const created = await createUser(admin); + + userId = created.id; + userToken = await login(api, { username: created.username, password: created.password }); + }); + + test.afterAll(async ({ admin }) => { + if (userId) { + await deleteUser(admin, userId); + } + }); + + test('GET /api/admin/users/{userId}/2fa reports whether 2FA is enabled', async ({ admin }) => { + const response = await admin.get(`/api/admin/users/${userId}/2fa`); + const unknown = await admin.get(`/api/admin/users/${UNKNOWN_UUID}/2fa`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ isEnabled: false }); + // Unknown users simply have no 2FA record. + expect(unknown.status).toBe(200); + expect(unknown.body).toEqual({ isEnabled: false }); + }); + + test('GET /api/admin/users/{userId}/2fa requires an admin session', async ({ + api, + user, + apiKey, + }) => { + expect((await api.get(`/api/admin/users/${userId}/2fa`)).status).toBe(401); + expect((await user.get(`/api/admin/users/${userId}/2fa`)).status).toBe(401); + expect((await apiKey.get(`/api/admin/users/${userId}/2fa`)).status).toBe(401); + }); + + test('POST /api/admin/users/{userId}/2fa validates the body', async ({ admin }) => { + const stringValue = await admin.post(`/api/admin/users/${userId}/2fa`, { required: 'true' }); + const missing = await admin.post(`/api/admin/users/${userId}/2fa`, {}); + + expect(stringValue.status).toBe(400); + expect(missing.status).toBe(400); + }); + + test('POST /api/admin/users/{userId}/2fa requires an admin session', async ({ + api, + user, + apiKey, + }) => { + const body = { required: false }; + + expect((await api.post(`/api/admin/users/${userId}/2fa`, body)).status).toBe(401); + expect((await user.post(`/api/admin/users/${userId}/2fa`, body)).status).toBe(401); + expect((await apiKey.post(`/api/admin/users/${userId}/2fa`, body)).status).toBe(401); + }); + + test('POST /api/admin/users/{userId}/2fa toggles the user requirement', async ({ + admin, + api, + }) => { + const throwaway = api.bearer(userToken); + const enabled = await admin.post(`/api/admin/users/${userId}/2fa`, { required: true }); + + expect(enabled.status).toBe(200); + expect(enabled.body).toEqual({ ok: true, userId, twoFactorRequired: true }); + + const status = await throwaway.get('/api/2fa/status'); + const me = await throwaway.get('/api/me'); + const listed = await admin.get('/api/admin/users', { params: { search: userId } }); + + expect(status.body).toMatchObject({ + isEnabled: false, + isRequired: true, + requiredReason: 'user', + globalRequired: false, + }); + expect(me.body.user.twoFactorRequired).toBe(true); + expect(listed.status).toBe(200); + + const disabled = await admin.post(`/api/admin/users/${userId}/2fa`, { required: false }); + + expect(disabled.status).toBe(200); + expect(disabled.body).toEqual({ ok: true, userId, twoFactorRequired: false }); + + const after = await throwaway.get('/api/2fa/status'); + + expect(after.body).toMatchObject({ isRequired: false, requiredReason: null }); + }); + + test('DELETE /api/admin/users/{userId}/2fa resets the user 2FA state', async ({ + admin, + api, + user, + apiKey, + }) => { + const throwaway = api.bearer(userToken); + + // Leave a pending setup behind so the reset has something to clear. + const initiated = await throwaway.post('/api/2fa/setup/initiate'); + + expect(initiated.status).toBe(200); + + const denied = await user.del(`/api/admin/users/${userId}/2fa`); + const viaApiKey = await apiKey.del(`/api/admin/users/${userId}/2fa`); + const response = await admin.del(`/api/admin/users/${userId}/2fa`); + const again = await admin.del(`/api/admin/users/${userId}/2fa`); + + expect(denied.status).toBe(401); + expect(viaApiKey.status).toBe(401); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + ok: true, + userId, + reset: { + twoFactorAuth: 1, + backupCodes: expect.any(Number), + otpUsed: expect.any(Number), + rateLimit: expect.any(Number), + }, + }); + expect(again.status).toBe(200); + expect(again.body.reset).toEqual({ + twoFactorAuth: 0, + backupCodes: 0, + otpUsed: 0, + rateLimit: 0, + }); + + // The pending setup is gone. + const confirm = await throwaway.post('/api/2fa/setup/confirm', { token: '000000' }); + + expect(confirm.status).toBe(400); + expect(confirm.body.error.code).toBe('two-factor-error-no-pending-setup'); + }); + }); +}); diff --git a/tests/api/analytics.spec.ts b/tests/api/analytics.spec.ts new file mode 100644 index 000000000..2ffdc3b69 --- /dev/null +++ b/tests/api/analytics.spec.ts @@ -0,0 +1,469 @@ +import { expect, test } from './fixtures'; +import { EVENT_TYPE, UNKNOWN_UUID } from './helpers/constants'; +import { dateRange, TIMEZONE } from './helpers/dates'; + +const point = { x: expect.any(String), y: expect.any(Number) }; + +test.describe('Analytics', () => { + test.describe.configure({ mode: 'serial' }); + + test('GET /api/websites/{websiteId}/stats returns totals for the range', async ({ + admin, + share, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/stats`; + const response = await admin.get(path, { params: dateRange(seed) }); + const shared = await (await share()).get(path, { params: dateRange(seed) }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + pageviews: expect.anything(), + visitors: expect.anything(), + visits: expect.anything(), + bounces: expect.anything(), + totaltime: expect.anything(), + comparison: expect.any(Object), + }); + // Other specs may send extra pageviews to the seeded website. + expect(Number(response.body.pageviews)).toBeGreaterThanOrEqual(seed.data.expectedPageviews); + expect(Number(response.body.visitors)).toBeGreaterThan(0); + expect(Number(response.body.visits)).toBeGreaterThan(0); + expect(shared.status).toBe(200); + }); + + test('GET /api/websites/{websiteId}/stats applies filters', async ({ admin, seed }) => { + const path = `/api/websites/${seed.website.id}/stats`; + const all = await admin.get(path, { params: dateRange(seed) }); + const filtered = await admin.get(path, { + params: dateRange(seed, { path: '/pricing' }), + }); + const none = await admin.get(path, { + params: dateRange(seed, { path: '/zzz-no-such-page' }), + }); + + expect(filtered.status).toBe(200); + expect(Number(filtered.body.pageviews)).toBeGreaterThan(0); + expect(Number(filtered.body.pageviews)).toBeLessThan(Number(all.body.pageviews)); + expect(Number(none.body.pageviews)).toBe(0); + }); + + test('GET /api/websites/{websiteId}/stats validates params and permissions', async ({ + admin, + api, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/stats`; + const missingRange = await admin.get(path); + const anonymous = await api.get(path, { params: dateRange(seed) }); + const denied = await viewer.get(path, { params: dateRange(seed) }); + const unknown = await viewer.get(`/api/websites/${UNKNOWN_UUID}/stats`, { + params: dateRange(seed), + }); + + expect(missingRange.status).toBe(400); + expect(anonymous.status).toBe(401); + expect(denied.status).toBe(401); + expect(unknown.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/pageviews returns pageview and session series', async ({ + admin, + share, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/pageviews`; + const params = dateRange(seed, { unit: 'day', timezone: TIMEZONE }); + const response = await admin.get(path, { params }); + const shared = await (await share()).get(path, { params }); + const denied = await viewer.get(path, { params }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + pageviews: expect.any(Array), + sessions: expect.any(Array), + }); + expect(response.body.pageviews.length).toBeGreaterThan(0); + expect(response.body.pageviews[0]).toMatchObject(point); + expect(response.body.sessions[0]).toMatchObject(point); + + const total = response.body.pageviews.reduce((sum: number, p: any) => sum + p.y, 0); + expect(total).toBeGreaterThanOrEqual(seed.data.expectedPageviews); + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/pageviews supports compare and validates unit', async ({ + admin, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/pageviews`; + const compare = await admin.get(path, { + params: dateRange(seed, { unit: 'day', timezone: TIMEZONE, compare: 'prev' }), + }); + const invalidUnit = await admin.get(path, { + params: dateRange(seed, { unit: 'fortnight', timezone: TIMEZONE }), + }); + + expect(compare.status).toBe(200); + expect(compare.body.compare).toEqual({ + pageviews: expect.any(Array), + sessions: expect.any(Array), + startDate: expect.any(String), + endDate: expect.any(String), + }); + expect(invalidUnit.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/metrics returns session, pageview and event metrics', async ({ + admin, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/metrics`; + const browser = await admin.get(path, { params: dateRange(seed, { type: 'browser' }) }); + const url = await admin.get(path, { params: dateRange(seed, { type: 'path' }) }); + const event = await admin.get(path, { params: dateRange(seed, { type: 'event' }) }); + const channel = await admin.get(path, { params: dateRange(seed, { type: 'channel' }) }); + const stats = await admin.get(`/api/websites/${seed.website.id}/stats`, { + params: dateRange(seed), + }); + + expect(browser.status).toBe(200); + expect(browser.body.length).toBeGreaterThan(0); + expect(browser.body[0]).toEqual(point); + + expect(url.status).toBe(200); + const paths = url.body.map((row: any) => row.x); + for (const page of seed.data.pages) { + expect(paths).toContain(page); + } + // `y` is the number of distinct sessions per path, so it never exceeds pageviews. + const total = url.body.reduce((sum: number, row: any) => sum + row.y, 0); + expect(total).toBeGreaterThan(0); + expect(total).toBeLessThanOrEqual(Number(stats.body.pageviews)); + + expect(event.status).toBe(200); + const events = event.body.map((row: any) => row.x); + for (const name of seed.data.eventNames) { + expect(events).toContain(name); + } + + expect(channel.status).toBe(200); + expect(Array.isArray(channel.body)).toBe(true); + }); + + test('GET /api/websites/{websiteId}/metrics supports search, limit and offset', async ({ + admin, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/metrics`; + const search = await admin.get(path, { + params: dateRange(seed, { type: 'path', search: 'pric' }), + }); + const limited = await admin.get(path, { + params: dateRange(seed, { type: 'path', limit: 2 }), + }); + const offset = await admin.get(path, { + params: dateRange(seed, { type: 'path', limit: 2, offset: 2 }), + }); + + expect(search.status).toBe(200); + expect(search.body.length).toBeGreaterThan(0); + for (const row of search.body) { + expect(row.x).toContain('pric'); + } + expect(limited.body).toHaveLength(2); + expect(offset.body).toHaveLength(2); + expect(offset.body[0].x).not.toBe(limited.body[0].x); + }); + + test('GET /api/websites/{websiteId}/metrics validates type and permissions', async ({ + admin, + share, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/metrics`; + const missingType = await admin.get(path, { params: dateRange(seed) }); + const invalidType = await admin.get(path, { params: dateRange(seed, { type: 'bogus' }) }); + const shared = await (await share()).get(path, { params: dateRange(seed, { type: 'os' }) }); + const denied = await viewer.get(path, { params: dateRange(seed, { type: 'os' }) }); + + expect(missingType.status).toBe(400); + expect(invalidType.status).toBe(400); + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/metrics/expanded returns rows with breakdown totals', async ({ + admin, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/metrics/expanded`; + const response = await admin.get(path, { params: dateRange(seed, { type: 'path' }) }); + const invalid = await admin.get(path, { params: dateRange(seed, { type: 'bogus' }) }); + const denied = await viewer.get(path, { params: dateRange(seed, { type: 'path' }) }); + + expect(response.status).toBe(200); + expect(response.body.length).toBeGreaterThan(0); + // Expanded rows label the dimension `name` rather than `x`. + expect(response.body[0]).toMatchObject({ + name: expect.any(String), + visitors: expect.anything(), + visits: expect.anything(), + pageviews: expect.anything(), + bounces: expect.anything(), + totaltime: expect.anything(), + }); + expect(response.body.map((row: any) => row.name)).toContain('/'); + const pageviews = response.body.reduce( + (sum: number, row: any) => sum + Number(row.pageviews), + 0, + ); + expect(pageviews).toBeGreaterThanOrEqual(seed.data.expectedPageviews); + expect(invalid.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/values returns distinct values for a field', async ({ + admin, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/values`; + const paths = await admin.get(path, { params: dateRange(seed, { type: 'path' }) }); + const events = await admin.get(path, { params: dateRange(seed, { type: 'event' }) }); + const distinct = await admin.get(path, { + params: dateRange(seed, { type: 'distinctId' }), + }); + const searched = await admin.get(path, { + params: dateRange(seed, { type: 'path', search: 'docs' }), + }); + const denied = await viewer.get(path, { params: dateRange(seed, { type: 'path' }) }); + + expect(paths.status).toBe(200); + expect(paths.body[0]).toEqual({ value: expect.any(String), count: expect.anything() }); + const values = paths.body.map((row: any) => row.value); + for (const page of seed.data.pages) { + expect(values).toContain(page); + } + + expect(events.status).toBe(200); + const names = events.body.map((row: any) => row.value); + for (const name of seed.data.eventNames) { + expect(names).toContain(name); + } + + expect(distinct.status).toBe(200); + const ids = distinct.body.map((row: any) => row.value); + for (const id of seed.data.distinctIds) { + expect(ids).toContain(id); + } + + expect(searched.status).toBe(200); + expect(searched.body.map((row: any) => row.value)).toContain('/docs'); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/values rejects unsupported types', async ({ + admin, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/values`; + const missing = await admin.get(path, { params: dateRange(seed) }); + const fullPath = await admin.get(path, { params: dateRange(seed, { type: 'fullPath' }) }); + const channel = await admin.get(path, { params: dateRange(seed, { type: 'channel' }) }); + + expect(missing.status).toBe(400); + expect(fullPath.status).toBe(400); + expect(channel.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/active returns the active visitor count', async ({ + admin, + share, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/active`; + const response = await admin.get(path); + const shared = await (await share()).get(path); + const denied = await viewer.get(path); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ visitors: expect.any(Number) }); + expect(response.body.visitors).toBeGreaterThanOrEqual(0); + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/events lists events with paging', async ({ admin, seed }) => { + const path = `/api/websites/${seed.website.id}/events`; + const response = await admin.get(path, { params: dateRange(seed, { pageSize: 5 }) }); + const second = await admin.get(path, { + params: dateRange(seed, { pageSize: 5, page: 2 }), + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: expect.any(Number), + page: 1, + pageSize: 5, + }); + expect(response.body.data).toHaveLength(5); + expect(response.body.count).toBeGreaterThanOrEqual(seed.data.expectedPageviews); + expect(response.body.data[0]).toMatchObject({ + id: expect.any(String), + sessionId: expect.any(String), + createdAt: expect.any(String), + urlPath: expect.any(String), + eventType: expect.any(Number), + }); + expect(response.body.data[0]).toHaveProperty('eventName'); + expect(response.body.data[0]).toHaveProperty('hasData'); + + expect(second.status).toBe(200); + expect(second.body.page).toBe(2); + expect(second.body.data[0].id).not.toBe(response.body.data[0].id); + }); + + test('GET /api/websites/{websiteId}/events filters custom events and validates', async ({ + admin, + api, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/events`; + const custom = await admin.get(path, { + params: dateRange(seed, { eventType: EVENT_TYPE.customEvent, pageSize: 100 }), + }); + const search = await admin.get(path, { + params: dateRange(seed, { eventType: EVENT_TYPE.customEvent, search: 'purchase' }), + }); + const missingRange = await admin.get(path); + const anonymous = await api.get(path, { params: dateRange(seed) }); + const denied = await viewer.get(path, { params: dateRange(seed) }); + + expect(custom.status).toBe(200); + expect(custom.body.data.length).toBeGreaterThan(0); + for (const row of custom.body.data) { + expect(row.eventType).toBe(EVENT_TYPE.customEvent); + expect(row.eventName).toEqual(expect.any(String)); + } + + // Other specs send their own custom events, so only check the seeded names are present. + const names = new Set(custom.body.data.map((row: any) => row.eventName)); + for (const name of seed.data.eventNames) { + expect(names).toContain(name); + } + + expect(search.status).toBe(200); + expect(search.body.data.length).toBeGreaterThan(0); + for (const row of search.body.data) { + expect(row.eventName).toBe('purchase'); + } + + expect(missingRange.status).toBe(400); + expect(anonymous.status).toBe(401); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/events/series returns per-event time series', async ({ + admin, + share, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/events/series`; + const params = dateRange(seed, { unit: 'day', timezone: TIMEZONE }); + const response = await admin.get(path, { params }); + const limited = await admin.get(path, { params: { ...params, limit: 1 } }); + const missingTimezone = await admin.get(path, { params: dateRange(seed, { unit: 'day' }) }); + const shared = await (await share()).get(path, { params }); + const denied = await viewer.get(path, { params }); + + expect(response.status).toBe(200); + expect(response.body.length).toBeGreaterThan(0); + expect(response.body[0]).toEqual({ + x: expect.any(String), + t: expect.any(String), + y: expect.any(Number), + }); + const names = new Set(response.body.map((row: any) => row.x)); + for (const name of seed.data.eventNames) { + expect(names).toContain(name); + } + + expect(limited.status).toBe(200); + expect(new Set(limited.body.map((row: any) => row.x)).size).toBe(1); + expect(missingTimezone.status).toBe(400); + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/events/stats returns event totals', async ({ + admin, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/events/stats`; + const response = await admin.get(path, { params: dateRange(seed) }); + const missingRange = await admin.get(path); + const denied = await viewer.get(path, { params: dateRange(seed) }); + + expect(response.status).toBe(200); + expect(response.body.data).toMatchObject({ + events: expect.anything(), + visitors: expect.anything(), + visits: expect.anything(), + uniqueEvents: expect.anything(), + comparison: expect.any(Object), + }); + expect(Number(response.body.data.events)).toBeGreaterThan(0); + expect(Number(response.body.data.uniqueEvents)).toBeGreaterThanOrEqual( + seed.data.eventNames.length, + ); + expect(missingRange.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('GET /api/realtime/{websiteId} returns realtime activity', async ({ + admin, + share, + viewer, + seed, + }) => { + const path = `/api/realtime/${seed.website.id}`; + const response = await admin.get(path); + const withUnit = await admin.get(path, { params: { unit: 'minute', timezone: TIMEZONE } }); + const invalidUnit = await admin.get(path, { params: { unit: 'bogus' } }); + const shared = await (await share()).get(path); + const denied = await viewer.get(path); + + expect(response.status).toBe(200); + // countries/urls/referrers are `{ [key]: count }` maps; events is the raw event list. + expect(response.body).toMatchObject({ + countries: expect.any(Object), + urls: expect.any(Object), + referrers: expect.any(Object), + events: expect.any(Array), + series: { views: expect.any(Array), visitors: expect.any(Array) }, + totals: { + views: expect.any(Number), + visitors: expect.any(Number), + events: expect.any(Number), + countries: expect.any(Number), + }, + timestamp: expect.any(Number), + }); + expect(Array.isArray(response.body.urls)).toBe(false); + expect(withUnit.status).toBe(200); + expect(invalidUnit.status).toBe(400); + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + }); +}); diff --git a/tests/api/annotations.spec.ts b/tests/api/annotations.spec.ts new file mode 100644 index 000000000..af781e0be --- /dev/null +++ b/tests/api/annotations.spec.ts @@ -0,0 +1,167 @@ +import { expect, test } from './fixtures'; +import { UNKNOWN_UUID } from './helpers/constants'; +import { createWebsite, deleteWebsite, uniqueName } from './helpers/entities'; + +test.describe('Annotations', () => { + test.describe.configure({ mode: 'serial' }); + + const date = new Date('2026-01-15T12:00:00.000Z'); + + let websiteId = ''; + let annotationId = ''; + let base = ''; + + test.beforeAll(async ({ admin }) => { + websiteId = (await createWebsite(admin)).id; + base = `/api/websites/${websiteId}/annotations`; + }); + + test.afterAll(async ({ admin }) => { + if (websiteId) { + await deleteWebsite(admin, websiteId); + } + }); + + test('POST /api/websites/{websiteId}/annotations creates an annotation', async ({ + admin, + seed, + }) => { + const note = uniqueName('note'); + const response = await admin.post(base, { date: date.toISOString(), note }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: expect.any(String), + websiteId, + userId: seed.admin.id, + date: date.toISOString(), + allDay: true, + note, + createdAt: expect.any(String), + }); + + annotationId = response.body.id; + }); + + test('POST /api/websites/{websiteId}/annotations validates and requires permission', async ({ + admin, + user, + viewer, + api, + }) => { + const missingNote = await admin.post(base, { date: date.toISOString() }); + const emptyNote = await admin.post(base, { date: date.toISOString(), note: '' }); + const badDate = await admin.post(base, { date: 'not-a-date', note: 'x' }); + const tooLong = await admin.post(base, { date: date.toISOString(), note: 'x'.repeat(501) }); + const notOwner = await user.post(base, { date: date.toISOString(), note: 'x' }); + const denied = await viewer.post(base, { date: date.toISOString(), note: 'x' }); + const anonymous = await api.post(base, { date: date.toISOString(), note: 'x' }); + + expect(missingNote.status).toBe(400); + expect(emptyNote.status).toBe(400); + expect(badDate.status).toBe(400); + expect(tooLong.status).toBe(400); + expect(notOwner.status).toBe(401); + expect(denied.status).toBe(401); + expect(anonymous.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/annotations lists annotations', async ({ + admin, + viewer, + share, + seed, + }) => { + const response = await admin.get(base); + const inRange = await admin.get(base, { + params: { startAt: date.getTime() - 1000, endAt: date.getTime() + 1000 }, + }); + const outOfRange = await admin.get(base, { + params: { startAt: date.getTime() + 86_400_000, endAt: date.getTime() + 2 * 86_400_000 }, + }); + const search = await admin.get(base, { params: { search: 'zzz-no-such-note' } }); + const paged = await admin.get(base, { params: { page: 1, pageSize: 1 } }); + const denied = await viewer.get(base); + const shared = await (await share()).get(`/api/websites/${seed.website.id}/annotations`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: 1, + page: 1, + pageSize: expect.any(Number), + }); + expect(response.body.data[0]).toMatchObject({ id: annotationId, websiteId }); + expect(inRange.body.data.map((a: any) => a.id)).toEqual([annotationId]); + expect(outOfRange.body.data).toHaveLength(0); + expect(search.body.data).toHaveLength(0); + expect(paged.body.pageSize).toBe(1); + expect(denied.status).toBe(401); + expect(shared.status).toBe(200); + }); + + test('GET /api/websites/{websiteId}/annotations/{annotationId} returns an annotation', async ({ + admin, + viewer, + }) => { + const response = await admin.get(`${base}/${annotationId}`); + const denied = await viewer.get(`${base}/${annotationId}`); + const unknown = await admin.get(`${base}/${UNKNOWN_UUID}`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: annotationId, websiteId, allDay: true }); + expect(denied.status).toBe(401); + expect(unknown.status).toBe(404); + }); + + test('POST /api/websites/{websiteId}/annotations/{annotationId} updates an annotation', async ({ + admin, + user, + }) => { + const note = uniqueName('updated'); + const newDate = new Date('2026-02-01T08:30:00.000Z'); + const response = await admin.post(`${base}/${annotationId}`, { + date: newDate.toISOString(), + note, + allDay: false, + }); + const invalid = await admin.post(`${base}/${annotationId}`, { date: newDate.toISOString() }); + const unknown = await admin.post(`${base}/${UNKNOWN_UUID}`, { + date: newDate.toISOString(), + note, + }); + const denied = await user.post(`${base}/${annotationId}`, { + date: newDate.toISOString(), + note, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: annotationId, + date: newDate.toISOString(), + note, + allDay: false, + }); + expect(invalid.status).toBe(400); + expect(unknown.status).toBe(404); + expect(denied.status).toBe(401); + }); + + test('DELETE /api/websites/{websiteId}/annotations/{annotationId} deletes an annotation', async ({ + admin, + user, + }) => { + const denied = await user.del(`${base}/${annotationId}`); + const response = await admin.del(`${base}/${annotationId}`); + const gone = await admin.get(`${base}/${annotationId}`); + const again = await admin.del(`${base}/${annotationId}`); + + expect(denied.status).toBe(401); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(gone.status).toBe(404); + expect(again.status).toBe(404); + + annotationId = ''; + }); +}); diff --git a/tests/api/auth.spec.ts b/tests/api/auth.spec.ts new file mode 100644 index 000000000..5606c9e04 --- /dev/null +++ b/tests/api/auth.spec.ts @@ -0,0 +1,129 @@ +import { expect, test } from './fixtures'; +import { login } from './helpers/auth'; + +test.describe('Auth', () => { + test('POST /api/auth/login returns a token and the user', async ({ api, seed }) => { + const response = await api.post('/api/auth/login', { + username: seed.admin.username, + password: seed.admin.password, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + token: expect.any(String), + user: { + id: seed.admin.id, + username: seed.admin.username, + role: 'admin', + createdAt: expect.any(String), + isAdmin: true, + teams: expect.any(Array), + }, + }); + expect(response.body).not.toHaveProperty('requiresTwoFactor'); + expect(response.body.user).not.toHaveProperty('password'); + }); + + test('POST /api/auth/login matches usernames case-insensitively', async ({ api, seed }) => { + const response = await api.post('/api/auth/login', { + username: seed.user.username.toUpperCase(), + password: seed.user.password, + }); + + expect(response.status).toBe(200); + expect(response.body.token).toEqual(expect.any(String)); + expect(response.body.user).toMatchObject({ id: seed.user.id, role: 'user', isAdmin: false }); + // The response echoes the submitted username verbatim; the lookup itself is case-insensitive. + expect(response.body.user.username.toLowerCase()).toBe(seed.user.username); + expect(response.body.user.teams.map((t: any) => t.id)).toContain(seed.team.id); + + // The session resolves to the stored (lowercase) username. + const me = await api.bearer(response.body.token).get('/api/me'); + + expect(me.status).toBe(200); + expect(me.body.user).toMatchObject({ id: seed.user.id, username: seed.user.username }); + }); + + test('POST /api/auth/login rejects bad credentials', async ({ api, seed }) => { + const wrongPassword = await api.post('/api/auth/login', { + username: seed.admin.username, + password: 'not-the-password', + }); + const unknownUser = await api.post('/api/auth/login', { + username: 'no-such-user', + password: seed.admin.password, + }); + + expect(wrongPassword.status).toBe(401); + expect(wrongPassword.body.error).toMatchObject({ + code: 'incorrect-username-password', + status: 401, + }); + expect(unknownUser.status).toBe(401); + expect(unknownUser.body.error.code).toBe('incorrect-username-password'); + }); + + test('POST /api/auth/login validates the body', async ({ api, seed }) => { + const missingPassword = await api.post('/api/auth/login', { username: seed.admin.username }); + const missingUsername = await api.post('/api/auth/login', { password: seed.admin.password }); + const empty = await api.post('/api/auth/login', {}); + + expect(missingPassword.status).toBe(400); + expect(missingUsername.status).toBe(400); + expect(empty.status).toBe(400); + expect(empty.body.error).toMatchObject({ status: 400 }); + }); + + test('POST /api/auth/verify returns the current session', async ({ admin, user, seed }) => { + const response = await admin.post('/api/auth/verify'); + const asUser = await user.post('/api/auth/verify'); + + expect(response.status).toBe(200); + // The body is the flat user record plus its teams. + expect(response.body).toMatchObject({ + id: seed.admin.id, + username: seed.admin.username, + role: 'admin', + createdAt: expect.any(String), + isAdmin: true, + twoFactorRequired: expect.any(Boolean), + teams: expect.any(Array), + }); + expect(response.body).not.toHaveProperty('password'); + expect(asUser.status).toBe(200); + expect(asUser.body.id).toBe(seed.user.id); + expect(asUser.body.teams.map((t: any) => t.id)).toContain(seed.team.id); + }); + + test('POST /api/auth/verify rejects anonymous, invalid and API key callers', async ({ + api, + apiKey, + }) => { + const anonymous = await api.post('/api/auth/verify'); + const garbage = await api.bearer('not-a-token').post('/api/auth/verify'); + const viaApiKey = await apiKey.post('/api/auth/verify'); + + expect(anonymous.status).toBe(401); + expect(garbage.status).toBe(401); + // API keys are blocked on /api/auth/*. + expect(viaApiKey.status).toBe(401); + }); + + test('POST /api/auth/logout acknowledges the logout', async ({ api, seed }) => { + // Use a dedicated session so the shared worker tokens are never touched. + const token = await login(api, seed.viewer); + const response = await api.bearer(token).post('/api/auth/logout'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + // Without Redis the stateless token remains valid, so no follow-up 401 is asserted. + }); + + test('POST /api/auth/logout requires a session', async ({ api, apiKey }) => { + const anonymous = await api.post('/api/auth/logout'); + const viaApiKey = await apiKey.post('/api/auth/logout'); + + expect(anonymous.status).toBe(401); + expect(viaApiKey.status).toBe(401); + }); +}); diff --git a/tests/api/boards.spec.ts b/tests/api/boards.spec.ts new file mode 100644 index 000000000..d5f051f0d --- /dev/null +++ b/tests/api/boards.spec.ts @@ -0,0 +1,327 @@ +import { expect, test } from './fixtures'; +import { UNKNOWN_UUID } from './helpers/constants'; +import { uniqueName } from './helpers/entities'; + +test.describe('Boards', () => { + test.describe.configure({ mode: 'serial' }); + + let boardId = ''; + let teamBoardId = ''; + let shareId = ''; + /** Every board created by this file; ids are pushed before any assertion so cleanup always runs. */ + const createdBoardIds: string[] = []; + + test.afterAll(async ({ admin }) => { + for (const id of createdBoardIds) { + await admin.del(`/api/boards/${id}`); + } + }); + + test('POST /api/boards creates a board for the current user', async ({ admin, seed }) => { + const name = uniqueName('board'); + const response = await admin.post('/api/boards', { + type: 'website', + name, + description: 'created by the api suite', + parameters: { websiteId: seed.website.id, rows: [] }, + }); + + boardId = response.body?.id ?? ''; + createdBoardIds.push(boardId); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: expect.any(String), + type: 'website', + name, + description: 'created by the api suite', + parameters: { websiteId: seed.website.id, rows: [] }, + userId: seed.admin.id, + teamId: null, + createdAt: expect.any(String), + }); + }); + + test('POST /api/boards creates a board for a team', async ({ admin, seed }) => { + const response = await admin.post('/api/boards', { + type: 'mixed', + name: uniqueName('team-board'), + description: '', + teamId: seed.team.id, + parameters: {}, + }); + + teamBoardId = response.body?.id ?? ''; + createdBoardIds.push(teamBoardId); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ type: 'mixed', teamId: seed.team.id, userId: null }); + }); + + test('POST /api/boards validates the body', async ({ admin, user, seed }) => { + const dashboard = await admin.post('/api/boards', { + type: 'dashboard', + name: 'x', + description: '', + }); + const longName = await admin.post('/api/boards', { + type: 'website', + name: 'x'.repeat(101), + description: '', + }); + const missingName = await admin.post('/api/boards', { type: 'website', description: '' }); + const inaccessible = await user.post('/api/boards', { + type: 'website', + name: 'x', + description: '', + parameters: { websiteId: seed.website.id }, + }); + + expect(dashboard.status).toBe(400); + expect(longName.status).toBe(400); + expect(missingName.status).toBe(400); + expect(inaccessible.status).toBe(400); + expect(inaccessible.body.error.message).toBe('Board contains inaccessible entities.'); + }); + + test('POST /api/boards requires a user that can create boards', async ({ + api, + viewer, + user, + seed, + }) => { + const body = { type: 'mixed', name: uniqueName('board'), description: '' }; + const anonymous = await api.post('/api/boards', body); + const viewOnly = await viewer.post('/api/boards', body); + const notMember = await viewer.post('/api/boards', { ...body, teamId: seed.team.id }); + // Team members hold website:create and can therefore create team boards. + const member = await user.post('/api/boards', { ...body, teamId: seed.team.id }); + + if (member.body?.id) { + createdBoardIds.push(member.body.id); + } + + expect(anonymous.status).toBe(401); + expect(viewOnly.status).toBe(401); + expect(notMember.status).toBe(401); + expect(member.status).toBe(200); + expect(member.body).toMatchObject({ teamId: seed.team.id, userId: null }); + }); + + test('GET /api/boards lists the caller own boards', async ({ admin, user, seed }) => { + const response = await admin.get('/api/boards'); + const search = await admin.get('/api/boards', { + params: { search: 'zzz-no-such-board', orderBy: 'name', sortDescending: 'true' }, + }); + const invalidSort = await admin.get('/api/boards', { params: { sortDescending: 'yes' } }); + const other = await user.get('/api/boards'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) }); + expect(response.body.data.map((b: any) => b.id)).toContain(boardId); + // Team boards and the personal dashboard are not part of the personal list. + expect(response.body.data.map((b: any) => b.id)).not.toContain(teamBoardId); + expect(response.body.data.map((b: any) => b.id)).not.toContain(seed.admin.id); + expect(search.status).toBe(200); + expect(search.body.data).toHaveLength(0); + expect(invalidSort.status).toBe(400); + expect(other.body.data.map((b: any) => b.id)).not.toContain(boardId); + }); + + test('GET /api/boards/{boardId} returns a board', async ({ admin, user, viewer, api, seed }) => { + const response = await admin.get(`/api/boards/${boardId}`); + const teamMember = await user.get(`/api/boards/${teamBoardId}`); + const denied = await viewer.get(`/api/boards/${boardId}`); + const anonymous = await api.get(`/api/boards/${boardId}`); + const unknown = await user.get(`/api/boards/${UNKNOWN_UUID}`); + const adminUnknown = await admin.get(`/api/boards/${UNKNOWN_UUID}`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: boardId, + type: 'website', + parameters: { websiteId: seed.website.id }, + }); + expect(teamMember.status).toBe(200); + expect(teamMember.body.id).toBe(teamBoardId); + expect(denied.status).toBe(401); + expect(anonymous.status).toBe(401); + // Non-admins are refused before the lookup; admins bypass the check and get null. + expect(unknown.status).toBe(401); + expect(adminUnknown.status).toBe(200); + expect(adminUnknown.body).toBeNull(); + }); + + test('POST /api/boards/{boardId} updates a board', async ({ admin, user, viewer, seed }) => { + const name = uniqueName('renamed'); + const response = await admin.post(`/api/boards/${boardId}`, { + name, + description: 'updated', + parameters: { websiteId: seed.website.id, rows: [{ id: 'row-1', columns: [] }] }, + }); + const longName = await admin.post(`/api/boards/${boardId}`, { name: 'x'.repeat(201) }); + const denied = await viewer.post(`/api/boards/${boardId}`, { name }); + const member = await user.post(`/api/boards/${teamBoardId}`, { name }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: boardId, + name, + description: 'updated', + parameters: { websiteId: seed.website.id, rows: [{ id: 'row-1', columns: [] }] }, + }); + expect(longName.status).toBe(400); + expect(denied.status).toBe(401); + // Team members hold website:update and can rename team boards. + expect(member.status).toBe(200); + expect(member.body).toMatchObject({ id: teamBoardId, name }); + }); + + test('POST /api/boards/{boardId}/clone copies a board', async ({ admin, user, viewer, seed }) => { + const name = uniqueName('clone'); + const response = await admin.post(`/api/boards/${boardId}/clone`, { name }); + const defaults = await admin.post(`/api/boards/${boardId}/clone`, {}); + const denied = await viewer.post(`/api/boards/${boardId}/clone`, { name }); + const unknown = await user.post(`/api/boards/${UNKNOWN_UUID}/clone`, { name }); + + for (const clone of [response, defaults]) { + if (clone.body?.id) { + createdBoardIds.push(clone.body.id); + } + } + + expect(response.status).toBe(200); + expect(response.body.id).not.toBe(boardId); + expect(response.body).toMatchObject({ + type: 'website', + name, + userId: seed.admin.id, + parameters: { websiteId: seed.website.id }, + }); + expect(defaults.status).toBe(200); + expect(defaults.body.id).not.toBe(boardId); + expect(defaults.body.name).toBe((await admin.get(`/api/boards/${boardId}`)).body.name); + expect(denied.status).toBe(401); + expect(unknown.status).toBe(401); + }); + + test('POST /api/boards/{boardId}/shares creates a share', async ({ admin, user, viewer }) => { + const name = uniqueName('share'); + const response = await admin.post(`/api/boards/${boardId}/shares`, { + name, + parameters: { theme: 'dark' }, + }); + const missingName = await admin.post(`/api/boards/${boardId}/shares`, {}); + const denied = await viewer.post(`/api/boards/${boardId}/shares`, { name }); + const unknown = await user.post(`/api/boards/${UNKNOWN_UUID}/shares`, { name }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: expect.any(String), + entityId: boardId, + name, + shareType: 4, + slug: expect.any(String), + parameters: { theme: 'dark' }, + }); + expect(response.body.slug).toHaveLength(16); + expect(missingName.status).toBe(400); + expect(denied.status).toBe(401); + expect(unknown.status).toBe(401); + + shareId = response.body.id; + }); + + test('GET /api/boards/{boardId}/shares lists the board shares', async ({ admin, viewer }) => { + const response = await admin.get(`/api/boards/${boardId}/shares`); + const paged = await admin.get(`/api/boards/${boardId}/shares`, { + params: { page: 2, pageSize: 1 }, + }); + const denied = await viewer.get(`/api/boards/${boardId}/shares`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) }); + expect(response.body.data.map((s: any) => s.id)).toContain(shareId); + expect(response.body.data[0]).toMatchObject({ + entityId: boardId, + shareType: 4, + slug: expect.any(String), + createdAt: expect.any(String), + }); + expect(paged.status).toBe(200); + expect(paged.body).toMatchObject({ page: 2, pageSize: 1, count: 1, data: [] }); + expect(denied.status).toBe(401); + }); + + test('DELETE /api/boards/{boardId} deletes a board', async ({ admin, user, viewer }) => { + const denied = await viewer.del(`/api/boards/${boardId}`); + const notOwner = await user.del(`/api/boards/${boardId}`); + const response = await admin.del(`/api/boards/${boardId}`); + const gone = await admin.get(`/api/boards/${boardId}`); + // Team members hold website:delete and can delete team boards. + const member = await user.del(`/api/boards/${teamBoardId}`); + + expect(denied.status).toBe(401); + expect(notOwner.status).toBe(401); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(gone.status).toBe(200); + expect(gone.body).toBeNull(); + expect(member.status).toBe(200); + }); +}); + +test.describe('Dashboard', () => { + test.describe.configure({ mode: 'serial' }); + + test('POST /api/dashboard creates or updates the caller dashboard', async ({ + user, + api, + seed, + }) => { + const name = uniqueName('dashboard'); + const response = await user.post('/api/dashboard', { + name, + description: 'api suite dashboard', + parameters: { rows: [] }, + }); + const updated = await user.post('/api/dashboard', { + name: `${name}-2`, + description: 'api suite dashboard', + parameters: { rows: [{ id: 'row-1', columns: [] }] }, + }); + const invalid = await user.post('/api/dashboard', { name: 'x'.repeat(201) }); + const anonymous = await api.post('/api/dashboard', { name, description: '' }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: seed.user.id, + type: 'dashboard', + userId: seed.user.id, + name, + parameters: { rows: [] }, + }); + expect(updated.status).toBe(200); + expect(updated.body).toMatchObject({ + id: seed.user.id, + name: `${name}-2`, + parameters: { rows: [{ id: 'row-1', columns: [] }] }, + }); + expect(invalid.status).toBe(400); + expect(anonymous.status).toBe(401); + }); + + test('GET /api/dashboard returns the caller dashboard', async ({ user, api, seed }) => { + const response = await user.get('/api/dashboard'); + const anonymous = await api.get('/api/dashboard'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: seed.user.id, + type: 'dashboard', + userId: seed.user.id, + parameters: { rows: [{ id: 'row-1', columns: [] }] }, + }); + expect(anonymous.status).toBe(401); + }); +}); diff --git a/tests/api/client.ts b/tests/api/client.ts new file mode 100644 index 000000000..c6d756a43 --- /dev/null +++ b/tests/api/client.ts @@ -0,0 +1,95 @@ +import type { APIRequestContext } from '@playwright/test'; +import { recordApiCall } from './coverage/recorder'; + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +export interface ApiRequestOptions { + /** JSON-serialised unless a string is given (sent verbatim). */ + data?: unknown; + params?: Record; + headers?: Record; +} + +export interface ApiResponse { + status: number; + ok: boolean; + /** Parsed JSON body, or undefined when the body is empty / not JSON. */ + body: T; + text: string; + headers: Record; +} + +/** + * Thin wrapper over Playwright's APIRequestContext that carries default headers + * (auth) and records every call for the coverage reporter. All specs must go + * through this class — calls made with the raw `request` fixture are invisible + * to coverage. + */ +export class ApiClient { + constructor( + private readonly context: APIRequestContext, + private readonly headers: Record = {}, + ) {} + + with(headers: Record) { + return new ApiClient(this.context, { ...this.headers, ...headers }); + } + + bearer(token: string) { + return this.with({ authorization: `Bearer ${token}` }); + } + + async request( + method: HttpMethod, + path: string, + options: ApiRequestOptions = {}, + ): Promise> { + const params = options.params + ? Object.fromEntries( + Object.entries(options.params).filter(([, value]) => value !== undefined), + ) + : undefined; + + const response = await this.context.fetch(path, { + method, + data: options.data, + params: params as Record, + headers: { ...this.headers, ...options.headers }, + }); + + recordApiCall(method, path); + + const text = await response.text(); + let body: any; + + try { + body = text ? JSON.parse(text) : undefined; + } catch { + body = undefined; + } + + return { + status: response.status(), + ok: response.ok(), + body, + text, + headers: response.headers(), + }; + } + + get(path: string, options?: ApiRequestOptions) { + return this.request('GET', path, options); + } + + post(path: string, data?: unknown, options?: ApiRequestOptions) { + return this.request('POST', path, { ...options, data }); + } + + put(path: string, data?: unknown, options?: ApiRequestOptions) { + return this.request('PUT', path, { ...options, data }); + } + + del(path: string, options?: ApiRequestOptions) { + return this.request('DELETE', path, options); + } +} diff --git a/tests/api/collection.spec.ts b/tests/api/collection.spec.ts new file mode 100644 index 000000000..3e0321546 --- /dev/null +++ b/tests/api/collection.spec.ts @@ -0,0 +1,241 @@ +import { expect, test } from './fixtures'; +import { CACHE_HEADER, UNKNOWN_UUID } from './helpers/constants'; +import { HOSTNAME, PERSONAS } from './seed/dataset'; + +const persona = PERSONAS[0]; + +function pageview(website: string, extra: Record = {}) { + return { + type: 'event', + payload: { + website, + hostname: HOSTNAME, + language: persona.language, + screen: persona.screen, + userAgent: persona.userAgent, + // Private IP: the test image has no GeoLite database. + ip: '10.0.9.1', + url: '/collection-spec', + title: 'Collection spec', + ...extra, + }, + }; +} + +test.describe('Collection', () => { + test.describe.configure({ mode: 'serial' }); + + test('POST /api/send records a pageview and returns a cache token', async ({ api, seed }) => { + const response = await api.post('/api/send', pageview(seed.website.id)); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + cache: expect.any(String), + sessionId: expect.any(String), + visitId: expect.any(String), + }); + }); + + test('POST /api/send continues the visit when the cache token is sent', async ({ api, seed }) => { + const first = await api.post('/api/send', pageview(seed.website.id)); + const second = await api.post('/api/send', pageview(seed.website.id, { url: '/second' }), { + headers: { [CACHE_HEADER]: first.body.cache }, + }); + + expect(second.status).toBe(200); + expect(second.body.sessionId).toBe(first.body.sessionId); + expect(second.body.visitId).toBe(first.body.visitId); + }); + + test('POST /api/send records custom, identify and performance payloads', async ({ + api, + seed, + }) => { + const custom = await api.post( + '/api/send', + pageview(seed.website.id, { name: 'spec-event', data: { value: 1, label: 'spec' } }), + ); + const identify = await api.post('/api/send', { + type: 'identify', + payload: { ...pageview(seed.website.id).payload, id: 'spec-user', data: { plan: 'spec' } }, + }); + const performance = await api.post('/api/send', { + type: 'performance', + payload: { + ...pageview(seed.website.id).payload, + lcp: 1500, + inp: 100, + cls: 0.1, + fcp: 800, + ttfb: 200, + }, + }); + + expect(custom.status).toBe(200); + expect(identify.status).toBe(200); + expect(performance.status).toBe(200); + }); + + test('POST /api/send accepts link and pixel sources', async ({ api, seed }) => { + const link = await api.post('/api/send', { + type: 'event', + payload: { link: seed.link.id, url: '/', userAgent: persona.userAgent, ip: '10.0.9.2' }, + }); + const pixel = await api.post('/api/send', { + type: 'event', + payload: { pixel: seed.pixel.id, url: '/', userAgent: persona.userAgent, ip: '10.0.9.2' }, + }); + + expect(link.status).toBe(200); + expect(pixel.status).toBe(200); + }); + + test('POST /api/send rejects an unknown website', async ({ api }) => { + const response = await api.post('/api/send', pageview(UNKNOWN_UUID)); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ message: 'Website not found.', status: 400 }); + }); + + test('POST /api/send requires exactly one of website, link or pixel', async ({ api, seed }) => { + const none = await api.post('/api/send', { type: 'event', payload: { url: '/' } }); + const two = await api.post('/api/send', pageview(seed.website.id, { link: seed.link.id })); + + expect(none.status).toBe(400); + expect(two.status).toBe(400); + expect(two.body.error.code).toBe('bad-request'); + }); + + test('POST /api/send rejects invalid payloads', async ({ api, seed }) => { + const formula = await api.post('/api/send', pageview(seed.website.id, { name: '=cmd()' })); + const vitals = await api.post('/api/send', { + type: 'performance', + payload: { ...pageview(seed.website.id).payload, lcp: -1 }, + }); + const type = await api.post('/api/send', { + type: 'bogus', + payload: { website: seed.website.id }, + }); + + expect(formula.status).toBe(400); + expect(vitals.status).toBe(400); + expect(type.status).toBe(400); + }); + + test('POST /api/batch processes each payload and reports per-item errors', async ({ + api, + seed, + }) => { + const response = await api.post('/api/batch', [ + pageview(seed.website.id, { url: '/batch-1' }), + pageview(UNKNOWN_UUID, { url: '/batch-2' }), + pageview(seed.website.id, { url: '/batch-3', name: 'batch-event' }), + ]); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + size: 3, + processed: 2, + errors: 1, + cache: expect.any(String), + }); + expect(response.body.details).toHaveLength(1); + expect(response.body.details[0].index).toBe(1); + }); + + test('POST /api/batch validates the body', async ({ api, seed }) => { + const object = await api.post('/api/batch', pageview(seed.website.id)); + const tooMany = await api.post( + '/api/batch', + Array.from({ length: 501 }, () => ({})), + ); + + expect(object.status).toBe(400); + expect(tooMany.status).toBe(400); + }); + + test('POST /api/record stores replay and heatmap events for a recorded visit', async ({ + api, + seed, + }) => { + const visit = await api.post('/api/send', pageview(seed.website.id)); + const headers = { [CACHE_HEADER]: visit.body.cache }; + const now = Date.now(); + + const replay = await api.post( + '/api/record', + { + type: 'record', + payload: { + website: seed.website.id, + events: [ + { + type: 4, + data: { href: `https://${HOSTNAME}/`, width: 1280, height: 800 }, + timestamp: now, + }, + { type: 2, data: { node: { type: 0, childNodes: [] } }, timestamp: now + 10 }, + ], + }, + }, + { headers }, + ); + const heatmap = await api.post( + '/api/record', + { + type: 'heatmap', + payload: { + website: seed.website.id, + events: [ + { type: 'click', url: `https://${HOSTNAME}/`, x: 1, y: 2, timestamp: now }, + { type: 'scroll', url: `https://${HOSTNAME}/`, scrollPct: 50, timestamp: now }, + ], + }, + }, + { headers }, + ); + + expect(replay.status).toBe(200); + expect(replay.body).toEqual({ ok: true }); + expect(heatmap.status).toBe(200); + expect(heatmap.body).toEqual({ ok: true }); + }); + + test('POST /api/record requires a session token', async ({ api, seed }) => { + const response = await api.post('/api/record', { + type: 'record', + payload: { website: seed.website.id, events: [{ type: 2 }] }, + }); + + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Missing session token.'); + }); + + test('POST /api/record reports when the recorder is disabled', async ({ api, seed }) => { + const visit = await api.post('/api/send', pageview(seed.website2.id)); + const response = await api.post( + '/api/record', + { type: 'record', payload: { website: seed.website2.id, events: [{ type: 2 }] } }, + { headers: { [CACHE_HEADER]: visit.body.cache } }, + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: false, reason: 'recorder_disabled' }); + }); + + test('POST /api/record rejects invalid and oversized payloads', async ({ api, seed }) => { + const invalid = await api.post('/api/record', { type: 'bogus', payload: {} }); + const oversized = await api.post( + '/api/record', + JSON.stringify({ + type: 'record', + payload: { website: seed.website.id, events: [{ type: 2, data: 'x'.repeat(1_000_100) }] }, + }), + { headers: { 'content-type': 'application/json' } }, + ); + + expect(invalid.status).toBe(400); + expect(oversized.status).toBe(413); + expect(oversized.body.error.code).toBe('payload-too-large'); + }); +}); diff --git a/tests/api/coverage/allowlist.ts b/tests/api/coverage/allowlist.ts new file mode 100644 index 000000000..922da4fd2 --- /dev/null +++ b/tests/api/coverage/allowlist.ts @@ -0,0 +1,30 @@ +export interface AllowlistEntry { + /** Operation key, e.g. "POST /api/auth/sso" */ + key: string; + /** Why this operation cannot be exercised by the harness. */ + reason: string; + /** Only applies when running against this backend (UMAMI_TEST_DB); omit for both. */ + db?: 'postgres' | 'clickhouse'; +} + +/** + * Operations that are intentionally not covered. Keep this list short and every + * entry justified — the reporter warns when an allowlisted operation is + * actually called, so entries can be removed once they gain a test. + */ +export const allowlist: AllowlistEntry[] = [ + { + key: 'POST /api/auth/sso', + reason: + 'Cloud single sign-on handoff. Requires REDIS_URL (returns 500 "Redis is disabled" otherwise); Redis is out of scope for the harness.', + }, + { + key: 'GET /api/auth/subscription', + reason: 'Cloud billing/subscription lookup; only meaningful under CLOUD_MODE.', + }, + { + key: 'DELETE /api/websites/{websiteId}/sessions/{sessionId}', + reason: 'Session deletion needs relational storage; sessions.spec.ts skips it on ClickHouse.', + db: 'clickhouse', + }, +]; diff --git a/tests/api/coverage/matcher.test.ts b/tests/api/coverage/matcher.test.ts new file mode 100644 index 000000000..f86d74e51 --- /dev/null +++ b/tests/api/coverage/matcher.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vitest'; +import { createMatcher, getPathname } from './matcher'; + +const templates = [ + '/api/websites', + '/api/websites/charts', + '/api/websites/{websiteId}', + '/api/websites/{websiteId}/replays', + '/api/websites/{websiteId}/replays/saved', + '/api/websites/{websiteId}/replays/{replayId}', + '/api/websites/{websiteId}/replays/saved/{replayId}', + '/api/websites/{websiteId}/sessions/stats', + '/api/websites/{websiteId}/sessions/weekly', + '/api/websites/{websiteId}/sessions/{sessionId}', + '/api/reports', + '/api/reports/funnel', + '/api/reports/{reportId}', + '/api/teams/join', + '/api/teams/{teamId}', + '/api/share/{slug}', + '/api/share/id/{shareId}', +]; + +const match = createMatcher(templates); + +describe('getPathname', () => { + test('strips origin, query and hash', () => { + expect(getPathname('http://localhost:3100/api/websites?page=1#x')).toBe('/api/websites'); + expect(getPathname('/api/websites?page=1')).toBe('/api/websites'); + }); + + test('strips trailing slashes but keeps root', () => { + expect(getPathname('/api/websites/')).toBe('/api/websites'); + expect(getPathname('/')).toBe('/'); + }); +}); + +describe('createMatcher', () => { + test('prefers literal segments over parameters', () => { + expect(match('/api/websites/charts')).toBe('/api/websites/charts'); + expect(match('/api/websites/abc-123')).toBe('/api/websites/{websiteId}'); + expect(match('/api/reports/funnel')).toBe('/api/reports/funnel'); + expect(match('/api/reports/abc-123')).toBe('/api/reports/{reportId}'); + expect(match('/api/teams/join')).toBe('/api/teams/join'); + expect(match('/api/teams/abc-123')).toBe('/api/teams/{teamId}'); + }); + + test('resolves nested literal/parameter ambiguity', () => { + expect(match('/api/websites/w1/replays/saved')).toBe('/api/websites/{websiteId}/replays/saved'); + expect(match('/api/websites/w1/replays/r1')).toBe( + '/api/websites/{websiteId}/replays/{replayId}', + ); + expect(match('/api/websites/w1/replays/saved/r1')).toBe( + '/api/websites/{websiteId}/replays/saved/{replayId}', + ); + expect(match('/api/websites/w1/sessions/stats')).toBe( + '/api/websites/{websiteId}/sessions/stats', + ); + expect(match('/api/websites/w1/sessions/weekly')).toBe( + '/api/websites/{websiteId}/sessions/weekly', + ); + expect(match('/api/websites/w1/sessions/s1')).toBe( + '/api/websites/{websiteId}/sessions/{sessionId}', + ); + }); + + test('distinguishes templates by segment count', () => { + expect(match('/api/share/abcdef')).toBe('/api/share/{slug}'); + expect(match('/api/share/id/abcdef')).toBe('/api/share/id/{shareId}'); + }); + + test('ignores query strings and absolute URLs', () => { + expect(match('http://localhost:3100/api/websites/w1?startAt=1&endAt=2')).toBe( + '/api/websites/{websiteId}', + ); + }); + + test('returns null for unknown paths', () => { + expect(match('/api/nope')).toBeNull(); + expect(match('/api/websites/w1/unknown')).toBeNull(); + }); +}); diff --git a/tests/api/coverage/matcher.ts b/tests/api/coverage/matcher.ts new file mode 100644 index 000000000..653c75c82 --- /dev/null +++ b/tests/api/coverage/matcher.ts @@ -0,0 +1,57 @@ +export type PathMatcher = (url: string) => string | null; + +/** Strips origin, query string, hash and trailing slashes from a URL or path. */ +export function getPathname(url: string): string { + const raw = /^https?:\/\//i.test(url) ? new URL(url).pathname : url.split(/[?#]/)[0]; + + return raw.length > 1 ? raw.replace(/\/+$/, '') : raw; +} + +/** + * Maps a concrete request path back to an OpenAPI path template. + * + * Candidates must have the same number of segments. A literal segment match + * scores higher than a `{param}` match, so `/api/websites/charts` resolves to + * `/api/websites/charts` rather than `/api/websites/{websiteId}`, and + * `/api/reports/funnel` beats `/api/reports/{reportId}`. + */ +export function createMatcher(templates: string[]): PathMatcher { + const candidates = [...new Set(templates)].map(template => ({ + template, + segments: template.split('/').filter(Boolean), + })); + + return url => { + const segments = getPathname(url).split('/').filter(Boolean); + let best: { template: string; score: number } | null = null; + + for (const candidate of candidates) { + if (candidate.segments.length !== segments.length) { + continue; + } + + let score = 0; + let matches = true; + + for (let i = 0; i < segments.length; i++) { + const expected = candidate.segments[i]; + const actual = segments[i]; + + if (expected === actual) { + score += 1; + } else if (expected.startsWith('{') && expected.endsWith('}') && actual) { + // parameter segment: matches anything non-empty + } else { + matches = false; + break; + } + } + + if (matches && (!best || score > best.score)) { + best = { template: candidate.template, score }; + } + } + + return best?.template ?? null; + }; +} diff --git a/tests/api/coverage/oracle.ts b/tests/api/coverage/oracle.ts new file mode 100644 index 000000000..4cc9d8527 --- /dev/null +++ b/tests/api/coverage/oracle.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs'; +import { OPENAPI_FILE } from '../paths'; + +/** + * The coverage oracle is the generated OpenAPI document. `pnpm build` cannot + * succeed with an undocumented route (see src/openapi/coverage.ts), so every + * operation in public/openapi.json is exactly the set of endpoints that exist. + */ +export interface ApiOperation { + /** Same format as src/openapi/operation.ts getOperationKey: "GET /api/websites" */ + key: string; + method: string; + /** OpenAPI path template, e.g. "/api/websites/{websiteId}" */ + path: string; + /** Route file that implements the operation (x-umami-source) */ + source: string; + tag: string; +} + +const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']; + +let cached: ApiOperation[] | undefined; + +export function loadOperations(): ApiOperation[] { + if (cached) { + return cached; + } + + const document = JSON.parse(readFileSync(OPENAPI_FILE, 'utf8')); + const operations: ApiOperation[] = []; + + for (const [pathTemplate, pathItem] of Object.entries>( + document.paths ?? {}, + )) { + for (const method of METHODS) { + const operation = pathItem?.[method]; + + if (!operation) { + continue; + } + + operations.push({ + key: `${method.toUpperCase()} ${pathTemplate}`, + method: method.toUpperCase(), + path: pathTemplate, + source: operation['x-umami-source'] ?? '', + tag: operation.tags?.[0] ?? 'Other', + }); + } + } + + cached = operations.sort( + (a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method), + ); + + return cached; +} diff --git a/tests/api/coverage/recorder.ts b/tests/api/coverage/recorder.ts new file mode 100644 index 000000000..f77c39f3f --- /dev/null +++ b/tests/api/coverage/recorder.ts @@ -0,0 +1,61 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { COVERAGE_DIR } from '../paths'; +import { createMatcher, getPathname, type PathMatcher } from './matcher'; +import { loadOperations } from './oracle'; + +/** + * Records every API call made through ApiClient as "METHOD /path/{template}". + * + * Playwright workers are separate processes, so each process appends to its + * own log file; the reporter merges them at the end of the run. + */ +export const UNMATCHED_PREFIX = 'UNMATCHED '; + +let enabled = true; +let matcher: PathMatcher | undefined; +let logFile: string | undefined; +const seen = new Set(); + +/** + * Global setup calls this so the seeding traffic does not count as coverage — + * only calls made from specs should satisfy the coverage check. + */ +export function disableCoverageRecording() { + enabled = false; +} + +function getMatcher() { + if (!matcher) { + matcher = createMatcher(loadOperations().map(operation => operation.path)); + } + + return matcher; +} + +function getLogFile() { + if (!logFile) { + mkdirSync(COVERAGE_DIR, { recursive: true }); + logFile = path.join(COVERAGE_DIR, `worker-${process.pid}.log`); + } + + return logFile; +} + +export function recordApiCall(method: string, url: string) { + if (!enabled) { + return; + } + + const template = getMatcher()(url); + const line = template + ? `${method.toUpperCase()} ${template}` + : `${UNMATCHED_PREFIX}${method.toUpperCase()} ${getPathname(url)}`; + + if (seen.has(line)) { + return; + } + + seen.add(line); + appendFileSync(getLogFile(), `${line}\n`); +} diff --git a/tests/api/coverage/report.ts b/tests/api/coverage/report.ts new file mode 100644 index 000000000..5ac90960d --- /dev/null +++ b/tests/api/coverage/report.ts @@ -0,0 +1,138 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { COVERAGE_DIR } from '../paths'; +import { allowlist } from './allowlist'; +import { type ApiOperation, loadOperations } from './oracle'; +import { UNMATCHED_PREFIX } from './recorder'; + +export interface CoverageSummary { + total: number; + covered: string[]; + allowlisted: string[]; + uncovered: ApiOperation[]; + /** Allowlisted operations that were actually called — the entry can be removed. */ + staleAllowlist: string[]; + /** Allowlist keys that don't exist in the OpenAPI document. */ + unknownAllowlist: string[]; + /** Calls whose path could not be mapped to any documented operation. */ + unmatched: string[]; +} + +export function readRecordedCalls() { + const covered = new Set(); + const unmatched = new Set(); + + if (!existsSync(COVERAGE_DIR)) { + return { covered, unmatched }; + } + + for (const file of readdirSync(COVERAGE_DIR)) { + if (!file.endsWith('.log')) { + continue; + } + + for (const line of readFileSync(path.join(COVERAGE_DIR, file), 'utf8').split('\n')) { + const entry = line.trim(); + + if (!entry) { + continue; + } + + if (entry.startsWith(UNMATCHED_PREFIX)) { + unmatched.add(entry.slice(UNMATCHED_PREFIX.length)); + } else { + covered.add(entry); + } + } + } + + return { covered, unmatched }; +} + +export function computeCoverage(): CoverageSummary { + const operations = loadOperations(); + const { covered, unmatched } = readRecordedCalls(); + const known = new Set(operations.map(operation => operation.key)); + const db = process.env.UMAMI_TEST_DB === 'clickhouse' ? 'clickhouse' : 'postgres'; + const allowed = new Set( + allowlist.filter(entry => !entry.db || entry.db === db).map(entry => entry.key), + ); + + return { + total: operations.length, + covered: operations.filter(op => covered.has(op.key)).map(op => op.key), + allowlisted: [...allowed].filter(key => known.has(key) && !covered.has(key)), + uncovered: operations.filter(op => !covered.has(op.key) && !allowed.has(op.key)), + staleAllowlist: [...allowed].filter(key => covered.has(key)), + unknownAllowlist: [...allowed].filter(key => !known.has(key)), + unmatched: [...unmatched].sort(), + }; +} + +function groupByTag(operations: ApiOperation[]) { + const groups = new Map(); + + for (const operation of operations) { + const group = groups.get(operation.tag) ?? []; + group.push(operation); + groups.set(operation.tag, group); + } + + return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)); +} + +export function formatCoverage(summary: CoverageSummary): string { + const lines: string[] = []; + const { total, covered, allowlisted, uncovered } = summary; + + lines.push(''); + lines.push( + `API coverage: ${covered.length + allowlisted.length}/${total} operations ` + + `(${covered.length} covered, ${allowlisted.length} allowlisted, ${uncovered.length} uncovered)`, + ); + + if (uncovered.length) { + lines.push(''); + lines.push('Uncovered operations:'); + + for (const [tag, operations] of groupByTag(uncovered)) { + lines.push(` ${tag}`); + + for (const operation of operations) { + const source = operation.source ? ` <- ${operation.source}` : ''; + lines.push(` ${operation.key}${source}`); + } + } + } + + if (summary.staleAllowlist.length) { + lines.push(''); + lines.push('Allowlisted operations that were called (remove them from coverage/allowlist.ts):'); + + for (const key of summary.staleAllowlist) { + lines.push(` ${key}`); + } + } + + if (summary.unknownAllowlist.length) { + lines.push(''); + lines.push('Allowlist entries that do not exist in public/openapi.json:'); + + for (const key of summary.unknownAllowlist) { + lines.push(` ${key}`); + } + } + + if (summary.unmatched.length) { + lines.push(''); + lines.push('Calls that did not match any documented operation:'); + + for (const entry of summary.unmatched) { + lines.push(` ${entry}`); + } + } + + lines.push(''); + + return lines.join('\n'); +} diff --git a/tests/api/coverage/reporter.ts b/tests/api/coverage/reporter.ts new file mode 100644 index 000000000..0824fbbc5 --- /dev/null +++ b/tests/api/coverage/reporter.ts @@ -0,0 +1,51 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { FullResult, Reporter } from '@playwright/test/reporter'; +import { COVERAGE_DIR } from '../paths'; +import { computeCoverage, formatCoverage } from './report'; + +/** + * Enforces endpoint coverage at the end of the run. + * + * API_COVERAGE=enforce (default) fail a passing run if any operation is uncovered + * API_COVERAGE=report print the summary only + * API_COVERAGE=off skip entirely + */ +class ApiCoverageReporter implements Reporter { + printsToStdio() { + return true; + } + + async onEnd(result: FullResult) { + const mode = process.env.API_COVERAGE ?? 'enforce'; + + if (mode === 'off') { + return; + } + + const summary = computeCoverage(); + + mkdirSync(COVERAGE_DIR, { recursive: true }); + writeFileSync(path.join(COVERAGE_DIR, 'summary.json'), JSON.stringify(summary, null, 2)); + + console.log(formatCoverage(summary)); + + if (mode !== 'enforce' || summary.uncovered.length === 0) { + return; + } + + if (result.status !== 'passed') { + console.log('API coverage not enforced because the run did not pass.\n'); + return; + } + + console.log( + `API coverage check failed: ${summary.uncovered.length} operation(s) were never called. ` + + 'Add a spec that exercises them or an entry in tests/api/coverage/allowlist.ts.\n', + ); + + return { status: 'failed' as const }; + } +} + +export default ApiCoverageReporter; diff --git a/tests/api/docker/clickhouse-init/00-create-db.sql b/tests/api/docker/clickhouse-init/00-create-db.sql new file mode 100644 index 000000000..db363a58d --- /dev/null +++ b/tests/api/docker/clickhouse-init/00-create-db.sql @@ -0,0 +1,3 @@ +-- CLICKHOUSE_DB already creates this before init scripts run; kept as a guard so +-- db/clickhouse/schema.sql (which hardcodes the `umami.` prefix) never fails. +CREATE DATABASE IF NOT EXISTS umami; diff --git a/tests/api/event-data.spec.ts b/tests/api/event-data.spec.ts new file mode 100644 index 000000000..552b6d7c8 --- /dev/null +++ b/tests/api/event-data.spec.ts @@ -0,0 +1,565 @@ +import { expect, test } from './fixtures'; +import { dateRange, TIMEZONE } from './helpers/dates'; + +/** + * Seeded event data on `seed.website` (see seed/dataset.ts): + * - `purchase` x9 (3 identified personas x 3 days) with revenue 49.5 / 59.5 / 69.5, + * currency 'USD', quantity 2, items ['sku-1','sku-2'], orderedAt (date), premium (boolean). + * - `signup` x6 with plan 'free' | 'pro' and seats 1..6. + * - `download` x6 with file 'guide.pdf'. + * Other specs may ingest extra events concurrently, so list assertions use `toContain` + * while `purchase`-scoped aggregates are asserted exactly. + */ + +const PURCHASE_REVENUES = [49.5, 59.5, 69.5]; +const PURCHASE_TOTAL = PURCHASE_REVENUES.reduce((sum, value) => sum + value, 0) * 3; + +test.describe('Event data', () => { + let base = ''; + + test.beforeAll(({ seed }) => { + base = `/api/websites/${seed.website.id}`; + }); + + test('GET /api/websites/{websiteId}/event-data lists events with their properties', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.get(`${base}/event-data`, { + params: dateRange(seed, { pageSize: 5 }), + }); + const filtered = await admin.get(`${base}/event-data`, { + params: dateRange(seed, { event: 'purchase', pageSize: 100 }), + }); + const shared = await (await share()).get(`${base}/event-data`, { params: dateRange(seed) }); + const denied = await viewer.get(`${base}/event-data`, { params: dateRange(seed) }); + const missingRange = await admin.get(`${base}/event-data`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: expect.any(Number), + page: 1, + pageSize: 5, + }); + expect(response.body.data.length).toBeLessThanOrEqual(5); + expect(response.body.data[0]).toMatchObject({ + websiteId: seed.website.id, + eventId: expect.any(String), + eventName: expect.any(String), + eventProperties: expect.arrayContaining([ + expect.objectContaining({ dataKey: expect.any(String), dataType: expect.any(Number) }), + ]), + }); + + expect(filtered.status).toBe(200); + expect(filtered.body.data.length).toBeGreaterThanOrEqual(9); + for (const row of filtered.body.data) { + expect(row.eventName).toBe('purchase'); + expect(row.eventProperties.map((p: any) => p.dataKey)).toEqual( + expect.arrayContaining([ + 'revenue', + 'currency', + 'quantity', + 'items', + 'orderedAt', + 'premium', + ]), + ); + } + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingRange.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/event-data/{eventId} returns the raw rows of one event', async ({ + admin, + viewer, + seed, + }) => { + const response = await admin.get(`${base}/event-data/${seed.data.eventId}`); + const denied = await viewer.get(`${base}/event-data/${seed.data.eventId}`); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBeGreaterThan(0); + for (const row of response.body) { + expect(row).toMatchObject({ + websiteId: seed.website.id, + eventId: seed.data.eventId, + dataKey: expect.any(String), + dataType: expect.any(Number), + createdAt: expect.any(String), + }); + } + + const byKey = Object.fromEntries(response.body.map((row: any) => [row.dataKey, row])); + expect(byKey.currency).toMatchObject({ stringValue: seed.data.currency, dataType: 1 }); + expect(PURCHASE_REVENUES).toContain(Number(byKey.revenue.numberValue)); + expect(byKey.revenue.dataType).toBe(2); + expect(byKey.premium.dataType).toBe(3); + expect(byKey.orderedAt.dataType).toBe(4); + expect(byKey.orderedAt.dateValue).toEqual(expect.any(String)); + expect(byKey.items.dataType).toBe(5); + + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/event-data/events breaks properties down per value', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.get(`${base}/event-data/events`, { params: dateRange(seed) }); + // Note: this endpoint filters on `event`, while /fields uses `eventName`. + const filtered = await admin.get(`${base}/event-data/events`, { + params: dateRange(seed, { event: 'purchase' }), + }); + const shared = await (await share()).get(`${base}/event-data/events`, { + params: dateRange(seed), + }); + const denied = await viewer.get(`${base}/event-data/events`, { params: dateRange(seed) }); + const missingRange = await admin.get(`${base}/event-data/events`); + + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventName: expect.any(String), + propertyName: expect.any(String), + dataType: expect.any(Number), + total: expect.any(Number), + }), + ]), + ); + + expect(filtered.status).toBe(200); + expect(filtered.body.every((row: any) => row.eventName === 'purchase')).toBe(true); + expect(filtered.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + propertyName: 'currency', + propertyValue: seed.data.currency, + dataType: 1, + total: 9, + }), + ]), + ); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingRange.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/event-data/fields lists the properties of an event', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.get(`${base}/event-data/fields`, { + params: dateRange(seed, { eventName: 'purchase' }), + }); + const all = await admin.get(`${base}/event-data/fields`, { params: dateRange(seed) }); + const shared = await (await share()).get(`${base}/event-data/fields`, { + params: dateRange(seed), + }); + const denied = await viewer.get(`${base}/event-data/fields`, { params: dateRange(seed) }); + const missingRange = await admin.get(`${base}/event-data/fields`); + + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.arrayContaining([ + { propertyName: 'currency', dataType: 1, total: 9 }, + { propertyName: 'revenue', dataType: 2, total: 9 }, + { propertyName: 'premium', dataType: 3, total: 9 }, + { propertyName: 'orderedAt', dataType: 4, total: 9 }, + { propertyName: 'items', dataType: 5, total: 9 }, + { propertyName: 'quantity', dataType: 2, total: 9 }, + ]), + ); + + expect(all.status).toBe(200); + expect(all.body.map((row: any) => row.propertyName)).toEqual( + expect.arrayContaining(['revenue', 'plan', 'seats', 'file']), + ); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingRange.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/event-data/properties lists properties per event', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.get(`${base}/event-data/properties`, { + params: dateRange(seed), + }); + const shared = await (await share()).get(`${base}/event-data/properties`, { + params: dateRange(seed), + }); + const denied = await viewer.get(`${base}/event-data/properties`, { params: dateRange(seed) }); + const missingRange = await admin.get(`${base}/event-data/properties`); + + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.arrayContaining([ + { eventName: 'purchase', propertyName: 'revenue', dataType: 2, total: 9 }, + { eventName: 'signup', propertyName: 'plan', dataType: 1, total: 6 }, + { eventName: 'signup', propertyName: 'seats', dataType: 2, total: 6 }, + { eventName: 'download', propertyName: 'file', dataType: 1, total: 6 }, + ]), + ); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingRange.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/event-data/stats returns totals', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.get(`${base}/event-data/stats`, { params: dateRange(seed) }); + const shared = await (await share()).get(`${base}/event-data/stats`, { + params: dateRange(seed), + }); + const denied = await viewer.get(`${base}/event-data/stats`, { params: dateRange(seed) }); + const missingRange = await admin.get(`${base}/event-data/stats`); + + expect(response.status).toBe(200); + expect(Object.keys(response.body).sort()).toEqual(['events', 'properties', 'records']); + // 9 purchases + 6 signups + 6 downloads (other specs may add more). + expect(Number(response.body.events)).toBeGreaterThanOrEqual(21); + // purchase: 6 props, signup: 2, download: 1. + expect(Number(response.body.properties)).toBeGreaterThanOrEqual(9); + // 9*6 + 6*2 + 6*1 = 72 property records. + expect(Number(response.body.records)).toBeGreaterThanOrEqual(72); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingRange.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/event-data/values returns value counts for a property', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.get(`${base}/event-data/values`, { + params: dateRange(seed, { propertyName: 'plan', eventName: 'signup' }), + }); + const typed = await admin.get(`${base}/event-data/values`, { + params: dateRange(seed, { propertyName: 'revenue', eventName: 'purchase', dataType: 2 }), + }); + const shared = await (await share()).get(`${base}/event-data/values`, { + params: dateRange(seed, { propertyName: 'plan' }), + }); + const denied = await viewer.get(`${base}/event-data/values`, { + params: dateRange(seed, { propertyName: 'plan' }), + }); + const missingProperty = await admin.get(`${base}/event-data/values`, { + params: dateRange(seed), + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.arrayContaining([ + { value: 'free', total: 3 }, + { value: 'pro', total: 3 }, + ]), + ); + + expect(typed.status).toBe(200); + expect(typed.body.map((row: any) => Number(row.value)).sort()).toEqual(PURCHASE_REVENUES); + expect(typed.body.every((row: any) => row.total === 3)).toBe(true); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingProperty.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/event-data-pivot pivots event properties into columns', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.get(`${base}/event-data-pivot`, { + params: dateRange(seed, { + eventName: 'purchase', + timezone: TIMEZONE, + unit: 'day', + pageSize: 100, + }), + }); + const paged = await admin.get(`${base}/event-data-pivot`, { + params: dateRange(seed, { eventName: 'purchase', page: 2, pageSize: 4 }), + }); + const propertyFiltered = await admin.get(`${base}/event-data-pivot`, { + params: dateRange(seed, { eventName: 'purchase', pf_premium: '3.eq.true', pageSize: 100 }), + }); + const shared = await (await share()).get(`${base}/event-data-pivot`, { + params: dateRange(seed, { eventName: 'purchase' }), + }); + const denied = await viewer.get(`${base}/event-data-pivot`, { + params: dateRange(seed, { eventName: 'purchase' }), + }); + const missingEvent = await admin.get(`${base}/event-data-pivot`, { + params: dateRange(seed), + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ count: 9, page: 1, pageSize: 100 }); + expect(response.body.data).toHaveLength(9); + for (const row of response.body.data) { + expect(row).toMatchObject({ + eventId: expect.any(String), + sessionId: expect.any(String), + eventName: 'purchase', + urlPath: '/', + createdAt: expect.any(String), + propertyKeys: expect.any(Array), + propertyValues: expect.any(Array), + }); + // Key order is backend-specific (Postgres sorts, ClickHouse keeps insertion order). + expect([...row.propertyKeys].sort()).toEqual([ + 'currency', + 'items', + 'orderedAt', + 'premium', + 'quantity', + 'revenue', + ]); + expect(row.propertyValues).toHaveLength(row.propertyKeys.length); + expect(row.propertyValues[row.propertyKeys.indexOf('currency')]).toBe(seed.data.currency); + } + + expect(paged.status).toBe(200); + expect(paged.body).toMatchObject({ count: 9, page: 2, pageSize: 4 }); + expect(paged.body.data).toHaveLength(4); + + // Only alice (persona index 0) buys with premium=true: 3 purchases. + expect(propertyFiltered.status).toBe(200); + expect(propertyFiltered.body.count).toBe(3); + expect( + propertyFiltered.body.data.every( + (row: any) => row.propertyValues[row.propertyKeys.indexOf('premium')] === 'true', + ), + ).toBe(true); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingEvent.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/event-data-pivot/array-series expands array properties over time', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { eventName: 'purchase', propertyName: 'items' }); + const response = await admin.get(`${base}/event-data-pivot/array-series`, { params }); + const shared = await (await share()).get(`${base}/event-data-pivot/array-series`, { params }); + const denied = await viewer.get(`${base}/event-data-pivot/array-series`, { params }); + const missingProperty = await admin.get(`${base}/event-data-pivot/array-series`, { + params: dateRange(seed, { eventName: 'purchase' }), + }); + + expect(response.status).toBe(200); + expect(response.body.length).toBeGreaterThan(0); + for (const point of response.body) { + expect(point).toEqual({ + x: expect.any(String), + t: expect.any(String), + y: expect.any(Number), + }); + } + expect(new Set(response.body.map((point: any) => point.x))).toEqual( + new Set(['sku-1', 'sku-2']), + ); + // Every purchase carries both skus: 9 occurrences each. + for (const sku of ['sku-1', 'sku-2']) { + const total = response.body + .filter((point: any) => point.x === sku) + .reduce((sum: number, point: any) => sum + point.y, 0); + expect(total).toBe(9); + } + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingProperty.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/event-data-pivot/date-series counts date properties', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { eventName: 'purchase', propertyName: 'orderedAt' }); + const response = await admin.get(`${base}/event-data-pivot/date-series`, { params }); + const shared = await (await share()).get(`${base}/event-data-pivot/date-series`, { params }); + const denied = await viewer.get(`${base}/event-data-pivot/date-series`, { params }); + const missingEvent = await admin.get(`${base}/event-data-pivot/date-series`, { + params: dateRange(seed, { propertyName: 'orderedAt' }), + }); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(9); + for (const point of response.body) { + expect(point).toEqual({ t: expect.any(String), y: 1 }); + expect(Number.isNaN(new Date(point.t).getTime())).toBe(false); + } + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingEvent.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/event-data-pivot/numeric-series aggregates numeric properties', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { + eventName: 'purchase', + propertyName: 'revenue', + unit: 'day', + timezone: TIMEZONE, + }); + const sum = await admin.get(`${base}/event-data-pivot/numeric-series`, { params }); + const avg = await admin.get(`${base}/event-data-pivot/numeric-series`, { + params: { ...params, metric: 'avg' }, + }); + const count = await admin.get(`${base}/event-data-pivot/numeric-series`, { + params: { ...params, metric: 'count' }, + }); + const shared = await (await share()).get(`${base}/event-data-pivot/numeric-series`, { + params, + }); + const denied = await viewer.get(`${base}/event-data-pivot/numeric-series`, { params }); + const badMetric = await admin.get(`${base}/event-data-pivot/numeric-series`, { + params: { ...params, metric: 'max' }, + }); + const missingProperty = await admin.get(`${base}/event-data-pivot/numeric-series`, { + params: dateRange(seed, { eventName: 'purchase' }), + }); + + // Purchases happen on 3 distinct days, 3 per day. + expect(sum.status).toBe(200); + expect(sum.body).toHaveLength(3); + for (const point of sum.body) { + expect(point).toEqual({ t: expect.any(String), y: expect.anything() }); + expect(Number(point.y)).toBeCloseTo(PURCHASE_TOTAL / 3, 5); + } + + expect(avg.status).toBe(200); + expect(avg.body).toHaveLength(3); + for (const point of avg.body) { + expect(Number(point.y)).toBeCloseTo(59.5, 5); + } + + expect(count.status).toBe(200); + expect(count.body).toHaveLength(3); + for (const point of count.body) { + expect(Number(point.y)).toBe(3); + } + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(badMetric.status).toBe(400); + expect(missingProperty.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/event-data-pivot/numeric-stats summarises a numeric property', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { eventName: 'purchase', propertyName: 'revenue' }); + const response = await admin.get(`${base}/event-data-pivot/numeric-stats`, { params }); + const shared = await (await share()).get(`${base}/event-data-pivot/numeric-stats`, { params }); + const denied = await viewer.get(`${base}/event-data-pivot/numeric-stats`, { params }); + const missingProperty = await admin.get(`${base}/event-data-pivot/numeric-stats`, { + params: dateRange(seed, { eventName: 'purchase' }), + }); + + expect(response.status).toBe(200); + expect(Object.keys(response.body).sort()).toEqual(['average', 'max', 'median', 'min', 'total']); + expect(Number(response.body.min)).toBeCloseTo(49.5, 5); + expect(Number(response.body.max)).toBeCloseTo(69.5, 5); + expect(Number(response.body.average)).toBeCloseTo(59.5, 5); + expect(Number(response.body.median)).toBeCloseTo(59.5, 5); + expect(Number(response.body.total)).toBeCloseTo(PURCHASE_TOTAL, 5); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingProperty.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/event-data-pivot/property-series counts property values over time', async ({ + admin, + share, + viewer, + seed, + }) => { + const premium = await admin.get(`${base}/event-data-pivot/property-series`, { + params: dateRange(seed, { + eventName: 'purchase', + propertyName: 'premium', + unit: 'day', + timezone: TIMEZONE, + }), + }); + const plan = await admin.get(`${base}/event-data-pivot/property-series`, { + params: dateRange(seed, { eventName: 'signup', propertyName: 'plan', unit: 'day' }), + }); + const shared = await (await share()).get(`${base}/event-data-pivot/property-series`, { + params: dateRange(seed, { eventName: 'signup', propertyName: 'plan' }), + }); + const denied = await viewer.get(`${base}/event-data-pivot/property-series`, { + params: dateRange(seed, { eventName: 'signup', propertyName: 'plan' }), + }); + const missingProperty = await admin.get(`${base}/event-data-pivot/property-series`, { + params: dateRange(seed, { eventName: 'signup' }), + }); + + expect(premium.status).toBe(200); + // 3 days x (2 non-premium + 1 premium). + expect(premium.body).toHaveLength(6); + for (const point of premium.body) { + expect(point).toEqual({ + x: expect.any(String), + t: expect.any(String), + y: expect.any(Number), + }); + } + const totals = (rows: any[]) => + rows.reduce>((acc, row) => { + acc[row.x] = (acc[row.x] ?? 0) + row.y; + return acc; + }, {}); + expect(totals(premium.body)).toEqual({ true: 3, false: 6 }); + + expect(plan.status).toBe(200); + expect(totals(plan.body)).toEqual({ pro: 3, free: 3 }); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingProperty.status).toBe(400); + }); +}); diff --git a/tests/api/fixtures.ts b/tests/api/fixtures.ts new file mode 100644 index 000000000..d07a29a1f --- /dev/null +++ b/tests/api/fixtures.ts @@ -0,0 +1,91 @@ +import { test as base, request } from '@playwright/test'; +import { ApiClient } from './client'; +import { login } from './helpers/auth'; +import { SHARE_CONTEXT_HEADER, SHARE_TOKEN_HEADER } from './helpers/constants'; +import { readSeedState, type SeedState } from './seed/state'; + +export interface WorkerTokens { + admin: string; + user: string; + viewer: string; + apiKey: { id: string; key: string }; +} + +interface WorkerFixtures { + /** Seeded entities and dataset metadata written by global setup. */ + seed: SeedState; + /** Auth tokens created once per worker process. */ + tokens: WorkerTokens; +} + +interface TestFixtures { + /** Unauthenticated client. */ + api: ApiClient; + /** Logged in as the built-in admin (owns the seeded website, link, pixel, team). */ + admin: ApiClient; + /** Logged in as the seeded regular user (owns website2, member of the seeded team). */ + user: ApiClient; + /** Logged in as the seeded view-only user, who owns nothing. */ + viewer: ApiClient; + /** Authenticated with an admin API key (`umami_…`) instead of a session token. */ + apiKey: ApiClient; + /** Resolves a share slug to a client authenticated with a share token. */ + share: (slug?: string) => Promise; +} + +export const test = base.extend({ + // biome-ignore lint/correctness/noEmptyPattern: Playwright requires the destructuring form + seed: [async ({}, use) => use(readSeedState()), { scope: 'worker' }], + + tokens: [ + async ({ seed }, use, workerInfo) => { + const context = await request.newContext({ baseURL: workerInfo.project.use.baseURL }); + const raw = new ApiClient(context); + + try { + const admin = await login(raw, seed.admin); + const user = await login(raw, seed.user); + const viewer = await login(raw, seed.viewer); + + const created = await raw + .bearer(admin) + .post('/api/me/api-keys', { name: `api-tests-worker-${workerInfo.workerIndex}` }); + + if (created.status !== 200 || !created.body?.key) { + throw new Error(`Failed to create API key (${created.status}): ${created.text}`); + } + + const apiKey = { id: created.body.id as string, key: created.body.key as string }; + + await use({ admin, user, viewer, apiKey }); + + await raw.bearer(admin).del(`/api/me/api-keys/${apiKey.id}`); + } finally { + await context.dispose(); + } + }, + { scope: 'worker' }, + ], + + api: async ({ request }, use) => use(new ApiClient(request)), + admin: async ({ api, tokens }, use) => use(api.bearer(tokens.admin)), + user: async ({ api, tokens }, use) => use(api.bearer(tokens.user)), + viewer: async ({ api, tokens }, use) => use(api.bearer(tokens.viewer)), + apiKey: async ({ api, tokens }, use) => use(api.bearer(tokens.apiKey.key)), + + share: async ({ api, seed }, use) => + use(async (slug = seed.share.slug) => { + const response = await api.get(`/api/share/${slug}`); + + if (response.status !== 200 || !response.body?.token) { + throw new Error(`Failed to resolve share ${slug} (${response.status}): ${response.text}`); + } + + return api.with({ + [SHARE_TOKEN_HEADER]: response.body.token, + [SHARE_CONTEXT_HEADER]: '1', + }); + }), +}); + +export { expect } from '@playwright/test'; diff --git a/tests/api/global-setup.ts b/tests/api/global-setup.ts new file mode 100644 index 000000000..c02ed29f5 --- /dev/null +++ b/tests/api/global-setup.ts @@ -0,0 +1,82 @@ +/* eslint-disable no-console */ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { type FullConfig, request } from '@playwright/test'; +import { ApiClient } from './client'; +import { disableCoverageRecording } from './coverage/recorder'; +import { assertDisposableTarget, COVERAGE_DIR, SEED_FILE } from './paths'; +import { seedEnvironment } from './seed/setup'; + +const HEARTBEAT_TIMEOUT_MS = 120_000; + +async function waitForHeartbeat(baseURL: string, timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + let lastError = ''; + + while (Date.now() < deadline) { + try { + const response = await fetch(`${baseURL}/api/heartbeat`); + + if (response.ok) { + return; + } + + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = (error as Error).message; + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + throw new Error( + `Timed out waiting for ${baseURL}/api/heartbeat (${lastError}). ` + + 'Start the stack with `pnpm test:api:up` or run the full flow with `pnpm test:api`.', + ); +} + +export default async function globalSetup(config: FullConfig) { + // Seeding traffic must not count towards endpoint coverage. + disableCoverageRecording(); + + const baseURL = config.projects[0]?.use?.baseURL; + + if (!baseURL) { + throw new Error('playwright.api.config.ts must define use.baseURL'); + } + + assertDisposableTarget(baseURL); + + // API_SKIP_SEED=1 reuses the seed from a previous run against a kept stack + // (fast iteration on a single spec). + const skipSeed = !!process.env.API_SKIP_SEED && existsSync(SEED_FILE); + + // Coverage must reflect this run only: the per-process logs are append-only and + // the reporter merges every file in the directory, so stale logs from an earlier + // run would let removed endpoint calls still count as covered. + rmSync(COVERAGE_DIR, { recursive: true, force: true }); + mkdirSync(COVERAGE_DIR, { recursive: true }); + + console.log(`Waiting for ${baseURL}/api/heartbeat ...`); + await waitForHeartbeat(baseURL, HEARTBEAT_TIMEOUT_MS); + + if (skipSeed) { + console.log(`Reusing seed state from ${SEED_FILE} (API_SKIP_SEED is set).`); + return; + } + + const context = await request.newContext({ baseURL }); + + try { + const started = Date.now(); + const state = await seedEnvironment(new ApiClient(context)); + + writeFileSync(SEED_FILE, JSON.stringify(state, null, 2)); + + console.log( + `Seeded ${state.db} environment in ${((Date.now() - started) / 1000).toFixed(1)}s ` + + `(${state.data.expectedPageviews} pageviews on ${state.website.domain})`, + ); + } finally { + await context.dispose(); + } +} diff --git a/tests/api/helpers/auth.ts b/tests/api/helpers/auth.ts new file mode 100644 index 000000000..ce09dc699 --- /dev/null +++ b/tests/api/helpers/auth.ts @@ -0,0 +1,16 @@ +import type { ApiClient } from '../client'; + +export interface Credentials { + username: string; + password: string; +} + +export async function login(api: ApiClient, { username, password }: Credentials) { + const response = await api.post('/api/auth/login', { username, password }); + + if (response.status !== 200 || !response.body?.token) { + throw new Error(`Login failed for ${username} (${response.status}): ${response.text}`); + } + + return response.body.token as string; +} diff --git a/tests/api/helpers/constants.ts b/tests/api/helpers/constants.ts new file mode 100644 index 000000000..a4e2970cb --- /dev/null +++ b/tests/api/helpers/constants.ts @@ -0,0 +1,45 @@ +/** + * Constants mirrored from src/lib/constants.ts. The API suite deliberately does + * not import from src/ (server-only modules, path aliases), so keep these in + * sync by hand — they change rarely. + */ + +/** Created by prisma/migrations/01_init on first boot. */ +export const ADMIN_USER = { + id: '41e2b680-648e-4b09-bcd7-3e2b10c06264', + username: 'admin', + password: 'umami', + role: 'admin', +} as const; + +export const ROLES = { + admin: 'admin', + user: 'user', + viewOnly: 'view-only', + teamOwner: 'team-owner', + teamManager: 'team-manager', + teamMember: 'team-member', + teamViewOnly: 'team-view-only', +} as const; + +export const EVENT_TYPE = { + pageView: 1, + customEvent: 2, + linkEvent: 3, + pixelEvent: 4, + performance: 5, +} as const; + +export const ENTITY_TYPE = { + website: 1, + link: 2, + pixel: 3, + board: 4, +} as const; + +export const SHARE_TOKEN_HEADER = 'x-umami-share-token'; +export const SHARE_CONTEXT_HEADER = 'x-umami-share-context'; +export const CACHE_HEADER = 'x-umami-cache'; + +/** A syntactically valid v4 UUID that never exists in the database. */ +export const UNKNOWN_UUID = '00000000-0000-4000-8000-000000000000'; diff --git a/tests/api/helpers/dates.ts b/tests/api/helpers/dates.ts new file mode 100644 index 000000000..af4438be1 --- /dev/null +++ b/tests/api/helpers/dates.ts @@ -0,0 +1,16 @@ +import type { SeedState } from '../seed/state'; + +export const TIMEZONE = 'UTC'; + +/** `startAt`/`endAt` (ms) query params spanning the whole seeded dataset. */ +export function dateRange(seed: SeedState, extra: Record = {}) { + return { startAt: seed.range.startAt, endAt: Date.now(), ...extra }; +} + +/** `startDate`/`endDate` ISO strings used by report parameters. */ +export function dateRangeIso(seed: SeedState) { + return { + startDate: new Date(seed.range.startAt).toISOString(), + endDate: new Date().toISOString(), + }; +} diff --git a/tests/api/helpers/entities.ts b/tests/api/helpers/entities.ts new file mode 100644 index 000000000..d8c831b08 --- /dev/null +++ b/tests/api/helpers/entities.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto'; +import type { ApiClient, ApiResponse } from '../client'; + +/** + * Helpers for creating throwaway entities. Every spec should create the + * mutable resources it needs (with unique names) and delete them in + * `afterAll`; the seeded entities are shared and must stay read-only. + */ + +export function uniqueName(prefix: string) { + return `${prefix}-${randomUUID().slice(0, 8)}`; +} + +/** Link/pixel slugs must be at least 8 characters. */ +export function uniqueSlug(prefix: string) { + return `${prefix}${randomUUID().replace(/-/g, '').slice(0, 12)}`; +} + +export function uniqueDomain() { + return `${uniqueName('site')}.test`; +} + +export function assertStatus(response: ApiResponse, expected: number, label: string) { + if (response.status !== expected) { + throw new Error( + `${label}: expected HTTP ${expected}, got ${response.status}: ${response.text}`, + ); + } + + return response; +} + +export async function createWebsite(client: ApiClient, overrides: Record = {}) { + const response = await client.post('/api/websites', { + name: uniqueName('website'), + domain: uniqueDomain(), + ...overrides, + }); + + return assertStatus(response, 200, 'create website').body; +} + +export async function deleteWebsite(client: ApiClient, websiteId: string) { + await client.del(`/api/websites/${websiteId}`); +} + +export async function createUser(admin: ApiClient, overrides: Record = {}) { + const password = 'throwaway-password'; + const response = await admin.post('/api/users', { + username: uniqueName('user'), + password, + role: 'user', + ...overrides, + }); + + return { ...assertStatus(response, 200, 'create user').body, password }; +} + +export async function deleteUser(admin: ApiClient, userId: string) { + await admin.del(`/api/users/${userId}`); +} + +/** POST /api/teams responds with `[team, teamUser]`. */ +export async function createTeam(client: ApiClient, overrides: Record = {}) { + const response = await client.post('/api/teams', { name: uniqueName('team'), ...overrides }); + const [team, teamUser] = assertStatus(response, 200, 'create team').body; + + return { ...team, teamUser }; +} + +export async function deleteTeam(client: ApiClient, teamId: string) { + await client.del(`/api/teams/${teamId}`); +} + +export async function createLink(client: ApiClient, overrides: Record = {}) { + const response = await client.post('/api/links', { + name: uniqueName('link'), + url: 'https://umami.is/', + slug: uniqueSlug('link'), + ...overrides, + }); + + return assertStatus(response, 200, 'create link').body; +} + +export async function deleteLink(client: ApiClient, linkId: string) { + await client.del(`/api/links/${linkId}`); +} + +export async function createPixel(client: ApiClient, overrides: Record = {}) { + const response = await client.post('/api/pixels', { + name: uniqueName('pixel'), + slug: uniqueSlug('pixel'), + ...overrides, + }); + + return assertStatus(response, 200, 'create pixel').body; +} + +export async function deletePixel(client: ApiClient, pixelId: string) { + await client.del(`/api/pixels/${pixelId}`); +} diff --git a/tests/api/helpers/totp.ts b/tests/api/helpers/totp.ts new file mode 100644 index 000000000..914473a87 --- /dev/null +++ b/tests/api/helpers/totp.ts @@ -0,0 +1,47 @@ +import { generateSync } from 'otplib'; + +/** TOTP defaults used by the server (otplib v13): SHA-1, 6 digits, 30 s step, no drift window. */ +const STEP_SECONDS = 30; + +/** Codes generated this close to the end of a step may expire before the server verifies them. */ +const SAFETY_MARGIN_SECONDS = 4; + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function secondsUntilNextStep() { + return STEP_SECONDS - (Math.floor(Date.now() / 1000) % STEP_SECONDS); +} + +/** + * Returns a TOTP code for `secret` (the Base32 `manualKey` from setup/initiate) + * that is safe to submit immediately. Every accepted code is single-use for + * 90 s, so pass the previously accepted code as `lastCode` to wait for a new + * time step when necessary. + */ +export async function nextCode(secret: string, lastCode?: string) { + for (;;) { + const remaining = secondsUntilNextStep(); + + if (remaining <= SAFETY_MARGIN_SECONDS) { + await sleep(remaining * 1000 + 250); + continue; + } + + const code = generateSync({ secret }); + + if (code !== lastCode) { + return code; + } + + await sleep(remaining * 1000 + 250); + } +} + +/** A 6-digit code that is guaranteed not to be the one currently valid for `secret`. */ +export function wrongCode(secret: string) { + const current = generateSync({ secret }); + + return current === '000000' ? '111111' : '000000'; +} diff --git a/tests/api/links.spec.ts b/tests/api/links.spec.ts new file mode 100644 index 000000000..ecb6f0916 --- /dev/null +++ b/tests/api/links.spec.ts @@ -0,0 +1,231 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from './fixtures'; +import { ENTITY_TYPE, UNKNOWN_UUID } from './helpers/constants'; +import { dateRange } from './helpers/dates'; +import { deleteLink, uniqueName, uniqueSlug } from './helpers/entities'; + +test.describe('Links', () => { + test.describe.configure({ mode: 'serial' }); + + let linkId = ''; + let slug = ''; + let shareId = ''; + + test.afterAll(async ({ admin }) => { + if (shareId) { + await admin.del(`/api/share/id/${shareId}`); + } + + if (linkId) { + await deleteLink(admin, linkId); + } + }); + + test('POST /api/links creates a link for the current user', async ({ admin, seed }) => { + const name = uniqueName('link'); + slug = uniqueSlug('link'); + const response = await admin.post('/api/links', { name, url: 'https://umami.is/', slug }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: expect.any(String), + name, + url: 'https://umami.is/', + slug, + userId: seed.admin.id, + teamId: null, + createdAt: expect.any(String), + deletedAt: null, + }); + + linkId = response.body.id; + }); + + test('POST /api/links accepts a fixed id and a team', async ({ admin, seed }) => { + const id = randomUUID(); + const response = await admin.post('/api/links', { + id, + name: uniqueName('team-link'), + url: 'https://umami.is/', + slug: uniqueSlug('team'), + teamId: seed.team.id, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id, teamId: seed.team.id, userId: null }); + + await deleteLink(admin, id); + }); + + test('POST /api/links validates the body and requires permission', async ({ + admin, + viewer, + api, + }) => { + const shortSlug = await admin.post('/api/links', { + name: 'x', + url: 'https://umami.is/', + slug: 'short', + }); + const missingUrl = await admin.post('/api/links', { name: 'x', slug: uniqueSlug('link') }); + const denied = await viewer.post('/api/links', { + name: 'x', + url: 'https://umami.is/', + slug: uniqueSlug('link'), + }); + const anonymous = await api.post('/api/links', { + name: 'x', + url: 'https://umami.is/', + slug: uniqueSlug('link'), + }); + + expect(shortSlug.status).toBe(400); + expect(shortSlug.body.error.code).toBe('bad-request'); + expect(missingUrl.status).toBe(400); + expect(denied.status).toBe(401); + expect(anonymous.status).toBe(401); + }); + + test('GET /api/links lists the current user links', async ({ admin, user, viewer }) => { + const response = await admin.get('/api/links'); + const search = await admin.get('/api/links', { params: { search: slug } }); + const sorted = await admin.get('/api/links', { + params: { orderBy: 'createdAt', sortDescending: 'true', pageSize: 1 }, + }); + const invalidSort = await admin.get('/api/links', { params: { sortDescending: 'yes' } }); + const other = await user.get('/api/links'); + const none = await viewer.get('/api/links'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: expect.any(Number), + page: 1, + pageSize: expect.any(Number), + }); + expect(response.body.data.map((l: any) => l.id)).toContain(linkId); + expect(search.body.data.map((l: any) => l.id)).toEqual([linkId]); + expect(sorted.status).toBe(200); + expect(sorted.body.data).toHaveLength(1); + expect(invalidSort.status).toBe(400); + expect(other.body.data.map((l: any) => l.id)).not.toContain(linkId); + expect(none.status).toBe(200); + expect(none.body.data).toHaveLength(0); + }); + + test('GET /api/links/{linkId} returns a link', async ({ admin, user, viewer, api }) => { + const response = await admin.get(`/api/links/${linkId}`); + const denied = await viewer.get(`/api/links/${linkId}`); + const anonymous = await api.get(`/api/links/${linkId}`); + const unknown = await user.get(`/api/links/${UNKNOWN_UUID}`); + // Admins bypass the ownership check, so an unknown id yields an empty 200. + const adminUnknown = await admin.get(`/api/links/${UNKNOWN_UUID}`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: linkId, slug }); + expect(denied.status).toBe(401); + expect(anonymous.status).toBe(401); + expect(unknown.status).toBe(401); + expect(adminUnknown.status).toBe(200); + expect(adminUnknown.body).toBeNull(); + }); + + test('POST /api/links/{linkId} updates a link', async ({ admin, user, seed }) => { + const name = uniqueName('renamed'); + slug = uniqueSlug('renamed'); + const response = await admin.post(`/api/links/${linkId}`, { + name, + slug, + url: 'https://umami.is/docs', + }); + const duplicate = await admin.post(`/api/links/${linkId}`, { slug: seed.link.slug }); + const denied = await user.post(`/api/links/${linkId}`, { name }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: linkId, name, slug, url: 'https://umami.is/docs' }); + expect(duplicate.status).toBe(400); + expect(duplicate.body.error.message).toBe('That slug is already taken.'); + expect(denied.status).toBe(401); + }); + + test('POST /api/links/{linkId}/shares creates a link share', async ({ admin, user }) => { + const name = uniqueName('link-share'); + const response = await admin.post(`/api/links/${linkId}/shares`, { + name, + parameters: { overview: true }, + }); + const invalid = await admin.post(`/api/links/${linkId}/shares`, {}); + const denied = await user.post(`/api/links/${linkId}/shares`, { name }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + entityId: linkId, + shareType: ENTITY_TYPE.link, + name, + slug: expect.any(String), + parameters: { overview: true }, + }); + expect(invalid.status).toBe(400); + expect(denied.status).toBe(401); + + shareId = response.body.id; + }); + + test('GET /api/links/{linkId}/shares lists link shares', async ({ admin, viewer, api }) => { + const response = await admin.get(`/api/links/${linkId}/shares`); + const paged = await admin.get(`/api/links/${linkId}/shares`, { + params: { page: 1, pageSize: 1 }, + }); + const denied = await viewer.get(`/api/links/${linkId}/shares`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ data: expect.any(Array), count: 1, page: 1 }); + expect(response.body.data[0]).toMatchObject({ id: shareId, entityId: linkId }); + expect(paged.body.pageSize).toBe(1); + expect(denied.status).toBe(401); + + // The share resolves publicly to a link-typed token. + const resolved = await api.get(`/api/share/${response.body.data[0].slug}`); + + expect(resolved.status).toBe(200); + expect(resolved.body).toMatchObject({ shareType: ENTITY_TYPE.link, linkId, websiteId: linkId }); + }); + + test('GET /api/links/charts returns chart data for accessible links', async ({ + admin, + viewer, + seed, + }) => { + const response = await admin.get('/api/links/charts', { + params: { ids: `${seed.link.id},${linkId}`, ...dateRange(seed), timezone: 'UTC' }, + }); + const defaultRange = await admin.get('/api/links/charts', { params: { ids: seed.link.id } }); + const denied = await viewer.get('/api/links/charts', { params: { ids: seed.link.id } }); + const invalid = await admin.get('/api/links/charts', { params: { ids: 'nope' } }); + const missing = await admin.get('/api/links/charts'); + + expect(response.status).toBe(200); + // `data` is keyed by link id; ids the caller cannot view are silently dropped. + expect(Object.keys(response.body.data)).toContain(seed.link.id); + expect(response.body.data[seed.link.id]).toBeTruthy(); + expect(defaultRange.status).toBe(200); + expect(denied.status).toBe(200); + expect(denied.body.data).toEqual({}); + expect(invalid.status).toBe(400); + expect(missing.status).toBe(400); + }); + + test('DELETE /api/links/{linkId} deletes a link', async ({ admin, user }) => { + const denied = await user.del(`/api/links/${linkId}`); + const response = await admin.del(`/api/links/${linkId}`); + const gone = await admin.get(`/api/links/${linkId}`); + + expect(denied.status).toBe(401); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(gone.status).toBe(200); + expect(gone.body).toBeNull(); + + linkId = ''; + }); +}); diff --git a/tests/api/paths.ts b/tests/api/paths.ts new file mode 100644 index 000000000..b2f4adab1 --- /dev/null +++ b/tests/api/paths.ts @@ -0,0 +1,43 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** Server under test (started by scripts/test-api.ts unless PLAYWRIGHT_BASE_URL is set). */ +export const BASE_URL = + process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${process.env.UMAMI_TEST_PORT ?? '3100'}`; + +/** + * Runtime state shared between global setup, workers and the coverage reporter. + * Namespaced by target host so concurrent runs against different servers (other + * ports, worktrees or sessions sharing this checkout) don't clobber each other. + */ +export const RUNTIME_DIR = path.join( + ROOT_DIR, + 'tests/api/.runtime', + new URL(BASE_URL).host.replace(/[^a-z0-9.-]+/gi, '-'), +); +export const COVERAGE_DIR = path.join(RUNTIME_DIR, 'coverage'); +export const SEED_FILE = path.join(RUNTIME_DIR, 'seed.json'); +export const OPENAPI_FILE = path.join(ROOT_DIR, 'public/openapi.json'); + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + +/** + * The suite is destructive: global setup deletes and recreates fixed-ID websites + * and ingests analytics data, and specs toggle the global 2FA requirement. Only + * loopback targets (the local Compose stack) run without an explicit opt-in. + */ +export function assertDisposableTarget(baseURL: string) { + const { hostname } = new URL(baseURL); + + if (LOOPBACK_HOSTS.has(hostname) || process.env.API_ALLOW_DESTRUCTIVE === '1') { + return; + } + + throw new Error( + `Refusing to run the API suite against ${baseURL}: it deletes and recreates websites, ` + + 'ingests analytics data and changes the global 2FA setting. Point PLAYWRIGHT_BASE_URL ' + + 'at a disposable server and set API_ALLOW_DESTRUCTIVE=1 to confirm.', + ); +} diff --git a/tests/api/pixels.spec.ts b/tests/api/pixels.spec.ts new file mode 100644 index 000000000..fa7aba360 --- /dev/null +++ b/tests/api/pixels.spec.ts @@ -0,0 +1,217 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from './fixtures'; +import { ENTITY_TYPE, UNKNOWN_UUID } from './helpers/constants'; +import { dateRange } from './helpers/dates'; +import { deletePixel, uniqueName, uniqueSlug } from './helpers/entities'; + +test.describe('Pixels', () => { + test.describe.configure({ mode: 'serial' }); + + let pixelId = ''; + let slug = ''; + let shareId = ''; + + test.afterAll(async ({ admin }) => { + if (shareId) { + await admin.del(`/api/share/id/${shareId}`); + } + + if (pixelId) { + await deletePixel(admin, pixelId); + } + }); + + test('POST /api/pixels creates a pixel for the current user', async ({ admin, seed }) => { + const name = uniqueName('pixel'); + slug = uniqueSlug('pixel'); + const response = await admin.post('/api/pixels', { name, slug }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: expect.any(String), + name, + slug, + userId: seed.admin.id, + teamId: null, + createdAt: expect.any(String), + deletedAt: null, + }); + + pixelId = response.body.id; + }); + + test('POST /api/pixels accepts a fixed id and a team', async ({ admin, seed }) => { + const id = randomUUID(); + const response = await admin.post('/api/pixels', { + id, + name: uniqueName('team-pixel'), + slug: uniqueSlug('team'), + teamId: seed.team.id, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id, teamId: seed.team.id, userId: null }); + + await deletePixel(admin, id); + }); + + test('POST /api/pixels validates the body and requires permission', async ({ + admin, + viewer, + api, + }) => { + const shortSlug = await admin.post('/api/pixels', { name: 'x', slug: 'short' }); + const missingName = await admin.post('/api/pixels', { slug: uniqueSlug('pixel') }); + const denied = await viewer.post('/api/pixels', { name: 'x', slug: uniqueSlug('pixel') }); + const anonymous = await api.post('/api/pixels', { name: 'x', slug: uniqueSlug('pixel') }); + + expect(shortSlug.status).toBe(400); + expect(shortSlug.body.error.code).toBe('bad-request'); + expect(missingName.status).toBe(400); + expect(denied.status).toBe(401); + expect(anonymous.status).toBe(401); + }); + + test('GET /api/pixels lists the current user pixels', async ({ admin, user, viewer }) => { + const response = await admin.get('/api/pixels'); + const search = await admin.get('/api/pixels', { params: { search: slug } }); + const sorted = await admin.get('/api/pixels', { + params: { orderBy: 'createdAt', sortDescending: 'true', pageSize: 1 }, + }); + const invalidSort = await admin.get('/api/pixels', { params: { sortDescending: 'yes' } }); + const other = await user.get('/api/pixels'); + const none = await viewer.get('/api/pixels'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: expect.any(Number), + page: 1, + pageSize: expect.any(Number), + }); + expect(response.body.data.map((p: any) => p.id)).toContain(pixelId); + expect(search.body.data.map((p: any) => p.id)).toEqual([pixelId]); + expect(sorted.status).toBe(200); + expect(sorted.body.data).toHaveLength(1); + expect(invalidSort.status).toBe(400); + expect(other.body.data.map((p: any) => p.id)).not.toContain(pixelId); + expect(none.status).toBe(200); + expect(none.body.data).toHaveLength(0); + }); + + test('GET /api/pixels/{pixelId} returns a pixel', async ({ admin, user, viewer, api }) => { + const response = await admin.get(`/api/pixels/${pixelId}`); + const denied = await viewer.get(`/api/pixels/${pixelId}`); + const anonymous = await api.get(`/api/pixels/${pixelId}`); + const unknown = await user.get(`/api/pixels/${UNKNOWN_UUID}`); + // Admins bypass the ownership check, so an unknown id yields an empty 200. + const adminUnknown = await admin.get(`/api/pixels/${UNKNOWN_UUID}`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: pixelId, slug }); + expect(denied.status).toBe(401); + expect(anonymous.status).toBe(401); + expect(unknown.status).toBe(401); + expect(adminUnknown.status).toBe(200); + expect(adminUnknown.body).toBeNull(); + }); + + test('POST /api/pixels/{pixelId} updates a pixel', async ({ admin, user, seed }) => { + const name = uniqueName('renamed'); + slug = uniqueSlug('renamed'); + const response = await admin.post(`/api/pixels/${pixelId}`, { name, slug }); + const duplicate = await admin.post(`/api/pixels/${pixelId}`, { slug: seed.pixel.slug }); + const denied = await user.post(`/api/pixels/${pixelId}`, { name }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: pixelId, name, slug }); + expect(duplicate.status).toBe(400); + expect(duplicate.body.error.message).toBe('That slug is already taken.'); + expect(denied.status).toBe(401); + }); + + test('POST /api/pixels/{pixelId}/shares creates a pixel share', async ({ admin, user }) => { + const name = uniqueName('pixel-share'); + const response = await admin.post(`/api/pixels/${pixelId}/shares`, { + name, + parameters: { overview: true }, + }); + const invalid = await admin.post(`/api/pixels/${pixelId}/shares`, {}); + const denied = await user.post(`/api/pixels/${pixelId}/shares`, { name }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + entityId: pixelId, + shareType: ENTITY_TYPE.pixel, + name, + slug: expect.any(String), + parameters: { overview: true }, + }); + expect(invalid.status).toBe(400); + expect(denied.status).toBe(401); + + shareId = response.body.id; + }); + + test('GET /api/pixels/{pixelId}/shares lists pixel shares', async ({ admin, viewer, api }) => { + const response = await admin.get(`/api/pixels/${pixelId}/shares`); + const paged = await admin.get(`/api/pixels/${pixelId}/shares`, { + params: { page: 1, pageSize: 1 }, + }); + const denied = await viewer.get(`/api/pixels/${pixelId}/shares`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ data: expect.any(Array), count: 1, page: 1 }); + expect(response.body.data[0]).toMatchObject({ id: shareId, entityId: pixelId }); + expect(paged.body.pageSize).toBe(1); + expect(denied.status).toBe(401); + + // The share resolves publicly to a pixel-typed token. + const resolved = await api.get(`/api/share/${response.body.data[0].slug}`); + + expect(resolved.status).toBe(200); + expect(resolved.body).toMatchObject({ + shareType: ENTITY_TYPE.pixel, + pixelId, + websiteId: pixelId, + }); + }); + + test('GET /api/pixels/charts returns chart data for accessible pixels', async ({ + admin, + viewer, + seed, + }) => { + const response = await admin.get('/api/pixels/charts', { + params: { ids: `${seed.pixel.id},${pixelId}`, ...dateRange(seed), timezone: 'UTC' }, + }); + const defaultRange = await admin.get('/api/pixels/charts', { params: { ids: seed.pixel.id } }); + const denied = await viewer.get('/api/pixels/charts', { params: { ids: seed.pixel.id } }); + const invalid = await admin.get('/api/pixels/charts', { params: { ids: 'nope' } }); + const missing = await admin.get('/api/pixels/charts'); + + expect(response.status).toBe(200); + // `data` is keyed by pixel id; ids the caller cannot view are silently dropped. + expect(Object.keys(response.body.data)).toContain(seed.pixel.id); + expect(response.body.data[seed.pixel.id]).toBeTruthy(); + expect(defaultRange.status).toBe(200); + expect(denied.status).toBe(200); + expect(denied.body.data).toEqual({}); + expect(invalid.status).toBe(400); + expect(missing.status).toBe(400); + }); + + test('DELETE /api/pixels/{pixelId} deletes a pixel', async ({ admin, user }) => { + const denied = await user.del(`/api/pixels/${pixelId}`); + const response = await admin.del(`/api/pixels/${pixelId}`); + const gone = await admin.get(`/api/pixels/${pixelId}`); + + expect(denied.status).toBe(401); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(gone.status).toBe(200); + expect(gone.body).toBeNull(); + + pixelId = ''; + }); +}); diff --git a/tests/api/replays.spec.ts b/tests/api/replays.spec.ts new file mode 100644 index 000000000..c72c429b6 --- /dev/null +++ b/tests/api/replays.spec.ts @@ -0,0 +1,349 @@ +import { expect, test } from './fixtures'; +import { UNKNOWN_UUID } from './helpers/constants'; +import { dateRange } from './helpers/dates'; +import { uniqueName } from './helpers/entities'; + +/** + * Global setup records one replay on `seed.website` (see seed/dataset.ts): + * three rrweb events spanning 20ms in a single chunk. Replay ids are visit ids. + * Other specs may record extra replays concurrently, so list assertions look for + * the seeded replay rather than asserting exact counts. + * + * Share tokens are rejected on every replay endpoint (session replay is never + * exposed through public shares). + */ + +const REPLAY_EVENT_COUNT = 3; + +test.describe('Replays', () => { + test.describe.configure({ mode: 'serial' }); + + let base = ''; + + test.beforeAll(({ seed }) => { + base = `/api/websites/${seed.website.id}`; + }); + + // The seeded replay is shared with other specs: always leave it unsaved. + test.afterAll(async ({ admin, seed }) => { + await admin.post(`${base}/replays/saved/${seed.data.replayVisitId}`, { isSaved: false }); + }); + + test('GET /api/websites/{websiteId}/replays lists recorded replays', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.get(`${base}/replays`, { + params: dateRange(seed, { pageSize: 100 }), + }); + const longOnly = await admin.get(`${base}/replays`, { + params: dateRange(seed, { minDuration: 60_000, pageSize: 100 }), + }); + const searched = await admin.get(`${base}/replays`, { + params: dateRange(seed, { search: 'zzz-no-such-replay' }), + }); + const shared = await (await share()).get(`${base}/replays`, { params: dateRange(seed) }); + const denied = await viewer.get(`${base}/replays`, { params: dateRange(seed) }); + const missingRange = await admin.get(`${base}/replays`); + const badDuration = await admin.get(`${base}/replays`, { + params: dateRange(seed, { minDuration: -1 }), + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: expect.any(Number), + page: 1, + pageSize: 100, + }); + + const replay = response.body.data.find((row: any) => row.id === seed.data.replayVisitId); + expect(replay).toEqual({ + id: seed.data.replayVisitId, + websiteId: seed.website.id, + sessionId: seed.data.replaySessionId, + createdAt: expect.any(String), + startedAt: expect.any(String), + endedAt: expect.any(String), + duration: expect.any(Number), + eventCount: REPLAY_EVENT_COUNT, + chunkCount: 1, + browser: 'chrome', + os: expect.any(String), + device: expect.any(String), + // Unresolved geo is null on Postgres but '' on ClickHouse (non-nullable String columns). + country: seed.db === 'clickhouse' ? '' : null, + city: seed.db === 'clickhouse' ? '' : null, + }); + expect(replay.duration).toBe( + new Date(replay.endedAt).getTime() - new Date(replay.startedAt).getTime(), + ); + + // The seeded replay only lasts 20ms. + expect(longOnly.status).toBe(200); + expect(longOnly.body.data.map((row: any) => row.id)).not.toContain(seed.data.replayVisitId); + + expect(searched.status).toBe(200); + expect(searched.body.data).toHaveLength(0); + + expect(shared.status).toBe(401); + expect(denied.status).toBe(401); + expect(missingRange.status).toBe(400); + expect(badDuration.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/replays/{replayId} returns the recorded events', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.get(`${base}/replays/${seed.data.replayVisitId}`); + const shared = await (await share()).get(`${base}/replays/${seed.data.replayVisitId}`); + const denied = await viewer.get(`${base}/replays/${seed.data.replayVisitId}`); + const unknown = await admin.get(`${base}/replays/${UNKNOWN_UUID}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + sessionId: seed.data.replaySessionId, + events: expect.any(Array), + startedAt: expect.any(String), + endedAt: expect.any(String), + eventCount: REPLAY_EVENT_COUNT, + chunkCount: 1, + }); + expect(response.body.events).toHaveLength(REPLAY_EVENT_COUNT); + expect(response.body.events.map((event: any) => event.type)).toEqual([4, 2, 3]); + expect(response.body.events[0]).toEqual({ + type: 4, + data: { href: `https://${seed.data.hostname}/`, width: 1280, height: 800 }, + timestamp: expect.any(Number), + }); + const timestamps = response.body.events.map((event: any) => event.timestamp); + // ClickHouse stores replay bounds at second precision; Postgres keeps milliseconds. + const bound = (value: number) => + seed.db === 'clickhouse' ? Math.floor(value / 1000) * 1000 : value; + expect(bound(timestamps[0])).toBe(new Date(response.body.startedAt).getTime()); + expect(bound(timestamps[timestamps.length - 1])).toBe( + new Date(response.body.endedAt).getTime(), + ); + + // Unknown replays are an empty recording, not a 404. + expect(unknown.status).toBe(200); + expect(unknown.body).toEqual({ + sessionId: null, + events: [], + startedAt: null, + endedAt: null, + eventCount: 0, + chunkCount: 0, + }); + + expect(shared.status).toBe(401); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/replays/{replayId} truncates the events with until, chunkIndex and eventIndex', async ({ + admin, + seed, + }) => { + const path = `${base}/replays/${seed.data.replayVisitId}`; + const full = await admin.get(path); + const secondTimestamp = full.body.events[1].timestamp; + + // `chunkIndex` is not an ordinal: /api/record stores each chunk under its ingest + // timestamp in seconds (`timestamp || Date.now() / 1000`), so the parameter is an + // inclusive upper bound on chunk timestamps. + const nowSeconds = Math.floor(Date.now() / 1000); + const startSeconds = Math.floor(new Date(full.body.startedAt).getTime() / 1000); + + const until = await admin.get(path, { params: { until: secondTimestamp } }); + const allChunks = await admin.get(path, { params: { chunkIndex: nowSeconds } }); + const beforeFirstChunk = await admin.get(path, { params: { chunkIndex: startSeconds - 1 } }); + const listed = await admin.get(`${base}/replays`, { + params: dateRange(seed, { pageSize: 100 }), + }); + + expect(until.status).toBe(200); + expect(until.body.events.map((event: any) => event.timestamp)).toEqual( + full.body.events + .map((event: any) => event.timestamp) + .filter((timestamp: number) => timestamp <= secondTimestamp), + ); + expect(until.body.events.length).toBeLessThan(REPLAY_EVENT_COUNT); + + expect(allChunks.status).toBe(200); + expect(allChunks.body.events).toEqual(full.body.events); + expect(allChunks.body.chunkCount).toBe(1); + + expect(beforeFirstChunk.status).toBe(200); + expect(beforeFirstChunk.body).toMatchObject({ events: [], chunkCount: 0 }); + + // `eventIndex` only applies to the chunk whose timestamp equals `chunkIndex`. The + // seeded chunk was ingested without an explicit timestamp, so its key is the ingest + // second, which sits within a second or two of the replay's `createdAt`. + const replay = listed.body.data.find((row: any) => row.id === seed.data.replayVisitId); + const createdSeconds = Math.floor(new Date(replay.createdAt).getTime() / 1000); + const truncated: any[] = []; + + for (const chunkIndex of [createdSeconds - 2, createdSeconds - 1, createdSeconds]) { + const response = await admin.get(path, { params: { chunkIndex, eventIndex: 0 } }); + + expect(response.status).toBe(200); + truncated.push(response.body.events); + } + + expect(truncated).toContainEqual([full.body.events[0]]); + }); + + test('POST /api/websites/{websiteId}/replays/saved/{replayId} saves a replay', async ({ + admin, + share, + viewer, + seed, + }) => { + const name = uniqueName('saved-replay'); + + // Saving an already-saved replay fails with a unique-constraint 500 (it is not an + // upsert), so start from a known unsaved state in case an earlier run aborted. + await admin.post(`${base}/replays/saved/${seed.data.replayVisitId}`, { isSaved: false }); + + const response = await admin.post(`${base}/replays/saved/${seed.data.replayVisitId}`, { + isSaved: true, + name, + }); + const shared = await (await share()).post(`${base}/replays/saved/${seed.data.replayVisitId}`, { + isSaved: true, + }); + const denied = await viewer.post(`${base}/replays/saved/${seed.data.replayVisitId}`, { + isSaved: true, + }); + const missingFlag = await admin.post(`${base}/replays/saved/${seed.data.replayVisitId}`, { + name, + }); + const longName = await admin.post(`${base}/replays/saved/${seed.data.replayVisitId}`, { + isSaved: true, + name: 'x'.repeat(101), + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(shared.status).toBe(401); + expect(denied.status).toBe(401); + expect(missingFlag.status).toBe(400); + expect(longName.status).toBe(400); + + const status = await admin.get(`${base}/replays/saved/${seed.data.replayVisitId}`); + const saved = await admin.get(`${base}/replays/saved`, { params: { pageSize: 100 } }); + const searched = await admin.get(`${base}/replays/saved`, { params: { search: name } }); + const missed = await admin.get(`${base}/replays/saved`, { + params: { search: 'zzz-no-such-saved-replay' }, + }); + const listShared = await (await share()).get(`${base}/replays/saved`); + const listDenied = await viewer.get(`${base}/replays/saved`); + const statusShared = await (await share()).get( + `${base}/replays/saved/${seed.data.replayVisitId}`, + ); + const statusDenied = await viewer.get(`${base}/replays/saved/${seed.data.replayVisitId}`); + + expect(status.status).toBe(200); + expect(status.body.isSaved).toBeTruthy(); + + expect(saved.status).toBe(200); + expect(saved.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) }); + expect(saved.body.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + websiteId: seed.website.id, + visitId: seed.data.replayVisitId, + name, + createdAt: expect.any(String), + }), + ]), + ); + + expect(searched.status).toBe(200); + expect(searched.body.data).toHaveLength(1); + expect(searched.body.data[0]).toMatchObject({ visitId: seed.data.replayVisitId, name }); + expect(missed.body.data).toHaveLength(0); + + expect(listShared.status).toBe(401); + expect(listDenied.status).toBe(401); + expect(statusShared.status).toBe(401); + expect(statusDenied.status).toBe(401); + }); + + test('POST /api/websites/{websiteId}/replays/saved/{replayId} unsaves a replay', async ({ + admin, + seed, + }) => { + const response = await admin.post(`${base}/replays/saved/${seed.data.replayVisitId}`, { + isSaved: false, + }); + const status = await admin.get(`${base}/replays/saved/${seed.data.replayVisitId}`); + const saved = await admin.get(`${base}/replays/saved`, { params: { pageSize: 100 } }); + const unknown = await admin.get(`${base}/replays/saved/${UNKNOWN_UUID}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(status.status).toBe(200); + expect(status.body.isSaved).toBeFalsy(); + expect(saved.body.data.map((row: any) => row.visitId)).not.toContain(seed.data.replayVisitId); + expect(unknown.status).toBe(200); + expect(unknown.body.isSaved).toBeFalsy(); + }); + + test('GET /api/websites/{websiteId}/sessions/{sessionId}/replays lists the replays of a session', async ({ + admin, + share, + viewer, + seed, + }) => { + const path = `${base}/sessions/${seed.data.replaySessionId}/replays`; + const response = await admin.get(path, { params: dateRange(seed) }); + const searched = await admin.get(path, { + params: dateRange(seed, { search: 'zzz-no-such-replay' }), + }); + const none = await admin.get(`${base}/sessions/${UNKNOWN_UUID}/replays`, { + params: dateRange(seed), + }); + const shared = await (await share()).get(path, { params: dateRange(seed) }); + const denied = await viewer.get(path, { params: dateRange(seed) }); + const missingRange = await admin.get(path); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: expect.any(Number), + page: 1, + pageSize: expect.any(Number), + }); + expect(response.body.data.length).toBeGreaterThan(0); + for (const row of response.body.data) { + expect(row).toMatchObject({ + id: expect.any(String), + websiteId: seed.website.id, + sessionId: seed.data.replaySessionId, + eventCount: expect.any(Number), + chunkCount: expect.any(Number), + duration: expect.any(Number), + startedAt: expect.any(String), + endedAt: expect.any(String), + }); + } + expect(response.body.data.map((row: any) => row.id)).toContain(seed.data.replayVisitId); + + expect(searched.status).toBe(200); + expect(searched.body.data).toHaveLength(0); + + expect(none.status).toBe(200); + expect(none.body.data).toHaveLength(0); + + expect(shared.status).toBe(401); + expect(denied.status).toBe(401); + expect(missingRange.status).toBe(400); + }); +}); diff --git a/tests/api/reports.spec.ts b/tests/api/reports.spec.ts new file mode 100644 index 000000000..334cf3b7f --- /dev/null +++ b/tests/api/reports.spec.ts @@ -0,0 +1,650 @@ +import { expect, test } from './fixtures'; +import { UNKNOWN_UUID } from './helpers/constants'; +import { dateRange, dateRangeIso } from './helpers/dates'; +import { uniqueName } from './helpers/entities'; +import type { SeedState } from './seed/state'; + +/** Body shared by every report run endpoint (filters + parameters date windows). */ +function runBody(seed: SeedState, type: string, parameters: Record = {}) { + return { + websiteId: seed.website.id, + type, + filters: { ...dateRange(seed), timezone: 'UTC', unit: 'day' }, + parameters: { ...dateRangeIso(seed), ...parameters }, + }; +} + +test.describe('Saved reports', () => { + test.describe.configure({ mode: 'serial' }); + + let reportId = ''; + + test.afterAll(async ({ admin }) => { + if (reportId) { + await admin.del(`/api/reports/${reportId}`); + } + }); + + test('POST /api/reports creates a report', async ({ admin, seed }) => { + const name = uniqueName('report'); + const response = await admin.post('/api/reports', { + websiteId: seed.website.id, + type: 'funnel', + name, + description: 'created by the api suite', + parameters: { window: 60, steps: [] }, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: expect.any(String), + userId: seed.admin.id, + websiteId: seed.website.id, + type: 'funnel', + name, + description: 'created by the api suite', + parameters: { window: 60, steps: [] }, + }); + + reportId = response.body.id; + }); + + test('POST /api/reports validates the body', async ({ admin, seed }) => { + const badType = await admin.post('/api/reports', { + websiteId: seed.website.id, + type: 'nope', + name: 'x', + parameters: {}, + }); + const missingParameters = await admin.post('/api/reports', { + websiteId: seed.website.id, + type: 'goal', + name: 'x', + }); + const longName = await admin.post('/api/reports', { + websiteId: seed.website.id, + type: 'goal', + name: 'x'.repeat(201), + parameters: {}, + }); + + expect(badType.status).toBe(400); + expect(missingParameters.status).toBe(400); + expect(longName.status).toBe(400); + }); + + test('POST /api/reports requires update access to the website', async ({ + viewer, + user, + api, + seed, + }) => { + const body = { websiteId: seed.website.id, type: 'goal', name: 'x', parameters: {} }; + const denied = await viewer.post('/api/reports', body); + const otherOwner = await user.post('/api/reports', body); + const anonymous = await api.post('/api/reports', body); + const unknown = await user.post('/api/reports', { ...body, websiteId: UNKNOWN_UUID }); + + expect(denied.status).toBe(401); + expect(otherOwner.status).toBe(401); + expect(anonymous.status).toBe(401); + expect(unknown.status).toBe(401); + }); + + test('GET /api/reports lists reports for a website', async ({ admin, seed }) => { + const response = await admin.get('/api/reports', { params: { websiteId: seed.website.id } }); + const byType = await admin.get('/api/reports', { + params: { websiteId: seed.website.id, type: 'funnel' }, + }); + const otherType = await admin.get('/api/reports', { + params: { websiteId: seed.website.id, type: 'retention' }, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: expect.any(Number), + page: 1, + pageSize: expect.any(Number), + }); + expect(response.body.data.map((r: any) => r.id)).toContain(reportId); + expect(byType.status).toBe(200); + expect(byType.body.data.map((r: any) => r.id)).toContain(reportId); + expect(byType.body.data.every((r: any) => r.type === 'funnel')).toBe(true); + expect(otherType.body.data.map((r: any) => r.id)).not.toContain(reportId); + }); + + test('GET /api/reports validates websiteId and checks access', async ({ + admin, + viewer, + share, + seed, + }) => { + const missing = await admin.get('/api/reports'); + const invalid = await admin.get('/api/reports', { params: { websiteId: 'nope' } }); + const badType = await admin.get('/api/reports', { + params: { websiteId: seed.website.id, type: 'nope' }, + }); + const denied = await viewer.get('/api/reports', { params: { websiteId: seed.website.id } }); + const shared = await (await share()).get('/api/reports', { + params: { websiteId: seed.website.id }, + }); + const sharedByType = await (await share()).get('/api/reports', { + params: { websiteId: seed.website.id, type: 'funnel' }, + }); + + expect(missing.status).toBe(400); + expect(invalid.status).toBe(400); + expect(badType.status).toBe(400); + expect(denied.status).toBe(401); + // Listing every type requires a real user; a share token can only list a single section. + expect(shared.status).toBe(401); + expect(sharedByType.status).toBe(200); + expect(sharedByType.body.data.map((r: any) => r.id)).toContain(reportId); + }); + + test('GET /api/websites/{websiteId}/reports lists the website reports', async ({ + admin, + viewer, + seed, + }) => { + const response = await admin.get(`/api/websites/${seed.website.id}/reports`); + const byType = await admin.get(`/api/websites/${seed.website.id}/reports`, { + params: { type: 'retention' }, + }); + const badType = await admin.get(`/api/websites/${seed.website.id}/reports`, { + params: { type: 'nope' }, + }); + const denied = await viewer.get(`/api/websites/${seed.website.id}/reports`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) }); + expect(response.body.data.map((r: any) => r.id)).toContain(reportId); + expect(byType.status).toBe(200); + expect(byType.body.data.map((r: any) => r.id)).not.toContain(reportId); + expect(badType.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('GET /api/reports/{reportId} returns a report', async ({ admin, viewer, api }) => { + const response = await admin.get(`/api/reports/${reportId}`); + const denied = await viewer.get(`/api/reports/${reportId}`); + const anonymous = await api.get(`/api/reports/${reportId}`); + const unknown = await admin.get(`/api/reports/${UNKNOWN_UUID}`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: reportId, + type: 'funnel', + parameters: { window: 60 }, + createdAt: expect.any(String), + }); + expect(denied.status).toBe(401); + expect(anonymous.status).toBe(401); + expect(unknown.status).toBe(404); + }); + + test('POST /api/reports/{reportId} updates a report', async ({ admin, viewer, seed }) => { + const name = uniqueName('renamed'); + const body = { + websiteId: seed.website.id, + type: 'funnel', + name, + description: 'updated', + parameters: { window: 30, steps: [{ type: 'path', value: '/' }] }, + }; + const response = await admin.post(`/api/reports/${reportId}`, body); + const invalid = await admin.post(`/api/reports/${reportId}`, { name }); + const denied = await viewer.post(`/api/reports/${reportId}`, body); + const unknown = await admin.post(`/api/reports/${UNKNOWN_UUID}`, body); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: reportId, + name, + description: 'updated', + parameters: { window: 30 }, + }); + expect(invalid.status).toBe(400); + expect(denied.status).toBe(401); + expect(unknown.status).toBe(404); + }); + + test('DELETE /api/reports/{reportId} deletes a report', async ({ admin, viewer }) => { + const denied = await viewer.del(`/api/reports/${reportId}`); + const response = await admin.del(`/api/reports/${reportId}`); + const gone = await admin.get(`/api/reports/${reportId}`); + const unknown = await admin.del(`/api/reports/${UNKNOWN_UUID}`); + + expect(denied.status).toBe(401); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(gone.status).toBe(404); + expect(unknown.status).toBe(404); + + reportId = ''; + }); +}); + +test.describe('Report runs', () => { + test('POST /api/reports/attribution returns attribution by channel', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.post( + '/api/reports/attribution', + runBody(seed, 'attribution', { model: 'first-click', type: 'path', step: '/pricing' }), + ); + const lastClick = await admin.post( + '/api/reports/attribution', + runBody(seed, 'attribution', { model: 'last-click', type: 'event', step: 'signup' }), + ); + const shared = await (await share()).post( + '/api/reports/attribution', + runBody(seed, 'attribution', { model: 'first-click', type: 'path', step: '/pricing' }), + ); + const invalid = await admin.post( + '/api/reports/attribution', + runBody(seed, 'attribution', { model: 'nope', type: 'path', step: '/' }), + ); + const denied = await viewer.post( + '/api/reports/attribution', + runBody(seed, 'attribution', { model: 'first-click', type: 'path', step: '/' }), + ); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + referrer: expect.any(Array), + paidAds: expect.any(Array), + utm_source: expect.any(Array), + utm_medium: expect.any(Array), + utm_campaign: expect.any(Array), + utm_content: expect.any(Array), + utm_term: expect.any(Array), + total: { + pageviews: expect.any(Number), + visitors: expect.any(Number), + visits: expect.any(Number), + }, + }); + expect(response.body.total.visitors).toBeGreaterThan(0); + expect(lastClick.status).toBe(200); + expect(shared.status).toBe(200); + expect(invalid.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('POST /api/reports/breakdown returns rows grouped by field', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.post( + '/api/reports/breakdown', + runBody(seed, 'breakdown', { fields: ['path'] }), + ); + const multi = await admin.post( + '/api/reports/breakdown', + runBody(seed, 'breakdown', { fields: ['browser', 'os'] }), + ); + const shared = await (await share()).post( + '/api/reports/breakdown', + runBody(seed, 'breakdown', { fields: ['path'] }), + ); + const invalid = await admin.post( + '/api/reports/breakdown', + runBody(seed, 'breakdown', { fields: ['nope'] }), + ); + const denied = await viewer.post( + '/api/reports/breakdown', + runBody(seed, 'breakdown', { fields: ['path'] }), + ); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + // Aggregates come back as numeric strings on Postgres and numbers on ClickHouse. + expect(response.body[0]).toMatchObject({ + path: expect.any(String), + views: expect.anything(), + visitors: expect.anything(), + visits: expect.anything(), + bounces: expect.anything(), + totaltime: expect.anything(), + }); + expect(Number(response.body[0].views)).toBeGreaterThan(0); + expect(response.body.map((r: any) => r.path)).toContain('/pricing'); + expect(multi.status).toBe(200); + expect(multi.body[0]).toMatchObject({ browser: expect.any(String), os: expect.any(String) }); + expect(shared.status).toBe(200); + expect(invalid.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('POST /api/reports/funnel returns conversion per step', async ({ + admin, + share, + viewer, + seed, + }) => { + const parameters = { + window: 60, + steps: [ + { type: 'path', value: '/' }, + { type: 'path', value: '/pricing' }, + { type: 'path', value: '/blog/hello-world' }, + ], + }; + const response = await admin.post('/api/reports/funnel', runBody(seed, 'funnel', parameters)); + const shared = await (await share()).post( + '/api/reports/funnel', + runBody(seed, 'funnel', parameters), + ); + const tooFewSteps = await admin.post( + '/api/reports/funnel', + runBody(seed, 'funnel', { window: 60, steps: [{ type: 'path', value: '/' }] }), + ); + const denied = await viewer.post('/api/reports/funnel', runBody(seed, 'funnel', parameters)); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(3); + expect(response.body[0]).toMatchObject({ + type: 'path', + value: '/', + visitors: expect.any(Number), + previous: expect.any(Number), + dropped: expect.any(Number), + remaining: expect.any(Number), + }); + expect(response.body[1]).toMatchObject({ value: '/pricing', dropoff: expect.any(Number) }); + expect(response.body[0].visitors).toBeGreaterThan(0); + expect(response.body[1].visitors).toBeGreaterThan(0); + expect(response.body[1].visitors).toBeLessThanOrEqual(response.body[0].visitors); + expect(response.body[2].visitors).toBeLessThanOrEqual(response.body[1].visitors); + expect(shared.status).toBe(200); + expect(tooFewSteps.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('POST /api/reports/goal returns goal completion', async ({ admin, share, viewer, seed }) => { + const response = await admin.post( + '/api/reports/goal', + runBody(seed, 'goal', { type: 'path', value: '/' }), + ); + const event = await admin.post( + '/api/reports/goal', + runBody(seed, 'goal', { type: 'event', value: 'signup' }), + ); + const shared = await (await share()).post( + '/api/reports/goal', + runBody(seed, 'goal', { type: 'path', value: '/' }), + ); + const invalid = await admin.post('/api/reports/goal', runBody(seed, 'goal', { type: 'path' })); + const denied = await viewer.post( + '/api/reports/goal', + runBody(seed, 'goal', { type: 'path', value: '/' }), + ); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ num: expect.any(Number), total: expect.any(Number) }); + expect(response.body.num).toBeGreaterThan(0); + expect(response.body.total).toBeGreaterThanOrEqual(response.body.num); + expect(event.status).toBe(200); + expect(event.body.num).toBeGreaterThan(0); + expect(shared.status).toBe(200); + expect(invalid.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('POST /api/reports/heatmap returns click and scroll data', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.post( + '/api/reports/heatmap', + runBody(seed, 'heatmap', { urlPath: '/', mode: 'click' }), + ); + const scroll = await admin.post( + '/api/reports/heatmap', + runBody(seed, 'heatmap', { urlPath: '/', mode: 'scroll' }), + ); + const invalid = await admin.post( + '/api/reports/heatmap', + runBody(seed, 'heatmap', { urlPath: '/', mode: 'nope' }), + ); + const shared = await (await share()).post( + '/api/reports/heatmap', + runBody(seed, 'heatmap', { urlPath: '/' }), + ); + const denied = await viewer.post( + '/api/reports/heatmap', + runBody(seed, 'heatmap', { urlPath: '/' }), + ); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + mode: 'click', + pages: expect.any(Array), + points: expect.any(Array), + scroll: { buckets: expect.any(Array), totalSessions: expect.any(Number) }, + }); + expect(response.body.pages.map((p: any) => p.urlPath ?? p.url ?? p.path)).toContain('/'); + expect(scroll.status).toBe(200); + expect(scroll.body.mode).toBe('scroll'); + expect(invalid.status).toBe(400); + // Heatmaps require a logged in user; share tokens are rejected. + expect(shared.status).toBe(401); + expect(denied.status).toBe(401); + }); + + test('POST /api/reports/journey returns paths between steps', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.post( + '/api/reports/journey', + runBody(seed, 'journey', { steps: 3 }), + ); + const anchored = await admin.post( + '/api/reports/journey', + runBody(seed, 'journey', { steps: 3, startStep: '/' }), + ); + const shared = await (await share()).post( + '/api/reports/journey', + runBody(seed, 'journey', { steps: 3 }), + ); + const invalid = await admin.post( + '/api/reports/journey', + runBody(seed, 'journey', { steps: 1 }), + ); + const denied = await viewer.post( + '/api/reports/journey', + runBody(seed, 'journey', { steps: 3 }), + ); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBeGreaterThan(0); + expect(response.body[0]).toMatchObject({ items: expect.any(Array), count: expect.any(Number) }); + expect(response.body[0].items.length).toBeGreaterThan(0); + expect(anchored.status).toBe(200); + expect(anchored.body.length).toBeGreaterThan(0); + expect(anchored.body.every((r: any) => r.items[0] === '/')).toBe(true); + expect(shared.status).toBe(200); + expect(invalid.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('POST /api/reports/performance returns web vitals', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.post( + '/api/reports/performance', + runBody(seed, 'performance', { unit: 'day', timezone: 'UTC' }), + ); + const metric = await admin.post( + '/api/reports/performance', + runBody(seed, 'performance', { metric: 'lcp' }), + ); + const shared = await (await share()).post( + '/api/reports/performance', + runBody(seed, 'performance'), + ); + const invalid = await admin.post( + '/api/reports/performance', + runBody(seed, 'performance', { metric: 'nope' }), + ); + const denied = await viewer.post('/api/reports/performance', runBody(seed, 'performance')); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + chart: expect.any(Array), + summary: { count: expect.any(Number) }, + pages: expect.any(Array), + pageTitles: expect.any(Array), + devices: expect.any(Array), + browsers: expect.any(Array), + }); + expect(response.body.summary.count).toBeGreaterThan(0); + expect(metric.status).toBe(200); + expect(shared.status).toBe(200); + expect(invalid.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('POST /api/reports/retention returns cohorts by day', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.post( + '/api/reports/retention', + runBody(seed, 'retention', { timezone: 'UTC' }), + ); + const shared = await (await share()).post('/api/reports/retention', runBody(seed, 'retention')); + const denied = await viewer.post('/api/reports/retention', runBody(seed, 'retention')); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBeGreaterThan(0); + expect(response.body[0]).toMatchObject({ + date: expect.any(String), + day: expect.any(Number), + visitors: expect.any(Number), + returnVisitors: expect.any(Number), + percentage: expect.any(Number), + }); + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test('POST /api/reports/revenue returns revenue totals', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.post( + '/api/reports/revenue', + runBody(seed, 'revenue', { currency: seed.data.currency }), + ); + const shared = await (await share()).post( + '/api/reports/revenue', + runBody(seed, 'revenue', { currency: seed.data.currency }), + ); + const missingCurrency = await admin.post('/api/reports/revenue', runBody(seed, 'revenue')); + const denied = await viewer.post( + '/api/reports/revenue', + runBody(seed, 'revenue', { currency: seed.data.currency }), + ); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + chart: expect.any(Array), + total: { + sum: expect.anything(), + count: expect.anything(), + average: expect.anything(), + unique_count: expect.anything(), + }, + country: expect.any(Array), + region: expect.any(Array), + referrer: expect.any(Array), + channel: expect.any(Array), + }); + expect(Number(response.body.total.sum)).toBeGreaterThan(0); + expect(Number(response.body.total.count)).toBeGreaterThan(0); + expect(shared.status).toBe(200); + expect(missingCurrency.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('POST /api/reports/utm returns views per utm parameter', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.post('/api/reports/utm', runBody(seed, 'utm')); + const shared = await (await share()).post('/api/reports/utm', runBody(seed, 'utm')); + const denied = await viewer.post('/api/reports/utm', runBody(seed, 'utm')); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + utm_source: expect.any(Array), + utm_medium: expect.any(Array), + utm_campaign: expect.any(Array), + utm_term: expect.any(Array), + utm_content: expect.any(Array), + }); + expect(response.body.utm_source[0]).toMatchObject({ + utm: expect.any(String), + views: expect.any(Number), + }); + expect(response.body.utm_source.map((r: any) => r.utm)).toContain('newsletter'); + expect(response.body.utm_medium.map((r: any) => r.utm)).toContain('email'); + expect(response.body.utm_campaign.map((r: any) => r.utm)).toContain('launch'); + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test('POST /api/reports/{type} validates the discriminator and envelope', async ({ + admin, + user, + api, + seed, + }) => { + const wrongType = await admin.post('/api/reports/utm', runBody(seed, 'goal')); + const missingFilters = await admin.post('/api/reports/utm', { + websiteId: seed.website.id, + type: 'utm', + parameters: dateRangeIso(seed), + }); + const missingParameters = await admin.post('/api/reports/utm', { + websiteId: seed.website.id, + type: 'utm', + filters: dateRange(seed), + }); + const anonymous = await api.post('/api/reports/utm', runBody(seed, 'utm')); + const unknownWebsite = await user.post('/api/reports/utm', { + ...runBody(seed, 'utm'), + websiteId: UNKNOWN_UUID, + }); + + expect(wrongType.status).toBe(400); + expect(missingFilters.status).toBe(400); + expect(missingParameters.status).toBe(400); + expect(anonymous.status).toBe(401); + expect(unknownWebsite.status).toBe(401); + }); +}); diff --git a/tests/api/revenue.spec.ts b/tests/api/revenue.spec.ts new file mode 100644 index 000000000..dfcd82b1d --- /dev/null +++ b/tests/api/revenue.spec.ts @@ -0,0 +1,245 @@ +import { expect, test } from './fixtures'; +import { dateRange, TIMEZONE } from './helpers/dates'; + +/** + * Seeded revenue on `seed.website` (see seed/dataset.ts): the 3 identified personas + * each send a `purchase` on 3 days with revenue 49.5 / 59.5 / 69.5 in USD. + * 9 purchases, sum 535.5, average 59.5. Nothing else in the suite ingests + * `revenue` for this website, so these totals are asserted exactly. + */ + +const PURCHASE_REVENUES = [49.5, 59.5, 69.5]; +const PURCHASE_COUNT = 9; +const PURCHASE_TOTAL = PURCHASE_REVENUES.reduce((sum, value) => sum + value, 0) * 3; + +test.describe('Revenue', () => { + let base = ''; + + test.beforeAll(({ seed }) => { + base = `/api/websites/${seed.website.id}`; + }); + + test('GET /api/websites/{websiteId}/revenue/chart returns revenue per event over time', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { + currency: seed.data.currency, + unit: 'day', + timezone: TIMEZONE, + }); + const response = await admin.get(`${base}/revenue/chart`, { params }); + const shared = await (await share()).get(`${base}/revenue/chart`, { params }); + const denied = await viewer.get(`${base}/revenue/chart`, { params }); + const missingCurrency = await admin.get(`${base}/revenue/chart`, { + params: dateRange(seed), + }); + const missingRange = await admin.get(`${base}/revenue/chart`, { + params: { currency: seed.data.currency }, + }); + + expect(response.status).toBe(200); + expect(Object.keys(response.body)).toEqual(['chart']); + // Purchases land on 3 distinct days, 3 per day. + expect(response.body.chart).toHaveLength(3); + for (const point of response.body.chart) { + expect(point).toEqual({ + x: 'purchase', + t: expect.any(String), + y: expect.anything(), + count: 3, + }); + expect(Number(point.y)).toBeCloseTo(PURCHASE_TOTAL / 3, 5); + } + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingCurrency.status).toBe(400); + expect(missingRange.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/revenue/metrics breaks revenue down by a dimension', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { currency: seed.data.currency }); + const byType: Record = {}; + + for (const type of ['country', 'region', 'referrer', 'channel']) { + byType[type] = await admin.get(`${base}/revenue/metrics`, { params: { ...params, type } }); + } + + const shared = await (await share()).get(`${base}/revenue/metrics`, { + params: { ...params, type: 'country' }, + }); + const denied = await viewer.get(`${base}/revenue/metrics`, { + params: { ...params, type: 'country' }, + }); + const badType = await admin.get(`${base}/revenue/metrics`, { + params: { ...params, type: 'browser' }, + }); + const missingType = await admin.get(`${base}/revenue/metrics`, { params }); + const missingCurrency = await admin.get(`${base}/revenue/metrics`, { + params: dateRange(seed, { type: 'country' }), + }); + + for (const [type, response] of Object.entries(byType)) { + expect(response.status, type).toBe(200); + expect(Array.isArray(response.body), type).toBe(true); + expect(response.body.length, type).toBeGreaterThan(0); + for (const row of response.body) { + // Region rows also carry the parent `country`. + expect(Object.keys(row).sort(), type).toEqual( + type === 'region' ? ['country', 'name', 'value'] : ['name', 'value'], + ); + } + // Every breakdown adds up to the full seeded revenue. + const total = response.body.reduce((sum: number, row: any) => sum + Number(row.value), 0); + expect(total, type).toBeCloseTo(PURCHASE_TOTAL, 5); + } + + // The test image has no GeoIP database, so country/region are unresolved + // (null on Postgres, '' on ClickHouse's non-nullable String columns). + const unresolved = seed.db === 'clickhouse' ? '' : null; + expect(byType.country.body).toEqual([{ name: unresolved, value: expect.anything() }]); + expect(byType.region.body).toEqual([ + { country: unresolved, name: unresolved, value: expect.anything() }, + ]); + + // alice (google.com, utm+gclid => paidAds) buys 3 x 49.5, bob (t.co => organicSocial) + // 3 x 59.5 and carol (no referrer, no query string => direct) 3 x 69.5. + const asMap = (rows: any[]) => + Object.fromEntries(rows.map(row => [String(row.name), Number(row.value)])); + expect(asMap(byType.referrer.body)).toEqual({ + 'google.com': 148.5, + 't.co': 178.5, + // No referrer is null on Postgres and '' on ClickHouse (see `unresolved` above). + [String(unresolved)]: 208.5, + }); + expect(asMap(byType.channel.body)).toEqual({ + paidAds: 148.5, + organicSocial: 178.5, + direct: 208.5, + }); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(badType.status).toBe(400); + expect(missingType.status).toBe(400); + expect(missingCurrency.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/revenue/sessions lists sessions with revenue', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { currency: seed.data.currency }); + const response = await admin.get(`${base}/revenue/sessions`, { + params: { ...params, pageSize: 100 }, + }); + const paged = await admin.get(`${base}/revenue/sessions`, { + params: { ...params, page: 2, pageSize: 2 }, + }); + const searched = await admin.get(`${base}/revenue/sessions`, { + params: { ...params, search: 'zzz-no-such-session' }, + }); + const shared = await (await share()).get(`${base}/revenue/sessions`, { params }); + const denied = await viewer.get(`${base}/revenue/sessions`, { params }); + const missingCurrency = await admin.get(`${base}/revenue/sessions`, { + params: dateRange(seed), + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: 3, + page: 1, + pageSize: 100, + }); + expect(response.body.data).toHaveLength(3); + for (const row of response.body.data) { + expect(row).toMatchObject({ + id: expect.any(String), + websiteId: seed.website.id, + hostname: seed.data.hostname, + browser: expect.any(String), + os: expect.any(String), + device: expect.any(String), + firstAt: expect.any(String), + lastAt: expect.any(String), + createdAt: expect.any(String), + }); + } + + expect(paged.status).toBe(200); + expect(paged.body).toMatchObject({ count: 3, page: 2, pageSize: 2 }); + expect(paged.body.data).toHaveLength(1); + + expect(searched.status).toBe(200); + expect(searched.body.data).toHaveLength(0); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingCurrency.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/revenue/stats returns revenue totals', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { currency: seed.data.currency }); + const response = await admin.get(`${base}/revenue/stats`, { params }); + const otherCurrency = await admin.get(`${base}/revenue/stats`, { + params: { ...params, currency: 'EUR' }, + }); + const shared = await (await share()).get(`${base}/revenue/stats`, { params }); + const denied = await viewer.get(`${base}/revenue/stats`, { params }); + const missingCurrency = await admin.get(`${base}/revenue/stats`, { params: dateRange(seed) }); + const missingRange = await admin.get(`${base}/revenue/stats`, { + params: { currency: seed.data.currency }, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + count: PURCHASE_COUNT, + unique_count: 3, + total_sessions: expect.any(Number), + }); + // The comparison window precedes the seeded range and holds no revenue. + expect(Object.keys(response.body.comparison).sort()).toEqual([ + 'arpu', + 'average', + 'count', + 'sum', + 'total_sessions', + 'unique_count', + ]); + expect(response.body.comparison.count).toBe(0); + expect(Number(response.body.comparison.sum ?? 0)).toBe(0); + expect(Number(response.body.sum)).toBeCloseTo(PURCHASE_TOTAL, 5); + expect(Number(response.body.average)).toBeCloseTo(PURCHASE_TOTAL / PURCHASE_COUNT, 5); + expect(response.body.total_sessions).toBeGreaterThanOrEqual(3); + expect(Number(response.body.arpu)).toBeCloseTo( + PURCHASE_TOTAL / response.body.total_sessions, + 5, + ); + + // Nothing was ingested in EUR. + expect(otherCurrency.status).toBe(200); + expect(otherCurrency.body.count).toBe(0); + expect(Number(otherCurrency.body.sum ?? 0)).toBe(0); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingCurrency.status).toBe(400); + expect(missingRange.status).toBe(400); + }); +}); diff --git a/tests/api/seed/dataset.ts b/tests/api/seed/dataset.ts new file mode 100644 index 000000000..71ab1c61e --- /dev/null +++ b/tests/api/seed/dataset.ts @@ -0,0 +1,412 @@ +import { SEED_IDS } from './ids'; + +/** + * Deterministic analytics dataset ingested through the real collection + * endpoints (/api/send, /api/batch, /api/record). Works for both Postgres and + * ClickHouse. Backdated events use `timestamp` (seconds). + * + * IPs are private on purpose: the test image has no GeoLite database and + * src/lib/detect.ts throws on a non-local IP lookup. + */ + +export interface SendPayload { + type: 'event' | 'identify' | 'performance'; + payload: Record; +} + +export interface RecordPayload { + type: 'record' | 'heatmap'; + payload: Record; +} + +/** Events sent sequentially, chained with the `x-umami-cache` token. */ +export interface Visit { + events: SendPayload[]; +} + +export interface Dataset { + range: { startAt: number; endAt: number }; + visits: Visit[]; + batch: SendPayload[]; + replay: { pageview: SendPayload; record: RecordPayload; heatmap: RecordPayload }; + realtime: SendPayload[]; + expected: { pageviews: number; website2Pageviews: number }; + distinctIds: string[]; + pages: string[]; + eventNames: string[]; +} + +export interface Persona { + id: string; + ip: string; + userAgent: string; + screen: string; + language: string; + referrer: string; + utm?: boolean; + tag?: string; +} + +export const HOSTNAME = 'example.test'; +export const CURRENCY = 'USD'; +export const DAYS = 7; +export const EVENT_NAMES = ['signup', 'purchase', 'download']; +export const UTM_QUERY = 'utm_source=newsletter&utm_medium=email&utm_campaign=launch&gclid=abc123'; + +/** The first N personas send `identify` calls and carry a distinct id. */ +const IDENTIFIED_PERSONAS = 3; +const DAY = 86_400; +const HOUR = 3_600; + +export const PAGES = [ + { url: '/', title: 'Home' }, + { url: '/pricing', title: 'Pricing' }, + { url: '/blog/hello-world', title: 'Hello World' }, + { url: '/docs', title: 'Documentation' }, + { url: '/signup', title: 'Sign up' }, +]; + +export const PERSONAS: Persona[] = [ + { + id: 'alice', + ip: '10.0.0.1', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + screen: '1920x1080', + language: 'en-US', + referrer: 'https://www.google.com/', + utm: true, + }, + { + id: 'bob', + ip: '10.0.0.2', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15', + screen: '2560x1440', + language: 'en-GB', + referrer: 'https://t.co/abc123', + }, + { + id: 'carol', + ip: '10.0.0.3', + userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0', + screen: '1440x900', + language: 'de-DE', + referrer: '', + tag: 'beta', + }, + { + id: 'dave', + ip: '10.0.0.4', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + screen: '412x915', + language: 'fr-FR', + referrer: 'https://www.bing.com/search?q=umami', + utm: true, + }, + { + id: 'erin', + ip: '10.0.0.5', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + screen: '390x844', + language: 'es-ES', + referrer: '', + }, + { + id: 'frank', + ip: '10.0.0.6', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0', + screen: '1366x768', + language: 'en-US', + referrer: 'https://github.com/umami-software/umami', + }, +]; + +export function getDistinctId(persona: Persona) { + return `user-${persona.id}`; +} + +function client(persona: Persona) { + return { + hostname: HOSTNAME, + language: persona.language, + screen: persona.screen, + userAgent: persona.userAgent, + ip: persona.ip, + }; +} + +function identity(persona: Persona, index: number) { + return index < IDENTIFIED_PERSONAS ? { id: getDistinctId(persona) } : {}; +} + +function pageview( + website: string, + persona: Persona, + index: number, + page: { url: string; title: string }, + extra: Record, +): SendPayload { + return { + type: 'event', + payload: { website, ...client(persona), ...identity(persona, index), ...page, ...extra }, + }; +} + +function customEvent( + website: string, + persona: Persona, + index: number, + name: string, + data: Record, + timestamp: number, +): SendPayload { + return { + type: 'event', + payload: { + website, + ...client(persona), + ...identity(persona, index), + url: '/', + title: 'Home', + name, + data, + timestamp, + }, + }; +} + +export function buildDataset(now = Date.now()): Dataset { + const nowSec = Math.floor(now / 1000); + const range = { startAt: (nowSec - DAYS * DAY) * 1000, endAt: now }; + const visits: Visit[] = []; + const batch: SendPayload[] = []; + let pageviews = 0; + let website2Pageviews = 0; + + // Backdated traffic: days 1..6 ago (today only receives the live events below). + for (let day = DAYS - 1; day >= 1; day--) { + PERSONAS.forEach((persona, index) => { + const start = nowSec - day * DAY - (index + 1) * HOUR; + const pageCount = 2 + ((day + index) % 3); + const pages = PAGES.slice(0, pageCount); + + visits.push({ + events: pages.map((page, step) => + pageview(SEED_IDS.website, persona, index, page, { + url: step === 0 && persona.utm ? `${page.url}?${UTM_QUERY}` : page.url, + referrer: step === 0 && persona.referrer ? persona.referrer : undefined, + tag: persona.tag, + timestamp: start + step * 60, + }), + ), + }); + pageviews += pages.length; + + const afterPages = start + pageCount * 60; + + if (day === 2) { + batch.push( + customEvent( + SEED_IDS.website, + persona, + index, + 'signup', + { plan: index % 2 ? 'pro' : 'free', seats: 1 + index }, + afterPages, + ), + ); + } + + if (index < IDENTIFIED_PERSONAS && [1, 3, 5].includes(day)) { + batch.push( + customEvent( + SEED_IDS.website, + persona, + index, + 'purchase', + { + revenue: 49.5 + index * 10, + currency: CURRENCY, + quantity: 2, + items: ['sku-1', 'sku-2'], + orderedAt: new Date(afterPages * 1000).toISOString(), + premium: index === 0, + }, + afterPages + 30, + ), + ); + } + + if (day === 4) { + batch.push( + customEvent( + SEED_IDS.website, + persona, + index, + 'download', + { file: 'guide.pdf' }, + afterPages + 60, + ), + ); + } + + if (index < IDENTIFIED_PERSONAS && day === DAYS - 1) { + batch.push({ + type: 'identify', + payload: { + website: SEED_IDS.website, + ...client(persona), + id: getDistinctId(persona), + data: { + plan: 'pro', + seats: 5 + index, + tags: ['early-adopter', 'newsletter'], + signedUpAt: new Date(afterPages * 1000).toISOString(), + active: true, + }, + timestamp: afterPages + 90, + }, + }); + } + + if (day % 2 === 0) { + batch.push({ + type: 'performance', + payload: { + website: SEED_IDS.website, + ...client(persona), + url: '/', + title: 'Home', + lcp: 1800 + index * 100, + inp: 120 + index * 10, + cls: 0.05, + fcp: 900 + index * 50, + ttfb: 250 + index * 20, + timestamp: start + 5, + }, + }); + } + }); + } + + // Secondary website (owned by the regular user): two personas, five days. + for (let day = 5; day >= 1; day--) { + PERSONAS.slice(0, 2).forEach((persona, index) => { + const timestamp = nowSec - day * DAY - (index + 1) * HOUR; + + visits.push({ + events: [ + pageview(SEED_IDS.website2, persona, index, PAGES[day % PAGES.length], { timestamp }), + ], + }); + website2Pageviews += 1; + }); + } + + batch.push( + customEvent( + SEED_IDS.website2, + PERSONAS[0], + 0, + 'purchase', + { revenue: 19.99, currency: CURRENCY, quantity: 1 }, + nowSec - 2 * DAY, + ), + ); + + // Link clicks and pixel hits (sessions are keyed by the link/pixel id). + PERSONAS.slice(0, 5).forEach((persona, index) => { + const timestamp = nowSec - (index + 1) * DAY - 30 * 60; + + batch.push({ + type: 'event', + payload: { link: SEED_IDS.link, ...client(persona), url: '/', timestamp }, + }); + batch.push({ + type: 'event', + payload: { pixel: SEED_IDS.pixel, ...client(persona), url: '/', timestamp: timestamp + 60 }, + }); + }); + + // Live traffic (no timestamp): one recorded visit plus a few realtime pageviews. + const replayPersona = PERSONAS[0]; + const replay = { + pageview: pageview(SEED_IDS.website, replayPersona, 0, PAGES[0], {}), + record: { + type: 'record' as const, + payload: { + website: SEED_IDS.website, + events: [ + { + type: 4, + data: { href: `https://${HOSTNAME}/`, width: 1280, height: 800 }, + timestamp: now, + }, + { + type: 2, + data: { node: { type: 0, childNodes: [] }, initialOffset: { left: 0, top: 0 } }, + timestamp: now + 10, + }, + { + type: 3, + data: { source: 1, positions: [{ x: 10, y: 20, id: 1, timeOffset: 0 }] }, + timestamp: now + 20, + }, + ], + }, + }, + heatmap: { + type: 'heatmap' as const, + payload: { + website: SEED_IDS.website, + events: [ + { + type: 'click', + url: `https://${HOSTNAME}/`, + x: 100, + y: 200, + pageX: 100, + pageY: 200, + pageW: 1280, + pageH: 3000, + viewportW: 1280, + viewportH: 800, + timestamp: now + 30, + }, + { + type: 'scroll', + url: `https://${HOSTNAME}/`, + scrollPct: 60, + pageW: 1280, + pageH: 3000, + viewportW: 1280, + viewportH: 800, + timestamp: now + 40, + }, + ], + }, + }, + }; + pageviews += 1; + + const realtime = PERSONAS.slice(1).map((persona, index) => + pageview(SEED_IDS.website, persona, index + 1, PAGES[index % PAGES.length], {}), + ); + pageviews += realtime.length; + + return { + range, + visits, + batch, + replay, + realtime, + expected: { pageviews, website2Pageviews }, + distinctIds: PERSONAS.slice(0, IDENTIFIED_PERSONAS).map(getDistinctId), + pages: PAGES.map(page => page.url), + eventNames: EVENT_NAMES, + }; +} diff --git a/tests/api/seed/ids.ts b/tests/api/seed/ids.ts new file mode 100644 index 000000000..82c37ab57 --- /dev/null +++ b/tests/api/seed/ids.ts @@ -0,0 +1,27 @@ +/** + * Fixed identifiers for seeded entities. Using fixed UUIDs lets global setup + * find and recreate them on re-runs against a kept stack (`--keep`). + */ +export const SEED_IDS = { + website: '1e5a0000-0000-4000-8000-000000000001', + website2: '1e5a0000-0000-4000-8000-000000000002', + link: '1e5a0000-0000-4000-8000-000000000003', + pixel: '1e5a0000-0000-4000-8000-000000000004', +} as const; + +export const SEED_USERS = { + /** Regular user; owns the secondary website. */ + user: { username: 'api-user', password: 'api-user-password', role: 'user' }, + /** View-only user that owns nothing — used for permission-denied cases. */ + viewer: { username: 'api-viewer', password: 'api-viewer-password', role: 'view-only' }, +} as const; + +export const SEED_WEBSITES = { + primary: { name: 'API Test Website', domain: 'example.test' }, + secondary: { name: 'API Test Website (user)', domain: 'user.example.test' }, +} as const; + +export const SEED_LINK = { name: 'API Test Link', url: 'https://umami.is/', slug: 'apitestlink' }; +export const SEED_PIXEL = { name: 'API Test Pixel', slug: 'apitestpixel' }; +export const SEED_TEAM = { name: 'API Test Team' }; +export const SEED_SHARE = { name: 'API Test Share' }; diff --git a/tests/api/seed/ingest.ts b/tests/api/seed/ingest.ts new file mode 100644 index 000000000..d901efab4 --- /dev/null +++ b/tests/api/seed/ingest.ts @@ -0,0 +1,88 @@ +import type { ApiClient } from '../client'; +import { CACHE_HEADER } from '../helpers/constants'; +import type { Dataset, RecordPayload, SendPayload } from './dataset'; + +export interface IngestResult { + replay: { sessionId: string; visitId: string }; +} + +const BATCH_LIMIT = 500; + +function chunk(items: T[], size: number) { + const chunks: T[][] = []; + + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)); + } + + return chunks; +} + +async function send(api: ApiClient, event: SendPayload, cache?: string) { + const response = await api.post('/api/send', event, { + headers: cache ? { [CACHE_HEADER]: cache } : undefined, + }); + + if (response.status !== 200) { + throw new Error(`POST /api/send failed (${response.status}): ${response.text}`); + } + + if (response.body?.beep) { + throw new Error( + 'POST /api/send classified the request as a bot. Start the server with DISABLE_BOT_CHECK=1.', + ); + } + + if (!response.body?.cache) { + throw new Error(`POST /api/send returned no cache token: ${response.text}`); + } + + return response.body as { cache: string; sessionId: string; visitId: string }; +} + +async function record(api: ApiClient, payload: RecordPayload, cache: string) { + const response = await api.post('/api/record', payload, { headers: { [CACHE_HEADER]: cache } }); + + if (response.status !== 200 || response.body?.ok !== true) { + throw new Error( + `POST /api/record (${payload.type}) failed (${response.status}): ${response.text}`, + ); + } +} + +export async function ingestDataset(api: ApiClient, dataset: Dataset): Promise { + for (const visit of dataset.visits) { + let cache: string | undefined; + + for (const event of visit.events) { + cache = (await send(api, event, cache)).cache; + } + } + + for (const items of chunk(dataset.batch, BATCH_LIMIT)) { + const response = await api.post('/api/batch', items); + + if (response.status !== 200) { + throw new Error(`POST /api/batch failed (${response.status}): ${response.text}`); + } + + if (response.body?.errors) { + throw new Error( + `POST /api/batch reported ${response.body.errors} error(s): ${JSON.stringify( + response.body.details?.slice(0, 3), + )}`, + ); + } + } + + const live = await send(api, dataset.replay.pageview); + + await record(api, dataset.replay.record, live.cache); + await record(api, dataset.replay.heatmap, live.cache); + + for (const event of dataset.realtime) { + await send(api, event); + } + + return { replay: { sessionId: live.sessionId, visitId: live.visitId } }; +} diff --git a/tests/api/seed/setup.ts b/tests/api/seed/setup.ts new file mode 100644 index 000000000..f95f7ac1a --- /dev/null +++ b/tests/api/seed/setup.ts @@ -0,0 +1,262 @@ +import type { ApiClient } from '../client'; +import { login } from '../helpers/auth'; +import { ADMIN_USER } from '../helpers/constants'; +import { assertStatus } from '../helpers/entities'; +import { buildDataset, CURRENCY, type Dataset, HOSTNAME } from './dataset'; +import { + SEED_IDS, + SEED_LINK, + SEED_PIXEL, + SEED_SHARE, + SEED_TEAM, + SEED_USERS, + SEED_WEBSITES, +} from './ids'; +import { type IngestResult, ingestDataset } from './ingest'; +import type { SeedCredentials, SeedState } from './state'; + +const READINESS_TIMEOUT_MS = 60_000; + +async function ensureUser( + admin: ApiClient, + credentials: { username: string; password: string; role: string }, +): Promise { + const created = await admin.post('/api/users', credentials); + + if (created.status === 200) { + return { ...credentials, id: created.body.id }; + } + + if (created.status !== 400) { + throw new Error( + `Failed to create ${credentials.username} (${created.status}): ${created.text}`, + ); + } + + // Already exists (re-run against a kept stack): look it up. + const users = assertStatus( + await admin.get('/api/admin/users', { params: { search: credentials.username } }), + 200, + 'list users', + ); + const user = users.body.data?.find((u: any) => u.username === credentials.username); + + if (!user) { + throw new Error(`User ${credentials.username} exists but could not be found`); + } + + return { ...credentials, id: user.id }; +} + +async function recreateWebsite( + client: ApiClient, + id: string, + data: { name: string; domain: string }, +) { + const existing = await client.get(`/api/websites/${id}`); + + // Admins bypass the permission check, so an unknown id yields 200 with a null body. + if (existing.status === 200 && existing.body?.id) { + assertStatus(await client.del(`/api/websites/${id}`), 200, `delete website ${id}`); + } + + return assertStatus(await client.post('/api/websites', { id, ...data }), 200, 'create website') + .body; +} + +async function ensureEntity( + client: ApiClient, + collection: 'links' | 'pixels', + id: string, + data: Record, +) { + const existing = await client.get(`/api/${collection}/${id}`); + + if (existing.status === 200 && existing.body?.id) { + return existing.body; + } + + return assertStatus( + await client.post(`/api/${collection}`, { id, ...data }), + 200, + `create ${collection}`, + ).body; +} + +async function ensureTeam(admin: ApiClient, memberId: string) { + const teams = assertStatus(await admin.get('/api/teams'), 200, 'list teams'); + let team = teams.body.data?.find((t: any) => t.name === SEED_TEAM.name); + + if (!team) { + const created = assertStatus(await admin.post('/api/teams', SEED_TEAM), 200, 'create team'); + team = created.body[0]; + } + + const detail = assertStatus(await admin.get(`/api/teams/${team.id}`), 200, 'get team').body; + const member = await admin.post(`/api/teams/${team.id}/users`, { + userId: memberId, + role: 'team-member', + }); + + // 400 = already a member (re-run). + if (member.status !== 200 && member.status !== 400) { + throw new Error(`Failed to add team member (${member.status}): ${member.text}`); + } + + return { + id: detail.id as string, + name: detail.name as string, + accessCode: detail.accessCode as string, + }; +} + +async function poll(label: string, fn: () => Promise, timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + let last: string | undefined; + + while (Date.now() < deadline) { + try { + const result = await fn(); + + if (result !== undefined) { + return result; + } + } catch (error) { + last = (error as Error).message; + } + + await new Promise(resolve => setTimeout(resolve, 500)); + } + + throw new Error(`Timed out waiting for ${label}${last ? ` (${last})` : ''}`); +} + +/** + * Waits until the analytics queries see the ingested data (ClickHouse + * materialized views are synchronous, but this guards against any lag) and + * harvests ids that specs need for path parameters. + */ +async function waitForAnalytics(admin: ApiClient, dataset: Dataset, ingested: IngestResult) { + const websiteId = SEED_IDS.website; + const range = () => ({ startAt: dataset.range.startAt, endAt: Date.now() }); + + await poll( + `${dataset.expected.pageviews} pageviews on ${websiteId}`, + async () => { + const response = await admin.get(`/api/websites/${websiteId}/stats`, { params: range() }); + // /stats returns flat numbers (possibly as strings under Postgres bigint). + const pageviews = Number(response.body?.pageviews ?? 0); + + return pageviews >= dataset.expected.pageviews ? pageviews : undefined; + }, + READINESS_TIMEOUT_MS, + ); + + const sessionId = await poll( + 'a session', + async () => { + const response = await admin.get(`/api/websites/${websiteId}/sessions`, { + params: { ...range(), pageSize: 1 }, + }); + + return response.body?.data?.[0]?.id as string | undefined; + }, + READINESS_TIMEOUT_MS, + ); + + const eventId = await poll( + 'a custom event with data', + async () => { + const response = await admin.get(`/api/websites/${websiteId}/events`, { + params: { ...range(), pageSize: 50, eventType: 2 }, + }); + const event = response.body?.data?.find((e: any) => e.hasData); + + return event?.id as string | undefined; + }, + READINESS_TIMEOUT_MS, + ); + + await poll( + 'the recorded replay', + async () => { + const response = await admin.get(`/api/websites/${websiteId}/replays`, { + params: range(), + }); + + return response.body?.data?.some((r: any) => r.id === ingested.replay.visitId) + ? true + : undefined; + }, + READINESS_TIMEOUT_MS, + ); + + return { sessionId, eventId }; +} + +export async function seedEnvironment(api: ApiClient): Promise { + const db = process.env.UMAMI_TEST_DB === 'clickhouse' ? 'clickhouse' : 'postgres'; + + const admin = api.bearer(await login(api, ADMIN_USER)); + const user = await ensureUser(admin, SEED_USERS.user); + const viewer = await ensureUser(admin, SEED_USERS.viewer); + const userApi = api.bearer(await login(api, SEED_USERS.user)); + + const website = await recreateWebsite(admin, SEED_IDS.website, SEED_WEBSITES.primary); + const website2 = await recreateWebsite(userApi, SEED_IDS.website2, SEED_WEBSITES.secondary); + + assertStatus( + await admin.post(`/api/websites/${website.id}`, { + replayConfig: { + replayEnabled: true, + heatmapEnabled: true, + sampleRate: 1, + heatmapSampleRate: 1, + }, + }), + 200, + 'enable recorder', + ); + + const share = assertStatus( + await admin.post(`/api/websites/${website.id}/shares`, { ...SEED_SHARE, parameters: {} }), + 200, + 'create share', + ).body; + + const link = await ensureEntity(admin, 'links', SEED_IDS.link, SEED_LINK); + const pixel = await ensureEntity(admin, 'pixels', SEED_IDS.pixel, SEED_PIXEL); + const team = await ensureTeam(admin, user.id); + + const dataset = buildDataset(); + const ingested = await ingestDataset(api, dataset); + const { sessionId, eventId } = await waitForAnalytics(admin, dataset, ingested); + + return { + db, + createdAt: new Date().toISOString(), + admin: { ...ADMIN_USER }, + user, + viewer, + website: { id: website.id, name: website.name, domain: website.domain }, + website2: { id: website2.id, name: website2.name, domain: website2.domain }, + link: { id: link.id, name: link.name, url: link.url, slug: link.slug }, + pixel: { id: pixel.id, name: pixel.name, slug: pixel.slug }, + team, + share: { id: share.id, slug: share.slug }, + range: dataset.range, + data: { + hostname: HOSTNAME, + currency: CURRENCY, + pages: dataset.pages, + eventNames: dataset.eventNames, + distinctIds: dataset.distinctIds, + expectedPageviews: dataset.expected.pageviews, + expectedWebsite2Pageviews: dataset.expected.website2Pageviews, + sessionId, + eventId, + replayVisitId: ingested.replay.visitId, + replaySessionId: ingested.replay.sessionId, + }, + }; +} diff --git a/tests/api/seed/state.ts b/tests/api/seed/state.ts new file mode 100644 index 000000000..eb52ab955 --- /dev/null +++ b/tests/api/seed/state.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs'; +import { SEED_FILE } from '../paths'; + +export interface SeedCredentials { + id: string; + username: string; + password: string; + role: string; +} + +/** Written by global setup to tests/api/.runtime/seed.json and read by the fixtures. */ +export interface SeedState { + db: 'postgres' | 'clickhouse'; + createdAt: string; + admin: SeedCredentials; + user: SeedCredentials; + viewer: SeedCredentials; + /** Admin-owned website carrying the full analytics dataset. */ + website: { id: string; name: string; domain: string }; + /** Website owned by `user` with a small dataset. */ + website2: { id: string; name: string; domain: string }; + link: { id: string; name: string; url: string; slug: string }; + pixel: { id: string; name: string; slug: string }; + team: { id: string; name: string; accessCode: string }; + /** Share for `website` granting every section. */ + share: { id: string; slug: string }; + /** Dataset window in ms. */ + range: { startAt: number; endAt: number }; + data: { + hostname: string; + currency: string; + pages: string[]; + eventNames: string[]; + distinctIds: string[]; + expectedPageviews: number; + expectedWebsite2Pageviews: number; + /** A session id from `website` (used for /sessions/{sessionId} endpoints). */ + sessionId: string; + /** A custom event id with event data (used for /event-data/{eventId}). */ + eventId: string; + /** Visit id of the recorded replay (replay ids are visit ids). */ + replayVisitId: string; + replaySessionId: string; + }; +} + +export function readSeedState(): SeedState { + return JSON.parse(readFileSync(SEED_FILE, 'utf8')); +} diff --git a/tests/api/segments.spec.ts b/tests/api/segments.spec.ts new file mode 100644 index 000000000..d34e9c235 --- /dev/null +++ b/tests/api/segments.spec.ts @@ -0,0 +1,209 @@ +import { expect, test } from './fixtures'; +import { UNKNOWN_UUID } from './helpers/constants'; +import { dateRange } from './helpers/dates'; +import { createWebsite, deleteWebsite, uniqueName } from './helpers/entities'; + +test.describe('Segments', () => { + test.describe.configure({ mode: 'serial' }); + + const parameters = { + filters: [{ name: 'path', operator: 'eq', value: '/pricing' }], + match: 'all', + }; + + let websiteId = ''; + let segmentId = ''; + let cohortId = ''; + let seedSegmentId = ''; + let base = ''; + + test.beforeAll(async ({ admin }) => { + websiteId = (await createWebsite(admin)).id; + base = `/api/websites/${websiteId}/segments`; + }); + + test.afterAll(async ({ admin, seed }) => { + if (seedSegmentId) { + await admin.del(`/api/websites/${seed.website.id}/segments/${seedSegmentId}`); + } + + if (websiteId) { + await deleteWebsite(admin, websiteId); + } + }); + + test('POST /api/websites/{websiteId}/segments creates a segment', async ({ admin }) => { + const name = uniqueName('segment'); + const response = await admin.post(base, { type: 'segment', name, parameters }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: expect.any(String), + websiteId, + type: 'segment', + name, + parameters, + createdAt: expect.any(String), + }); + + segmentId = response.body.id; + }); + + test('POST /api/websites/{websiteId}/segments creates a cohort', async ({ admin }) => { + const name = uniqueName('cohort'); + const response = await admin.post(base, { + type: 'cohort', + name, + parameters: { ...parameters, dateRange: '30d', action: { type: 'event', value: 'signup' } }, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ websiteId, type: 'cohort', name }); + + cohortId = response.body.id; + }); + + test('POST /api/websites/{websiteId}/segments validates and requires permission', async ({ + admin, + user, + viewer, + api, + }) => { + const badType = await admin.post(base, { type: 'nope', name: 'x', parameters }); + const missingParams = await admin.post(base, { type: 'segment', name: 'x' }); + const badOperator = await admin.post(base, { + type: 'segment', + name: 'x', + parameters: { filters: [{ name: 'path', operator: 'nope', value: '/' }] }, + }); + const tooLong = await admin.post(base, { type: 'segment', name: 'x'.repeat(201), parameters }); + const notOwner = await user.post(base, { type: 'segment', name: 'x', parameters }); + const denied = await viewer.post(base, { type: 'segment', name: 'x', parameters }); + const anonymous = await api.post(base, { type: 'segment', name: 'x', parameters }); + + expect(badType.status).toBe(400); + expect(missingParams.status).toBe(400); + expect(badOperator.status).toBe(400); + expect(tooLong.status).toBe(400); + expect(notOwner.status).toBe(401); + expect(denied.status).toBe(401); + expect(anonymous.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/segments lists segments by type', async ({ + admin, + viewer, + share, + seed, + }) => { + const segments = await admin.get(base, { params: { type: 'segment' } }); + const cohorts = await admin.get(base, { params: { type: 'cohort' } }); + const search = await admin.get(base, { + params: { type: 'segment', search: 'zzz-no-such-segment' }, + }); + const missingType = await admin.get(base); + const badType = await admin.get(base, { params: { type: 'nope' } }); + const denied = await viewer.get(base, { params: { type: 'segment' } }); + const shared = await (await share()).get(`/api/websites/${seed.website.id}/segments`, { + params: { type: 'segment' }, + }); + + expect(segments.status).toBe(200); + expect(segments.body).toMatchObject({ + data: expect.any(Array), + count: 1, + page: 1, + pageSize: expect.any(Number), + }); + expect(segments.body.data[0]).toMatchObject({ id: segmentId, type: 'segment', parameters }); + expect(cohorts.body.data.map((s: any) => s.id)).toEqual([cohortId]); + expect(search.body.data).toHaveLength(0); + expect(missingType.status).toBe(400); + expect(badType.status).toBe(400); + expect(denied.status).toBe(401); + expect(shared.status).toBe(200); + }); + + test('GET /api/websites/{websiteId}/segments/{segmentId} returns a segment', async ({ + admin, + viewer, + }) => { + const response = await admin.get(`${base}/${segmentId}`); + const denied = await viewer.get(`${base}/${segmentId}`); + const unknown = await admin.get(`${base}/${UNKNOWN_UUID}`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: segmentId, websiteId, type: 'segment', parameters }); + expect(denied.status).toBe(401); + expect(unknown.status).toBe(404); + }); + + test('POST /api/websites/{websiteId}/segments/{segmentId} updates a segment', async ({ + admin, + user, + }) => { + const name = uniqueName('renamed'); + const updated = { filters: [{ name: 'path', operator: 'c', value: '/docs' }], match: 'any' }; + const response = await admin.post(`${base}/${segmentId}`, { + type: 'segment', + name, + parameters: updated, + }); + const invalid = await admin.post(`${base}/${segmentId}`, { name }); + const unknown = await admin.post(`${base}/${UNKNOWN_UUID}`, { + type: 'segment', + name, + parameters: updated, + }); + const denied = await user.post(`${base}/${segmentId}`, { + type: 'segment', + name, + parameters: updated, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: segmentId, name, parameters: updated }); + expect(invalid.status).toBe(400); + expect(unknown.status).toBe(404); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/stats accepts a segment filter', async ({ admin, seed }) => { + const created = await admin.post(`/api/websites/${seed.website.id}/segments`, { + type: 'segment', + name: uniqueName('segment'), + parameters, + }); + + expect(created.status).toBe(200); + seedSegmentId = created.body.id; + + const stats = await admin.get(`/api/websites/${seed.website.id}/stats`, { + params: dateRange(seed, { segment: seedSegmentId }), + }); + + expect(stats.status).toBe(200); + expect(stats.body).toMatchObject({ pageviews: expect.anything(), visitors: expect.anything() }); + }); + + test('DELETE /api/websites/{websiteId}/segments/{segmentId} deletes a segment', async ({ + admin, + user, + }) => { + const denied = await user.del(`${base}/${segmentId}`); + const response = await admin.del(`${base}/${segmentId}`); + const cohort = await admin.del(`${base}/${cohortId}`); + const gone = await admin.get(`${base}/${segmentId}`); + const unknown = await admin.del(`${base}/${UNKNOWN_UUID}`); + + expect(denied.status).toBe(401); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(cohort.status).toBe(200); + expect(gone.status).toBe(404); + expect(unknown.status).toBe(404); + + segmentId = ''; + cohortId = ''; + }); +}); diff --git a/tests/api/session-data.spec.ts b/tests/api/session-data.spec.ts new file mode 100644 index 000000000..3289dc8af --- /dev/null +++ b/tests/api/session-data.spec.ts @@ -0,0 +1,381 @@ +import { expect, test } from './fixtures'; +import { dateRange, TIMEZONE } from './helpers/dates'; + +/** + * Seeded session data on `seed.website` comes from the `identify` calls of the + * three identified personas (see seed/dataset.ts): + * { plan: 'pro', seats: 5 | 6 | 7, tags: ['early-adopter','newsletter'], signedUpAt, active: true } + * Other specs may identify extra sessions concurrently (e.g. plan 'spec'), so + * list assertions use `toContain`; `seats`/`tags`/`signedUpAt` are only seeded here + * and are asserted exactly. + */ + +const SEATS = [5, 6, 7]; +const SEATS_TOTAL = SEATS.reduce((sum, value) => sum + value, 0); + +test.describe('Session data', () => { + let base = ''; + + test.beforeAll(({ seed }) => { + base = `/api/websites/${seed.website.id}`; + }); + + test('GET /api/websites/{websiteId}/session-data-pivot pivots session properties into columns', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.get(`${base}/session-data-pivot`, { + params: dateRange(seed, { + propertyName: 'seats', + timezone: TIMEZONE, + unit: 'day', + pageSize: 100, + }), + }); + const paged = await admin.get(`${base}/session-data-pivot`, { + params: dateRange(seed, { propertyName: 'seats', page: 2, pageSize: 2 }), + }); + const propertyFiltered = await admin.get(`${base}/session-data-pivot`, { + params: dateRange(seed, { propertyName: 'seats', pf_seats: '2.gt.5', pageSize: 100 }), + }); + const shared = await (await share()).get(`${base}/session-data-pivot`, { + params: dateRange(seed, { propertyName: 'plan' }), + }); + const denied = await viewer.get(`${base}/session-data-pivot`, { + params: dateRange(seed, { propertyName: 'plan' }), + }); + const missingProperty = await admin.get(`${base}/session-data-pivot`, { + params: dateRange(seed), + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ count: 3, page: 1, pageSize: 100 }); + expect(response.body.data).toHaveLength(3); + for (const row of response.body.data) { + expect(row).toMatchObject({ + sessionId: expect.any(String), + distinctId: expect.any(String), + createdAt: expect.any(String), + propertyKeys: expect.any(Array), + propertyValues: expect.any(Array), + }); + // Key order is backend-specific (Postgres sorts, ClickHouse keeps insertion order). + expect([...row.propertyKeys].sort()).toEqual([ + 'active', + 'plan', + 'seats', + 'signedUpAt', + 'tags', + ]); + expect(row.propertyValues).toHaveLength(5); + expect(seed.data.distinctIds).toContain(row.distinctId); + expect(row.propertyValues[row.propertyKeys.indexOf('plan')]).toBe('pro'); + expect(SEATS).toContain(Number(row.propertyValues[row.propertyKeys.indexOf('seats')])); + } + + expect(paged.status).toBe(200); + expect(paged.body).toMatchObject({ count: 3, page: 2, pageSize: 2 }); + expect(paged.body.data).toHaveLength(1); + + // seats > 5 leaves bob (6) and carol (7). + expect(propertyFiltered.status).toBe(200); + expect(propertyFiltered.body.count).toBe(2); + expect( + propertyFiltered.body.data + .map((row: any) => Number(row.propertyValues[row.propertyKeys.indexOf('seats')])) + .sort(), + ).toEqual([6, 7]); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingProperty.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/session-data/array-series expands array properties over time', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { propertyName: 'tags' }); + const response = await admin.get(`${base}/session-data/array-series`, { params }); + const shared = await (await share()).get(`${base}/session-data/array-series`, { params }); + const denied = await viewer.get(`${base}/session-data/array-series`, { params }); + const missingProperty = await admin.get(`${base}/session-data/array-series`, { + params: dateRange(seed), + }); + + expect(response.status).toBe(200); + expect(response.body.length).toBeGreaterThan(0); + for (const point of response.body) { + expect(point).toEqual({ + x: expect.any(String), + t: expect.any(String), + y: expect.any(Number), + }); + } + expect(new Set(response.body.map((point: any) => point.x))).toEqual( + new Set(['early-adopter', 'newsletter']), + ); + for (const tag of ['early-adopter', 'newsletter']) { + const total = response.body + .filter((point: any) => point.x === tag) + .reduce((sum: number, point: any) => sum + point.y, 0); + expect(total).toBe(3); + } + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingProperty.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/session-data/date-series counts date properties', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { propertyName: 'signedUpAt' }); + const response = await admin.get(`${base}/session-data/date-series`, { params }); + const shared = await (await share()).get(`${base}/session-data/date-series`, { params }); + const denied = await viewer.get(`${base}/session-data/date-series`, { params }); + const missingProperty = await admin.get(`${base}/session-data/date-series`, { + params: dateRange(seed), + }); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(3); + for (const point of response.body) { + expect(point).toEqual({ t: expect.any(String), y: 1 }); + expect(Number.isNaN(new Date(point.t).getTime())).toBe(false); + } + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingProperty.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/session-data/numeric-series aggregates numeric properties', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { propertyName: 'seats', unit: 'day', timezone: TIMEZONE }); + const sum = await admin.get(`${base}/session-data/numeric-series`, { params }); + const avg = await admin.get(`${base}/session-data/numeric-series`, { + params: { ...params, metric: 'avg' }, + }); + const count = await admin.get(`${base}/session-data/numeric-series`, { + params: { ...params, metric: 'count' }, + }); + const shared = await (await share()).get(`${base}/session-data/numeric-series`, { params }); + const denied = await viewer.get(`${base}/session-data/numeric-series`, { params }); + const badMetric = await admin.get(`${base}/session-data/numeric-series`, { + params: { ...params, metric: 'max' }, + }); + const missingProperty = await admin.get(`${base}/session-data/numeric-series`, { + params: dateRange(seed), + }); + + const total = (rows: any[]) => rows.reduce((acc, row) => acc + Number(row.y), 0); + + // All three identify calls land on the same (backdated) day. + expect(sum.status).toBe(200); + expect(sum.body.length).toBeGreaterThan(0); + for (const point of sum.body) { + expect(point).toEqual({ t: expect.any(String), y: expect.anything() }); + } + expect(total(sum.body)).toBe(SEATS_TOTAL); + + expect(avg.status).toBe(200); + expect(total(avg.body) / avg.body.length).toBeCloseTo(6, 5); + + expect(count.status).toBe(200); + expect(total(count.body)).toBe(3); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(badMetric.status).toBe(400); + expect(missingProperty.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/session-data/numeric-stats summarises a numeric property', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { propertyName: 'seats' }); + const response = await admin.get(`${base}/session-data/numeric-stats`, { params }); + const shared = await (await share()).get(`${base}/session-data/numeric-stats`, { params }); + const denied = await viewer.get(`${base}/session-data/numeric-stats`, { params }); + const missingProperty = await admin.get(`${base}/session-data/numeric-stats`, { + params: dateRange(seed), + }); + + expect(response.status).toBe(200); + expect(Object.keys(response.body).sort()).toEqual(['average', 'max', 'median', 'min', 'total']); + expect(Number(response.body.min)).toBe(5); + expect(Number(response.body.max)).toBe(7); + expect(Number(response.body.average)).toBeCloseTo(6, 5); + expect(Number(response.body.median)).toBeCloseTo(6, 5); + expect(Number(response.body.total)).toBe(SEATS_TOTAL); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingProperty.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/session-data/property-series counts property values over time', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { propertyName: 'plan', unit: 'day', timezone: TIMEZONE }); + const response = await admin.get(`${base}/session-data/property-series`, { params }); + const shared = await (await share()).get(`${base}/session-data/property-series`, { params }); + const denied = await viewer.get(`${base}/session-data/property-series`, { params }); + const missingProperty = await admin.get(`${base}/session-data/property-series`, { + params: dateRange(seed), + }); + + expect(response.status).toBe(200); + for (const point of response.body) { + expect(point).toEqual({ + x: expect.any(String), + t: expect.any(String), + y: expect.any(Number), + }); + } + const pro = response.body + .filter((point: any) => point.x === 'pro') + .reduce((sum: number, point: any) => sum + point.y, 0); + expect(pro).toBe(3); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingProperty.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/session-data/stats aggregates activity per property value', async ({ + admin, + share, + viewer, + seed, + }) => { + const params = dateRange(seed, { propertyName: 'plan' }); + const response = await admin.get(`${base}/session-data/stats`, { params }); + const shared = await (await share()).get(`${base}/session-data/stats`, { params }); + const denied = await viewer.get(`${base}/session-data/stats`, { params }); + const missingProperty = await admin.get(`${base}/session-data/stats`, { + params: dateRange(seed), + }); + + expect(response.status).toBe(200); + for (const row of response.body) { + expect(Object.keys(row).sort()).toEqual([ + 'activity', + 'events', + 'label', + 'sessions', + 'views', + 'visits', + ]); + } + const pro = response.body.find((row: any) => row.label === 'pro'); + expect(pro).toBeDefined(); + expect(Number(pro.sessions)).toBe(3); + expect(Number(pro.views)).toBeGreaterThan(0); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingProperty.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/session-data/properties lists session properties', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.get(`${base}/session-data/properties`, { + params: dateRange(seed), + }); + const single = await admin.get(`${base}/session-data/properties`, { + params: dateRange(seed, { propertyName: 'seats' }), + }); + const shared = await (await share()).get(`${base}/session-data/properties`, { + params: dateRange(seed), + }); + const denied = await viewer.get(`${base}/session-data/properties`, { + params: dateRange(seed), + }); + const missingRange = await admin.get(`${base}/session-data/properties`); + + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.arrayContaining([ + { propertyName: 'seats', dataType: 2, total: 3 }, + { propertyName: 'active', dataType: 3, total: 3 }, + { propertyName: 'signedUpAt', dataType: 4, total: 3 }, + { propertyName: 'tags', dataType: 5, total: 3 }, + expect.objectContaining({ propertyName: 'plan', dataType: 1 }), + ]), + ); + + // `propertyName` scopes to sessions that carry that property, then lists every + // property of those sessions (co-occurrence), so `plan` is limited to the 3 seeded ones. + expect(single.status).toBe(200); + expect(single.body).toHaveLength(5); + expect(single.body).toEqual( + expect.arrayContaining([ + { propertyName: 'seats', dataType: 2, total: 3 }, + { propertyName: 'plan', dataType: 1, total: 3 }, + ]), + ); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingRange.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/session-data/values returns value counts for a property', async ({ + admin, + share, + viewer, + seed, + }) => { + const plan = await admin.get(`${base}/session-data/values`, { + params: dateRange(seed, { propertyName: 'plan' }), + }); + const seats = await admin.get(`${base}/session-data/values`, { + params: dateRange(seed, { propertyName: 'seats', dataType: 2 }), + }); + const shared = await (await share()).get(`${base}/session-data/values`, { + params: dateRange(seed, { propertyName: 'plan' }), + }); + const denied = await viewer.get(`${base}/session-data/values`, { + params: dateRange(seed, { propertyName: 'plan' }), + }); + const missingRange = await admin.get(`${base}/session-data/values`, { + params: { propertyName: 'plan' }, + }); + + expect(plan.status).toBe(200); + for (const row of plan.body) { + expect(row).toEqual({ value: expect.any(String), total: expect.any(Number) }); + } + expect(plan.body.map((row: any) => row.value)).toContain('pro'); + + expect(seats.status).toBe(200); + expect(seats.body.map((row: any) => Number(row.value)).sort()).toEqual(SEATS); + + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + expect(missingRange.status).toBe(400); + }); +}); diff --git a/tests/api/sessions.spec.ts b/tests/api/sessions.spec.ts new file mode 100644 index 000000000..1ca741dd7 --- /dev/null +++ b/tests/api/sessions.spec.ts @@ -0,0 +1,334 @@ +import { expect, test } from './fixtures'; +import { EVENT_TYPE, UNKNOWN_UUID } from './helpers/constants'; +import { dateRange, TIMEZONE } from './helpers/dates'; +import { createWebsite, deleteWebsite } from './helpers/entities'; +import { PERSONAS } from './seed/dataset'; + +const sessionShape = { + id: expect.any(String), + websiteId: expect.any(String), + firstAt: expect.any(String), + lastAt: expect.any(String), + createdAt: expect.any(String), + visits: expect.anything(), + views: expect.anything(), + events: expect.anything(), +}; + +test.describe('Sessions', () => { + test.describe.configure({ mode: 'serial' }); + + /** A seeded session that carries a distinct id (identified persona). */ + let identifiedSessionId = ''; + + test('GET /api/websites/{websiteId}/sessions lists sessions with paging', async ({ + admin, + share, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/sessions`; + const response = await admin.get(path, { params: dateRange(seed, { pageSize: 5 }) }); + const second = await admin.get(path, { params: dateRange(seed, { pageSize: 5, page: 2 }) }); + const shared = await (await share()).get(path, { params: dateRange(seed) }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: expect.any(Number), + page: 1, + pageSize: 5, + }); + expect(response.body.data).toHaveLength(5); + expect(response.body.count).toBeGreaterThan(5); + expect(response.body.data[0]).toMatchObject({ ...sessionShape, websiteId: seed.website.id }); + // `distinctId` is omitted from rows for anonymous sessions. + for (const key of ['browser', 'os', 'device', 'screen', 'language', 'country']) { + expect(response.body.data[0]).toHaveProperty(key); + } + expect(response.body.data.map((s: any) => s.id)).not.toContain(second.body.data[0].id); + expect(shared.status).toBe(200); + }); + + test('GET /api/websites/{websiteId}/sessions filters by distinctId and search', async ({ + admin, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/sessions`; + const [distinctId] = seed.data.distinctIds; + const unfiltered = await admin.get(path, { params: dateRange(seed) }); + const filtered = await admin.get(path, { params: dateRange(seed, { distinctId }) }); + const searched = await admin.get(path, { params: dateRange(seed, { search: distinctId }) }); + const none = await admin.get(path, { + params: dateRange(seed, { distinctId: 'zzz-no-such-user' }), + }); + + expect(filtered.status).toBe(200); + expect(filtered.body.data.length).toBeGreaterThan(0); + expect(filtered.body.count).toBeLessThan(unfiltered.body.count); + expect(filtered.body.data[0]).toMatchObject({ ...sessionShape, websiteId: seed.website.id }); + expect(searched.status).toBe(200); + expect(searched.body.data.length).toBeGreaterThan(0); + expect(none.body.data).toHaveLength(0); + expect(none.body.count).toBe(0); + + // Only the session that received the `identify` call carries session data. + for (const session of filtered.body.data) { + const properties = await admin.get(`${path}/${session.id}/properties`); + + if (properties.body?.length) { + identifiedSessionId = session.id; + break; + } + } + + expect(identifiedSessionId).not.toBe(''); + }); + + test('GET /api/websites/{websiteId}/sessions validates params and permissions', async ({ + admin, + api, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/sessions`; + const missingRange = await admin.get(path); + const anonymous = await api.get(path, { params: dateRange(seed) }); + const denied = await viewer.get(path, { params: dateRange(seed) }); + const unknown = await viewer.get(`/api/websites/${UNKNOWN_UUID}/sessions`, { + params: dateRange(seed), + }); + + expect(missingRange.status).toBe(400); + expect(anonymous.status).toBe(401); + expect(denied.status).toBe(401); + expect(unknown.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/sessions/{sessionId} returns a session', async ({ + admin, + share, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/sessions/${seed.data.sessionId}`; + const response = await admin.get(path); + const identified = await admin.get( + `/api/websites/${seed.website.id}/sessions/${identifiedSessionId}`, + ); + const shared = await (await share()).get(path); + const denied = await viewer.get(path); + const unknown = await admin.get(`/api/websites/${seed.website.id}/sessions/${UNKNOWN_UUID}`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: seed.data.sessionId, + websiteId: seed.website.id, + canDelete: seed.db === 'postgres', + stitchedSessionCount: expect.any(Number), + }); + expect(response.body.stitchedSessionCount).toBeGreaterThanOrEqual(1); + + expect(identified.status).toBe(200); + expect(identified.body.distinctId).toBe(seed.data.distinctIds[0]); + expect(identified.body.stitchedSessionCount).toBeGreaterThanOrEqual(1); + + expect(shared.status).toBe(200); + expect(shared.body.id).toBe(seed.data.sessionId); + expect(shared.body.canDelete).toBe(false); + expect(denied.status).toBe(401); + expect(unknown.status).toBe(404); + }); + + test('GET /api/websites/{websiteId}/sessions/{sessionId}/activity returns the event log', async ({ + admin, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/sessions/${seed.data.sessionId}/activity`; + const response = await admin.get(path, { params: dateRange(seed) }); + const stitched = await admin.get( + `/api/websites/${seed.website.id}/sessions/${identifiedSessionId}/activity`, + { params: dateRange(seed, { distinctId: seed.data.distinctIds[0] }) }, + ); + const missingRange = await admin.get(path); + const invalid = await admin.get(path, { params: { startAt: 'abc', endAt: 'def' } }); + const denied = await viewer.get(path, { params: dateRange(seed) }); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBeGreaterThan(0); + expect(response.body[0]).toMatchObject({ + eventId: expect.any(String), + visitId: expect.any(String), + createdAt: expect.any(String), + urlPath: expect.any(String), + eventType: expect.any(Number), + }); + expect(response.body[0]).toHaveProperty('eventName'); + expect(response.body[0]).toHaveProperty('hasData'); + expect(response.body.some((row: any) => row.eventType === EVENT_TYPE.pageView)).toBe(true); + + expect(stitched.status).toBe(200); + expect(stitched.body.length).toBeGreaterThan(0); + expect(missingRange.status).toBe(400); + expect(invalid.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/sessions/{sessionId}/properties returns session data', async ({ + admin, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/sessions/${identifiedSessionId}/properties`; + const response = await admin.get(path); + const unknown = await admin.get( + `/api/websites/${seed.website.id}/sessions/${UNKNOWN_UUID}/properties`, + ); + const denied = await viewer.get(path); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBeGreaterThan(0); + expect(response.body[0]).toMatchObject({ + sessionId: identifiedSessionId, + dataKey: expect.any(String), + dataType: expect.any(Number), + }); + for (const key of ['stringValue', 'numberValue', 'dateValue']) { + expect(response.body[0]).toHaveProperty(key); + } + expect(unknown.status).toBe(200); + expect(unknown.body).toEqual([]); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/sessions/stats returns session totals', async ({ + admin, + share, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/sessions/stats`; + const response = await admin.get(path, { params: dateRange(seed) }); + const missingRange = await admin.get(path); + const shared = await (await share()).get(path, { params: dateRange(seed) }); + const denied = await viewer.get(path, { params: dateRange(seed) }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + pageviews: { value: expect.anything() }, + visitors: { value: expect.anything() }, + visits: { value: expect.anything() }, + countries: { value: expect.anything() }, + events: { value: expect.anything() }, + }); + expect(Number(response.body.pageviews.value)).toBeGreaterThanOrEqual( + seed.data.expectedPageviews, + ); + expect(Number(response.body.visitors.value)).toBeGreaterThan(0); + expect(Number(response.body.events.value)).toBeGreaterThan(0); + expect(missingRange.status).toBe(400); + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/sessions/weekly returns a 7x24 grid', async ({ + admin, + share, + viewer, + seed, + }) => { + const path = `/api/websites/${seed.website.id}/sessions/weekly`; + const params = dateRange(seed, { timezone: TIMEZONE }); + const response = await admin.get(path, { params }); + const missingTimezone = await admin.get(path, { params: dateRange(seed) }); + const shared = await (await share()).get(path, { params }); + const denied = await viewer.get(path, { params }); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(7); + for (const day of response.body) { + expect(day).toHaveLength(24); + for (const hour of day) { + expect(typeof hour).toBe('number'); + } + } + const total = response.body.flat().reduce((sum: number, n: number) => sum + n, 0); + expect(total).toBeGreaterThan(0); + expect(missingTimezone.status).toBe(400); + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test.describe('DELETE /api/websites/{websiteId}/sessions/{sessionId}', () => { + let websiteId = ''; + let sessionId = ''; + + test.beforeAll(async ({ admin, api, seed }) => { + test.skip(seed.db === 'clickhouse', 'Session deletion requires relational storage'); + + websiteId = (await createWebsite(admin)).id; + + const sent = await api.post('/api/send', { + type: 'event', + payload: { + website: websiteId, + hostname: 'x.test', + url: '/', + ip: '10.0.9.9', + userAgent: PERSONAS[0].userAgent, + }, + }); + + expect(sent.status).toBe(200); + + await expect + .poll( + async () => { + const sessions = await admin.get(`/api/websites/${websiteId}/sessions`, { + params: { startAt: Date.now() - 60 * 60 * 1000, endAt: Date.now() }, + }); + sessionId = sessions.body?.data?.[0]?.id ?? ''; + return sessionId; + }, + { timeout: 15_000 }, + ) + .not.toBe(''); + }); + + test.afterAll(async ({ admin }) => { + if (websiteId) { + await deleteWebsite(admin, websiteId); + } + }); + + test('DELETE /api/websites/{websiteId}/sessions/{sessionId} deletes a session', async ({ + admin, + api, + user, + seed, + }) => { + const path = `/api/websites/${websiteId}/sessions/${sessionId}`; + const anonymous = await api.del(path); + const denied = await user.del(path); + const before = await admin.get(path); + const response = await admin.del(path); + const after = await admin.get(path); + const unknown = await admin.del(`/api/websites/${websiteId}/sessions/${UNKNOWN_UUID}`); + const list = await admin.get(`/api/websites/${websiteId}/sessions`, { + params: dateRange(seed), + }); + + expect(anonymous.status).toBe(401); + expect(denied.status).toBe(401); + expect(before.status).toBe(200); + expect(before.body.canDelete).toBe(true); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(after.status).toBe(404); + expect(unknown.status).toBe(404); + expect(list.body.data).toHaveLength(0); + }); + }); +}); diff --git a/tests/api/shares.spec.ts b/tests/api/shares.spec.ts new file mode 100644 index 000000000..e2619e6c2 --- /dev/null +++ b/tests/api/shares.spec.ts @@ -0,0 +1,270 @@ +import { expect, test } from './fixtures'; +import { ENTITY_TYPE, UNKNOWN_UUID } from './helpers/constants'; +import { dateRange } from './helpers/dates'; +import { createWebsite, deleteWebsite, uniqueName, uniqueSlug } from './helpers/entities'; + +test.describe('Shares', () => { + test.describe.configure({ mode: 'serial' }); + + let websiteId = ''; + let shareId = ''; + let slug = ''; + let websiteShareId = ''; + let gatedShareId = ''; + let gatedSlug = ''; + + test.beforeAll(async ({ admin }) => { + websiteId = (await createWebsite(admin)).id; + }); + + test.afterAll(async ({ admin }) => { + for (const id of [shareId, websiteShareId, gatedShareId]) { + if (id) { + await admin.del(`/api/share/id/${id}`); + } + } + + if (websiteId) { + await deleteWebsite(admin, websiteId); + } + }); + + test('POST /api/share creates a share for an entity', async ({ admin }) => { + const name = uniqueName('share'); + slug = uniqueSlug('share'); + const response = await admin.post('/api/share', { + entityId: websiteId, + shareType: ENTITY_TYPE.website, + name, + slug, + parameters: { overview: true }, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: expect.any(String), + entityId: websiteId, + shareType: ENTITY_TYPE.website, + name, + slug, + parameters: { overview: true }, + createdAt: expect.any(String), + }); + + shareId = response.body.id; + }); + + test('POST /api/share generates a slug when omitted', async ({ admin }) => { + const response = await admin.post('/api/share', { + entityId: websiteId, + shareType: ENTITY_TYPE.website, + name: uniqueName('share'), + parameters: {}, + }); + + expect(response.status).toBe(200); + expect(response.body.slug).toMatch(/^[A-Za-z0-9]{16}$/); + + await admin.del(`/api/share/id/${response.body.id}`); + }); + + test('POST /api/share validates the body and checks entity access', async ({ + admin, + user, + viewer, + api, + }) => { + const missing = await admin.post('/api/share', { entityId: websiteId, name: 'x' }); + const badId = await admin.post('/api/share', { + entityId: 'nope', + shareType: ENTITY_TYPE.website, + name: 'x', + parameters: {}, + }); + // Non-admin callers cannot share an entity that does not exist (or is not theirs). + const unknown = await user.post('/api/share', { + entityId: UNKNOWN_UUID, + shareType: ENTITY_TYPE.website, + name: 'x', + parameters: {}, + }); + const denied = await viewer.post('/api/share', { + entityId: websiteId, + shareType: ENTITY_TYPE.website, + name: 'x', + parameters: {}, + }); + const anonymous = await api.post('/api/share', { + entityId: websiteId, + shareType: ENTITY_TYPE.website, + name: 'x', + parameters: {}, + }); + + expect(missing.status).toBe(400); + expect(badId.status).toBe(400); + expect(unknown.status).toBe(401); + expect(denied.status).toBe(401); + expect(anonymous.status).toBe(401); + }); + + test('GET /api/share/{slug} publicly resolves a share to a token', async ({ api, seed }) => { + const response = await api.get(`/api/share/${slug}`); + const seeded = await api.get(`/api/share/${seed.share.slug}`); + const unknown = await api.get('/api/share/no-such-slug'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + shareId, + shareType: ENTITY_TYPE.website, + websiteId, + parameters: { overview: true }, + token: expect.any(String), + }); + expect(seeded.status).toBe(200); + expect(seeded.body).toMatchObject({ shareId: seed.share.id, websiteId: seed.website.id }); + expect(unknown.status).toBe(404); + }); + + test('GET /api/share/id/{shareId} returns a share', async ({ admin, viewer, api }) => { + const response = await admin.get(`/api/share/id/${shareId}`); + const denied = await viewer.get(`/api/share/id/${shareId}`); + const anonymous = await api.get(`/api/share/id/${shareId}`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: shareId, entityId: websiteId, slug }); + expect(denied.status).toBe(401); + expect(anonymous.status).toBe(401); + }); + + test('POST /api/share/id/{shareId} updates a share', async ({ admin, viewer }) => { + const name = uniqueName('renamed'); + slug = uniqueSlug('renamed'); + const response = await admin.post(`/api/share/id/${shareId}`, { + name, + slug, + parameters: { overview: true, events: true }, + }); + const partial = await admin.post(`/api/share/id/${shareId}`, { name }); + const unknown = await admin.post(`/api/share/id/${UNKNOWN_UUID}`, { + name, + slug: uniqueSlug('x'), + parameters: {}, + }); + const denied = await viewer.post(`/api/share/id/${shareId}`, { + name, + slug, + parameters: {}, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: shareId, + name, + slug, + parameters: { overview: true, events: true }, + }); + expect(partial.status).toBe(400); + expect(unknown.status).toBe(404); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/shares lists website shares', async ({ + admin, + viewer, + share, + seed, + }) => { + const response = await admin.get(`/api/websites/${websiteId}/shares`); + const paged = await admin.get(`/api/websites/${websiteId}/shares`, { + params: { page: 1, pageSize: 1 }, + }); + const denied = await viewer.get(`/api/websites/${websiteId}/shares`); + const shared = await (await share()).get(`/api/websites/${seed.website.id}/shares`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + count: expect.any(Number), + page: 1, + pageSize: expect.any(Number), + }); + expect(response.body.data.map((s: any) => s.id)).toContain(shareId); + expect(paged.status).toBe(200); + expect(paged.body.data).toHaveLength(1); + expect(paged.body.pageSize).toBe(1); + expect(denied.status).toBe(401); + expect(shared.status).toBe(401); + }); + + test('POST /api/websites/{websiteId}/shares creates a website share', async ({ admin, user }) => { + const name = uniqueName('site-share'); + const response = await admin.post(`/api/websites/${websiteId}/shares`, { name }); + const invalid = await admin.post(`/api/websites/${websiteId}/shares`, {}); + const denied = await user.post(`/api/websites/${websiteId}/shares`, { name }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + entityId: websiteId, + shareType: ENTITY_TYPE.website, + name, + slug: expect.any(String), + parameters: {}, + }); + expect(response.body.slug).toHaveLength(16); + expect(invalid.status).toBe(400); + expect(denied.status).toBe(401); + + websiteShareId = response.body.id; + }); + + test('share parameters gate which sections a share token may access', async ({ + admin, + api, + share, + seed, + }) => { + gatedSlug = uniqueSlug('gated'); + const created = await admin.post('/api/share', { + entityId: seed.website.id, + shareType: ENTITY_TYPE.website, + name: uniqueName('gated'), + slug: gatedSlug, + parameters: { overview: true, events: false }, + }); + + expect(created.status).toBe(200); + gatedShareId = created.body.id; + + const resolved = await api.get(`/api/share/${gatedSlug}`); + + expect(resolved.status).toBe(200); + expect(resolved.body).toMatchObject({ + shareId: gatedShareId, + websiteId: seed.website.id, + parameters: { overview: true, events: false }, + }); + + const client = await share(gatedSlug); + const allowed = await client.get(`/api/websites/${seed.website.id}/active`); + const denied = await client.get(`/api/websites/${seed.website.id}/events`, { + params: dateRange(seed), + }); + + expect(allowed.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test('DELETE /api/share/id/{shareId} deletes a share', async ({ admin, viewer, api }) => { + const denied = await viewer.del(`/api/share/id/${shareId}`); + const response = await admin.del(`/api/share/id/${shareId}`); + const gone = await api.get(`/api/share/${slug}`); + + expect(denied.status).toBe(401); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(gone.status).toBe(404); + + shareId = ''; + }); +}); diff --git a/tests/api/system.spec.ts b/tests/api/system.spec.ts new file mode 100644 index 000000000..6fbf2bf49 --- /dev/null +++ b/tests/api/system.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from './fixtures'; + +test.describe('System', () => { + test('GET /api/heartbeat responds without authentication', async ({ api }) => { + const response = await api.get('/api/heartbeat'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + }); + + test('GET /api/config reports the runtime feature flags', async ({ api, seed }) => { + const response = await api.get('/api/config'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + cloudMode: false, + privateMode: false, + // Session deletion is only supported when analytics live in Postgres. + sessionDeletionEnabled: seed.db === 'postgres', + }); + expect(typeof response.body.telemetryDisabled).toBe('boolean'); + expect(typeof response.body.updatesDisabled).toBe('boolean'); + }); + + test('GET /api/scripts/telemetry serves a javascript response', async ({ api }) => { + const response = await api.get('/api/scripts/telemetry'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('javascript'); + expect(response.text.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/api/teams.spec.ts b/tests/api/teams.spec.ts new file mode 100644 index 000000000..2f1613fea --- /dev/null +++ b/tests/api/teams.spec.ts @@ -0,0 +1,206 @@ +import { expect, test } from './fixtures'; +import { UNKNOWN_UUID } from './helpers/constants'; +import { createWebsite, deleteWebsite, uniqueDomain, uniqueName } from './helpers/entities'; + +test.describe('Teams', () => { + test.describe.configure({ mode: 'serial' }); + + let teamId = ''; + let accessCode = ''; + + test.afterAll(async ({ admin }) => { + if (teamId) { + await admin.del(`/api/teams/${teamId}`); + } + }); + + test('POST /api/teams creates a team owned by the caller', async ({ admin, seed }) => { + const name = uniqueName('team'); + const response = await admin.post('/api/teams', { name }); + + expect(response.status).toBe(200); + expect(response.body[0]).toMatchObject({ name }); + expect(response.body[1]).toMatchObject({ userId: seed.admin.id, role: 'team-owner' }); + + teamId = response.body[0].id; + }); + + test('POST /api/teams validates the body and requires authentication', async ({ admin, api }) => { + const tooLong = await admin.post('/api/teams', { name: 'x'.repeat(51) }); + const anonymous = await api.post('/api/teams', { name: 'x' }); + + expect(tooLong.status).toBe(400); + expect(anonymous.status).toBe(401); + }); + + test('GET /api/teams lists the caller teams', async ({ admin, viewer }) => { + const response = await admin.get('/api/teams'); + const none = await viewer.get('/api/teams'); + + expect(response.status).toBe(200); + expect(response.body.data.map((t: any) => t.id)).toContain(teamId); + expect(none.status).toBe(200); + expect(none.body.data.map((t: any) => t.id)).not.toContain(teamId); + }); + + test('GET /api/teams/{teamId} returns the team', async ({ admin, viewer }) => { + const response = await admin.get(`/api/teams/${teamId}`); + const denied = await viewer.get(`/api/teams/${teamId}`); + const unknown = await admin.get(`/api/teams/${UNKNOWN_UUID}`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: teamId, accessCode: expect.any(String) }); + expect(denied.status).toBe(401); + expect(unknown.status).toBe(404); + + accessCode = response.body.accessCode; + }); + + test('POST /api/teams/{teamId} updates the team', async ({ admin, user }) => { + const name = uniqueName('renamed'); + const newCode = `team_${uniqueName('code')}`; + const response = await admin.post(`/api/teams/${teamId}`, { name, accessCode: newCode }); + const denied = await user.post(`/api/teams/${teamId}`, { name }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: teamId, name, accessCode: newCode }); + expect(denied.status).toBe(401); + + accessCode = newCode; + }); + + test('POST /api/teams/join adds the caller to a team', async ({ viewer, seed }) => { + const response = await viewer.post('/api/teams/join', { accessCode }); + const again = await viewer.post('/api/teams/join', { accessCode }); + const unknown = await viewer.post('/api/teams/join', { accessCode: 'team_nope' }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ teamId, userId: seed.viewer.id, role: 'team-member' }); + expect(again.status).toBe(400); + expect(unknown.status).toBe(404); + expect(unknown.body.error.code).toBe('team-not-found'); + }); + + test('GET /api/teams/{teamId}/users lists the members', async ({ admin, viewer, user }) => { + const response = await admin.get(`/api/teams/${teamId}/users`); + const member = await viewer.get(`/api/teams/${teamId}/users`); + const denied = await user.get(`/api/teams/${teamId}/users`); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(2); + expect(response.body.data[0]).toMatchObject({ + teamId, + user: { id: expect.any(String), username: expect.any(String) }, + }); + expect(member.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test('GET /api/teams/{teamId}/users/{userId} returns a member', async ({ + admin, + viewer, + seed, + }) => { + const response = await admin.get(`/api/teams/${teamId}/users/${seed.viewer.id}`); + const denied = await viewer.get(`/api/teams/${teamId}/users/${seed.viewer.id}`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ teamId, userId: seed.viewer.id, role: 'team-member' }); + // Members that are not owners/managers cannot inspect memberships. + expect(denied.status).toBe(401); + }); + + test('POST /api/teams/{teamId}/users/{userId} updates a member role', async ({ + admin, + viewer, + seed, + }) => { + const response = await admin.post(`/api/teams/${teamId}/users/${seed.viewer.id}`, { + role: 'team-view-only', + }); + const notMember = await admin.post(`/api/teams/${teamId}/users/${seed.user.id}`, { + role: 'team-member', + }); + const denied = await viewer.post(`/api/teams/${teamId}/users/${seed.viewer.id}`, { + role: 'team-manager', + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ userId: seed.viewer.id, role: 'team-view-only' }); + expect(notMember.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('DELETE /api/teams/{teamId}/users/{userId} removes a member', async ({ admin, seed }) => { + const response = await admin.del(`/api/teams/${teamId}/users/${seed.viewer.id}`); + const again = await admin.del(`/api/teams/${teamId}/users/${seed.viewer.id}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(again.status).toBe(400); + }); + + test('POST /api/teams/{teamId}/users adds a member', async ({ admin, user, seed }) => { + const response = await admin.post(`/api/teams/${teamId}/users`, { + userId: seed.user.id, + role: 'team-member', + }); + const again = await admin.post(`/api/teams/${teamId}/users`, { + userId: seed.user.id, + role: 'team-member', + }); + const badRole = await admin.post(`/api/teams/${teamId}/users`, { + userId: seed.viewer.id, + role: 'team-owner', + }); + const denied = await user.post(`/api/teams/${teamId}/users`, { + userId: seed.viewer.id, + role: 'team-member', + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ userId: seed.user.id, role: 'team-member' }); + expect(again.status).toBe(400); + expect(badRole.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('GET /api/teams/{teamId}/websites|boards|links|pixels list team resources', async ({ + admin, + user, + viewer, + }) => { + const website = await createWebsite(admin, { teamId, domain: uniqueDomain() }); + + try { + const websites = await user.get(`/api/teams/${teamId}/websites`); + const boards = await admin.get(`/api/teams/${teamId}/boards`); + const links = await admin.get(`/api/teams/${teamId}/links`); + const pixels = await admin.get(`/api/teams/${teamId}/pixels`); + const denied = await viewer.get(`/api/teams/${teamId}/websites`); + + expect(websites.status).toBe(200); + expect(websites.body.data.map((w: any) => w.id)).toContain(website.id); + + for (const response of [boards, links, pixels]) { + expect(response.status).toBe(200); + expect(Array.isArray(response.body.data)).toBe(true); + } + + expect(denied.status).toBe(401); + } finally { + await deleteWebsite(admin, website.id); + } + }); + + test('DELETE /api/teams/{teamId} deletes the team', async ({ admin, user }) => { + const denied = await user.del(`/api/teams/${teamId}`); + const response = await admin.del(`/api/teams/${teamId}`); + + expect(denied.status).toBe(401); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + + teamId = ''; + }); +}); diff --git a/tests/api/tsconfig.json b/tests/api/tsconfig.json new file mode 100644 index 000000000..c547e6a93 --- /dev/null +++ b/tests/api/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"], + "rootDir": "../.." + }, + "include": ["./**/*.ts", "../../playwright.api.config.ts", "../../scripts/test-api.ts"], + "exclude": [".runtime"] +} diff --git a/tests/api/two-factor.spec.ts b/tests/api/two-factor.spec.ts new file mode 100644 index 000000000..a63d9e376 --- /dev/null +++ b/tests/api/two-factor.spec.ts @@ -0,0 +1,348 @@ +import { expect, test } from './fixtures'; +import { login } from './helpers/auth'; +import { createUser, deleteUser } from './helpers/entities'; +import { nextCode, wrongCode } from './helpers/totp'; + +/** + * Full two-factor lifecycle on a throwaway user. TOTP codes are single-use and + * bound to 30 s time steps, so several tests wait for the next step; the + * describe timeout is raised accordingly. + */ +test.describe('Two-factor authentication', () => { + test.describe.configure({ mode: 'serial', timeout: 120_000 }); + + let userId = ''; + let username = ''; + let password = ''; + /** Regular session token for the throwaway user (issued before 2FA was enabled). */ + let sessionToken = ''; + let secret = ''; + let lastCode = ''; + let backupCodes: string[] = []; + let partialToken = ''; + + test.beforeAll(async ({ admin, api }) => { + const created = await createUser(admin); + + userId = created.id; + username = created.username; + password = created.password; + sessionToken = await login(api, { username, password }); + }); + + test.afterAll(async ({ admin }) => { + if (userId) { + // Clear any 2FA state and lockouts before removing the user. + await admin.post(`/api/admin/users/${userId}/2fa`, { required: false }); + await admin.del(`/api/admin/users/${userId}/2fa`); + await deleteUser(admin, userId); + } + }); + + test('GET /api/2fa/status reports the initial state', async ({ api, apiKey }) => { + const session = api.bearer(sessionToken); + const response = await session.get('/api/2fa/status'); + const anonymous = await api.get('/api/2fa/status'); + const viaApiKey = await apiKey.get('/api/2fa/status'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + isEnabled: false, + isRequired: false, + requiredReason: null, + isConfigured: true, + globalRequired: false, + }); + expect(anonymous.status).toBe(401); + // API keys are blocked on /api/2fa/*. + expect(viaApiKey.status).toBe(401); + }); + + test('POST /api/2fa/setup/confirm rejects invalid bodies and missing setups', async ({ + api, + apiKey, + }) => { + const session = api.bearer(sessionToken); + const tooShort = await session.post('/api/2fa/setup/confirm', { token: '123' }); + const missing = await session.post('/api/2fa/setup/confirm', {}); + const noSetup = await session.post('/api/2fa/setup/confirm', { token: '000000' }); + const anonymous = await api.post('/api/2fa/setup/confirm', { token: '000000' }); + const viaApiKey = await apiKey.post('/api/2fa/setup/confirm', { token: '000000' }); + + expect(tooShort.status).toBe(400); + expect(missing.status).toBe(400); + expect(noSetup.status).toBe(400); + expect(noSetup.body.error.code).toBe('two-factor-error-no-pending-setup'); + expect(anonymous.status).toBe(401); + expect(viaApiKey.status).toBe(401); + }); + + test('POST /api/2fa/setup/initiate returns a secret and QR code', async ({ api, apiKey }) => { + const session = api.bearer(sessionToken); + const response = await session.post('/api/2fa/setup/initiate'); + const anonymous = await api.post('/api/2fa/setup/initiate'); + const viaApiKey = await apiKey.post('/api/2fa/setup/initiate'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + qrCodeDataUrl: expect.stringMatching(/^data:image\/png;base64,/), + manualKey: expect.stringMatching(/^[A-Z2-7]+=*$/), + }); + expect(anonymous.status).toBe(401); + expect(viaApiKey.status).toBe(401); + + // Status stays disabled until the setup is confirmed. + const status = await session.get('/api/2fa/status'); + + expect(status.body.isEnabled).toBe(false); + }); + + test('POST /api/2fa/setup/cancel discards the pending setup', async ({ api, apiKey }) => { + const session = api.bearer(sessionToken); + const response = await session.post('/api/2fa/setup/cancel'); + const again = await session.post('/api/2fa/setup/cancel'); + const confirm = await session.post('/api/2fa/setup/confirm', { token: '000000' }); + const anonymous = await api.post('/api/2fa/setup/cancel'); + const viaApiKey = await apiKey.post('/api/2fa/setup/cancel'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + // Cancelling is idempotent. + expect(again.status).toBe(200); + expect(again.body).toEqual({ ok: true }); + expect(confirm.status).toBe(400); + expect(confirm.body.error.code).toBe('two-factor-error-no-pending-setup'); + expect(anonymous.status).toBe(401); + expect(viaApiKey.status).toBe(401); + }); + + test('POST /api/2fa/setup/initiate can be repeated and rejects wrong codes', async ({ api }) => { + const session = api.bearer(sessionToken); + const response = await session.post('/api/2fa/setup/initiate'); + + expect(response.status).toBe(200); + + secret = response.body.manualKey; + + // A single wrong attempt (the lockout threshold is five). + const invalid = await session.post('/api/2fa/setup/confirm', { token: wrongCode(secret) }); + + expect(invalid.status).toBe(400); + expect(invalid.body.error).toMatchObject({ + code: 'two-factor-error-invalid-code', + message: 'Invalid verification code', + status: 400, + }); + }); + + test('POST /api/2fa/setup/confirm enables 2FA and returns backup codes', async ({ api }) => { + const session = api.bearer(sessionToken); + lastCode = await nextCode(secret); + + const response = await session.post('/api/2fa/setup/confirm', { token: lastCode }); + + expect(response.status).toBe(200); + expect(response.body.backupCodes).toHaveLength(10); + expect(new Set(response.body.backupCodes).size).toBe(10); + + for (const code of response.body.backupCodes) { + expect(typeof code).toBe('string'); + expect(code.length).toBeGreaterThan(0); + } + + backupCodes = response.body.backupCodes; + + const status = await session.get('/api/2fa/status'); + + expect(status.body).toMatchObject({ isEnabled: true, isRequired: false }); + + // Confirming again is rejected: there is no longer a pending setup. + const again = await session.post('/api/2fa/setup/confirm', { token: lastCode }); + + expect(again.status).toBe(400); + expect(again.body.error.code).toBe('two-factor-error-no-pending-setup'); + }); + + test('POST /api/2fa/setup/initiate rejects users with 2FA enabled', async ({ api }) => { + const session = api.bearer(sessionToken); + const response = await session.post('/api/2fa/setup/initiate'); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe('two-factor-error-already-enabled'); + }); + + test('POST /api/auth/login returns a partial token when 2FA is enabled', async ({ api }) => { + const response = await api.post('/api/auth/login', { username, password }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ requiresTwoFactor: true, partialToken: expect.any(String) }); + + partialToken = response.body.partialToken; + + // NOTE: the partial token is currently accepted as a full session by + // checkAuth (it only looks at payload.userId, not payload.type), which + // bypasses the second factor. Not asserted here; reported as a bug. + }); + + test('POST /api/2fa/verify rejects missing, invalid and non-partial tokens', async ({ + api, + apiKey, + }) => { + const session = api.bearer(sessionToken); + const body = { token: '000000' }; + const missing = await api.post('/api/2fa/verify', body); + const garbage = await api.bearer('not-a-token').post('/api/2fa/verify', body); + const fullSession = await session.post('/api/2fa/verify', body); + const viaApiKey = await apiKey.post('/api/2fa/verify', body); + + expect(missing.status).toBe(401); + expect(missing.body.error.code).toBe('two-factor-error-missing-token'); + expect(garbage.status).toBe(401); + expect(garbage.body.error.code).toBe('two-factor-error-invalid-partial-token'); + expect(fullSession.status).toBe(401); + expect(fullSession.body.error.code).toBe('two-factor-error-invalid-partial-token'); + expect(viaApiKey.status).toBe(401); + }); + + test('POST /api/2fa/verify validates the body', async ({ api }) => { + const partial = api.bearer(partialToken); + const empty = await partial.post('/api/2fa/verify', {}); + const both = await partial.post('/api/2fa/verify', { token: '000000', backupCode: 'x' }); + const extra = await partial.post('/api/2fa/verify', { token: '000000', extra: true }); + const tooShort = await partial.post('/api/2fa/verify', { token: '12345' }); + const emptyBackup = await partial.post('/api/2fa/verify', { backupCode: '' }); + + expect(empty.status).toBe(400); + expect(both.status).toBe(400); + expect(extra.status).toBe(400); + expect(tooShort.status).toBe(400); + expect(emptyBackup.status).toBe(400); + }); + + test('POST /api/2fa/verify rejects a code that was already used', async ({ api }) => { + // The code accepted by setup/confirm stays blocked for 90 s. + const response = await api.bearer(partialToken).post('/api/2fa/verify', { token: lastCode }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ + code: 'two-factor-error-code-used', + message: 'Code already used', + }); + }); + + test('POST /api/2fa/verify completes the login with a TOTP code', async ({ api }) => { + const code = await nextCode(secret, lastCode); + const response = await api.bearer(partialToken).post('/api/2fa/verify', { token: code }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + token: expect.any(String), + user: { id: userId, username, role: 'user', isAdmin: false, teams: expect.any(Array) }, + }); + expect(response.body).not.toHaveProperty('requiresTwoFactor'); + + lastCode = code; + + // The issued token is a full session. + const me = await api.bearer(response.body.token).get('/api/me'); + + expect(me.status).toBe(200); + expect(me.body.user.id).toBe(userId); + }); + + test('POST /api/2fa/verify accepts a backup code once', async ({ api }) => { + const loginResponse = await api.post('/api/auth/login', { username, password }); + + expect(loginResponse.body.requiresTwoFactor).toBe(true); + + const partial = api.bearer(loginResponse.body.partialToken); + const response = await partial.post('/api/2fa/verify', { backupCode: backupCodes[0] }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ token: expect.any(String), user: { id: userId } }); + + // Backup codes are single-use (this counts as one failed attempt). + const reused = await api + .bearer((await api.post('/api/auth/login', { username, password })).body.partialToken) + .post('/api/2fa/verify', { backupCode: backupCodes[0] }); + + expect(reused.status).toBe(400); + expect(reused.body.error.code).toBe('two-factor-error-invalid-backup-code'); + }); + + test('POST /api/2fa/disable validates the body and password', async ({ api, apiKey }) => { + const session = api.bearer(sessionToken); + const missingToken = await session.post('/api/2fa/disable', { password }); + const missingPassword = await session.post('/api/2fa/disable', { token: '000000' }); + const shortToken = await session.post('/api/2fa/disable', { password, token: '123' }); + const wrongPassword = await session.post('/api/2fa/disable', { + password: 'wrong-password', + token: '000000', + }); + const anonymous = await api.post('/api/2fa/disable', { password, token: '000000' }); + const viaApiKey = await apiKey.post('/api/2fa/disable', { password, token: '000000' }); + + expect(missingToken.status).toBe(400); + expect(missingPassword.status).toBe(400); + expect(shortToken.status).toBe(400); + expect(wrongPassword.status).toBe(400); + expect(wrongPassword.body.error).toMatchObject({ + code: 'two-factor-error-incorrect-password', + message: 'Incorrect password', + }); + expect(anonymous.status).toBe(401); + expect(viaApiKey.status).toBe(401); + }); + + test('POST /api/2fa/disable is forbidden while 2FA is required', async ({ api, admin }) => { + const session = api.bearer(sessionToken); + const required = await admin.post(`/api/admin/users/${userId}/2fa`, { required: true }); + + expect(required.status).toBe(200); + + try { + const status = await session.get('/api/2fa/status'); + const response = await session.post('/api/2fa/disable', { password, token: '000000' }); + + expect(status.body).toMatchObject({ + isEnabled: true, + isRequired: true, + requiredReason: 'user', + }); + expect(response.status).toBe(403); + expect(response.body.error).toMatchObject({ + code: 'two-factor-error-disable-not-allowed', + message: '2FA is required and cannot be disabled', + }); + } finally { + const optional = await admin.post(`/api/admin/users/${userId}/2fa`, { required: false }); + + expect(optional.status).toBe(200); + } + }); + + test('POST /api/2fa/disable turns 2FA off', async ({ api, admin }) => { + const session = api.bearer(sessionToken); + const code = await nextCode(secret, lastCode); + const response = await session.post('/api/2fa/disable', { password, token: code }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + + lastCode = code; + + const status = await session.get('/api/2fa/status'); + const adminView = await admin.get(`/api/admin/users/${userId}/2fa`); + const loginResponse = await api.post('/api/auth/login', { username, password }); + const again = await session.post('/api/2fa/disable', { password, token: code }); + + expect(status.body).toMatchObject({ isEnabled: false, isRequired: false }); + expect(adminView.body).toEqual({ isEnabled: false }); + // Login is back to a single step. + expect(loginResponse.status).toBe(200); + expect(loginResponse.body.token).toEqual(expect.any(String)); + expect(loginResponse.body).not.toHaveProperty('requiresTwoFactor'); + expect(again.status).toBe(400); + expect(again.body.error.code).toBe('two-factor-error-not-enabled'); + }); +}); diff --git a/tests/api/users.spec.ts b/tests/api/users.spec.ts new file mode 100644 index 000000000..e407333d7 --- /dev/null +++ b/tests/api/users.spec.ts @@ -0,0 +1,161 @@ +import { expect, test } from './fixtures'; +import { login } from './helpers/auth'; +import { UNKNOWN_UUID } from './helpers/constants'; +import { uniqueName } from './helpers/entities'; + +test.describe('Users', () => { + test.describe.configure({ mode: 'serial' }); + + const username = uniqueName('user'); + const password = 'spec-password-1'; + let userId = ''; + + test.afterAll(async ({ admin }) => { + if (userId) { + await admin.del(`/api/users/${userId}`); + } + }); + + test('POST /api/users creates a user', async ({ admin }) => { + const response = await admin.post('/api/users', { + username: username.toUpperCase(), + password, + role: 'user', + }); + + expect(response.status).toBe(200); + // Usernames are stored lowercased. + expect(response.body).toMatchObject({ username, role: 'user' }); + + userId = response.body.id; + }); + + test('POST /api/users rejects duplicates and invalid bodies', async ({ admin }) => { + const duplicate = await admin.post('/api/users', { username, password, role: 'user' }); + const shortPassword = await admin.post('/api/users', { + username: uniqueName('user'), + password: 'short', + role: 'user', + }); + const badRole = await admin.post('/api/users', { + username: uniqueName('user'), + password, + role: 'superuser', + }); + + expect(duplicate.status).toBe(400); + expect(duplicate.body.error.message).toBe('User already exists'); + expect(shortPassword.status).toBe(400); + expect(badRole.status).toBe(400); + }); + + test('POST /api/users requires an admin session', async ({ api, user, apiKey }) => { + const body = { username: uniqueName('user'), password, role: 'user' }; + + expect((await api.post('/api/users', body)).status).toBe(401); + expect((await user.post('/api/users', body)).status).toBe(401); + // API keys are blocked on /api/users even for admins. + expect((await apiKey.post('/api/users', body)).status).toBe(401); + }); + + test('GET /api/users/{userId} returns a user', async ({ admin, api, viewer }) => { + const response = await admin.get(`/api/users/${userId}`); + const self = await api + .bearer(await login(api, { username, password })) + .get(`/api/users/${userId}`); + const denied = await viewer.get(`/api/users/${userId}`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: userId, username, role: 'user' }); + expect(response.body).not.toHaveProperty('password'); + expect(self.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test('POST /api/users/{userId} lets admins update role, username and password', async ({ + admin, + api, + }) => { + const newPassword = 'spec-password-2'; + const response = await admin.post(`/api/users/${userId}`, { + role: 'view-only', + password: newPassword, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: userId, username, role: 'view-only' }); + + // The new password works and the old one does not. + expect(await login(api, { username, password: newPassword })).toEqual(expect.any(String)); + expect((await api.post('/api/auth/login', { username, password })).status).toBe(401); + + await admin.post(`/api/users/${userId}`, { password }); + }); + + test('POST /api/users/{userId} ignores role changes from non-admins', async ({ user, seed }) => { + const response = await user.post(`/api/users/${seed.user.id}`, { role: 'admin' }); + + expect(response.status).toBe(200); + expect(response.body.role).toBe('user'); + }); + + test('POST /api/users/{userId} rejects unknown users and other users', async ({ + admin, + user, + }) => { + const unknown = await admin.post(`/api/users/${UNKNOWN_UUID}`, { role: 'user' }); + const denied = await user.post(`/api/users/${userId}`, { password: 'spec-password-3' }); + + expect(unknown.status).toBe(404); + expect(denied.status).toBe(401); + }); + + test('GET /api/users/{userId}/websites lists a user websites', async ({ + admin, + user, + viewer, + seed, + }) => { + const response = await admin.get(`/api/users/${seed.user.id}/websites`); + const self = await user.get(`/api/users/${seed.user.id}/websites`, { + params: { includeTeams: 'true' }, + }); + const denied = await viewer.get(`/api/users/${seed.user.id}/websites`); + + expect(response.status).toBe(200); + expect(response.body.data.map((w: any) => w.id)).toContain(seed.website2.id); + expect(self.status).toBe(200); + expect(self.body.data.map((w: any) => w.id)).toContain(seed.website2.id); + expect(denied.status).toBe(401); + }); + + test('GET /api/users/{userId}/teams lists a user teams', async ({ + admin, + user, + viewer, + seed, + }) => { + const response = await admin.get(`/api/users/${seed.user.id}/teams`); + const self = await user.get(`/api/users/${seed.user.id}/teams`); + const denied = await viewer.get(`/api/users/${seed.user.id}/teams`); + + expect(response.status).toBe(200); + expect(response.body.data.map((t: any) => t.id)).toContain(seed.team.id); + expect(self.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test('DELETE /api/users/{userId} deletes a user', async ({ admin, user, seed }) => { + const denied = await user.del(`/api/users/${userId}`); + const self = await admin.del(`/api/users/${seed.admin.id}`); + const response = await admin.del(`/api/users/${userId}`); + + expect(denied.status).toBe(401); + expect(self.status).toBe(400); + expect(self.body.error.message).toBe('You cannot delete yourself.'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + + userId = ''; + }); +}); diff --git a/tests/api/websites.spec.ts b/tests/api/websites.spec.ts new file mode 100644 index 000000000..921c610a8 --- /dev/null +++ b/tests/api/websites.spec.ts @@ -0,0 +1,293 @@ +import { randomUUID } from 'node:crypto'; +import { expect, test } from './fixtures'; +import { UNKNOWN_UUID } from './helpers/constants'; +import { dateRange } from './helpers/dates'; +import { + createTeam, + deleteTeam, + deleteWebsite, + uniqueDomain, + uniqueName, +} from './helpers/entities'; +import { HOSTNAME } from './seed/dataset'; + +test.describe('Websites', () => { + test.describe.configure({ mode: 'serial' }); + + let teamId = ''; + let websiteId = ''; + let teamWebsiteId = ''; + + test.beforeAll(async ({ admin }) => { + teamId = (await createTeam(admin)).id; + }); + + test.afterAll(async ({ admin }) => { + for (const id of [websiteId, teamWebsiteId]) { + if (id) { + await deleteWebsite(admin, id); + } + } + + if (teamId) { + await deleteTeam(admin, teamId); + } + }); + + test('POST /api/websites creates a website for the current user', async ({ admin, seed }) => { + const name = uniqueName('website'); + const domain = uniqueDomain(); + const response = await admin.post('/api/websites', { name, domain }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ name, domain, userId: seed.admin.id, shareId: null }); + + websiteId = response.body.id; + }); + + test('POST /api/websites creates a website for a team', async ({ admin }) => { + const response = await admin.post('/api/websites', { + name: uniqueName('team-website'), + domain: uniqueDomain(), + teamId, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ teamId, userId: null }); + + teamWebsiteId = response.body.id; + }); + + test('POST /api/websites accepts a fixed id', async ({ admin }) => { + const id = randomUUID(); + const response = await admin.post('/api/websites', { + id, + name: uniqueName('website'), + domain: uniqueDomain(), + }); + + expect(response.status).toBe(200); + expect(response.body.id).toBe(id); + + await deleteWebsite(admin, id); + }); + + test('POST /api/websites validates the body', async ({ admin }) => { + const domain = await admin.post('/api/websites', { name: 'x', domain: 'not a domain' }); + const name = await admin.post('/api/websites', { domain: uniqueDomain() }); + + expect(domain.status).toBe(400); + expect(domain.body.error.code).toBe('bad-request'); + expect(name.status).toBe(400); + }); + + test('POST /api/websites requires a user that can create websites', async ({ api, viewer }) => { + const anonymous = await api.post('/api/websites', { name: 'x', domain: uniqueDomain() }); + const viewOnly = await viewer.post('/api/websites', { name: 'x', domain: uniqueDomain() }); + + expect(anonymous.status).toBe(401); + expect(viewOnly.status).toBe(401); + }); + + test('GET /api/websites lists the current user websites', async ({ admin, user }) => { + const response = await admin.get('/api/websites'); + const search = await admin.get('/api/websites', { params: { search: 'zzz-no-such-website' } }); + const other = await user.get('/api/websites'); + + expect(response.status).toBe(200); + expect(response.body.data.map((w: any) => w.id)).toContain(websiteId); + expect(response.body.data.map((w: any) => w.id)).not.toContain(teamWebsiteId); + expect(search.body.data).toHaveLength(0); + expect(other.body.data.map((w: any) => w.id)).not.toContain(websiteId); + }); + + test('GET /api/websites?includeTeams includes team websites', async ({ admin }) => { + const response = await admin.get('/api/websites', { params: { includeTeams: 'true' } }); + + expect(response.status).toBe(200); + expect(response.body.data.map((w: any) => w.id)).toContain(teamWebsiteId); + }); + + test('GET /api/websites/{websiteId} returns a website', async ({ + admin, + api, + viewer, + share, + seed, + }) => { + const response = await admin.get(`/api/websites/${websiteId}`); + const shared = await (await share()).get(`/api/websites/${seed.website.id}`); + const anonymous = await api.get(`/api/websites/${websiteId}`); + const denied = await viewer.get(`/api/websites/${websiteId}`); + + expect(response.status).toBe(200); + expect(response.body.id).toBe(websiteId); + expect(shared.status).toBe(200); + expect(shared.body.id).toBe(seed.website.id); + expect(anonymous.status).toBe(401); + expect(denied.status).toBe(401); + }); + + test('POST /api/websites/{websiteId} updates name and domain', async ({ admin, user }) => { + const name = uniqueName('renamed'); + const domain = uniqueDomain(); + const response = await admin.post(`/api/websites/${websiteId}`, { name, domain }); + const denied = await user.post(`/api/websites/${websiteId}`, { name }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ id: websiteId, name, domain }); + expect(denied.status).toBe(401); + }); + + test('POST /api/websites/{websiteId} manages the legacy share id', async ({ admin }) => { + const created = await admin.post(`/api/websites/${websiteId}`, { shareId: 'SPECSHARE1' }); + const removed = await admin.post(`/api/websites/${websiteId}`, { shareId: null }); + + expect(created.status).toBe(200); + expect(created.body.shareId).toBe('SPECSHARE1'); + expect(removed.status).toBe(200); + expect(removed.body.shareId).toBeNull(); + }); + + test('POST /api/websites/{websiteId} updates the recorder config', async ({ admin, api }) => { + const enabled = await admin.post(`/api/websites/${websiteId}`, { + replayConfig: { replayEnabled: true, sampleRate: 0.5, maskLevel: 'strict' }, + }); + const recorder = await api.get(`/api/websites/${websiteId}/recorder`); + const disabled = await admin.post(`/api/websites/${websiteId}`, { replayConfig: null }); + const recorderOff = await api.get(`/api/websites/${websiteId}/recorder`); + + expect(enabled.status).toBe(200); + expect(recorder.status).toBe(200); + expect(recorder.body).toMatchObject({ + enabled: true, + replayEnabled: true, + heatmapEnabled: false, + sampleRate: 0.5, + maskLevel: 'strict', + }); + expect(disabled.status).toBe(200); + expect(recorderOff.body).toEqual({ enabled: false }); + }); + + test('GET /api/websites/{websiteId}/recorder is public and CORS enabled', async ({ api }) => { + const response = await api.get(`/api/websites/${UNKNOWN_UUID}/recorder`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ enabled: false }); + expect(response.headers['access-control-allow-origin']).toBe('*'); + }); + + test('GET /api/websites/charts returns chart data for accessible websites', async ({ + admin, + viewer, + seed, + }) => { + const response = await admin.get('/api/websites/charts', { + params: { ids: `${seed.website.id},${seed.website2.id}` }, + }); + const denied = await viewer.get('/api/websites/charts', { params: { ids: seed.website.id } }); + const invalid = await admin.get('/api/websites/charts', { params: { ids: 'nope' } }); + + expect(response.status).toBe(200); + // `data` is keyed by website id; ids the caller cannot view are silently dropped. + expect(Object.keys(response.body.data).sort()).toEqual( + [seed.website.id, seed.website2.id].sort(), + ); + expect(denied.status).toBe(200); + expect(denied.body.data).toEqual({}); + expect(invalid.status).toBe(400); + }); + + test('GET /api/websites/{websiteId}/daterange returns the data window', async ({ + admin, + share, + viewer, + seed, + }) => { + const response = await admin.get(`/api/websites/${seed.website.id}/daterange`); + const shared = await (await share()).get(`/api/websites/${seed.website.id}/daterange`); + const denied = await viewer.get(`/api/websites/${seed.website.id}/daterange`); + + expect(response.status).toBe(200); + expect(new Date(response.body.startDate).getTime()).toBeGreaterThanOrEqual(seed.range.startAt); + expect(new Date(response.body.endDate).getTime()).toBeGreaterThanOrEqual( + new Date(response.body.startDate).getTime(), + ); + expect(shared.status).toBe(200); + expect(denied.status).toBe(401); + }); + + test('GET /api/websites/{websiteId}/export returns a zip archive', async ({ + admin, + share, + seed, + }) => { + const response = await admin.get(`/api/websites/${seed.website.id}/export`, { + params: dateRange(seed), + }); + const shared = await (await share()).get(`/api/websites/${seed.website.id}/export`, { + params: dateRange(seed), + }); + const missingRange = await admin.get(`/api/websites/${seed.website.id}/export`); + + expect(response.status).toBe(200); + // base64 of the "PK\x03\x04" zip signature + expect(response.body.zip).toMatch(/^UEsDB/); + expect(shared.status).toBe(401); + expect(missingRange.status).toBe(400); + }); + + test('POST /api/websites/{websiteId}/reset clears the website data', async ({ + admin, + api, + user, + }) => { + await api.post('/api/send', { + type: 'event', + payload: { website: websiteId, hostname: HOSTNAME, url: '/reset', ip: '10.0.9.3' }, + }); + + const denied = await user.post(`/api/websites/${websiteId}/reset`); + const response = await admin.post(`/api/websites/${websiteId}/reset`); + const website = await admin.get(`/api/websites/${websiteId}`); + + expect(denied.status).toBe(401); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(website.body.resetAt).toBeTruthy(); + }); + + test('POST /api/websites/{websiteId}/transfer moves a website between owners', async ({ + admin, + user, + seed, + }) => { + const toTeam = await admin.post(`/api/websites/${websiteId}/transfer`, { teamId }); + const toUser = await admin.post(`/api/websites/${websiteId}/transfer`, { + userId: seed.admin.id, + }); + const empty = await admin.post(`/api/websites/${websiteId}/transfer`, {}); + const denied = await user.post(`/api/websites/${websiteId}/transfer`, { userId: seed.user.id }); + + expect(toTeam.status).toBe(200); + expect(toTeam.body).toMatchObject({ teamId, userId: null }); + expect(toUser.status).toBe(200); + expect(toUser.body).toMatchObject({ teamId: null, userId: seed.admin.id }); + expect(empty.status).toBe(400); + expect(denied.status).toBe(401); + }); + + test('DELETE /api/websites/{websiteId} deletes a website', async ({ admin, user }) => { + const denied = await user.del(`/api/websites/${teamWebsiteId}`); + const response = await admin.del(`/api/websites/${websiteId}`); + const gone = await admin.get(`/api/websites/${websiteId}`); + + expect(denied.status).toBe(401); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(gone.body).toBeNull(); + + websiteId = ''; + }); +}); diff --git a/tsconfig.json b/tsconfig.json index a876a46bb..92ae5f450 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,5 +39,11 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules", "./playwright.config.ts", "tests/e2e"] + "exclude": [ + "node_modules", + "./playwright.config.ts", + "./playwright.api.config.ts", + "tests/e2e", + "tests/api" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index 23a84a672..8816a15a8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ }, test: { environment: 'jsdom', - include: ['src/**/*.test.{ts,tsx}'], + include: ['src/**/*.test.{ts,tsx}', 'tests/api/coverage/**/*.test.ts'], setupFiles: ['./src/test/setup.ts'], }, });