diff --git a/backend/src/modules/note-collaborators/routes.test.ts b/backend/src/modules/note-collaborators/routes.test.ts index 4c1d8da..aca7ef1 100644 --- a/backend/src/modules/note-collaborators/routes.test.ts +++ b/backend/src/modules/note-collaborators/routes.test.ts @@ -91,15 +91,26 @@ describe('note-collaborators routes', () => { describe('GET /notes/:id/collaborators', () => { it('lists collaborators', async () => { + getNoteMock.mockResolvedValueOnce({ id: 'n1', userId: 'user_1', productId: 'notelett' }); listCollaboratorsForNoteMock.mockResolvedValueOnce([ { id: 'c1', sharedWithUserId: 'user_2', permission: 'view' }, ]); const app = await buildApp(); - const res = await app.inject({ method: 'GET', url: '/api/notes/n1/collaborators' }); + const res = await app.inject({ method: 'GET', url: '/api/notes/n1/collaborators?workspaceId=ws-1' }); expect(res.statusCode).toBe(200); expect(res.json().items).toHaveLength(1); }); + + it('requires owner access to list collaborators', async () => { + getNoteMock.mockResolvedValueOnce({ id: 'n1', userId: 'owner_1', productId: 'notelett' }); + findCollaboratorMock.mockResolvedValueOnce({ id: 'c1', workspaceId: 'ws-1', permission: 'view' }); + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/notes/n1/collaborators?workspaceId=ws-1' }); + + expect(res.statusCode).toBe(404); + expect(listCollaboratorsForNoteMock).not.toHaveBeenCalled(); + }); }); describe('GET /shared-with-me', () => { @@ -144,7 +155,7 @@ describe('note-collaborators routes', () => { describe('POST /notes/:id/export-text', () => { it('exports note as text formats', async () => { - getNoteMock.mockResolvedValueOnce({ + getNoteMock.mockResolvedValue({ id: 'n1', userId: 'user_1', productId: 'notelett', title: 'Test Note', body: '
Hello world
', }); @@ -163,6 +174,23 @@ describe('note-collaborators routes', () => { expect(body.html).toBe('Hello world
'); }); + it('allows a direct collaborator to export note text', async () => { + getNoteMock.mockResolvedValue({ + id: 'n1', userId: 'owner_1', productId: 'notelett', + title: 'Shared Note', body: 'Hello shared
', + }); + findCollaboratorMock.mockResolvedValueOnce({ id: 'c1', workspaceId: 'ws-1', permission: 'view' }); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/notes/n1/export-text', + payload: { workspaceId: 'ws-1' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().title).toBe('Shared Note'); + }); + it('returns 404 for missing note', async () => { getNoteMock.mockResolvedValueOnce(null); const app = await buildApp(); @@ -178,7 +206,7 @@ describe('note-collaborators routes', () => { describe('GET /notes/:id/deep-link', () => { it('returns deep link URLs', async () => { - getNoteMock.mockResolvedValueOnce({ id: 'n1', productId: 'notelett' }); + getNoteMock.mockResolvedValue({ id: 'n1', userId: 'user_1', productId: 'notelett' }); const app = await buildApp(); const res = await app.inject({ method: 'GET', diff --git a/backend/src/modules/note-collaborators/routes.ts b/backend/src/modules/note-collaborators/routes.ts index 4c40a9d..42f7b0b 100644 --- a/backend/src/modules/note-collaborators/routes.ts +++ b/backend/src/modules/note-collaborators/routes.ts @@ -14,7 +14,20 @@ import * as noteRepo from '../notes/repository.js'; import * as collabRepo from './repository.js'; import { ShareWithUserSchema, ExportTextSchema } from './types.js'; import type { NoteCollaboratorDoc } from './types.js'; -import { config } from '../../lib/config.js'; +async function getNoteAccess(noteId: string, workspaceId: string, userId: string): Promise<'owner' | 'view' | 'comment' | 'edit' | null> { + const note = await noteRepo.getNote(noteId, workspaceId); + if (!note || note.productId !== PRODUCT_ID) { + return null; + } + if (note.userId === userId) { + return 'owner'; + } + const collaborator = await collabRepo.findCollaborator(noteId, userId, PRODUCT_ID); + if (!collaborator || collaborator.workspaceId !== workspaceId) { + return null; + } + return collaborator.permission; +} export async function noteCollaboratorRoutes(app: FastifyInstance): Promise- Updated {new Date(note.updatedAt).toLocaleString()} · Note ID {note.noteId} + Updated {new Date(note.updatedAt).toLocaleString()} · {note.expiresAt ? `Expires ${new Date(note.expiresAt).toLocaleDateString()}` : "Expires when revoked"} · Note ID {note.noteId}
Body
" })), + listCollaboratorsMock: vi.fn(async () => ({ + items: [{ id: "collab-1", sharedWithUserId: "user-2", permission: "view", createdAt: "2026-05-05T00:00:00.000Z" }], + total: 1, + })), + removeCollaboratorMock: vi.fn(async () => {}), + shareNoteWithUserMock: vi.fn(async () => ({})), + toastMock: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/lib/notes-client", () => ({ + createNoteShare: createNoteShareMock, + listNoteShares: listNoteSharesMock, + revokeNoteShare: revokeNoteShareMock, +})); + +vi.mock("@/lib/intake-client", () => ({ + exportNoteText: exportNoteTextMock, + listCollaborators: listCollaboratorsMock, + removeCollaborator: removeCollaboratorMock, + shareNoteWithUser: shareNoteWithUserMock, +})); + +vi.mock("@/lib/product-config", () => ({ + getWebAppOrigin: () => "https://notelett.app", +})); + +vi.mock("@/lib/toast", () => ({ + toast: toastMock, +})); + +import { ShareDialog } from "@/components/ShareDialog"; + +describe("ShareDialog", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(navigator, { + clipboard: { writeText: vi.fn(async () => {}) }, + }); + }); + + it("shows expiring public links and revokes them", async () => { + render(- Generate a public read-only link anyone can view. + Generate a public read-only link that expires in 30 days. Revoke links you no longer want available.
+ {publicLinks.length === 0 ? ( ++ No active public links. +
+ ) : ( ++ No direct collaborators yet. +
+ ) : ( +