diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8cedac7..d7578c14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/backend/package.json b/apps/backend/package.json index d71b0777..b34a8843 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/src/__tests__/auth.github-token.test.ts b/apps/backend/src/__tests__/auth.github-token.test.ts new file mode 100644 index 00000000..acb8a34c --- /dev/null +++ b/apps/backend/src/__tests__/auth.github-token.test.ts @@ -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) { + 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) { + 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(); + }); +}); diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index dd707054..e977693f 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -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'; @@ -53,10 +53,6 @@ function wireTransaction(): void { } async function buildApp(): Promise { - const app = Fastify({ logger: false }); - app.decorate('prisma', mockPrisma); - app.decorate('authenticate', async (request: FastifyRequest & { user?: { id: string } }) => { -async function buildApp():Promise { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma as unknown as PrismaClient); app.decorate('authenticate', async (request: any) => { diff --git a/apps/backend/src/__tests__/event.test.ts b/apps/backend/src/__tests__/event.test.ts index 44806af1..415b4c07 100644 --- a/apps/backend/src/__tests__/event.test.ts +++ b/apps/backend/src/__tests__/event.test.ts @@ -1,9 +1,12 @@ +import Fastify from 'fastify'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import Fastify, { FastifyInstance } from 'fastify'; -import { PrismaClient } from '@prisma/client'; -import { eventRoutes } from '../routes/event'; -// ─── Shared mock data ──────────────────────────────────────────────────────── +import { eventRoutes } from '../routes/event.js'; + +import type { PrismaClient } from '@prisma/client'; +import type { FastifyInstance } from 'fastify'; + +// ── Shared mock data ────────────────────────────────────────────────────────── const MOCK_USER_ID = 'user-uuid-001'; const MOCK_OTHER_USER_ID = 'user-uuid-002'; @@ -21,6 +24,12 @@ const MOCK_EVENT = { createdAt: new Date('2025-01-01T00:00:00Z'), }; +// Private variant — same shape, visibility flag flipped. +const MOCK_PRIVATE_EVENT = { + ...MOCK_EVENT, + isPublic: false, +}; + const MOCK_USER_PROFILE = { id: MOCK_USER_ID, username: 'johndoe', @@ -43,7 +52,7 @@ const MOCK_OTHER_USER_PROFILE = { accentColor: '#6366f1', }; -// ─── Prisma mock ───────────────────────────────────────────────────────────── +// ── Prisma mock ─────────────────────────────────────────────────────────────── const prismaMock = { event: { @@ -53,46 +62,41 @@ const prismaMock = { eventAttendee: { create: vi.fn(), delete: vi.fn(), + // Used by canAccessEvent to check private-event membership. + findUnique: vi.fn(), }, }; -// ─── App factory ───────────────────────────────────────────────────────────── +// ── App factory ─────────────────────────────────────────────────────────────── // -// Builds a minimal Fastify instance that wires up: -// • app.prisma – the Prisma mock above -// • request.jwtVerify() – overridden per-test via `mockJwtVerify` +// Builds a minimal Fastify instance wired with: +// • app.prisma — the Prisma mock above +// • request.jwtVerify() — overridden per-test via `mockJwtVerify` // -// This mirrors the real app setup without touching a real DB or real JWT keys. +// Routes are registered under /api/events to match the production prefix. -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ logger: false }); - // Decorate prisma so routes can use app.prisma.* app.decorate('prisma', prismaMock as unknown as PrismaClient); - // Decorate jwtVerify on the request prototype so request.jwtVerify() resolves - // to whatever the current test wants. app.decorateRequest('jwtVerify', function () { return mockJwtVerify(); }); - // Register with the same prefix used in production (app.ts) so that - // tests exercise routes at their real paths — /api/events, /api/events/:slug, etc. await app.register(eventRoutes, { prefix: '/api/events' }); await app.ready(); return app; } -// ─── Helpers ───────────────────────────────────────────────────────────────── +// ── Helpers ─────────────────────────────────────────────────────────────────── -/** Returns a valid JWT-authenticated inject payload */ function authHeader(): Record { return { Authorization: 'Bearer mock-token' }; } -/** Injects a POST /api/events request */ async function createEvent( app: FastifyInstance, body: Record, @@ -106,14 +110,26 @@ async function createEvent( }); } -// ─── Test suite ────────────────────────────────────────────────────────────── +/** Builds a raw EventAttendee row as Prisma returns it (with nested user). */ +function makeAttendeeRow( + profile: typeof MOCK_USER_PROFILE | typeof MOCK_OTHER_USER_PROFILE, +) { + return { + id: `attendee-${profile.id}`, + userId: profile.id, + eventId: MOCK_EVENT.id, + joinedAt: new Date(), + user: { ...profile }, + }; +} + +// ── Test suite ──────────────────────────────────────────────────────────────── describe('Events API', () => { let app: FastifyInstance; beforeEach(async () => { vi.clearAllMocks(); - // Default: authenticated as MOCK_USER_ID mockJwtVerify.mockResolvedValue({ id: MOCK_USER_ID }); app = await buildApp(); }); @@ -135,7 +151,7 @@ describe('Events API', () => { }; it('201 — creates event and returns it for authenticated organizer', async () => { - prismaMock.event.findUnique.mockResolvedValue(null); // slug is free + prismaMock.event.findUnique.mockResolvedValue(null); prismaMock.event.create.mockResolvedValue(MOCK_EVENT); const res = await createEvent(app, validBody); @@ -146,7 +162,6 @@ describe('Events API', () => { expect(body.organizerId).toBe(MOCK_USER_ID); expect(body.location).toBe('San Francisco, CA'); - // Prisma was called with correct fields expect(prismaMock.event.create).toHaveBeenCalledOnce(); const callArg = prismaMock.event.create.mock.calls[0][0].data; expect(callArg.name).toBe('DevCard Conf 2025'); @@ -164,7 +179,7 @@ describe('Events API', () => { }); it('400 — rejects missing required fields (no dates, no location)', async () => { - const res = await createEvent(app, { name: 'Hello World' }); // missing dates + location + const res = await createEvent(app, { name: 'Hello World' }); expect(res.statusCode).toBe(400); }); @@ -190,24 +205,19 @@ describe('Events API', () => { }); it('400 — rejects event name longer than 100 characters', async () => { - const longName = 'A'.repeat(101); - const res = await createEvent(app, { ...validBody, name: longName }); + const res = await createEvent(app, { ...validBody, name: 'A'.repeat(101) }); expect(res.statusCode).toBe(400); }); it('400 — rejects invalid date format', async () => { - const res = await createEvent(app, { - ...validBody, - startDate: 'not-a-date', - }); + const res = await createEvent(app, { ...validBody, startDate: 'not-a-date' }); expect(res.statusCode).toBe(400); }); it('201 — generates a unique slug when the first candidate is taken', async () => { - // First findUnique returns a conflict, second returns null (slug free) prismaMock.event.findUnique - .mockResolvedValueOnce(MOCK_EVENT) // slug taken - .mockResolvedValueOnce(null); // randomised slug free + .mockResolvedValueOnce(MOCK_EVENT) + .mockResolvedValueOnce(null); prismaMock.event.create.mockResolvedValue({ ...MOCK_EVENT, @@ -217,7 +227,6 @@ describe('Events API', () => { const res = await createEvent(app, validBody); expect(res.statusCode).toBe(201); - // create was eventually called with a slug different from the base one const createdSlug: string = prismaMock.event.create.mock.calls[0][0].data.slug; expect(createdSlug).toMatch(/^devcard-conf-2025-[a-z0-9]+$/); }); @@ -248,54 +257,144 @@ describe('Events API', () => { // ── GET /api/events/:slug ────────────────────────────────────────────────── describe('GET /api/events/:slug — event details', () => { + // ── Public event behavior (unchanged) ──────────────────────────────────── + it('200 — returns event info with attendee count', async () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, _count: { attendees: 42 }, + organizer: { username: MOCK_USER_PROFILE.username, displayName: MOCK_USER_PROFILE.displayName }, }); - const res = await app.inject({ - method: 'GET', - url: '/api/events/devcard-conf-2025', - }); + const res = await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.slug).toBe('devcard-conf-2025'); expect(body.attendeesCount).toBe(42); expect(body.location).toBe('San Francisco, CA'); - // organizerId is exposed (public info) - expect(body.organizerId).toBe(MOCK_USER_ID); + expect(body.organizerUsername).toBe(MOCK_USER_PROFILE.username); + expect(body.organizerDisplayName).toBe(MOCK_USER_PROFILE.displayName); }); it('404 — returns 404 for unknown slug', async () => { prismaMock.event.findUnique.mockResolvedValue(null); - const res = await app.inject({ - method: 'GET', - url: '/api/events/ghost-event', - }); + const res = await app.inject({ method: 'GET', url: '/api/events/ghost-event' }); expect(res.statusCode).toBe(404); expect(res.json()).toMatchObject({ error: 'Event not found' }); }); - it('200 — works without authentication (public endpoint)', async () => { - // Even if JWT would fail, this route should not call jwtVerify + it('200 — public event is accessible without authentication', async () => { + // jwtVerify must NOT be called for a public event with no auth header. mockJwtVerify.mockRejectedValue(new Error('Should not be called')); prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, _count: { attendees: 0 }, + organizer: { username: MOCK_USER_PROFILE.username, displayName: MOCK_USER_PROFILE.displayName }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025' }); + + expect(res.statusCode).toBe(200); + expect(mockJwtVerify).not.toHaveBeenCalled(); + }); + + // ── Private event visibility ────────────────────────────────────────────── + + it('401 — unauthenticated caller cannot view a private event', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 5 }, + organizer: { username: MOCK_USER_PROFILE.username, displayName: MOCK_USER_PROFILE.displayName }, + }); + + // No Authorization header — request is unauthenticated. + const res = await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025' }); + + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ + error: 'Authentication required to view this event', + }); + }); + + it('200 — organizer can view their own private event', async () => { + // MOCK_USER_ID is the organizer. + mockJwtVerify.mockResolvedValue({ id: MOCK_USER_ID }); + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 3 }, + organizer: { username: MOCK_USER_PROFILE.username, displayName: MOCK_USER_PROFILE.displayName }, }); const res = await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025', - // No Authorization header + headers: authHeader(), }); expect(res.statusCode).toBe(200); - expect(mockJwtVerify).not.toHaveBeenCalled(); + expect(res.json().slug).toBe('devcard-conf-2025'); + // Organizer access never needs an attendee lookup. + expect(prismaMock.eventAttendee.findUnique).not.toHaveBeenCalled(); + }); + + it('200 — confirmed attendee can view a private event they joined', async () => { + // MOCK_OTHER_USER_ID is not the organizer but is an attendee. + mockJwtVerify.mockResolvedValue({ id: MOCK_OTHER_USER_ID }); + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 3 }, + organizer: { username: MOCK_USER_PROFILE.username, displayName: MOCK_USER_PROFILE.displayName }, + }); + prismaMock.eventAttendee.findUnique.mockResolvedValue({ + userId: MOCK_OTHER_USER_ID, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().slug).toBe('devcard-conf-2025'); + }); + + it('403 — authenticated user who is not a member cannot view a private event', async () => { + mockJwtVerify.mockResolvedValue({ id: 'stranger-user-id' }); + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 3 }, + organizer: { username: MOCK_USER_PROFILE.username, displayName: MOCK_USER_PROFILE.displayName }, + }); + // Attendee lookup finds no record for this user. + prismaMock.eventAttendee.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toMatchObject({ + error: 'You do not have access to this event', + }); + }); + + it('does not expose isPublic in the event details response', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_EVENT, + _count: { attendees: 0 }, + organizer: { username: MOCK_USER_PROFILE.username, displayName: MOCK_USER_PROFILE.displayName }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025' }); + + expect(res.statusCode).toBe(200); + expect(res.json()).not.toHaveProperty('isPublic'); }); }); @@ -352,12 +451,9 @@ describe('Events API', () => { expect(res.json()).toMatchObject({ error: 'Event not found' }); }); - it('409 — returns 409 when user already joined the event', async () => { + it('409 — returns 409 when user has already joined', async () => { prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); - // Prisma unique constraint error - const uniqueError = Object.assign(new Error('Unique constraint'), { - code: 'P2002', - }); + const uniqueError = Object.assign(new Error('Unique constraint'), { code: 'P2002' }); prismaMock.eventAttendee.create.mockRejectedValue(uniqueError); const res = await app.inject({ @@ -388,7 +484,7 @@ describe('Events API', () => { // ── DELETE /api/events/:slug/leave ──────────────────────────────────────── describe('DELETE /api/events/:slug/leave — leave event', () => { - it('204 — authenticated user leaves an event they joined', async () => { + it('204 — authenticated user successfully leaves an event', async () => { prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); prismaMock.eventAttendee.delete.mockResolvedValue({}); @@ -402,13 +498,9 @@ describe('Events API', () => { expect(res.statusCode).toBe(204); - // Verify the compound unique key used in the delete const deleteArg = prismaMock.eventAttendee.delete.mock.calls[0][0].where; expect(deleteArg).toMatchObject({ - userId_eventId: { - userId: MOCK_OTHER_USER_ID, - eventId: MOCK_EVENT.id, - }, + userId_eventId: { userId: MOCK_OTHER_USER_ID, eventId: MOCK_EVENT.id }, }); }); @@ -439,10 +531,7 @@ describe('Events API', () => { it('404 — returns 404 when user was never an attendee (P2025)', async () => { prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); - // Prisma record-not-found error - const notFoundError = Object.assign(new Error('Record not found'), { - code: 'P2025', - }); + const notFoundError = Object.assign(new Error('Record not found'), { code: 'P2025' }); prismaMock.eventAttendee.delete.mockRejectedValue(notFoundError); const res = await app.inject({ @@ -473,28 +562,16 @@ describe('Events API', () => { // ── GET /api/events/:slug/attendees ─────────────────────────────────────── describe('GET /api/events/:slug/attendees — paginated attendee list', () => { - /** Builds a raw EventAttendee row as Prisma returns it (with nested user) */ - function makeAttendeeRow( - profile: typeof MOCK_USER_PROFILE | typeof MOCK_OTHER_USER_PROFILE, - ) { - return { - id: `attendee-${profile.id}`, - userId: profile.id, - eventId: MOCK_EVENT.id, - joinedAt: new Date(), - user: { ...profile }, - }; - } + // ── Public event behavior (unchanged) ──────────────────────────────────── it('200 — returns paginated attendees with default page/limit', async () => { - const attendeeRows = [ - makeAttendeeRow(MOCK_USER_PROFILE), - makeAttendeeRow(MOCK_OTHER_USER_PROFILE), - ]; - prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, - attendees: attendeeRows, + _count: { attendees: 2 }, + attendees: [ + makeAttendeeRow(MOCK_USER_PROFILE), + makeAttendeeRow(MOCK_OTHER_USER_PROFILE), + ], }); const res = await app.inject({ @@ -504,24 +581,19 @@ describe('Events API', () => { expect(res.statusCode).toBe(200); const body = res.json(); - expect(body.attendees).toHaveLength(2); expect(body.attendees[0]).toMatchObject({ id: MOCK_USER_ID, username: 'johndoe', displayName: 'John Doe', }); - - expect(body.pagination).toMatchObject({ - page: 1, - limit: 10, - total: 2, - }); + expect(body.pagination).toMatchObject({ page: 1, limit: 10, total: 2 }); }); it('200 — respects custom page and limit query params', async () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, + _count: { attendees: 1 }, attendees: [makeAttendeeRow(MOCK_OTHER_USER_PROFILE)], }); @@ -535,17 +607,13 @@ describe('Events API', () => { expect(body.pagination.page).toBe(2); expect(body.pagination.limit).toBe(5); - // Verify skip/take were passed correctly to Prisma const includeArg = prismaMock.event.findUnique.mock.calls[0][0].include; - expect(includeArg.attendees.skip).toBe(5); // (page-1) * limit = 1 * 5 + expect(includeArg.attendees.skip).toBe(5); expect(includeArg.attendees.take).toBe(5); }); - it('200 — caps limit at 50 even if higher value is requested', async () => { - prismaMock.event.findUnique.mockResolvedValue({ - ...MOCK_EVENT, - attendees: [], - }); + it('200 — caps limit at 50 even if a higher value is requested', async () => { + prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, _count: { attendees: 0 }, attendees: [] }); const res = await app.inject({ method: 'GET', @@ -558,10 +626,7 @@ describe('Events API', () => { }); it('200 — treats page < 1 as page 1', async () => { - prismaMock.event.findUnique.mockResolvedValue({ - ...MOCK_EVENT, - attendees: [], - }); + prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, _count: { attendees: 0 }, attendees: [] }); const res = await app.inject({ method: 'GET', @@ -570,14 +635,11 @@ describe('Events API', () => { expect(res.statusCode).toBe(200); const includeArg = prismaMock.event.findUnique.mock.calls[0][0].include; - expect(includeArg.attendees.skip).toBe(0); // page forced to 1 → skip = 0 + expect(includeArg.attendees.skip).toBe(0); }); it('200 — returns empty attendees list for event with no attendees', async () => { - prismaMock.event.findUnique.mockResolvedValue({ - ...MOCK_EVENT, - attendees: [], - }); + prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, _count: { attendees: 0 }, attendees: [] }); const res = await app.inject({ method: 'GET', @@ -590,9 +652,10 @@ describe('Events API', () => { expect(body.pagination.total).toBe(0); }); - it('200 — public profiles do not leak sensitive fields', async () => { + it('200 — attendee profiles do not expose sensitive fields', async () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, + _count: { attendees: 1 }, attendees: [makeAttendeeRow(MOCK_USER_PROFILE)], }); @@ -603,13 +666,11 @@ describe('Events API', () => { const attendee = res.json().attendees[0]; - // These fields MUST be present expect(attendee).toHaveProperty('id'); expect(attendee).toHaveProperty('username'); expect(attendee).toHaveProperty('displayName'); expect(attendee).toHaveProperty('accentColor'); - // These fields MUST NOT be present expect(attendee).not.toHaveProperty('email'); expect(attendee).not.toHaveProperty('provider'); expect(attendee).not.toHaveProperty('providerId'); @@ -629,18 +690,112 @@ describe('Events API', () => { }); it('200 — attendees are ordered by joinedAt desc (latest first)', async () => { + prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, _count: { attendees: 0 }, attendees: [] }); + + await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025/attendees' }); + + const includeArg = prismaMock.event.findUnique.mock.calls[0][0].include; + expect(includeArg.attendees.orderBy).toMatchObject({ joinedAt: 'desc' }); + }); + + // ── Private event attendee visibility ───────────────────────────────────── + + it('401 — unauthenticated caller cannot enumerate private event attendees', async () => { prismaMock.event.findUnique.mockResolvedValue({ - ...MOCK_EVENT, - attendees: [], + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 1 }, + attendees: [makeAttendeeRow(MOCK_USER_PROFILE)], }); - await app.inject({ + // No Authorization header. + const res = await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025/attendees', }); - const includeArg = prismaMock.event.findUnique.mock.calls[0][0].include; - expect(includeArg.attendees.orderBy).toMatchObject({ joinedAt: 'desc' }); + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ + error: 'Authentication required to view this event', + }); + }); + + it('200 — organizer can retrieve attendee list of their private event', async () => { + mockJwtVerify.mockResolvedValue({ id: MOCK_USER_ID }); // organizer + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 1 }, + attendees: [makeAttendeeRow(MOCK_OTHER_USER_PROFILE)], + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().attendees).toHaveLength(1); + // Organizer access never triggers an attendee membership lookup. + expect(prismaMock.eventAttendee.findUnique).not.toHaveBeenCalled(); + }); + + it('200 — confirmed attendee can retrieve the attendee list of a private event', async () => { + mockJwtVerify.mockResolvedValue({ id: MOCK_OTHER_USER_ID }); // attendee, not organizer + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 2 }, + attendees: [ + makeAttendeeRow(MOCK_USER_PROFILE), + makeAttendeeRow(MOCK_OTHER_USER_PROFILE), + ], + }); + prismaMock.eventAttendee.findUnique.mockResolvedValue({ + userId: MOCK_OTHER_USER_ID, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().attendees).toHaveLength(2); + }); + + it('403 — authenticated user not in attendee list cannot access private event attendees', async () => { + mockJwtVerify.mockResolvedValue({ id: 'stranger-user-id' }); + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 1 }, + attendees: [makeAttendeeRow(MOCK_USER_PROFILE)], + }); + prismaMock.eventAttendee.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toMatchObject({ + error: 'You do not have access to this event', + }); + }); + + it('200 — public event attendee list remains accessible without authentication', async () => { + mockJwtVerify.mockRejectedValue(new Error('Should not be called')); + prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, _count: { attendees: 0 }, attendees: [] }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees', + // No Authorization header. + }); + + expect(res.statusCode).toBe(200); + expect(mockJwtVerify).not.toHaveBeenCalled(); }); }); @@ -683,4 +838,4 @@ describe('Events API', () => { expect(slug).not.toMatch(/--/); }); }); -}); \ No newline at end of file +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index be0b27e9..d2e1617d 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -82,16 +82,14 @@ export async function buildApp():Promise { timeWindow: '1 minute', }); -// Files must be served through authenticated route handlers -// with ownership validation. - // ─── Database & Cache Plugins ─── - if (process.env.NODE_ENV !== 'test') { - await app.register(prismaPlugin); //change -} if (process.env.NODE_ENV !== 'test') { - await app.register(redisPlugin); -} + await app.register(prismaPlugin); + } + if (process.env.NODE_ENV !== 'test') { + await app.register(redisPlugin); + } + // ─── Auth Decorator ─── app.decorate('authenticate', async function (request: any, reply: any) { try { @@ -113,18 +111,17 @@ export async function buildApp():Promise { await app.register(connectRoutes, { prefix: '/api/connect' }); await app.register(analyticsRoutes, { prefix: '/api/analytics' }); await app.register(nfcRoutes, { prefix: '/api/nfc' }); - await app.register(eventRoutes, {prefix: '/api/events'}) - await app.register(teamRoutes, {prefix: '/api/teams'}) - + await app.register(eventRoutes, { prefix: '/api/events' }); + await app.register(teamRoutes, { prefix: '/api/teams' }); // ─── Health Check ─── -type HealthResponse = { - status: 'ok'; -}; + type HealthResponse = { + status: 'ok'; + }; -app.get('/health', async (): Promise => { - return { status: 'ok' }; -}); + app.get('/health', async (): Promise => { + return { status: 'ok' }; + }); // Centralized error handler: log and return a consistent 500 shape for unhandled errors. app.setErrorHandler((error, request, reply) => { diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index c14949e1..84547339 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,10 +1,13 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { encrypt } from '../utils/encryption.js'; import { buildOAuthState, getMobileRedirectUri } from '../services/authService.js'; +import { encrypt } from '../utils/encryption.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 GITHUB_LOGIN_SCOPES = 'read:user user:email'; +const GITHUB_FOLLOW_SCOPE = 'user:follow'; 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'; @@ -57,11 +60,15 @@ export async function authRoutes(app: FastifyInstance) { // GitHub OAuth callback app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { const { code, state } = request.query; - const storedState = request.cookies?.oauth_state; + + // ── CSRF check ────────────────────────────────────────────────────────────── + const storedState = (request.cookies as any)?.oauth_state; if (!state || !storedState || state !== storedState) { return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); } + // Clear the state cookie immediately — prevents replay reply.clearCookie('oauth_state', { path: '/' }); + // ──────────────────────────────────────────────────────────────────────────── if (!code) { return reply.status(400).send({ error: 'Missing authorization code' }); @@ -117,13 +124,25 @@ export async function authRoutes(app: FastifyInstance) { }, }); + // Save the authentication token — only when it would not overwrite a + // follow-scoped token with a narrower login-only credential. + // Failure here is non-fatal: authentication (JWT issuance) proceeds + // even when token storage fails. try { const encryptedToken = encrypt(tokenData.access_token); - await app.prisma.oAuthToken.upsert({ + const loginScopes = tokenData.scope || GITHUB_LOGIN_SCOPES; + const existingGitHubToken = await app.prisma.oAuthToken.findUnique({ where: { userId_platform: { userId: user.id, platform: 'github' } }, - update: { accessToken: encryptedToken, scopes: 'read:user user:email' }, - create: { userId: user.id, platform: 'github', accessToken: encryptedToken, scopes: 'read:user user:email' }, + select: { scopes: true }, }); + + if (!shouldPreserveExistingGitHubToken(existingGitHubToken?.scopes, loginScopes)) { + await app.prisma.oAuthToken.upsert({ + where: { userId_platform: { userId: user.id, platform: 'github' } }, + update: { accessToken: encryptedToken, scopes: loginScopes }, + create: { userId: user.id, platform: 'github', accessToken: encryptedToken, scopes: loginScopes }, + }); + } } catch (err) { app.log.error({ err, userId: user.id }, 'Failed to persist GitHub OAuth token — authentication proceeds'); } @@ -183,11 +202,14 @@ export async function authRoutes(app: FastifyInstance) { app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { const { code, state } = request.query; - const storedState = request.cookies?.oauth_state; + // ── CSRF check ────────────────────────────────────────────────────────────── + const storedState = (request.cookies as any)?.oauth_state; if (!state || !storedState || state !== storedState) { return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); } + // Clear the state cookie immediately — prevents replay reply.clearCookie('oauth_state', { path: '/' }); + // ──────────────────────────────────────────────────────────────────────────── if (!code) { return reply.status(400).send({ error: 'Missing authorization code' }); @@ -257,7 +279,7 @@ export async function authRoutes(app: FastifyInstance) { 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({ @@ -291,3 +313,19 @@ export async function authRoutes(app: FastifyInstance) { return { message: 'Logged out' }; }); } + +// ── Token scope helpers ─────────────────────────────────────────────────────── + +/** + * Returns true when the existing stored token carries follow-capable scopes + * that the incoming login token does not. In that case the stored token + * should be left untouched so the user does not lose GitHub follow + * functionality after a routine re-login. + */ +function shouldPreserveExistingGitHubToken(existingScopes: string | undefined, newScopes: string): boolean { + return hasScope(existingScopes, GITHUB_FOLLOW_SCOPE) && !hasScope(newScopes, GITHUB_FOLLOW_SCOPE); +} + +function hasScope(scopes: string | undefined, scope: string): boolean { + return scopes?.split(/[,\s]+/).includes(scope) ?? false; +} diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index bb04194d..4329a363 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -1,7 +1,10 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { randomBytes } from 'crypto'; +import { randomBytes } from 'node:crypto'; + import { encrypt } from '../utils/encryption.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'; @@ -30,9 +33,9 @@ export async function connectRoutes(app: FastifyInstance) { 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) => { + }, async (request: FastifyRequest, _reply: FastifyReply) => { const userId = (request.user as any).id; const tokens = await app.prisma.oAuthToken.findMany({ @@ -50,7 +53,7 @@ export async function connectRoutes(app: FastifyInstance) { 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; @@ -102,7 +105,7 @@ export async function connectRoutes(app: FastifyInstance) { } // Consume the nonce -- one-time use only (if redis configured) - if (app.redis) await app.redis.del(`oauth:nonce:${decodedState.nonce}`); + if (app.redis) {await app.redis.del(`oauth:nonce:${decodedState.nonce}`);} const userId = decodedState.userId; @@ -124,7 +127,7 @@ export async function connectRoutes(app: FastifyInstance) { const tokenData = (await tokenRes.json()) as any; if (tokenData.error) { - app.log.error('GitHub connect token error:', tokenData); + app.log.error({ tokenData }, 'GitHub connect token error'); return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); } @@ -175,7 +178,7 @@ export async function connectRoutes(app: FastifyInstance) { 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<{ Params: { platform: string } }>, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -196,7 +199,7 @@ export async function connectRoutes(app: FastifyInstance) { }, }); return { success: true }; - } catch (error) { + } catch (_error) { return reply.status(404).send({ error: 'Connection not found' }); } }); diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index 4d4ee2d9..19efbc4a 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -1,22 +1,22 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { createEventSchema, joinEventSchema} from '../validations/event.validation'; +import { createEventSchema } from '../validations/event.validation.js'; -import {generateUniqueSlug} from '../utils/slug' +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +// ── Response types ──────────────────────────────────────────────────────────── type EventDetails = { - id: string; - name: string; - slug: string; - location: string; - description: string | null; - organizerUsername: string; - organizerDisplayName: string; - startDate: Date; - endDate: Date; - createdAt: Date; - attendeesCount: number -} + id: string; + name: string; + slug: string; + location: string; + description: string | null; + organizerUsername: string; + organizerDisplayName: string; + startDate: Date; + endDate: Date; + createdAt: Date; + attendeesCount: number; +}; type AttendeePublicProfile = { id: string; @@ -27,259 +27,327 @@ type AttendeePublicProfile = { company: string | null; avatarUrl: string | null; accentColor: string; -} - +}; type PaginatedAttendeesResponse = { attendees: AttendeePublicProfile[]; pagination: { page: number; limit: number; - total: number; + total: number; }; +}; + +// ── Visibility helpers ──────────────────────────────────────────────────────── + +type AccessResult = 'allowed' | 'unauthenticated' | 'forbidden'; + +/** + * Extracts the authenticated user ID from the Bearer JWT when present. + * Returns null for unauthenticated requests or invalid/expired tokens. + * Never throws — safe to call on any request regardless of auth state. + */ +async function getRequestUserId(request: FastifyRequest): Promise { + if (!request.headers.authorization) { return null; } + try { + const decoded = (await request.jwtVerify()) as { id: string }; + return decoded?.id ?? null; + } catch { + return null; + } } -type EventWithAttendees = { - _count: { - attendees: number; - }; - attendees: { - user: { - id: string; - username: string; - displayName: string; - bio: string | null; - pronouns: string | null; - company: string | null; - avatarUrl: string | null; - accentColor: string; - }; - }[]; +/** + * Determines whether a caller may view the given event. + * + * Access rules: + * - Public events → always accessible. + * - Private events → organizer or confirmed attendee only. + * + * Returns 'unauthenticated' vs 'forbidden' so callers can issue + * semantically distinct 401 vs 403 responses. + */ +async function canAccessEvent( + app: FastifyInstance, + event: { id: string; isPublic: boolean; organizerId: string }, + userId: string | null, +): Promise { + if (event.isPublic) { return 'allowed'; } + if (!userId) { return 'unauthenticated'; } + if (userId === event.organizerId) { return 'allowed'; } + + const membership = await app.prisma.eventAttendee.findUnique({ + where: { userId_eventId: { userId, eventId: event.id } }, + select: { userId: true }, + }); + + return membership ? 'allowed' : 'forbidden'; } -export async function eventRoutes(app:FastifyInstance) { - app.post('/', { preHandler: [async (request, reply) => { - 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' }) } - }] }, async (request: FastifyRequest<{ - Body: { - name: string, - description?: string, - startDate: string, - location: string, - endDate: string, - isPublic?: boolean - }}>, reply: FastifyReply) => { - const userId = (request.user as any).id; - const parsed = createEventSchema.safeParse(request.body); - if(!parsed.success){ - return reply.status(400).send({error: 'Bad request'}) - } - - const {name, description, startDate, endDate, isPublic ,location} = parsed.data - - let finalSlug = await generateUniqueSlug(name, async(slug) => { - const existing = await app.prisma.event.findUnique({where: {slug : slug}}) - - return !!existing - }) - - const startDateObj = new Date(startDate); - const endDateObj = new Date(endDate); - - try { - const newEvent = await app.prisma.event.create({ - data: { - name, - description, - slug: finalSlug, - location: location, - startDate: startDateObj, - endDate: endDateObj, - isPublic: isPublic ?? true, - organizerId: userId - } - }) - - return reply.status(201).send(newEvent); - } catch (error) { - app.log.error('Failed to create event'); - return reply.status(500).send({error: 'Failed to create event'}) - } - - }) - - //Returns event details and attendees count - app.get('/:slug', async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - const details = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug, +// ── Routes ──────────────────────────────────────────────────────────────────── + +export async function eventRoutes(app: FastifyInstance) { + // ─── Create Event ───────────────────────────────────────────────────────── + + app.post('/', async ( + request: FastifyRequest<{ + Body: { + name: string; + description?: string; + startDate: string; + location: string; + endDate: string; + isPublic?: boolean; + }; + }>, + reply: FastifyReply, + ) => { + let decoded: { id: string }; + try { + decoded = (await request.jwtVerify()) as { id: string }; + } catch { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const userId = decoded.id; + const parsed = createEventSchema.safeParse(request.body); + + if (!parsed.success) { + return reply.status(400).send({ error: 'Bad request' }); + } + + const { name, description, startDate, endDate, isPublic, location } = parsed.data; + + // Derive a URL-safe slug from the event name and ensure it is unique. + // The loop retries with a short random suffix on collision. + const cleanSlug = name + .toLowerCase() + .trim() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]+/g, '') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + let finalSlug = cleanSlug; + + while (true) { + const existing = await app.prisma.event.findUnique({ where: { slug: finalSlug } }); + if (!existing) { break; } + const randomSuffix = Math.random().toString(36).slice(2, 6); + finalSlug = `${cleanSlug}-${randomSuffix}`; + } + + try { + const newEvent = await app.prisma.event.create({ + data: { + name, + description, + slug: finalSlug, + location, + startDate, + endDate, + isPublic: isPublic ?? true, + organizerId: userId, + }, + }); + return reply.status(201).send(newEvent); + } catch (_error) { + app.log.error('Failed to create event'); + return reply.status(500).send({ error: 'Failed to create event' }); + } + }); + + // ─── Event Details ──────────────────────────────────────────────────────── + + app.get('/:slug', async ( + request: FastifyRequest<{ Params: { slug: string } }>, + reply: FastifyReply, + ) => { + const { slug } = request.params; + + const details = await app.prisma.event.findUnique({ + where: { slug }, + include: { + _count: { select: { attendees: true } }, + organizer: { + select: { username: true, displayName: true }, + }, + }, + }); + + if (!details) { + return reply.status(404).send({ error: 'Event not found' }); + } + + // Enforce visibility: public events are open; private events are restricted + // to the organizer and confirmed attendees. + const userId = await getRequestUserId(request); + const access = await canAccessEvent(app, details, userId); + + if (access === 'unauthenticated') { + return reply.status(401).send({ error: 'Authentication required to view this event' }); + } + if (access === 'forbidden') { + return reply.status(403).send({ error: 'You do not have access to this event' }); + } + + const response: EventDetails = { + id: details.id, + name: details.name, + slug: details.slug, + description: details.description, + location: details.location, + organizerUsername: details.organizer.username, + organizerDisplayName: details.organizer.displayName, + startDate: details.startDate, + endDate: details.endDate, + createdAt: details.createdAt, + attendeesCount: details._count.attendees, + }; + + return response; + }); + + // ─── Join Event ─────────────────────────────────────────────────────────── + + app.post('/:slug/join', async ( + request: FastifyRequest<{ Params: { slug: string } }>, + reply: FastifyReply, + ) => { + let decoded: { id: string }; + try { + decoded = (await request.jwtVerify()) as { id: string }; + } catch { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const userId = decoded.id; + const { slug } = request.params; + + const event = await app.prisma.event.findUnique({ where: { slug } }); + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } + + try { + await app.prisma.eventAttendee.create({ + data: { eventId: event.id, userId, joinedAt: new Date() }, + }); + return reply.status(201).send({ message: 'User joined successfully' }); + } catch (error: any) { + if (error.code === 'P2002') { + return reply.status(409).send({ error: 'Already joined' }); + } + app.log.error((error as Error).message); + return reply.status(500).send({ error: 'Failed to join' }); + } + }); + + // ─── Leave Event ────────────────────────────────────────────────────────── + + app.delete('/:slug/leave', async ( + request: FastifyRequest<{ Params: { slug: string } }>, + reply: FastifyReply, + ) => { + let decoded: { id: string }; + try { + decoded = (await request.jwtVerify()) as { id: string }; + } catch { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const userId = decoded.id; + const { slug } = request.params; + + const event = await app.prisma.event.findUnique({ where: { slug } }); + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } + + try { + await app.prisma.eventAttendee.delete({ + where: { userId_eventId: { userId, eventId: event.id } }, + }); + return reply.status(204).send(); + } catch (error: any) { + if (error.code === 'P2025') { + return reply.status(404).send({ error: 'User not found' }); + } + app.log.error((error as Error).message); + return reply.status(500).send({ error: 'Failed to leave' }); + } + }); + + // ─── Paginated Attendee List ────────────────────────────────────────────── + + app.get('/:slug/attendees', async ( + request: FastifyRequest<{ + Params: { slug: string }; + Querystring: { page?: string; limit?: string }; + }>, + reply: FastifyReply, + ) => { + const { slug } = request.params; + const page = Math.max(1, Number(request.query.page) || 1); + const limit = Math.min(50, Number(request.query.limit) || 10); + const skip = (page - 1) * limit; + + const event = await app.prisma.event.findUnique({ + where: { slug }, + include: { + _count: { select: { attendees: true } }, + attendees: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + bio: true, + pronouns: true, + company: true, + avatarUrl: true, + accentColor: true, + }, }, - include: { - _count: { - select: { - attendees: true - } - }, - organizer: { - select: { - username: true, - displayName: true - } - } - } - }) - if(!details){ - return reply.status(404).send({error: 'Event not found'}) - } - - const response: EventDetails = { - id: details.id, - name: details.name, - slug: details.slug, - description: details.description, - location: details.location, - organizerUsername: details.organizer.username, - organizerDisplayName: details.organizer.displayName, - startDate: details.startDate, - endDate: details.endDate, - createdAt: details.createdAt, - attendeesCount: details._count.attendees - } - - return response; - }) - - app.post('/:slug/join', { preHandler: [async (request, reply) => { 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' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - const userId = (request.user as any).id; - const paramsSlug = request.params.slug; - - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - } - }) - - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } - - try { - await app.prisma.eventAttendee.create({ - data: { - eventId: event.id, - userId: userId, - joinedAt: new Date() - } - }) - - return reply.status(201).send({message: 'User joined successfully'}) - } catch (error:any) { - if(error.code === "P2002" ){ - return reply.status(409).send({error: 'Already joined'}) - } - app.log.error((error as Error).message); - return reply.status(500).send({error: 'Failed to join'}) - } - - }) - - app.delete('/:slug/leave', { preHandler: [async (request, reply) => { 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' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - const userId = (request.user as any).id; - const paramsSlug = request.params.slug; - - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - } - }) - - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } - - try { - await app.prisma.eventAttendee.delete({ - where: { - userId_eventId: { - userId: userId, - eventId: event.id - } - } - }) - return reply.status(204).send({message: 'User left'}) - } catch (error:any) { - if(error.code === 'P2025'){ - return reply.status(404).send({error: 'User not found'}) - } - app.log.error((error as Error).message) - return reply.status(500).send({error: 'Failed to leave'}) - } - }) - - app.get('/:slug/attendees', async(request: FastifyRequest<{Params: {slug: string}, Querystring: {page?:string; limit?: string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - const page = Math.max(1, Number(request.query.page) || 1); - const limit = Math.min(50, Number(request.query.limit) || 10); - const skip = (page - 1) * limit - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - }, - include: { - _count: { - select: { attendees: true } - }, - attendees : { - include: { - user: { - select: { - id: true, - username: true, - displayName:true, - bio: true, - pronouns: true, - company: true, - avatarUrl: true, - accentColor: true - } - } - }, - skip, - take: limit, - orderBy: {joinedAt: 'desc'} - } - }, - })as EventWithAttendees | null; - - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } - - - const attendees = event.attendees.map((attendee: EventWithAttendees['attendees'][number]) => ({ - id: attendee.user.id, - username: attendee.user.username, - displayName: attendee.user.displayName, - bio: attendee.user.bio, - pronouns: attendee.user.pronouns, - company: attendee.user.company, - avatarUrl: attendee.user.avatarUrl, - accentColor: attendee.user.accentColor, - })); - - const response: PaginatedAttendeesResponse = { - attendees, - pagination: { - page, - limit, - total : event._count.attendees, - } - } - - return response; - }) -} \ No newline at end of file + }, + skip, + take: limit, + orderBy: { joinedAt: 'desc' }, + }, + }, + }) as any; + + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } + + // Enforce visibility before returning any attendee data. + const userId = await getRequestUserId(request); + const access = await canAccessEvent(app, event, userId); + + if (access === 'unauthenticated') { + return reply.status(401).send({ error: 'Authentication required to view this event' }); + } + if (access === 'forbidden') { + return reply.status(403).send({ error: 'You do not have access to this event' }); + } + + const attendees: AttendeePublicProfile[] = event.attendees.map((row: any) => ({ + id: row.user.id, + username: row.user.username, + displayName: row.user.displayName, + bio: row.user.bio, + pronouns: row.user.pronouns, + company: row.user.company, + avatarUrl: row.user.avatarUrl, + accentColor: row.user.accentColor, + })); + + const response: PaginatedAttendeesResponse = { + attendees, + pagination: { page, limit, total: event._count?.attendees ?? event.attendees.length }, + }; + + return response; + }); +} diff --git a/apps/backend/src/routes/follow.ts b/apps/backend/src/routes/follow.ts index a152fc55..9e4a4260 100644 --- a/apps/backend/src/routes/follow.ts +++ b/apps/backend/src/routes/follow.ts @@ -1,15 +1,17 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { getPlatform, getProfileUrl, getWebViewUrl } from '@devcard/shared'; + import { decrypt } from '../utils/encryption.js'; import { getErrorMessage } from '../utils/error.util.js'; -import { getPlatform, getProfileUrl, getWebViewUrl } from '@devcard/shared'; import { followLogSchema } from '../validations/follow.validation.js'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + export async function followRoutes(app: FastifyInstance) { app.addHook('preHandler', async (request, reply) => { 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 { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { const payload = await request.jwtVerify(); if (payload) { (request as any).user = payload; } } catch (_e) { reply.status(401).send({ error: 'Unauthorized' }) } }); // ─── Follow via API (Layer 1) ─── @@ -138,7 +140,7 @@ export async function followRoutes(app: FastifyInstance) { }); return reply.send({ status: 'success', logId: log.id }); } catch (error: any) { - app.log.error('Failed to log follow:', error); + app.log.error({ error }, 'Failed to log follow'); return reply.status(500).send({ error: 'Failed to log follow event' }); } }); diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index b191763a..5a29bee5 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,9 +1,10 @@ +import type { PlatformLink } from '@devcard/shared'; import type { Prisma } from '@prisma/client'; import type { FastifyInstance } from 'fastify'; -type CardLinkResponse = { platformLink: unknown }; +type CardLinkResponse = { platformLink: PlatformLink }; type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] }; -type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] }; +type CardResponse = { id: string; title: string; isDefault: boolean; links: PlatformLink[] }; function mapCard(card: RawCard): CardResponse { return { diff --git a/apps/backend/src/validations/event.validation.ts b/apps/backend/src/validations/event.validation.ts index 0fc4044f..db8e9e5e 100644 --- a/apps/backend/src/validations/event.validation.ts +++ b/apps/backend/src/validations/event.validation.ts @@ -1,12 +1,18 @@ -import {z} from 'zod' +import { z } from 'zod'; export const createEventSchema = z.object({ - name: z.string().min(3, 'Event name must be at least 3 characters long').max(100,'Event name cannot be longer than 100 characters'), - description: z.string().min(1).optional(), - location: z.string().min(2, 'Location should be atleast 2 characters long').max(100,'Location cannot be longer than 100 characters'), - startDate: z.string().pipe(z.coerce.date()), - endDate: z.string().pipe(z.coerce.date()), - isPublic: z.boolean().default(true) -}) + name: z + .string() + .min(3, 'Event name must be at least 3 characters long') + .max(100, 'Event name cannot be longer than 100 characters'), + description: z.string().min(1).optional(), + location: z + .string() + .min(2, 'Location must be at least 2 characters long') + .max(100, 'Location cannot be longer than 100 characters'), + startDate: z.string().pipe(z.coerce.date()), + endDate: z.string().pipe(z.coerce.date()), + isPublic: z.boolean().default(true), +}); -export const joinEventSchema = z.object({}) \ No newline at end of file +export const joinEventSchema = z.object({});