mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
8a453e3b98
commit
971963e0cc
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof notes.$inferInsert> = {}
|
||||
) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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/');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user