diff --git a/packages/api/src/auth/dev-login.test.ts b/packages/api/src/auth/dev-login.test.ts index 4230d2af..9ad43930 100644 --- a/packages/api/src/auth/dev-login.test.ts +++ b/packages/api/src/auth/dev-login.test.ts @@ -25,6 +25,10 @@ mock.module('@proxy/db/user', () => ({ mock.module('../orgs/provision', () => ({signUp: createUser})); +const markOwnedOrganizationsInsider = mock(); + +mock.module('@proxy/db/organization', () => ({markOwnedOrganizationsInsider})); + mock.module('../observability/log', () => ({ log: {debug: mock(), info: mock(), warn: mock(), error: mock()}, serializeError: mock(() => ({})), @@ -61,6 +65,7 @@ beforeEach(() => { getUserByEmail.mockResolvedValue(Ok(null)); createUser.mockResolvedValue(Ok(devUser)); createSession.mockResolvedValue(Ok(undefined)); + markOwnedOrganizationsInsider.mockResolvedValue(Ok(undefined)); }); describe('handleDevLoginRoute', () => { @@ -86,6 +91,7 @@ describe('handleDevLoginRoute', () => { expect(createUserArgs.email).toBe('dev@dev.proxy.local'); expect(createUserArgs.name).toBe('Dev User'); expect(createUserArgs.emailVerified).toBeInstanceOf(Date); + expect(markOwnedOrganizationsInsider).toHaveBeenCalledWith(devUser.id); const sessionArgs = createSession.mock.calls[0]?.[0] as {tokenHash: string; userId: string; expires: Date}; expect(sessionArgs.userId).toBe(devUser.id); @@ -115,6 +121,7 @@ describe('handleDevLoginRoute', () => { await handleDevLoginRoute({body: {}, headers: {}} as never, res as never); expect(createUser).not.toHaveBeenCalled(); + expect(markOwnedOrganizationsInsider).toHaveBeenCalledWith(devUser.id); const sessionArgs = createSession.mock.calls[0]?.[0] as {userId: string}; expect(sessionArgs.userId).toBe(devUser.id); expect(res.json).toHaveBeenCalledWith({ok: true}); @@ -130,6 +137,7 @@ describe('handleDevLoginRoute', () => { expect(getUserByEmail).not.toHaveBeenCalled(); const createUserArgs = createUser.mock.calls[0]?.[0] as {email: string}; expect(createUserArgs.email).toMatch(/^dev-[0-9a-f]{16}@dev\.proxy\.local$/); + expect(markOwnedOrganizationsInsider).not.toHaveBeenCalled(); const sessionArgs = createSession.mock.calls[0]?.[0] as {userId: string}; expect(sessionArgs.userId).toBe(throwawayUser.id); }); @@ -160,6 +168,19 @@ describe('handleDevLoginRoute', () => { expect(res.cookies).toHaveLength(0); }); + test('insider marking failure → 500, no session', async () => { + getUserByEmail.mockResolvedValue(Ok(devUser)); + markOwnedOrganizationsInsider.mockResolvedValue(Err(ApiErr.dbError(new Error('boom')))); + + const {handleDevLoginRoute} = await import('./dev-login'); + const res = makeRes(); + await handleDevLoginRoute({body: {}, headers: {}} as never, res as never); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({error: 'user_creation_failed'}); + expect(createSession).not.toHaveBeenCalled(); + }); + test('session creation failure → 500, no cookie', async () => { createSession.mockResolvedValue(Err(ApiErr.dbError(new Error('boom')))); diff --git a/packages/api/src/auth/dev-login.ts b/packages/api/src/auth/dev-login.ts index 44302722..68501e79 100644 --- a/packages/api/src/auth/dev-login.ts +++ b/packages/api/src/auth/dev-login.ts @@ -3,8 +3,9 @@ import {randomBytes} from 'node:crypto'; import type {Request, Response} from 'express'; import {Ok, type Result} from 'ts-results-es'; +import {markOwnedOrganizationsInsider} from '@proxy/db/organization'; import {type CreatedUser, getUserByEmail} from '@proxy/db/user'; -import type {ApiError} from '@proxy/utils'; +import {type ApiError, Do} from '@proxy/utils'; import {log} from '../observability/log'; import {signUp} from '../orgs/provision'; @@ -17,7 +18,9 @@ const DEV_USER_EMAIL = `dev@${DEV_USER_EMAIL_DOMAIN}`; /** * `POST /auth/dev/login` — signs the browser in as the dev user, made on its first sign-in and the - * same one every time after, skipping Google and the magic link. With `{fresh: true}` it makes a + * same one every time after, skipping Google and the magic link. Every organization the dev user + * owns is made an insider on each sign-in, so it has every experiment and its personas' model to + * choose, one made since too. With `{fresh: true}` it makes a * throwaway user instead, whose organization still has the welcome flow ahead of it, as the screen * map's crawl needs. Any NODE_ENV but development gets a 404, as if the route did not exist. */ @@ -48,6 +51,14 @@ export async function handleDevLoginRoute(req: Request, res: Response): Promise< } async function findOrCreateDevUser(): Promise> { + return Do(async ($) => { + const user = $(await getOrSignUpDevUser()); + $(await markOwnedOrganizationsInsider(user.id)); + return user; + }); +} + +async function getOrSignUpDevUser(): Promise> { const existing = await getUserByEmail(DEV_USER_EMAIL); if (existing.isErr()) { return existing; diff --git a/packages/db/src/organization.ts b/packages/db/src/organization.ts index 6ad95439..bcdf0edc 100644 --- a/packages/db/src/organization.ts +++ b/packages/db/src/organization.ts @@ -456,3 +456,17 @@ export async function markOrganizationOnboarded(orgId: string): Promise undefined); } + +/** Makes every organization the person owns an insider; development's dev sign-in, never the app's. */ +export async function markOwnedOrganizationsInsider( + userId: string, +): Promise> { + return ( + await wrapDb(() => + db.organization.updateMany({ + where: {ownerUserId: userId, insider: false}, + data: {insider: true}, + }), + ) + ).map(() => undefined); +}