Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions __tests__/activation-privacy-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Privacy contract test for RolePatch activation events.
//
// The fleet 4-event taxonomy (`signup`, `activated`, `core_action`,
// `returned`) is the only channel through which RolePatch may report
// activation evidence to PostHog / Foundry. Resume text, job descriptions,
// stash content, cover letters, fit-score payloads, contact bodies, and
// credentials must never enter those events.
//
// This is a source-level contract test: it reads the analytics module and
// every activation call site as text and asserts that:
// 1. The `AnalyticsEventMap` only declares `project_id` (and `action` for
// `core_action`) — no payload fields for resume/JD/stash/cover-letter.
// 2. Every `trackActivated` / `trackCoreAction` call site passes only the
// allowed arguments (action enum + optional distinctId).
// 3. No resume/JD/stash/cover-letter variable is passed into `trackEvent`
// or `emit`.
//
// A future regression that adds `trackCoreAction('tailor_completed', resume)`
// or extends the event map with a `resume` field will fail this test before
// it ships.

import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';

import { describe, expect, it } from 'vitest';

const ROOT = resolve(__dirname, '..');

const ACTIVATION_FILES = [
'src/lib/actions/tailor-action.ts',
'src/lib/actions/cover-letter-action.ts',
'src/lib/actions/fit-score-action.ts',
];

async function read(relativePath: string): Promise<string> {
return readFile(resolve(ROOT, relativePath), 'utf8');
}

describe('analytics event map only declares sanitized fields', () => {
it('AnalyticsEventMap carries only project_id (and action for core_action)', async () => {
const analytics = await read('src/lib/analytics.ts');
// The event map must declare project_id for every event and action only
// for core_action. No resume/JD/stash/cover-letter/fit-score payload fields.
expect(analytics).toMatch(/interface AnalyticsEventMap/);
expect(analytics).toMatch(/signup:\s*\{\s*project_id:\s*typeof PROJECT\s*\}/);
expect(analytics).toMatch(/activated:\s*\{\s*project_id:\s*typeof PROJECT\s*\}/);
expect(analytics).toMatch(
/core_action:\s*\{\s*project_id:\s*typeof PROJECT;\s*action:\s*CoreAction\s*\}/
);
expect(analytics).toMatch(/returned:\s*\{\s*project_id:\s*typeof PROJECT\s*\}/);
// Extract just the AnalyticsEventMap block and assert it carries no
// payload fields beyond project_id / action. The surrounding file
// legitimately mentions "resume-tailor" (project slug) and
// "cover_letter_generated" (action enum) — those are not payload fields.
const mapBlock = analytics.match(/interface AnalyticsEventMap\s*\{([\s\S]*?)\}/)?.[1] ?? '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fix AnalyticsEventMap extraction to scan the whole interface

When AnalyticsEventMap contains object-shaped members, this non-greedy regex stops at the first } inside the interface, which is the end of the signup property type. That means the negative field scan never sees activated, core_action, returned, or any later added event, so a future extra: { project_id: typeof PROJECT; resume: string } declaration after signup would still pass this contract test even though it violates the 4-event privacy contract.

Useful? React with 👍 / 👎.

expect(mapBlock).toBeTruthy();
for (const field of [
'resume',
'jd',
'stash',
'coverLetter',
'cover_letter',
'fitScore',
'fit_score',
'companyResearch',
'company_research',
'contact',
'email',
'phone',
]) {
expect(mapBlock).not.toMatch(new RegExp(`\\b${field}\\b`));
}
});

it('CoreAction enum is closed to the three product verbs', async () => {
const analytics = await read('src/lib/analytics.ts');
expect(analytics).toMatch(
/export type CoreAction = 'tailor_completed' \| 'cover_letter_generated' \| 'fit_score_run'/
);
});
});

describe('activation call sites pass only sanitized arguments', () => {
it.each(
ACTIVATION_FILES
)('%s calls trackActivated/trackCoreAction without private payloads', async (file) => {
const source = await read(file);
// Every trackCoreAction call must pass only the action enum + optional
// `userId` distinctId. The regex is strict: only `userId` (or
// `userId ?? undefined`) is permitted as the second argument — any other
// variable (resumeSource, jdText, stashContent, etc.) fails the match.
const coreActionCalls = source.match(/trackCoreAction\([^)]*\)/g) ?? [];
expect(coreActionCalls.length).toBeGreaterThan(0);
for (const call of coreActionCalls) {
expect(call).toMatch(
/^trackCoreAction\(['"][a-z_]+['"](?:,\s*userId(?:\s*\?\?\s*undefined)?)?\)$/
);
}
// trackActivated calls (only in tailor-action) must pass only `userId`.
const activatedCalls = source.match(/trackActivated\([^)]*\)/g) ?? [];
for (const call of activatedCalls) {
expect(call).toMatch(/^trackActivated\(userId\)$/);
}
});
});

describe('trackEvent signature does not accept raw private payloads', () => {
it('trackEvent accepts event + properties + distinctId only', async () => {
const analytics = await read('src/lib/analytics.ts');
expect(analytics).toMatch(
/export function trackEvent\(\s*event:\s*string,\s*properties:\s*Record<string,\s*unknown>\s*=\s*\{\},\s*distinctId\?:\s*string\s*\)/
);
Comment on lines +110 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make trackEvent test reject arbitrary properties

This assertion currently blesses properties: Record<string, unknown>, which is exactly what allows a direct call such as trackEvent('core_action', { resume: resumeSource }) to compile and pass the suite. Since the test does not scan direct trackEvent call sites for private keys, it does not enforce the stated privacy contract that raw private payloads must not enter analytics events.

Useful? React with 👍 / 👎.

});
});