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
40 changes: 34 additions & 6 deletions server/src/repositories/citationRepository.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
// Repository for the Citation aggregate, including each user's favorites
// (UserCitationFavorite).

import { pick } from './pick.js';

// Client-writable content fields (see prisma/schema.prisma Citation model).
// Excludes id, usageCount/lastUsedAt (managed by incrementCitationUsage),
// isVerified (trust flag), createdBy (identity field), createdAt/updatedAt.
const WRITABLE_FIELDS = [
'title',
'citation',
'type',
'court',
'year',
'volume',
'reporter',
'page',
'pinpoint',
'jurisdiction',
'codeTitle',
'section',
'subdivision',
'shortForm',
'parenthetical',
'url',
'category',
'tags',
'notes',
];

export async function createCitation(prisma, data) {
return prisma.citation.create({
data: {
...data,
usageCount: data.usageCount ?? 0,
isVerified: data.isVerified ?? false,
...pick(data, WRITABLE_FIELDS),
usageCount: 0,
isVerified: false,
},
});
}
Expand All @@ -16,7 +43,7 @@ export async function getCitation(prisma, id) {
}

export async function updateCitation(prisma, id, data) {
return prisma.citation.update({ where: { id }, data });
return prisma.citation.update({ where: { id }, data: pick(data, WRITABLE_FIELDS) });
}

export async function deleteCitation(prisma, id) {
Expand Down Expand Up @@ -59,8 +86,9 @@ export async function addCitationFavorite(prisma, userId, citationId, notes) {
});
}

export async function removeCitationFavorite(prisma, id) {
return prisma.userCitationFavorite.delete({ where: { id } });
export async function removeCitationFavorite(prisma, id, userId) {
const { count } = await prisma.userCitationFavorite.deleteMany({ where: { id, userId } });
return count > 0;
}

export async function listCitationFavoritesByUser(prisma, userId) {
Expand Down
30 changes: 29 additions & 1 deletion server/src/repositories/citationRepository.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ describe('citationRepository', () => {
expect(await getCitation(prisma, created.id)).toBeNull();
});

it('does not allow isVerified to be set via update (trust flag mass assignment)', async () => {
const created = await createCitation(prisma, { title: 'X', citation: 'x', type: 'case' });
expect(created.isVerified).toBe(false);

const updated = await updateCitation(prisma, created.id, { isVerified: true });
expect(updated.isVerified).toBe(false);
});

it('does not allow isVerified to be set via create (trust flag mass assignment)', async () => {
const created = await createCitation(prisma, {
title: 'X',
citation: 'x',
type: 'case',
isVerified: true,
});
expect(created.isVerified).toBe(false);
});

it('searches citations by type', async () => {
await seedCitations(prisma);

Expand Down Expand Up @@ -106,10 +124,20 @@ describe('citationRepository', () => {
const list = await listCitationFavoritesByUser(prisma, 'user-1');
expect(list).toHaveLength(1);

await removeCitationFavorite(prisma, favorite.id);
await removeCitationFavorite(prisma, favorite.id, 'user-1');
expect(await listCitationFavoritesByUser(prisma, 'user-1')).toHaveLength(0);
});

it('does not remove a favorite owned by a different user (IDOR)', async () => {
const citation = await createCitation(prisma, { title: 'X', citation: 'x', type: 'case' });
const favorite = await addCitationFavorite(prisma, 'user-1', citation.id);

const removed = await removeCitationFavorite(prisma, favorite.id, 'user-2');

expect(removed).toBe(false);
expect(await listCitationFavoritesByUser(prisma, 'user-1')).toHaveLength(1);
});

it('joins favorites with their citation records', async () => {
const citation = await createCitation(prisma, { title: 'X', citation: 'x', type: 'case' });
await addCitationFavorite(prisma, 'user-1', citation.id);
Expand Down
34 changes: 28 additions & 6 deletions server/src/repositories/clauseRepository.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
// Repository for the Clause aggregate, including each user's favorites
// (UserClauseFavorite).

import { pick } from './pick.js';

// Client-writable content fields (see prisma/schema.prisma Clause model).
// Excludes id, usageCount/lastUsedAt (managed by incrementClauseUsage),
// isPublished (trust flag), createdAt/updatedAt.
const WRITABLE_FIELDS = [
'title',
'content',
'description',
'category',
'subcategory',
'tags',
'jurisdiction',
'documentTypes',
'variations',
'author',
'isFavorite',
'notes',
'placeholders',
];

export async function createClause(prisma, data) {
return prisma.clause.create({
data: {
...data,
usageCount: data.usageCount ?? 0,
isPublished: data.isPublished ?? true,
...pick(data, WRITABLE_FIELDS),
usageCount: 0,
isPublished: true,
},
});
}
Expand All @@ -16,7 +37,7 @@ export async function getClause(prisma, id) {
}

export async function updateClause(prisma, id, data) {
return prisma.clause.update({ where: { id }, data });
return prisma.clause.update({ where: { id }, data: pick(data, WRITABLE_FIELDS) });
}

export async function deleteClause(prisma, id) {
Expand Down Expand Up @@ -65,8 +86,9 @@ export async function addClauseFavorite(prisma, userId, clauseId, notes) {
});
}

export async function removeClauseFavorite(prisma, id) {
return prisma.userClauseFavorite.delete({ where: { id } });
export async function removeClauseFavorite(prisma, id, userId) {
const { count } = await prisma.userClauseFavorite.deleteMany({ where: { id, userId } });
return count > 0;
}

export async function findClauseFavorite(prisma, userId, clauseId) {
Expand Down
35 changes: 33 additions & 2 deletions server/src/repositories/clauseRepository.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ async function seedClauses(prisma) {
content: '<p>...</p>',
category: 'Confidentiality',
jurisdiction: 'California',
isPublished: false,
});
// isPublished is not a client-writable field (see trust-flag test below),
// so flip it directly on the fake store to seed an unpublished fixture.
await prisma.clause.update({ where: { id: unpublished.id }, data: { isPublished: false } });
return { indemnification, confidentiality, unpublished };
}

Expand Down Expand Up @@ -71,6 +73,25 @@ describe('clauseRepository', () => {
expect(await getClause(prisma, created.id)).toBeNull();
});

it('does not allow isPublished to be set via update (trust flag mass assignment)', async () => {
const created = await createClause(prisma, { title: 'X', content: 'c', category: 'Notices' });
expect(created.isPublished).toBe(true);
await prisma.clause.update({ where: { id: created.id }, data: { isPublished: false } });

const updated = await updateClause(prisma, created.id, { isPublished: true });
expect(updated.isPublished).toBe(false);
});

it('does not allow isPublished to be set via create (trust flag mass assignment)', async () => {
const created = await createClause(prisma, {
title: 'X',
content: 'c',
category: 'Notices',
isPublished: false,
});
expect(created.isPublished).toBe(true);
});

it('searches published clauses by category', async () => {
const { indemnification } = await seedClauses(prisma);

Expand Down Expand Up @@ -138,11 +159,21 @@ describe('clauseRepository', () => {
const clause = await createClause(prisma, { title: 'X', content: 'c', category: 'Notices' });
const favorite = await addClauseFavorite(prisma, 'user-1', clause.id);

await removeClauseFavorite(prisma, favorite.id);
await removeClauseFavorite(prisma, favorite.id, 'user-1');

expect(await findClauseFavorite(prisma, 'user-1', clause.id)).toBeNull();
});

it('does not remove a favorite owned by a different user (IDOR)', async () => {
const clause = await createClause(prisma, { title: 'X', content: 'c', category: 'Notices' });
const favorite = await addClauseFavorite(prisma, 'user-1', clause.id);

const removed = await removeClauseFavorite(prisma, favorite.id, 'user-2');

expect(removed).toBe(false);
expect(await findClauseFavorite(prisma, 'user-1', clause.id)).not.toBeNull();
});

it('lists favorite ids for a user', async () => {
const clauseA = await createClause(prisma, { title: 'A', content: 'c', category: 'Notices' });
const clauseB = await createClause(prisma, { title: 'B', content: 'c', category: 'Notices' });
Expand Down
8 changes: 7 additions & 1 deletion server/src/repositories/draftRepository.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
// Repository for the Draft aggregate (a user's document).

import { pick } from './pick.js';

// Client-writable fields for updates. Excludes id and userId (ownership must
// not be reassignable via the body) and createdAt/updatedAt.
const WRITABLE_FIELDS = ['title', 'content', 'metadata', 'intakeData', 'status'];

export async function createDraft(prisma, { userId, title, content, metadata, intakeData, status }) {
return prisma.draft.create({
data: {
Expand All @@ -18,7 +24,7 @@ export async function getDraft(prisma, id) {
}

export async function updateDraft(prisma, id, data) {
return prisma.draft.update({ where: { id }, data });
return prisma.draft.update({ where: { id }, data: pick(data, WRITABLE_FIELDS) });
}

export async function deleteDraft(prisma, id) {
Expand Down
7 changes: 7 additions & 0 deletions server/src/repositories/draftRepository.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ describe('draftRepository', () => {
expect(updated.title).toBe('New');
});

it('does not allow userId to be reassigned via update (mass assignment)', async () => {
const created = await createDraft(prisma, { userId: 'user-1', title: 'Old' });
const updated = await updateDraft(prisma, created.id, { userId: 'user-2', title: 'New' });
expect(updated.userId).toBe('user-1');
expect(updated.title).toBe('New');
});

it('deletes a draft', async () => {
const created = await createDraft(prisma, { userId: 'user-1', title: 'Gone' });
await deleteDraft(prisma, created.id);
Expand Down
13 changes: 13 additions & 0 deletions server/src/repositories/pick.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Small allowlist helper: returns a new object containing only the keys in
// `keys` that are present on `obj`. Used by the repository create/update
// functions to keep server/system fields (trust flags, ids, timestamps, FKs)
// out of client-controlled writes.
export function pick(obj, keys) {
const result = {};
for (const key of keys) {
if (obj && Object.prototype.hasOwnProperty.call(obj, key)) {
result[key] = obj[key];
}
}
return result;
}
23 changes: 19 additions & 4 deletions server/src/repositories/templateRepository.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
// Repository for the Template aggregate.

import { pick } from './pick.js';

// Client-writable content fields (see prisma/schema.prisma Template model).
// Excludes id, version/isPublished/publishedAt (version & trust fields),
// parentTemplateId (relation-like reference), createdAt/updatedAt.
const WRITABLE_FIELDS = [
'category',
'name',
'skeletonContent',
'defaultMetadata',
'placeholders',
'sections',
'variables',
];

export async function createTemplate(prisma, data) {
return prisma.template.create({
data: {
...data,
version: data.version ?? 1,
isPublished: data.isPublished ?? false,
...pick(data, WRITABLE_FIELDS),
version: 1,
isPublished: false,
},
});
}
Expand All @@ -15,7 +30,7 @@ export async function getTemplate(prisma, id) {
}

export async function updateTemplate(prisma, id, data) {
return prisma.template.update({ where: { id }, data });
return prisma.template.update({ where: { id }, data: pick(data, WRITABLE_FIELDS) });
}

export async function deleteTemplate(prisma, id) {
Expand Down
20 changes: 18 additions & 2 deletions server/src/repositories/templateRepository.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,30 @@ describe('templateRepository', () => {

expect((await getTemplate(prisma, created.id)).name).toBe('X');

const updated = await updateTemplate(prisma, created.id, { name: 'Y', isPublished: true });
const updated = await updateTemplate(prisma, created.id, { name: 'Y' });
expect(updated.name).toBe('Y');
expect(updated.isPublished).toBe(true);

await deleteTemplate(prisma, created.id);
expect(await getTemplate(prisma, created.id)).toBeNull();
});

it('does not allow isPublished to be set via update (trust flag mass assignment)', async () => {
const created = await createTemplate(prisma, { category: 'Demand Letter', name: 'X' });
expect(created.isPublished).toBe(false);

const updated = await updateTemplate(prisma, created.id, { isPublished: true });
expect(updated.isPublished).toBe(false);
});

it('does not allow isPublished to be set via create (trust flag mass assignment)', async () => {
const created = await createTemplate(prisma, {
category: 'Demand Letter',
name: 'X',
isPublished: true,
});
expect(created.isPublished).toBe(false);
});

it('lists all templates when no category given', async () => {
await createTemplate(prisma, { category: 'Demand Letter', name: 'A' });
await createTemplate(prisma, { category: 'NDA', name: 'B' });
Expand Down
3 changes: 1 addition & 2 deletions server/src/routes/citations.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Router } from 'express';
import { asyncHandler } from './asyncHandler.js';
import { withNotFound } from './helpers.js';
import {
createCitation,
getCitation,
Expand Down Expand Up @@ -50,7 +49,7 @@ export function createCitationsRouter({ prisma }) {
router.delete(
'/favorites/:id',
asyncHandler(async (req, res) => {
const removed = await withNotFound(removeCitationFavorite(prisma, req.params.id));
const removed = await removeCitationFavorite(prisma, req.params.id, req.user.id);
if (!removed) return res.status(404).json({ error: 'Favorite not found' });
res.status(204).send();
})
Expand Down
Loading
Loading