Skip to content
Merged
Show file tree
Hide file tree
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
21 changes: 21 additions & 0 deletions packages/api/src/auth/dev-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({})),
Expand Down Expand Up @@ -61,6 +65,7 @@ beforeEach(() => {
getUserByEmail.mockResolvedValue(Ok(null));
createUser.mockResolvedValue(Ok(devUser));
createSession.mockResolvedValue(Ok(undefined));
markOwnedOrganizationsInsider.mockResolvedValue(Ok(undefined));
});

describe('handleDevLoginRoute', () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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});
Expand All @@ -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);
});
Expand Down Expand Up @@ -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'))));

Expand Down
15 changes: 13 additions & 2 deletions packages/api/src/auth/dev-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
*/
Expand Down Expand Up @@ -48,6 +51,14 @@ export async function handleDevLoginRoute(req: Request, res: Response): Promise<
}

async function findOrCreateDevUser(): Promise<Result<CreatedUser, ApiError>> {
return Do(async ($) => {
const user = $(await getOrSignUpDevUser());
$(await markOwnedOrganizationsInsider(user.id));
return user;
});
}

async function getOrSignUpDevUser(): Promise<Result<CreatedUser, ApiError>> {
const existing = await getUserByEmail(DEV_USER_EMAIL);
if (existing.isErr()) {
return existing;
Expand Down
14 changes: 14 additions & 0 deletions packages/db/src/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,3 +456,17 @@ export async function markOrganizationOnboarded(orgId: string): Promise<Result<v
)
).map(() => 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<Result<void, ApiError>> {
return (
await wrapDb(() =>
db.organization.updateMany({
where: {ownerUserId: userId, insider: false},
data: {insider: true},
}),
)
).map(() => undefined);
}
Loading