From de1fc5686d9194ddee680839bec7eaaaba39f408 Mon Sep 17 00:00:00 2001 From: Sarthak Doshi Date: Sun, 10 May 2026 23:58:55 +0530 Subject: [PATCH 1/2] feat: add GET /api/nfc/payload endpoint for NFC tag generation - Add apps/backend/src/routes/nfc.ts with authenticated GET /api/nfc/payload that returns an NDEF-compatible URI record for the user's profile page - Optional ?card= query param returns card-specific URL after validating that the card belongs to the requesting user (403 on failure) - Register nfcRoutes in app.ts at prefix /api/nfc - Add unit tests covering: 401 unauthenticated, profile URL, card URL, 403 on unowned card Closes #35 --- apps/backend/src/__tests__/nfc.test.ts | 60 ++++++++++++++++++++++++++ apps/backend/src/app.ts | 2 + apps/backend/src/routes/nfc.ts | 46 ++++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 apps/backend/src/__tests__/nfc.test.ts create mode 100644 apps/backend/src/routes/nfc.ts diff --git a/apps/backend/src/__tests__/nfc.test.ts b/apps/backend/src/__tests__/nfc.test.ts new file mode 100644 index 0000000..443f297 --- /dev/null +++ b/apps/backend/src/__tests__/nfc.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from 'vitest'; +import Fastify from 'fastify'; +import { nfcRoutes } from '../routes/nfc.js'; + +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 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).toContain('/u/testuser'); + }); + + it('returns 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).toContain('/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..c36acf3 --- /dev/null +++ b/apps/backend/src/routes/nfc.ts @@ -0,0 +1,46 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +export async function nfcRoutes(app: FastifyInstance) { + // GET /api/nfc/payload — returns NDEF URI payload for the user's profile or a specific card + 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; + + // Fetch user to get username for the 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' }); + } + + const appUrl = process.env.PUBLIC_APP_URL || 'https://devcard.dev'; + + if (cardId) { + // Validate card ownership + 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 profile URL + return { + type: 'URI', + payload: `${appUrl}/u/${user.username}`, + }; + }); +} From fcf7cddf368da5a1845a0aeb30b9ff7c9fa0e202 Mon Sep 17 00:00:00 2001 From: Sarthak Doshi Date: Mon, 11 May 2026 00:16:47 +0530 Subject: [PATCH 2/2] fix: skip user lookup when cardId present, assert full URL in tests, set PUBLIC_APP_URL in test env --- apps/backend/src/__tests__/nfc.test.ts | 23 +++++++++++++---- apps/backend/src/routes/nfc.ts | 35 ++++++++++---------------- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/apps/backend/src/__tests__/nfc.test.ts b/apps/backend/src/__tests__/nfc.test.ts index 443f297..e5ce6b3 100644 --- a/apps/backend/src/__tests__/nfc.test.ts +++ b/apps/backend/src/__tests__/nfc.test.ts @@ -1,7 +1,12 @@ -import { describe, it, expect, vi } from 'vitest'; +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(); @@ -31,24 +36,32 @@ describe('GET /api/nfc/payload', () => { expect(res.statusCode).toBe(401); }); - it('returns profile URI payload when no cardId given', async () => { + 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).toContain('/u/testuser'); + 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 card URI payload when cardId is provided and owned', async () => { + 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).toContain('/devcard/card-1'); + expect(body.payload).toBe(`${TEST_APP_URL}/devcard/card-1`); }); it('returns 403 when cardId does not belong to the user', async () => { diff --git a/apps/backend/src/routes/nfc.ts b/apps/backend/src/routes/nfc.ts index c36acf3..84ed7ce 100644 --- a/apps/backend/src/routes/nfc.ts +++ b/apps/backend/src/routes/nfc.ts @@ -1,27 +1,15 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; export async function nfcRoutes(app: FastifyInstance) { - // GET /api/nfc/payload — returns NDEF URI payload for the user's profile or a specific card 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; - - // Fetch user to get username for the 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' }); - } - const appUrl = process.env.PUBLIC_APP_URL || 'https://devcard.dev'; if (cardId) { - // Validate card ownership + // 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 }, @@ -31,16 +19,19 @@ export async function nfcRoutes(app: FastifyInstance) { return reply.status(403).send({ error: 'Card not found or access denied' }); } - return { - type: 'URI', - payload: `${appUrl}/devcard/${cardId}`, - }; + 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' }); } - // Default: return profile URL - return { - type: 'URI', - payload: `${appUrl}/u/${user.username}`, - }; + return { type: 'URI', payload: `${appUrl}/u/${user.username}` }; }); }