mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Both cookies were read straight into database columns after nothing more than a format check. httpOnly keeps JavaScript out of them and does nothing about curl, so the anonymous id was a string the caller picked: enough to write a first-touch row for a visitor who never existed, to file it under a channel of their choosing, and to claim that id's events at signup, since the backfill matches on the id alone. They are now signed with an HMAC over AUTH_SECRET, through Web Crypto rather than node:crypto because the proxy runs on the edge and the pages that read the cookies back run in Node. The first-touch body moved to base64url on the way: cookie values are percent-encoded and decoded by several layers that do not agree on how many times, and a payload carrying its own percent escapes comes back subtly different and takes the signature with it. Signing stops a caller choosing an id, not collecting one, since dropping the cookie and asking for the landing page again mints another. So the bot and prefetch filters moved to where the rows are written rather than only where the cookies are issued, which also fixes a returning visitor's prefetch of /register recording a signup start, and a per-client hourly ceiling now sits in front of the write. The ceiling is skipped when TRUSTED_PROXY_MODE is unset, where every caller resolves to 127.0.0.1 and the bucket would empty on real traffic long before it emptied on a flood. Four smaller things around it: - /api/events checked the flag and the origin after paying for a rate-limit write, so a host who never turned analytics on was still writing a row per anonymous POST. Both checks are free and now come first, and the limiter answers 204 rather than 429: a beacon has nobody to tell, and a flooder should not be handed the reset time. - /api/onboarding/source was keyed by IP on an authenticated route. Without TRUSTED_PROXY_MODE that is five answers an hour for the whole deployment, and with it a shared office address locks out everyone after one colleague answered. Keyed by account, like /api/onboarding/complete beside it. - The cookies took their Secure flag from request.nextUrl.protocol, which behind a TLS-terminating reverse proxy is the container-internal http address. It comes off the configured public origin now. - sanitizeLandingPath took anything that started with a slash, including from the cookie, so a hand-written one could put newlines and markup into a column an admin table may render one day. Also: the paid-account query had no LIMIT and returned every active account's name and email, the growth route answered 403 where it meant 401, and the schema claimed no free text is stored when self_reported_note holds 200 characters of it.
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
// The property the whole acquisition system rests on: every funnel event is
|
|
// recorded exactly once, against the right account, and nothing at all is
|
|
// recorded when the feature flag is off.
|
|
//
|
|
// The dedupe key is a UNIQUE column, so these tests are checking that each call
|
|
// site derives the right key. A key that varies when it should not shows up here
|
|
// as a duplicated row, which is the failure that would quietly inflate the
|
|
// scoreboard.
|
|
|
|
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
|
import { db } from '@/lib/db';
|
|
import { POST as beacon } from '@/app/api/events/route';
|
|
import { POST as register } from '@/app/api/auth/register/route';
|
|
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
|
|
import { recordSignupCompleted } from '@/lib/analytics/signup';
|
|
import { signAnonymousId, signFirstTouch, type FirstTouch } from '@/lib/analytics/cookies';
|
|
import { apiRequest, callRoute } from '../helpers/request';
|
|
import { signedOut } from '../helpers/session';
|
|
import { createUser } from '../factories';
|
|
|
|
const ANON_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
|
|
const INVITE_CODE = 'test-invite';
|
|
const ORIGIN = 'http://localhost:3000';
|
|
const SECRET = 'analytics-test-secret';
|
|
const BROWSER_UA =
|
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36';
|
|
|
|
const GITHUB_TOUCH: FirstTouch = {
|
|
channel: 'GITHUB',
|
|
utmSource: 'github',
|
|
utmMedium: 'readme',
|
|
utmCampaign: null,
|
|
referrerHost: 'github.com',
|
|
landingPath: '/',
|
|
};
|
|
|
|
/** What the proxy would have set. Signed, because nothing downstream trusts anything else. */
|
|
async function visitorCookies(anonymousId = ANON_ID, touch: FirstTouch = GITHUB_TOUCH) {
|
|
return {
|
|
of_aid: (await signAnonymousId(anonymousId)) ?? '',
|
|
of_ft: (await signFirstTouch(touch)) ?? '',
|
|
};
|
|
}
|
|
|
|
async function beaconRequest(options?: {
|
|
name?: string;
|
|
origin?: string | null;
|
|
userAgent?: string | null;
|
|
cookies?: Record<string, string>;
|
|
}) {
|
|
const headers: Record<string, string> = {};
|
|
const origin = options?.origin === undefined ? ORIGIN : options.origin;
|
|
if (origin) headers.origin = origin;
|
|
const userAgent = options?.userAgent === undefined ? BROWSER_UA : options.userAgent;
|
|
if (userAgent) headers['user-agent'] = userAgent;
|
|
|
|
return apiRequest('/api/events', {
|
|
body: { name: options?.name ?? 'cta_clicked' },
|
|
headers,
|
|
cookies: options?.cookies ?? (await visitorCookies()),
|
|
});
|
|
}
|
|
|
|
async function eventNames(): Promise<string[]> {
|
|
const rows = await db.analyticsEvent.findMany({ orderBy: { dedupeKey: 'asc' } });
|
|
return rows.map((row) => row.name);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
signedOut();
|
|
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
|
vi.stubEnv('NEXTAUTH_SECRET', SECRET);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe('POST /api/events', () => {
|
|
it('records a CTA click and the first touch behind it', async () => {
|
|
const response = await callRoute(beacon, await beaconRequest());
|
|
|
|
expect(response.status).toBe(204);
|
|
|
|
const events = await db.analyticsEvent.findMany();
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]).toMatchObject({
|
|
name: 'CTA_CLICKED',
|
|
anonymousId: ANON_ID,
|
|
channel: 'GITHUB',
|
|
userId: null,
|
|
});
|
|
|
|
const touches = await db.acquisitionTouch.findMany();
|
|
expect(touches).toHaveLength(1);
|
|
expect(touches[0]).toMatchObject({
|
|
anonymousId: ANON_ID,
|
|
channel: 'GITHUB',
|
|
utmSource: 'github',
|
|
referrerHost: 'github.com',
|
|
});
|
|
});
|
|
|
|
it('records one event however many times the same visitor clicks', async () => {
|
|
await callRoute(beacon, await beaconRequest());
|
|
await callRoute(beacon, await beaconRequest());
|
|
await callRoute(beacon, await beaconRequest());
|
|
|
|
expect(await db.analyticsEvent.count()).toBe(1);
|
|
});
|
|
|
|
it('counts two different visitors separately', async () => {
|
|
await callRoute(beacon, await beaconRequest());
|
|
await callRoute(
|
|
beacon,
|
|
await beaconRequest({ cookies: await visitorCookies('f0e1d2c3b4a596877869504132231415') })
|
|
);
|
|
|
|
expect(await db.analyticsEvent.count()).toBe(2);
|
|
});
|
|
|
|
it('never keeps the first touch of a visitor who came back through another link', async () => {
|
|
await callRoute(beacon, await beaconRequest());
|
|
await callRoute(
|
|
beacon,
|
|
await beaconRequest({
|
|
cookies: await visitorCookies(ANON_ID, {
|
|
...GITHUB_TOUCH,
|
|
channel: 'GOOGLE',
|
|
utmSource: null,
|
|
}),
|
|
})
|
|
);
|
|
|
|
const touches = await db.acquisitionTouch.findMany();
|
|
expect(touches).toHaveLength(1);
|
|
expect(touches[0]?.channel).toBe('GITHUB');
|
|
});
|
|
|
|
it('refuses to record an event name the beacon does not own', async () => {
|
|
// Without this the endpoint would let any anonymous caller write a payment
|
|
// into the funnel.
|
|
for (const name of ['subscription_started', 'signup_completed', 'SUBSCRIPTION_STARTED', '']) {
|
|
const response = await callRoute(beacon, await beaconRequest({ name }));
|
|
expect(response.status, name).toBe(204);
|
|
}
|
|
|
|
expect(await db.analyticsEvent.count()).toBe(0);
|
|
});
|
|
|
|
it('ignores a cross-origin caller', async () => {
|
|
const response = await callRoute(
|
|
beacon,
|
|
await beaconRequest({ origin: 'https://evil.example' })
|
|
);
|
|
|
|
expect(response.status).toBe(204);
|
|
expect(await db.analyticsEvent.count()).toBe(0);
|
|
});
|
|
|
|
it('ignores a caller with no anonymous id cookie', async () => {
|
|
await callRoute(beacon, await beaconRequest({ cookies: {} }));
|
|
|
|
expect(await db.analyticsEvent.count()).toBe(0);
|
|
expect(await db.acquisitionTouch.count()).toBe(0);
|
|
});
|
|
|
|
it('ignores a hand-written cookie, whatever channel it claims', async () => {
|
|
// httpOnly stops JavaScript, not curl. Without the signature this is a
|
|
// visitor of the caller's choosing, filed under a channel of their choosing,
|
|
// and every row on the scoreboard is theirs to write.
|
|
await callRoute(
|
|
beacon,
|
|
await beaconRequest({
|
|
cookies: {
|
|
of_aid: 'deadbeefdeadbeefdeadbeefdeadbeef',
|
|
of_ft: encodeURIComponent(JSON.stringify({ c: 'GITHUB', p: '/' })),
|
|
},
|
|
})
|
|
);
|
|
|
|
expect(await db.analyticsEvent.count()).toBe(0);
|
|
expect(await db.acquisitionTouch.count()).toBe(0);
|
|
});
|
|
|
|
it('ignores a cookie signed by another deployment', async () => {
|
|
const cookies = await visitorCookies();
|
|
vi.stubEnv('NEXTAUTH_SECRET', 'some-other-secret');
|
|
|
|
await callRoute(beacon, await beaconRequest({ cookies }));
|
|
|
|
expect(await db.analyticsEvent.count()).toBe(0);
|
|
});
|
|
|
|
it('ignores a script that sends no user agent', async () => {
|
|
await callRoute(beacon, await beaconRequest({ userAgent: null }));
|
|
|
|
expect(await db.analyticsEvent.count()).toBe(0);
|
|
});
|
|
|
|
it('writes nothing at all when the flag is off', async () => {
|
|
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
|
|
|
|
const response = await callRoute(beacon, await beaconRequest());
|
|
|
|
expect(response.status).toBe(204);
|
|
expect(await db.analyticsEvent.count()).toBe(0);
|
|
expect(await db.acquisitionTouch.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('signup attribution', () => {
|
|
async function registerWithCookies(email: string) {
|
|
return callRoute(
|
|
register,
|
|
apiRequest('/api/auth/register', {
|
|
body: {
|
|
name: 'New User',
|
|
email,
|
|
password: 'correct horse battery',
|
|
inviteCode: INVITE_CODE,
|
|
},
|
|
headers: { 'user-agent': BROWSER_UA },
|
|
cookies: await visitorCookies(),
|
|
})
|
|
);
|
|
}
|
|
|
|
it('copies the first touch onto the account and records the signup once', async () => {
|
|
const response = await registerWithCookies('[email protected]');
|
|
expect(response.status).toBe(201);
|
|
|
|
const user = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
|
|
const acquisition = await db.userAcquisition.findUniqueOrThrow({
|
|
where: { userId: user.id },
|
|
});
|
|
|
|
expect(acquisition).toMatchObject({
|
|
channel: 'GITHUB',
|
|
utmSource: 'github',
|
|
utmMedium: 'readme',
|
|
referrerHost: 'github.com',
|
|
anonymousId: ANON_ID,
|
|
});
|
|
|
|
const signups = await db.analyticsEvent.findMany({ where: { name: 'SIGNUP_COMPLETED' } });
|
|
expect(signups).toHaveLength(1);
|
|
expect(signups[0]?.userId).toBe(user.id);
|
|
});
|
|
|
|
it('claims the events the visitor produced before they had an account', async () => {
|
|
await callRoute(beacon, await beaconRequest());
|
|
await registerWithCookies('[email protected]');
|
|
|
|
const user = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
|
|
const click = await db.analyticsEvent.findFirstOrThrow({ where: { name: 'CTA_CLICKED' } });
|
|
|
|
// Without the backfill the click and the signup are two unrelated rows and
|
|
// no query can tell you which channel converted.
|
|
expect(click.userId).toBe(user.id);
|
|
});
|
|
|
|
it('records one signup even if the helper runs twice', async () => {
|
|
const user = await createUser();
|
|
|
|
await recordSignupCompleted({
|
|
userId: user.id,
|
|
visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH, clientIp: null },
|
|
});
|
|
await recordSignupCompleted({
|
|
userId: user.id,
|
|
visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH, clientIp: null },
|
|
});
|
|
|
|
expect(await db.analyticsEvent.count({ where: { name: 'SIGNUP_COMPLETED' } })).toBe(1);
|
|
expect(await db.userAcquisition.count({ where: { userId: user.id } })).toBe(1);
|
|
});
|
|
|
|
it('records no acquisition row when the flag is off', async () => {
|
|
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
|
|
|
|
const response = await registerWithCookies('[email protected]');
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(await db.userAcquisition.count()).toBe(0);
|
|
expect(await db.analyticsEvent.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('subscription transitions', () => {
|
|
const SUB = 'sub_test_1';
|
|
const periodEnd = new Date('2026-09-01T00:00:00.000Z');
|
|
|
|
async function transition(params: {
|
|
userId: string;
|
|
beforeStatus: 'FREE' | 'TRIALING' | 'ACTIVE' | 'CANCELED';
|
|
afterStatus: 'FREE' | 'TRIALING' | 'ACTIVE' | 'CANCELED';
|
|
beforeCancelAtPeriodEnd?: boolean;
|
|
afterCancelAtPeriodEnd?: boolean;
|
|
hadTrial?: boolean;
|
|
trialEndsAt?: Date | null;
|
|
}) {
|
|
await recordSubscriptionTransition({
|
|
userId: params.userId,
|
|
subscriptionId: SUB,
|
|
before: {
|
|
status: params.beforeStatus,
|
|
cancelAtPeriodEnd: params.beforeCancelAtPeriodEnd ?? false,
|
|
hadTrial: params.hadTrial ?? false,
|
|
},
|
|
after: {
|
|
status: params.afterStatus,
|
|
cancelAtPeriodEnd: params.afterCancelAtPeriodEnd ?? false,
|
|
trialEndsAt: params.trialEndsAt ?? null,
|
|
currentPeriodEnd: periodEnd,
|
|
},
|
|
});
|
|
}
|
|
|
|
it('records the trial once and the conversion to paid once', async () => {
|
|
const user = await createUser();
|
|
|
|
await transition({
|
|
userId: user.id,
|
|
beforeStatus: 'FREE',
|
|
afterStatus: 'TRIALING',
|
|
trialEndsAt: new Date('2026-08-15T00:00:00.000Z'),
|
|
});
|
|
// The same webhook arriving again, which Stripe does routinely.
|
|
await transition({
|
|
userId: user.id,
|
|
beforeStatus: 'FREE',
|
|
afterStatus: 'TRIALING',
|
|
trialEndsAt: new Date('2026-08-15T00:00:00.000Z'),
|
|
});
|
|
await transition({
|
|
userId: user.id,
|
|
beforeStatus: 'TRIALING',
|
|
afterStatus: 'ACTIVE',
|
|
hadTrial: true,
|
|
});
|
|
|
|
expect(await eventNames()).toEqual(['SUBSCRIPTION_STARTED', 'TRIAL_STARTED']);
|
|
});
|
|
|
|
it('does not record a second trial for an account that already had one', async () => {
|
|
const user = await createUser();
|
|
|
|
await transition({
|
|
userId: user.id,
|
|
beforeStatus: 'CANCELED',
|
|
afterStatus: 'TRIALING',
|
|
hadTrial: true,
|
|
trialEndsAt: new Date('2026-08-15T00:00:00.000Z'),
|
|
});
|
|
|
|
expect(await eventNames()).toEqual([]);
|
|
});
|
|
|
|
it('counts one cancellation for the flag and the status that follow each other', async () => {
|
|
const user = await createUser();
|
|
|
|
// The customer cancels in the portal: cancel_at_period_end flips on.
|
|
await transition({
|
|
userId: user.id,
|
|
beforeStatus: 'ACTIVE',
|
|
afterStatus: 'ACTIVE',
|
|
afterCancelAtPeriodEnd: true,
|
|
});
|
|
// The term ends weeks later and Stripe marks the subscription canceled.
|
|
await transition({
|
|
userId: user.id,
|
|
beforeStatus: 'ACTIVE',
|
|
afterStatus: 'CANCELED',
|
|
beforeCancelAtPeriodEnd: true,
|
|
});
|
|
|
|
expect(await db.analyticsEvent.count({ where: { name: 'SUBSCRIPTION_CANCELED' } })).toBe(1);
|
|
});
|
|
|
|
it('records a reactivation when the customer changes their mind', async () => {
|
|
const user = await createUser();
|
|
|
|
await transition({
|
|
userId: user.id,
|
|
beforeStatus: 'ACTIVE',
|
|
afterStatus: 'ACTIVE',
|
|
afterCancelAtPeriodEnd: true,
|
|
});
|
|
await transition({
|
|
userId: user.id,
|
|
beforeStatus: 'ACTIVE',
|
|
afterStatus: 'ACTIVE',
|
|
beforeCancelAtPeriodEnd: true,
|
|
afterCancelAtPeriodEnd: false,
|
|
});
|
|
|
|
expect(await eventNames()).toEqual(['SUBSCRIPTION_CANCELED', 'SUBSCRIPTION_REACTIVATED']);
|
|
});
|
|
|
|
it('records nothing when nothing changed', async () => {
|
|
const user = await createUser();
|
|
|
|
await transition({ userId: user.id, beforeStatus: 'ACTIVE', afterStatus: 'ACTIVE' });
|
|
|
|
expect(await eventNames()).toEqual([]);
|
|
});
|
|
|
|
it('writes nothing when the flag is off', async () => {
|
|
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
|
|
const user = await createUser();
|
|
|
|
await transition({ userId: user.id, beforeStatus: 'FREE', afterStatus: 'ACTIVE' });
|
|
|
|
expect(await db.analyticsEvent.count()).toBe(0);
|
|
});
|
|
});
|