Skip to content
Merged
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
3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions server/prisma/downgradeDemoAdmin.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
52 changes: 52 additions & 0 deletions server/prisma/downgradeDemoAdmin.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 3 additions & 1 deletion server/prisma/seed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -411,3 +411,5 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
process.exit(1);
});
}

export { DEMO_USER_EMAIL };
15 changes: 15 additions & 0 deletions server/prisma/seed.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
4 changes: 3 additions & 1 deletion server/test-utils/fakePrismaClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
}
Expand Down
Loading