Skip to content
Open
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
146 changes: 146 additions & 0 deletions apps/backend/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import cookie from '@fastify/cookie';
import jwt from '@fastify/jwt';
import Fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { authRoutes } from '../routes/auth.js';

import type { PrismaClient } from '@prisma/client';

const mockUser = {
id: 'user-123',
username: 'octocat',
};

const prismaMock = {
user: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
oAuthToken: {
upsert: vi.fn(),
},
};

function mobileState(redirectUri: string): string {
const encodedRedirect = Buffer.from(redirectUri, 'utf8').toString('base64url');
return `mobile_login.${encodedRedirect}.nonce`;
}

async function buildApp() {

Check warning on line 30 in apps/backend/src/__tests__/auth.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const app = Fastify();
await app.register(jwt, { secret: 'test-secret' });
await app.register(cookie);
app.decorate('prisma', prismaMock as unknown as PrismaClient);
app.decorate('authenticate', async () => {});
await app.register(authRoutes, { prefix: '/auth' });
await app.ready();
return app;
}

describe('auth mobile OAuth redirects', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('BACKEND_URL', 'http://localhost:3000');
vi.stubEnv('PUBLIC_APP_URL', 'http://localhost:5173');
vi.stubEnv('MOBILE_REDIRECT_URI', 'devcard://auth/callback');
vi.stubEnv('GITHUB_CLIENT_ID', 'github-client-id');
vi.stubEnv('GITHUB_CLIENT_SECRET', 'github-client-secret');
vi.stubEnv('ENCRYPTION_KEY', '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef');
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({
json: vi.fn().mockResolvedValue({ access_token: 'github-token', scope: 'read:user' }),
})
.mockResolvedValueOnce({
json: vi.fn().mockResolvedValue({
id: 123,
login: 'octocat',
email: 'octocat@example.com',
name: 'Octo Cat',
avatar_url: 'https://example.com/avatar.png',
bio: null,
company: null,
}),
}));
prismaMock.user.upsert.mockResolvedValue(mockUser);
prismaMock.oAuthToken.upsert.mockResolvedValue({});
});

afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});

it('accepts an allowlisted mobile redirect URI', async () => {
const state = mobileState('devcard://auth/callback');
const app = await buildApp();

const res = await app.inject({
method: 'GET',
url: `/auth/github/callback?code=abc&state=${encodeURIComponent(state)}`,
headers: { cookie: `oauth_state=${state}` },
});

expect(res.statusCode).toBe(302);
expect(res.headers.location).toMatch(/^devcard:\/\/auth\/callback#token=/);
await app.close();
});

it('rejects an arbitrary https mobile redirect URI', async () => {
const state = mobileState('https://evil.example/callback');
const app = await buildApp();

const res = await app.inject({
method: 'GET',
url: `/auth/github/callback?code=abc&state=${encodeURIComponent(state)}`,
headers: { cookie: `oauth_state=${state}` },
});

expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({ error: 'Invalid mobile redirect URI' });
expect(fetch).not.toHaveBeenCalled();
expect(prismaMock.user.upsert).not.toHaveBeenCalled();
await app.close();
});

it('rejects a malformed mobile redirect URI', async () => {
const app = await buildApp();

const res = await app.inject({
method: 'GET',
url: '/auth/github?state=mobile_login&mobile_redirect_uri=not-a-uri',
});

expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({ error: 'Invalid mobile redirect URI' });
await app.close();
});

it('rejects an unknown mobile redirect scheme', async () => {
const app = await buildApp();

const res = await app.inject({
method: 'GET',
url: '/auth/github?state=mobile_login&mobile_redirect_uri=evil://auth/callback',
});

expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({ error: 'Invalid mobile redirect URI' });
await app.close();
});

it('preserves the existing web OAuth redirect flow', async () => {
const state = 'web_state.nonce';
const app = await buildApp();

const res = await app.inject({
method: 'GET',
url: `/auth/github/callback?code=abc&state=${encodeURIComponent(state)}`,
headers: { cookie: `oauth_state=${state}` },
});

expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe('http://localhost:5173/dashboard');
await app.close();
});
});
61 changes: 55 additions & 6 deletions apps/backend/src/__tests__/cards.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Fastify, { type FastifyInstance } from 'fastify';
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
import { describe, it, expect, beforeEach, vi } from 'vitest';

import { cardRoutes } from '../routes/cards.js';
Expand Down Expand Up @@ -48,15 +48,15 @@ const mockPrisma = {
// against the same mock client, preserving existing per-operation mocks.
function wireTransaction(): void {
mockPrisma.$transaction.mockImplementation(
async (callback: (tx: typeof mockPrisma) => Promise<unknown>) => callback(mockPrisma),
async (callback: (tx: typeof mockPrisma) => Promise<unknown>, _options?: unknown) => callback(mockPrisma),
);
}

async function buildApp():Promise<FastifyInstance> {
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
app.decorate('authenticate', async (request: any) => {
request.user = { id: USER_ID };
app.decorate('authenticate', async (request: FastifyRequest & { user?: { id: string } }) => {
(request as any).user = { id: USER_ID };
});
app.register(cardRoutes, { prefix: '/api/cards' });
await app.ready();
Expand Down Expand Up @@ -182,6 +182,55 @@ describe('POST /api/cards — link ownership validation', () => {

expect(res.statusCode).toBe(500);
});

it('wraps creation in a Serializable transaction to prevent race conditions', async () => {
mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]);
mockPrisma.card.count.mockResolvedValue(0);
mockPrisma.card.create.mockResolvedValue({ ...mockCard, cardLinks: [] });

const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/cards',
payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID] },
});

expect(res.statusCode).toBe(201);
expect(mockPrisma.$transaction).toHaveBeenCalledWith(
expect.any(Function),
{ isolationLevel: 'Serializable' }
);
});

it('retries the transaction on P2034 serialization failure', async () => {
mockPrisma.platformLink.findMany.mockResolvedValue([]);

// First attempt fails with P2034 (serialization conflict)
// Second attempt succeeds
const error = new Error('Serialization failure') as Error & { code: string };
error.code = 'P2034';

// We mock $transaction to fail once, then succeed
mockPrisma.$transaction
.mockRejectedValueOnce(error)
.mockImplementationOnce(
async (callback: (tx: typeof mockPrisma) => Promise<unknown>) => callback(mockPrisma)
);

mockPrisma.card.count.mockResolvedValue(1); // second attempt sees count > 0
mockPrisma.card.create.mockResolvedValue({ ...mockCard, isDefault: false, cardLinks: [] });

const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/cards',
payload: { title: 'Test Card', linkIds: [] },
});

expect(res.statusCode).toBe(201);
expect(res.json().isDefault).toBe(false);
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(2);
});
});

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -440,4 +489,4 @@ describe('PUT /api/cards/:id/default', () => {
expect(mockPrisma.card.updateMany).toHaveBeenCalled();
expect(mockPrisma.card.update).toHaveBeenCalled();
});
});
});
24 changes: 19 additions & 5 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { buildOAuthState, getMobileRedirectUri, isAllowedMobileRedirectUri } from '../services/authService.js';
import { encrypt } from '../utils/encryption.js';
import { buildOAuthState, getMobileRedirectUri } from '../services/authService.js';

import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';

const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const GITHUB_USER_URL = 'https://api.github.com/user';
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const GOOGLE_USER_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
const INVALID_MOBILE_REDIRECT_ERROR = 'Invalid mobile redirect URI';

interface OAuthCallbackQuery {
code: string;
state?: string;
}

export async function authRoutes(app: FastifyInstance) {

Check warning on line 19 in apps/backend/src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
// Developer login bypass (development only)
if (process.env.NODE_ENV !== 'production') {
app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => {
Expand All @@ -32,6 +34,9 @@
const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`;
const clientState = (request.query as any).state || '';
const mobileRedirectUri = (request.query as any).mobile_redirect_uri || '';
if (clientState.startsWith('mobile_') && mobileRedirectUri && !isAllowedMobileRedirectUri(mobileRedirectUri)) {
return reply.status(400).send({ error: INVALID_MOBILE_REDIRECT_ERROR });
}
const state = buildOAuthState(clientState, mobileRedirectUri);

reply.setCookie('oauth_state', state, {
Expand Down Expand Up @@ -66,6 +71,10 @@
if (!code) {
return reply.status(400).send({ error: 'Missing authorization code' });
}
const mobileRedirect = state?.startsWith('mobile_') ? getMobileRedirectUri(state) : null;
if (state?.startsWith('mobile_') && !mobileRedirect) {
return reply.status(400).send({ error: INVALID_MOBILE_REDIRECT_ERROR });
}

try {
const tokenRes = await fetch(GITHUB_TOKEN_URL, {
Expand Down Expand Up @@ -131,7 +140,6 @@
const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' });

if (request.query.state?.startsWith('mobile_')) {
const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI;
return reply.redirect(`${mobileRedirect}#token=${token}`);
}

Expand All @@ -155,6 +163,9 @@
const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`;
const clientState = (request.query as any).state || '';
const mobileRedirectUri = (request.query as any).mobile_redirect_uri || '';
if (clientState.startsWith('mobile_') && mobileRedirectUri && !isAllowedMobileRedirectUri(mobileRedirectUri)) {
return reply.status(400).send({ error: INVALID_MOBILE_REDIRECT_ERROR });
}
const state = buildOAuthState(clientState, mobileRedirectUri);

reply.setCookie('oauth_state', state, {
Expand Down Expand Up @@ -192,6 +203,10 @@
if (!code) {
return reply.status(400).send({ error: 'Missing authorization code' });
}
const mobileRedirect = state?.startsWith('mobile_') ? getMobileRedirectUri(state) : null;
if (state?.startsWith('mobile_') && !mobileRedirect) {
return reply.status(400).send({ error: INVALID_MOBILE_REDIRECT_ERROR });
}

try {
const tokenRes = await fetch(GOOGLE_TOKEN_URL, {
Expand Down Expand Up @@ -233,7 +248,6 @@
const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' });

if (request.query.state?.startsWith('mobile_')) {
const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI;
return reply.redirect(`${mobileRedirect}#token=${token}`);
}

Expand All @@ -257,7 +271,7 @@
const server = request.server as any;
if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return }
if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return }
try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) }
try { await request.jwtVerify() } catch (_e) { reply.status(401).send({ error: 'Unauthorized' }) }
}] }, async (request: FastifyRequest, reply: FastifyReply) => {
const userId = (request.user as any).id;
const user = await app.prisma.user.findUnique({
Expand Down
Loading