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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ jobs:
- name: Install backend dependencies
run: npm --prefix apps/backend install

- name: Generate Prisma client
run: cd apps/backend && pnpm prisma generate

- name: Backend lint
id: backend_lint
continue-on-error: true
Expand Down
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"postinstall": "prisma generate",
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
Expand Down
156 changes: 156 additions & 0 deletions apps/backend/src/__tests__/auth.github-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import cookie from '@fastify/cookie';
import Fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

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

// Mock the encrypt import used directly in auth.ts
vi.mock('../utils/encryption.js', () => ({
encrypt: vi.fn((token: string) => `encrypted:${token}`),
}));

const githubUser = {
id: 12345,
login: 'octocat',
email: 'octocat@example.com',
name: 'Octo Cat',
avatar_url: 'https://github.com/images/error/octocat_happy.gif',
};

function mockGitHubResponses(scope: string) {

Check warning on line 20 in apps/backend/src/__tests__/auth.github-token.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
vi.mocked(fetch)
.mockResolvedValueOnce({
json: async () => ({ access_token: 'github-login-token', scope }),
} as Response)
.mockResolvedValueOnce({
json: async () => githubUser,
} as Response);
}

// Convenience: inject the callback URL with the oauth_state cookie pre-set
// so the upstream CSRF check passes without affecting the token-preservation logic.
const CALLBACK_STATE = 'mobile_github';
const CALLBACK_COOKIE = `oauth_state=${CALLBACK_STATE}`;

async function buildApp(existingToken: { scopes: string } | null) {

Check warning on line 35 in apps/backend/src/__tests__/auth.github-token.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const app = Fastify({ logger: false });
await app.register(cookie); // required for request.cookies (CSRF check)

const findUniqueToken = vi.fn().mockResolvedValue(existingToken);
const upsertToken = vi.fn().mockResolvedValue({});
const upsertUser = vi.fn().mockResolvedValue({
id: 'user-1',
username: githubUser.login,
});
const sign = vi.fn().mockReturnValue('jwt-token');

app.decorate('prisma', {
user: {
upsert: upsertUser,
},
oAuthToken: {
findUnique: findUniqueToken,
upsert: upsertToken,
},
} as any);
app.decorate('jwt', { sign } as any);
app.decorate('authenticate', async (request: any) => {
request.user = { id: 'user-1' };
});

await app.register(authRoutes, { prefix: '/auth' });
await app.ready();

return { app, findUniqueToken, upsertToken, upsertUser, sign };
}

describe('GitHub OAuth token persistence', () => {
beforeEach(() => {
process.env.BACKEND_URL = 'https://api.example.com';
process.env.PUBLIC_APP_URL = 'https://app.example.com';
process.env.MOBILE_REDIRECT_URI = 'devcard://auth';
process.env.GITHUB_CLIENT_ID = 'github-client-id';
process.env.GITHUB_CLIENT_SECRET = 'github-client-secret';
vi.stubGlobal('fetch', vi.fn());
});

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

it('preserves an existing follow-capable token when GitHub login returns reduced scopes', async () => {
mockGitHubResponses('read:user,user:email');
const { app, findUniqueToken, upsertToken, sign } = await buildApp({ scopes: 'user:follow read:user' });

const response = await app.inject({
method: 'GET',
url: `/auth/github/callback?code=login-code&state=${CALLBACK_STATE}`,
headers: { Cookie: CALLBACK_COOKIE },
});

expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe('devcard://auth#token=jwt-token');
expect(sign).toHaveBeenCalledWith(
{ id: 'user-1', username: githubUser.login },
{ expiresIn: '30d' }
);
expect(findUniqueToken).toHaveBeenCalledWith({
where: { userId_platform: { userId: 'user-1', platform: 'github' } },
select: { scopes: true },
});
expect(upsertToken).not.toHaveBeenCalled();

await app.close();
});

it('stores a GitHub login token when no integration token exists', async () => {
mockGitHubResponses('read:user,user:email');
const { app, upsertToken } = await buildApp(null);

const response = await app.inject({
method: 'GET',
url: `/auth/github/callback?code=login-code&state=${CALLBACK_STATE}`,
headers: { Cookie: CALLBACK_COOKIE },
});

expect(response.statusCode).toBe(302);
expect(upsertToken).toHaveBeenCalledWith({
where: { userId_platform: { userId: 'user-1', platform: 'github' } },
update: { accessToken: 'encrypted:github-login-token', scopes: 'read:user,user:email' },
create: {
userId: 'user-1',
platform: 'github',
accessToken: 'encrypted:github-login-token',
scopes: 'read:user,user:email',
},
});

await app.close();
});

it('allows a GitHub token replacement when the new token keeps follow scope', async () => {
mockGitHubResponses('read:user,user:email,user:follow');
const { app, upsertToken } = await buildApp({ scopes: 'read:user user:email' });

const response = await app.inject({
method: 'GET',
url: `/auth/github/callback?code=login-code&state=${CALLBACK_STATE}`,
headers: { Cookie: CALLBACK_COOKIE },
});

expect(response.statusCode).toBe(302);
expect(upsertToken).toHaveBeenCalledWith({
where: { userId_platform: { userId: 'user-1', platform: 'github' } },
update: { accessToken: 'encrypted:github-login-token', scopes: 'read:user,user:email,user:follow' },
create: {
userId: 'user-1',
platform: 'github',
accessToken: 'encrypted:github-login-token',
scopes: 'read:user,user:email,user:follow',
},
});

await app.close();
});
});
6 changes: 1 addition & 5 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, type FastifyRequest } from 'fastify';
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, it, expect, beforeEach, vi } from 'vitest';

import { cardRoutes } from '../routes/cards.js';
Expand Down Expand Up @@ -53,10 +53,6 @@
}

async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
app.decorate('prisma', mockPrisma);
app.decorate('authenticate', async (request: FastifyRequest & { user?: { id: string } }) => {
async function buildApp():Promise<FastifyInstance> {
const app = Fastify({ logger: false });
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
app.decorate('authenticate', async (request: any) => {
Expand Down Expand Up @@ -409,7 +405,7 @@
const app = await buildApp();
const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` });

expect(res.statusCode).toBe(404);

Check failure on line 408 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

src/__tests__/cards.test.ts > DELETE /api/cards/:id > returns 404 when the card is not owned by the user

AssertionError: expected 500 to be 404 // Object.is equality - Expected + Received - 404 + 500 ❯ src/__tests__/cards.test.ts:408:28
expect(mockPrisma.card.delete).not.toHaveBeenCalled();
});

Expand All @@ -420,7 +416,7 @@
const app = await buildApp();
const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` });

expect(res.statusCode).toBe(400);

Check failure on line 419 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

src/__tests__/cards.test.ts > DELETE /api/cards/:id > returns 400 when attempting to delete the last remaining card

AssertionError: expected 500 to be 400 // Object.is equality - Expected + Received - 400 + 500 ❯ src/__tests__/cards.test.ts:419:28
expect(res.json().error).toBe('Cannot delete the last remaining card. A user must have at least one card.');
expect(mockPrisma.card.delete).not.toHaveBeenCalled();
});
Expand Down
Loading
Loading