From 971963e0cca1b501eb190c22ab6a805233cf09f2 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Fri, 20 Mar 2026 21:22:45 +0100 Subject: [PATCH] test: add unit and e2e tests for public note sharing Unit tests: - shares-service: create, idempotent create, revoke, get, batch fetch - ip-rate-limit: under/over limit, IP independence, window reset E2E tests: - Create public share link via UI - Public page renders without auth - Revoke share returns 404 - Trashed note share returns 404 - Globe icon on shared card - Copy link to clipboard Also fix sharing-indicator test-id in existing sharing.spec.ts and add shared_notes table to test-helpers and db/index.ts init. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/db/index.ts | 7 ++ src/lib/server/db/test-helpers.ts | 7 ++ src/lib/server/ip-rate-limit.test.ts | 46 ++++++++ src/lib/server/shares-service.test.ts | 164 ++++++++++++++++++++++++++ tests/e2e/public-sharing.spec.ts | 150 +++++++++++++++++++++++ tests/e2e/sharing.spec.ts | 4 +- 6 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 src/lib/server/ip-rate-limit.test.ts create mode 100644 src/lib/server/shares-service.test.ts create mode 100644 tests/e2e/public-sharing.spec.ts diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 931834d..29d00e4 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -124,6 +124,13 @@ sqlite.exec(` updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS shared_notes ( + note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + expires_at INTEGER + ); + CREATE TABLE IF NOT EXISTS note_versions ( id TEXT PRIMARY KEY, note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, diff --git a/src/lib/server/db/test-helpers.ts b/src/lib/server/db/test-helpers.ts index e33487f..a81db3e 100644 --- a/src/lib/server/db/test-helpers.ts +++ b/src/lib/server/db/test-helpers.ts @@ -137,6 +137,13 @@ export function createTestDb(options?: { seedUser?: boolean }) { ); CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key); + CREATE TABLE shared_notes ( + note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + expires_at INTEGER + ); + CREATE TABLE note_versions ( id TEXT PRIMARY KEY, note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, diff --git a/src/lib/server/ip-rate-limit.test.ts b/src/lib/server/ip-rate-limit.test.ts new file mode 100644 index 0000000..7aedf9c --- /dev/null +++ b/src/lib/server/ip-rate-limit.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// We need to re-import for each test to get a fresh module state +let checkIpRateLimit: (ip: string) => boolean; + +beforeEach(async () => { + vi.resetModules(); + const mod = await import('./ip-rate-limit.js'); + checkIpRateLimit = mod.checkIpRateLimit; +}); + +describe('checkIpRateLimit', () => { + it('allows requests under the limit', () => { + for (let i = 0; i < 60; i++) { + expect(checkIpRateLimit('1.2.3.4')).toBe(true); + } + }); + + it('blocks requests over the limit', () => { + for (let i = 0; i < 60; i++) { + checkIpRateLimit('1.2.3.4'); + } + expect(checkIpRateLimit('1.2.3.4')).toBe(false); + }); + + it('tracks different IPs independently', () => { + for (let i = 0; i < 60; i++) { + checkIpRateLimit('1.1.1.1'); + } + expect(checkIpRateLimit('1.1.1.1')).toBe(false); + expect(checkIpRateLimit('2.2.2.2')).toBe(true); + }); + + it('resets after the window expires', () => { + vi.useFakeTimers(); + for (let i = 0; i < 60; i++) { + checkIpRateLimit('1.2.3.4'); + } + expect(checkIpRateLimit('1.2.3.4')).toBe(false); + + // Advance past the 1-minute window + vi.advanceTimersByTime(61_000); + expect(checkIpRateLimit('1.2.3.4')).toBe(true); + vi.useRealTimers(); + }); +}); diff --git a/src/lib/server/shares-service.test.ts b/src/lib/server/shares-service.test.ts new file mode 100644 index 0000000..02d9e20 --- /dev/null +++ b/src/lib/server/shares-service.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb } from './db/test-helpers.js'; +import { notes, users, attachments } from './db/schema.js'; +import type { Db } from './db/index.js'; +import { + fetchSharesForNotes, + createShare, + revokeShare, + getShareByNoteId, + getSharedNote +} from './shares-service.js'; + +let db: Db; + +const OWNER_ID = 1; + +function seedUsers() { + db.insert(users) + .values([ + { + email: 'owner@test.com', + displayName: 'Owner', + role: 'admin', + authProvider: 'password', + createdAt: new Date() + } + ]) + .run(); +} + +function seedNote( + id: string, + overrides: Partial = {} +) { + const now = new Date(); + db.insert(notes) + .values({ + id, + userId: OWNER_ID, + title: 'Test Note', + content: 'Test content', + createdAt: now, + updatedAt: now, + ...overrides + }) + .run(); + return id; +} + +beforeEach(() => { + const result = createTestDb(); + db = result.db; + seedUsers(); +}); + +describe('createShare', () => { + it('creates a share and returns a token', () => { + seedNote('note-1'); + const { token } = createShare(db, 'note-1', OWNER_ID); + expect(token).toBeDefined(); + expect(token.length).toBe(21); + }); + + it('returns the same token on duplicate creation (idempotent)', () => { + seedNote('note-1'); + const first = createShare(db, 'note-1', OWNER_ID); + const second = createShare(db, 'note-1', OWNER_ID); + expect(first.token).toBe(second.token); + }); + + it('throws 404 for non-existent note', () => { + expect(() => createShare(db, 'nonexistent', OWNER_ID)).toThrow(); + }); +}); + +describe('revokeShare', () => { + it('removes the share token', () => { + seedNote('note-1'); + createShare(db, 'note-1', OWNER_ID); + revokeShare(db, 'note-1', OWNER_ID); + const share = getShareByNoteId(db, 'note-1'); + expect(share).toBeNull(); + }); +}); + +describe('getShareByNoteId', () => { + it('returns null when no share exists', () => { + seedNote('note-1'); + expect(getShareByNoteId(db, 'note-1')).toBeNull(); + }); + + it('returns share when one exists', () => { + seedNote('note-1'); + const { token } = createShare(db, 'note-1', OWNER_ID); + const share = getShareByNoteId(db, 'note-1'); + expect(share).not.toBeNull(); + expect(share!.token).toBe(token); + }); +}); + +describe('getSharedNote', () => { + it('returns note data for valid token', () => { + seedNote('note-1', { title: 'Shared Title', content: 'Shared content' }); + const { token } = createShare(db, 'note-1', OWNER_ID); + + const data = getSharedNote(db, token); + expect(data).not.toBeNull(); + expect(data!.title).toBe('Shared Title'); + expect(data!.content).toBe('Shared content'); + }); + + it('returns null for trashed note', () => { + seedNote('note-1', { trashed: true }); + const { token } = createShare(db, 'note-1', OWNER_ID); + + expect(getSharedNote(db, token)).toBeNull(); + }); + + it('returns null for invalid token', () => { + expect(getSharedNote(db, 'invalid-token')).toBeNull(); + }); + + it('includes attachments', () => { + seedNote('note-1'); + db.insert(attachments) + .values({ + id: 'att-1', + userId: OWNER_ID, + noteId: 'note-1', + filename: 'image.png', + mimeType: 'image/png', + size: 1024, + path: '/data/attachments/att-1.png', + createdAt: new Date() + }) + .run(); + const { token } = createShare(db, 'note-1', OWNER_ID); + + const data = getSharedNote(db, token); + expect(data!.attachments).toHaveLength(1); + expect(data!.attachments[0].filename).toBe('image.png'); + }); +}); + +describe('fetchSharesForNotes', () => { + it('returns empty map for no notes', () => { + const result = fetchSharesForNotes(db, []); + expect(result.size).toBe(0); + }); + + it('returns tokens for shared notes', () => { + seedNote('note-1'); + seedNote('note-2'); + seedNote('note-3'); + const { token: token1 } = createShare(db, 'note-1', OWNER_ID); + const { token: token2 } = createShare(db, 'note-2', OWNER_ID); + + const result = fetchSharesForNotes(db, ['note-1', 'note-2', 'note-3']); + expect(result.size).toBe(2); + expect(result.get('note-1')).toBe(token1); + expect(result.get('note-2')).toBe(token2); + expect(result.get('note-3')).toBeUndefined(); + }); +}); diff --git a/tests/e2e/public-sharing.spec.ts b/tests/e2e/public-sharing.spec.ts new file mode 100644 index 0000000..7e9977b --- /dev/null +++ b/tests/e2e/public-sharing.spec.ts @@ -0,0 +1,150 @@ +import { test, expect, noteCard } from './helpers/fixtures.js'; +import type { Page } from '@playwright/test'; + +/** + * Create a note via API and return it. + */ +async function createNoteViaApi(page: Page, title: string, content = 'Public share content') { + const res = await page.request.post('/api/notes', { + data: { title, content } + }); + return res.json(); +} + +/** + * Create a public share link for a note via API and return the token. + */ +async function createShareViaApi(page: Page, noteId: string): Promise { + const res = await page.request.post(`/api/notes/${noteId}/share`); + const data = await res.json(); + return data.token; +} + +test.describe('Public Note Sharing', () => { + test('Scenario: User creates a public share link', async ({ + authenticatedPage: page + }) => { + // Given a note titled "Public Test" exists + const note = await createNoteViaApi(page, 'Public Test', 'Hello world'); + await page.reload(); + await page.waitForLoadState('networkidle'); + + // When the owner opens the share dialog and enables the public link + await noteCard(page, 'Public Test').click(); + await page.getByTestId('share-toggle').click(); + await expect(page.getByTestId('share-dialog')).toBeVisible(); + await page.getByTestId('public-share-toggle').click(); + + // Then a share URL is displayed + await expect(page.getByTestId('share-url-container')).toBeVisible({ timeout: 5000 }); + const urlText = await page.getByTestId('share-url-text').textContent(); + expect(urlText).toContain('/s/'); + }); + + test('Scenario: Public page renders note content without auth', async ({ + authenticatedPage: page, + browser + }) => { + // Given a publicly shared note exists + const note = await createNoteViaApi(page, 'Shared Publicly', 'This is **public** content'); + const token = await createShareViaApi(page, note.id); + + // When an unauthenticated user visits the share URL + const context = await browser.newContext(); + const publicPage = await context.newPage(); + await publicPage.goto(`/s/${token}`); + + // Then the note content is rendered + await expect(publicPage.getByTestId('shared-note')).toBeVisible(); + await expect(publicPage.getByTestId('shared-note-title')).toHaveText('Shared Publicly'); + await expect(publicPage.getByTestId('shared-note-content')).toContainText('public'); + + await context.close(); + }); + + test('Scenario: User revokes share and link returns 404', async ({ + authenticatedPage: page, + browser + }) => { + // Given a publicly shared note exists + const note = await createNoteViaApi(page, 'Revoke Test', 'Will be revoked'); + const token = await createShareViaApi(page, note.id); + + // And the public page is accessible + const context = await browser.newContext(); + const publicPage = await context.newPage(); + await publicPage.goto(`/s/${token}`); + await expect(publicPage.getByTestId('shared-note')).toBeVisible(); + + // When the owner revokes the share + const revokeRes = await page.request.delete(`/api/notes/${note.id}/share`); + expect(revokeRes.status()).toBe(204); + + // Then the public page returns 404 + const res = await publicPage.request.get(`/api/shared/${token}`); + expect(res.status()).toBe(404); + + await context.close(); + }); + + test('Scenario: Trashed note share link returns 404', async ({ + authenticatedPage: page + }) => { + // Given a publicly shared note that is then trashed + const note = await createNoteViaApi(page, 'Trash Share Test', 'Will be trashed'); + const token = await createShareViaApi(page, note.id); + + // When the note is trashed + await page.request.patch(`/api/notes/${note.id}`, { + data: { trashed: true } + }); + + // Then the shared API returns 404 + const res = await page.request.get(`/api/shared/${token}`); + expect(res.status()).toBe(404); + }); + + test('Scenario: Globe icon appears on shared note card', async ({ + authenticatedPage: page + }) => { + // Given a note with a public share link + const note = await createNoteViaApi(page, 'Globe Icon Test'); + await createShareViaApi(page, note.id); + + // When the user views the notes list + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Then the note card shows a sharing indicator + const card = noteCard(page, 'Globe Icon Test'); + await expect(card).toBeVisible(); + await expect(card.getByTestId('sharing-indicator')).toBeVisible(); + }); + + test('Scenario: Copy link button copies to clipboard', async ({ + authenticatedPage: page + }) => { + // Given a note with a public share link + const note = await createNoteViaApi(page, 'Copy Link Test'); + await createShareViaApi(page, note.id); + await page.reload(); + await page.waitForLoadState('networkidle'); + + // When the owner opens the share dialog + await noteCard(page, 'Copy Link Test').click(); + await page.getByTestId('share-toggle').click(); + await expect(page.getByTestId('share-dialog')).toBeVisible(); + + // Then the share URL is already visible (token was created via API) + await expect(page.getByTestId('share-url-container')).toBeVisible({ timeout: 5000 }); + + // And clicking copy shows the check icon + // Grant clipboard permissions for the test + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await page.getByTestId('copy-share-url-btn').click(); + + // The clipboard should contain the share URL + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText).toContain('/s/'); + }); +}); diff --git a/tests/e2e/sharing.spec.ts b/tests/e2e/sharing.spec.ts index a3f9549..1219a7c 100644 --- a/tests/e2e/sharing.spec.ts +++ b/tests/e2e/sharing.spec.ts @@ -54,8 +54,8 @@ test.describe('Note Sharing', () => { await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } }); await page.getByTestId('close-editor-btn').click(); - // Then the owner sees a collaborator indicator - await expect(noteCard(page, 'Share Test').getByTestId('collaborator-indicator')).toBeVisible(); + // Then the owner sees a sharing indicator + await expect(noteCard(page, 'Share Test').getByTestId('sharing-indicator')).toBeVisible(); // And the collaborator sees the shared note in their list await collabPage.reload();