diff --git a/server/package.json b/server/package.json index 1792323..6415318 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,8 @@ "dev": "node --watch src/index.js", "start": "node src/index.js", "test": "vitest run", - "prisma:generate": "prisma generate" + "prisma:generate": "prisma generate", + "fix:demo-role": "node prisma/downgradeDemoAdmin.js" }, "dependencies": { "@prisma/client": "^5.22.0", diff --git a/server/prisma/downgradeDemoAdmin.js b/server/prisma/downgradeDemoAdmin.js new file mode 100644 index 0000000..ad6a400 --- /dev/null +++ b/server/prisma/downgradeDemoAdmin.js @@ -0,0 +1,41 @@ +// Targeted, idempotent remediation script for the demo account privilege +// exposure (issue #55): downgrades demo@lexforge.app to role 'user' if it +// currently has an elevated role, without touching any other data. Safe to +// run repeatedly against prod. Import-safe: only executes when run directly +// (`node prisma/downgradeDemoAdmin.js`), never as a side effect of importing +// this module. + +import { pathToFileURL } from 'node:url'; +import { PrismaClient } from '@prisma/client'; +import { DEMO_USER_EMAIL } from './seed.js'; + +export async function downgradeDemoAdmin(prisma) { + const existing = await prisma.user.findUnique({ where: { email: DEMO_USER_EMAIL } }); + if (!existing) { + console.log(`No user found for ${DEMO_USER_EMAIL}; nothing to downgrade.`); + return null; + } + + const user = await prisma.user.update({ + where: { email: DEMO_USER_EMAIL }, + data: { role: 'user' }, + }); + + return user.role; +} + +// Only run when executed directly (`node prisma/downgradeDemoAdmin.js`), +// never as a side effect of importing this module. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const prisma = new PrismaClient(); + downgradeDemoAdmin(prisma) + .then((role) => { + console.log(`Demo user role is now: ${role}`); + }) + .then(() => prisma.$disconnect()) + .catch(async (error) => { + console.error(error); + await prisma.$disconnect(); + process.exit(1); + }); +} diff --git a/server/prisma/downgradeDemoAdmin.test.js b/server/prisma/downgradeDemoAdmin.test.js new file mode 100644 index 0000000..6d27483 --- /dev/null +++ b/server/prisma/downgradeDemoAdmin.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createFakePrismaClient } from '../test-utils/fakePrismaClient.js'; +import { downgradeDemoAdmin } from './downgradeDemoAdmin.js'; +import { DEMO_USER_EMAIL } from './seed.js'; + +describe('downgradeDemoAdmin', () => { + let prisma; + + beforeEach(() => { + prisma = createFakePrismaClient(); + }); + + it('downgrades an existing demo admin user to role "user"', async () => { + await prisma.user.create({ + data: { + email: DEMO_USER_EMAIL, + passwordHash: 'irrelevant-hash', + name: 'Demo User', + role: 'admin', + }, + }); + + const role = await downgradeDemoAdmin(prisma); + + expect(role).toBe('user'); + + const user = await prisma.user.findUnique({ where: { email: DEMO_USER_EMAIL } }); + expect(user.role).toBe('user'); + }); + + it('is idempotent when run a second time', async () => { + await prisma.user.create({ + data: { + email: DEMO_USER_EMAIL, + passwordHash: 'irrelevant-hash', + name: 'Demo User', + role: 'admin', + }, + }); + + await downgradeDemoAdmin(prisma); + const role = await downgradeDemoAdmin(prisma); + + expect(role).toBe('user'); + }); + + it('does not throw when the demo user does not exist', async () => { + const role = await downgradeDemoAdmin(prisma); + + expect(role).toBeNull(); + }); +}); diff --git a/server/prisma/seed.js b/server/prisma/seed.js index a019edb..ca7eba0 100644 --- a/server/prisma/seed.js +++ b/server/prisma/seed.js @@ -352,7 +352,7 @@ export async function seed(prisma) { const user = await prisma.user.upsert({ where: { email: DEMO_USER_EMAIL }, - update: {}, + update: { role: 'user' }, create: { email: DEMO_USER_EMAIL, passwordHash, @@ -411,3 +411,5 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(1); }); } + +export { DEMO_USER_EMAIL }; diff --git a/server/prisma/seed.test.js b/server/prisma/seed.test.js index c7fbb5c..3c91c1f 100644 --- a/server/prisma/seed.test.js +++ b/server/prisma/seed.test.js @@ -57,4 +57,19 @@ describe('seed function', () => { expect(user.email).toBe('demo@lexforge.app'); expect(user.role).toBe('user'); }); + + it('should downgrade an existing demo admin user to role "user" when re-run', async () => { + await prisma.user.create({ + data: { + email: 'demo@lexforge.app', + passwordHash: 'irrelevant-hash', + name: 'Demo User', + role: 'admin', + }, + }); + + const { user } = await seed(prisma); + + expect(user.role).toBe('user'); + }); }); diff --git a/server/test-utils/fakePrismaClient.js b/server/test-utils/fakePrismaClient.js index 18de772..7f13737 100644 --- a/server/test-utils/fakePrismaClient.js +++ b/server/test-utils/fakePrismaClient.js @@ -91,7 +91,9 @@ function createFakeModel({ uniqueFields = [] } = {}) { }, async update({ where, data }) { - const existing = where.id !== undefined ? rows.get(where.id) : undefined; + const existing = where.id !== undefined + ? rows.get(where.id) + : [...rows.values()].find((r) => matchesWhere(r, where)); if (!existing) { throw new Error(`Record not found for update: ${JSON.stringify(where)}`); }