diff --git a/apps/backend/src/__tests__/nfc.test.ts b/apps/backend/src/__tests__/nfc.test.ts new file mode 100644 index 0000000..e5ce6b3 --- /dev/null +++ b/apps/backend/src/__tests__/nfc.test.ts @@ -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 = {}, 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); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index dc023a2..7169a83 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -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)); @@ -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 () => ({ diff --git a/apps/backend/src/routes/nfc.ts b/apps/backend/src/routes/nfc.ts new file mode 100644 index 0000000..84ed7ce --- /dev/null +++ b/apps/backend/src/routes/nfc.ts @@ -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'; + + 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}` }; + }); +}