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
73 changes: 73 additions & 0 deletions apps/backend/src/__tests__/nfc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import Fastify from 'fastify';
import { nfcRoutes } from '../routes/nfc.js';

const TEST_APP_URL = 'https://test.devcard.dev';

beforeAll(() => { process.env.PUBLIC_APP_URL = TEST_APP_URL; });
afterAll(() => { delete process.env.PUBLIC_APP_URL; });

function buildApp(prismaOverrides: Record<string, any> = {}, authenticateReject = false) {
const app = Fastify();

(app as any).prisma = {
user: {
findUnique: prismaOverrides.findUser ?? vi.fn(async () => ({ username: 'testuser' })),
},
card: {
findFirst: prismaOverrides.findCard ?? vi.fn(async () => ({ id: 'card-1' })),
},
};

(app as any).authenticate = async (request: any, reply: any) => {
if (authenticateReject) return reply.status(401).send({ error: 'Unauthorized' });
request.user = { id: 'user-1' };
};

app.register(nfcRoutes, { prefix: '/api/nfc' });
return app;
}

describe('GET /api/nfc/payload', () => {
it('returns 401 when unauthenticated', async () => {
const app = buildApp({}, true);
await app.ready();
const res = await app.inject({ method: 'GET', url: '/api/nfc/payload' });
expect(res.statusCode).toBe(401);
});

it('returns full profile URI payload when no cardId given', async () => {
const app = buildApp();
await app.ready();
const res = await app.inject({ method: 'GET', url: '/api/nfc/payload' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.type).toBe('URI');
expect(body.payload).toBe(`${TEST_APP_URL}/u/testuser`);
});

it('does not call user lookup when cardId is provided', async () => {
const findUser = vi.fn(async () => ({ username: 'testuser' }));
const app = buildApp({ findUser });
await app.ready();
await app.inject({ method: 'GET', url: '/api/nfc/payload?card=card-1' });
expect(findUser).not.toHaveBeenCalled();
});

it('returns full card URI payload when cardId is provided and owned', async () => {
const app = buildApp();
await app.ready();
const res = await app.inject({ method: 'GET', url: '/api/nfc/payload?card=card-1' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.type).toBe('URI');
expect(body.payload).toBe(`${TEST_APP_URL}/devcard/card-1`);
});

it('returns 403 when cardId does not belong to the user', async () => {
const app = buildApp({ findCard: vi.fn(async () => null) });
await app.ready();
const res = await app.inject({ method: 'GET', url: '/api/nfc/payload?card=other-card' });
expect(res.statusCode).toBe(403);
});
});
2 changes: 2 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { publicRoutes } from './routes/public.js';
import { followRoutes } from './routes/follow.js';
import { connectRoutes } from './routes/connect.js';
import { analyticsRoutes } from './routes/analytics.js';
import { nfcRoutes } from './routes/nfc.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -74,6 +75,7 @@ export async function buildApp() {
await app.register(followRoutes, { prefix: '/api/follow' });
await app.register(connectRoutes, { prefix: '/api/connect' });
await app.register(analyticsRoutes, { prefix: '/api/analytics' });
await app.register(nfcRoutes, { prefix: '/api/nfc' });

// ─── Health Check ───
app.get('/health', async () => ({
Expand Down
37 changes: 37 additions & 0 deletions apps/backend/src/routes/nfc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';

export async function nfcRoutes(app: FastifyInstance) {
app.get('/payload', {
preHandler: [app.authenticate],
}, async (request: FastifyRequest<{ Querystring: { card?: string } }>, reply: FastifyReply) => {
const userId = (request.user as any).id;
const { card: cardId } = request.query;
const appUrl = process.env.PUBLIC_APP_URL || 'https://devcard.dev';

Comment on lines +9 to +10
if (cardId) {
// Validate card ownership without fetching the user (not needed for card URL).
const card = await app.prisma.card.findFirst({
where: { id: cardId, userId },
select: { id: true },
});

if (!card) {
return reply.status(403).send({ error: 'Card not found or access denied' });
}

return { type: 'URI', payload: `${appUrl}/devcard/${cardId}` };
}

// Default: return the user's profile URL.
const user = await app.prisma.user.findUnique({
where: { id: userId },
select: { username: true },
});

if (!user) {
return reply.status(404).send({ error: 'User not found' });
}

return { type: 'URI', payload: `${appUrl}/u/${user.username}` };
});
}