mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
test: add 52 unit tests for notes-service, api-utils, rate-limit, tags, collaborators
- notes-service.test.ts (24 tests): listNotes, getNote, createNote, updateNote, deleteNote, searchNotes, reorderNotes with owner/collaborator patterns - api-utils.test.ts (10 tests): canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId - rate-limit.test.ts (8 tests): checkRateLimit, recordLoginAttempt, clearOldAttempts - tags.test.ts (6 tests): syncNoteTags, fetchTagsForNotes - collaborators.test.ts (4 tests): fetchCollaboratorsForNotes, addCollaborator, removeCollaborator Also fixes test-helpers.ts missing thumbnail_path/featured columns and rate-limit.ts raw SQL Date binding issue. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
e794d09978
commit
914b4fc772
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteCollaborators, users } from './db/schema.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId } from './api-utils.js';
|
||||
|
||||
let db: Db;
|
||||
const OWNER_ID = 1;
|
||||
const COLLAB_ID = 2;
|
||||
const STRANGER_ID = 3;
|
||||
|
||||
function seedUsers() {
|
||||
db.insert(users)
|
||||
.values([
|
||||
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'stranger@test.com', displayName: 'Stranger', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string) {
|
||||
db.insert(notes)
|
||||
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
function shareNote(noteId: string) {
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId, userId: COLLAB_ID, addedBy: OWNER_ID, addedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUsers();
|
||||
});
|
||||
|
||||
describe('canAccessNote', () => {
|
||||
it('should allow owner to access their note', () => {
|
||||
seedNote('n1');
|
||||
const result = canAccessNote(db, 'n1', OWNER_ID);
|
||||
expect(result).toEqual({ canAccess: true, isOwner: true });
|
||||
});
|
||||
|
||||
it('should allow collaborator to access shared note', () => {
|
||||
seedNote('n1');
|
||||
shareNote('n1');
|
||||
const result = canAccessNote(db, 'n1', COLLAB_ID);
|
||||
expect(result).toEqual({ canAccess: true, isOwner: false });
|
||||
});
|
||||
|
||||
it('should deny non-collaborator access', () => {
|
||||
seedNote('n1');
|
||||
const result = canAccessNote(db, 'n1', STRANGER_ID);
|
||||
expect(result).toEqual({ canAccess: false, isOwner: false });
|
||||
});
|
||||
|
||||
it('should return false for non-existent note', () => {
|
||||
const result = canAccessNote(db, 'nonexistent', OWNER_ID);
|
||||
expect(result).toEqual({ canAccess: false, isOwner: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireNoteAccess', () => {
|
||||
it('should throw 404 for unauthorized user', () => {
|
||||
seedNote('n1');
|
||||
expect(() => requireNoteAccess(db, 'n1', STRANGER_ID)).toThrow();
|
||||
});
|
||||
|
||||
it('should return isOwner for authorized owner', () => {
|
||||
seedNote('n1');
|
||||
expect(requireNoteAccess(db, 'n1', OWNER_ID)).toEqual({ isOwner: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireNoteOwnership', () => {
|
||||
it('should throw 404 for collaborator', () => {
|
||||
seedNote('n1');
|
||||
shareNote('n1');
|
||||
expect(() => requireNoteOwnership(db, 'n1', COLLAB_ID)).toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for owner', () => {
|
||||
seedNote('n1');
|
||||
expect(() => requireNoteOwnership(db, 'n1', OWNER_ID)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserId', () => {
|
||||
it('should throw 401 when no user in locals', () => {
|
||||
expect(() => getUserId({ locals: {} as App.Locals })).toThrow();
|
||||
});
|
||||
|
||||
it('should return user id from locals', () => {
|
||||
const id = getUserId({ locals: { user: { id: 42, role: 'user', email: '', displayName: '' } } as App.Locals });
|
||||
expect(id).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import { fetchCollaboratorsForNotes, addCollaborator, removeCollaborator } from './collaborators.js';
|
||||
|
||||
let db: Db;
|
||||
const OWNER_ID = 1;
|
||||
const COLLAB_ID = 2;
|
||||
|
||||
function seedUsers() {
|
||||
db.insert(users)
|
||||
.values([
|
||||
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string) {
|
||||
db.insert(notes)
|
||||
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUsers();
|
||||
});
|
||||
|
||||
describe('fetchCollaboratorsForNotes', () => {
|
||||
it('should return Map with user details for shared notes', () => {
|
||||
seedNote('n1');
|
||||
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
|
||||
|
||||
const map = fetchCollaboratorsForNotes(db, ['n1']);
|
||||
const collabs = map.get('n1');
|
||||
expect(collabs).toHaveLength(1);
|
||||
expect(collabs![0].userId).toBe(COLLAB_ID);
|
||||
expect(collabs![0].displayName).toBe('Collab');
|
||||
expect(collabs![0].email).toBe('collab@test.com');
|
||||
});
|
||||
|
||||
it('should return empty Map for notes with no collaborators', () => {
|
||||
seedNote('n1');
|
||||
const map = fetchCollaboratorsForNotes(db, ['n1']);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addCollaborator', () => {
|
||||
it('should insert collaborator record', () => {
|
||||
seedNote('n1');
|
||||
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
|
||||
|
||||
const rows = db.select().from(noteCollaborators).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].userId).toBe(COLLAB_ID);
|
||||
expect(rows[0].addedBy).toBe(OWNER_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCollaborator', () => {
|
||||
it('should clean up noteUserState on removal', () => {
|
||||
seedNote('n1');
|
||||
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'n1', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 0 })
|
||||
.run();
|
||||
|
||||
removeCollaborator(db, 'n1', COLLAB_ID);
|
||||
|
||||
const collabs = db.select().from(noteCollaborators).all();
|
||||
expect(collabs).toHaveLength(0);
|
||||
|
||||
const states = db.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, COLLAB_ID)))
|
||||
.all();
|
||||
expect(states).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,8 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
thumbnail_path TEXT,
|
||||
featured INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import {
|
||||
listNotes,
|
||||
getNote,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
searchNotes,
|
||||
reorderNotes
|
||||
} from './notes-service.js';
|
||||
|
||||
let db: Db;
|
||||
|
||||
const OWNER_ID = 1;
|
||||
const COLLAB_ID = 2;
|
||||
|
||||
function seedUsers() {
|
||||
db.insert(users)
|
||||
.values([
|
||||
{
|
||||
email: 'owner@test.com',
|
||||
displayName: 'Owner',
|
||||
role: 'admin',
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
email: 'collab@test.com',
|
||||
displayName: 'Collab',
|
||||
role: 'user',
|
||||
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: '',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
function shareNote(noteId: string, userId = COLLAB_ID) {
|
||||
db.insert(noteCollaborators)
|
||||
.values({
|
||||
noteId,
|
||||
userId,
|
||||
addedBy: OWNER_ID,
|
||||
addedAt: new Date()
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUsers();
|
||||
});
|
||||
|
||||
describe('listNotes', () => {
|
||||
it('should return only active owned notes (excludes archived/trashed)', () => {
|
||||
seedNote('active', { title: 'Active' });
|
||||
seedNote('archived', { title: 'Archived', archived: true });
|
||||
seedNote('trashed', { title: 'Trashed', trashed: true });
|
||||
|
||||
const result = listNotes(db, OWNER_ID);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Active');
|
||||
});
|
||||
|
||||
it('should include shared notes with per-user state overlay', () => {
|
||||
seedNote('shared', { title: 'Shared Note' });
|
||||
shareNote('shared');
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 5 })
|
||||
.run();
|
||||
|
||||
const result = listNotes(db, COLLAB_ID);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Shared Note');
|
||||
expect(result[0].pinned).toBeTruthy();
|
||||
expect(result[0].sortOrder).toBe(5);
|
||||
});
|
||||
|
||||
it('should sort pinned first, then by updatedAt desc', () => {
|
||||
const old = new Date('2024-01-01');
|
||||
const recent = new Date('2024-01-02');
|
||||
seedNote('old-pinned', { title: 'Old Pinned', pinned: true, updatedAt: old, createdAt: old });
|
||||
seedNote('recent', { title: 'Recent', updatedAt: recent, createdAt: recent });
|
||||
seedNote('old', { title: 'Old', updatedAt: old, createdAt: old });
|
||||
|
||||
const result = listNotes(db, OWNER_ID);
|
||||
expect(result.map((n) => n.title)).toEqual(['Old Pinned', 'Recent', 'Old']);
|
||||
});
|
||||
|
||||
it('should return only archived notes with archived filter', () => {
|
||||
seedNote('active', { title: 'Active' });
|
||||
seedNote('archived', { title: 'Archived', archived: true });
|
||||
seedNote('shared', { title: 'Shared Archived' });
|
||||
shareNote('shared');
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 0 })
|
||||
.run();
|
||||
|
||||
// Owner sees their archived note
|
||||
const ownerResult = listNotes(db, OWNER_ID, 'archived');
|
||||
expect(ownerResult).toHaveLength(1);
|
||||
expect(ownerResult[0].title).toBe('Archived');
|
||||
|
||||
// Collaborator sees shared note they archived
|
||||
const collabResult = listNotes(db, COLLAB_ID, 'archived');
|
||||
expect(collabResult).toHaveLength(1);
|
||||
expect(collabResult[0].title).toBe('Shared Archived');
|
||||
});
|
||||
|
||||
it('should exclude shared notes from trashed filter', () => {
|
||||
seedNote('trashed', { title: 'Trashed', trashed: true });
|
||||
seedNote('shared', { title: 'Shared' });
|
||||
shareNote('shared');
|
||||
|
||||
const result = listNotes(db, COLLAB_ID, 'trashed');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNote', () => {
|
||||
it('should return null for non-existent note', () => {
|
||||
expect(getNote(db, OWNER_ID, 'nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if user has no access', () => {
|
||||
seedNote('private', { title: 'Private' });
|
||||
expect(getNote(db, COLLAB_ID, 'private')).toBeNull();
|
||||
});
|
||||
|
||||
it('should overlay collaborator per-user state', () => {
|
||||
seedNote('shared', { title: 'Shared', pinned: true, sortOrder: 10 });
|
||||
shareNote('shared');
|
||||
db.insert(noteUserState)
|
||||
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 5 })
|
||||
.run();
|
||||
|
||||
const note = getNote(db, COLLAB_ID, 'shared');
|
||||
expect(note).not.toBeNull();
|
||||
expect(note!.pinned).toBeFalsy();
|
||||
expect(note!.archived).toBeTruthy();
|
||||
expect(note!.sortOrder).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNote', () => {
|
||||
it('should generate UUID and set version=1', () => {
|
||||
const note = createNote(db, OWNER_ID, { title: 'Test' });
|
||||
expect(note.id).toBeDefined();
|
||||
expect(note.id.length).toBe(36); // UUID format
|
||||
expect(note.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should use provided ID when given', () => {
|
||||
const note = createNote(db, OWNER_ID, { id: 'custom-id', title: 'Test' });
|
||||
expect(note.id).toBe('custom-id');
|
||||
});
|
||||
|
||||
it('should extract tags from content', () => {
|
||||
const note = createNote(db, OWNER_ID, {
|
||||
title: 'My #todo note',
|
||||
content: 'Some #important content'
|
||||
});
|
||||
expect(note.tags).toContain('todo');
|
||||
expect(note.tags).toContain('important');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateNote', () => {
|
||||
it('should allow owner to update shared fields directly', () => {
|
||||
seedNote('n1', { title: 'Original' });
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Updated' });
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.title).toBe('Updated');
|
||||
});
|
||||
|
||||
it('should allow collaborator to edit content', () => {
|
||||
seedNote('n1', { title: 'Original', content: 'Old' });
|
||||
shareNote('n1');
|
||||
const updated = updateNote(db, COLLAB_ID, 'n1', { content: 'New content' });
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.content).toBe('New content');
|
||||
});
|
||||
|
||||
it('should route collaborator per-user fields to noteUserState', () => {
|
||||
seedNote('n1', { title: 'Note', pinned: false });
|
||||
shareNote('n1');
|
||||
updateNote(db, COLLAB_ID, 'n1', { pinned: true, sortOrder: 7 });
|
||||
|
||||
// Owner's note pinned should be unchanged
|
||||
const ownerView = getNote(db, OWNER_ID, 'n1');
|
||||
expect(ownerView!.pinned).toBeFalsy();
|
||||
|
||||
// Collaborator sees their per-user state
|
||||
const collabView = getNote(db, COLLAB_ID, 'n1');
|
||||
expect(collabView!.pinned).toBeTruthy();
|
||||
expect(collabView!.sortOrder).toBe(7);
|
||||
});
|
||||
|
||||
it('should return null when collaborator tries to trash', () => {
|
||||
seedNote('n1', { title: 'Note' });
|
||||
shareNote('n1');
|
||||
const result = updateNote(db, COLLAB_ID, 'n1', { trashed: true });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should increment version on update', () => {
|
||||
seedNote('n1', { title: 'V1' });
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'V2' });
|
||||
expect(updated!.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should extract and sync tags on title/content change', () => {
|
||||
seedNote('n1', { title: 'Original' });
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
|
||||
expect(updated!.tags).toContain('newtag');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteNote', () => {
|
||||
it('should allow owner to permanently delete', () => {
|
||||
seedNote('n1', { title: 'Delete me' });
|
||||
const result = deleteNote(db, OWNER_ID, 'n1');
|
||||
expect(result).toBe(true);
|
||||
expect(getNote(db, OWNER_ID, 'n1')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return false for non-existent or unauthorized', () => {
|
||||
seedNote('n1', { title: 'Private' });
|
||||
expect(deleteNote(db, COLLAB_ID, 'n1')).toBe(false);
|
||||
expect(deleteNote(db, OWNER_ID, 'nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchNotes', () => {
|
||||
it('should match title and content', () => {
|
||||
seedNote('n1', { title: 'Meeting notes', content: 'Discuss budget' });
|
||||
seedNote('n2', { title: 'Shopping list', content: 'Buy milk' });
|
||||
|
||||
const results = searchNotes(db, OWNER_ID, 'budget');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('n1');
|
||||
});
|
||||
|
||||
it('should include shared notes in results', () => {
|
||||
seedNote('shared', { title: 'Shared doc', content: 'Collaboration' });
|
||||
shareNote('shared');
|
||||
|
||||
const results = searchNotes(db, COLLAB_ID, 'Collaboration');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('shared');
|
||||
});
|
||||
|
||||
it('should return empty array for empty query', () => {
|
||||
seedNote('n1', { title: 'Test' });
|
||||
expect(searchNotes(db, OWNER_ID, '')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderNotes', () => {
|
||||
it('should update owner sortOrder in notes table', () => {
|
||||
seedNote('n1', { title: 'A' });
|
||||
seedNote('n2', { title: 'B' });
|
||||
reorderNotes(db, OWNER_ID, [
|
||||
{ id: 'n1', sortOrder: 2 },
|
||||
{ id: 'n2', sortOrder: 1 }
|
||||
]);
|
||||
|
||||
const n1 = getNote(db, OWNER_ID, 'n1');
|
||||
const n2 = getNote(db, OWNER_ID, 'n2');
|
||||
expect(n1!.sortOrder).toBe(2);
|
||||
expect(n2!.sortOrder).toBe(1);
|
||||
});
|
||||
|
||||
it('should update collaborator sortOrder in noteUserState', () => {
|
||||
seedNote('n1', { title: 'Note', sortOrder: 0 });
|
||||
shareNote('n1');
|
||||
reorderNotes(db, COLLAB_ID, [{ id: 'n1', sortOrder: 5 }]);
|
||||
|
||||
// Owner's sortOrder unchanged
|
||||
const ownerView = getNote(db, OWNER_ID, 'n1');
|
||||
expect(ownerView!.sortOrder).toBe(0);
|
||||
|
||||
// Collaborator sees their sortOrder
|
||||
const collabView = getNote(db, COLLAB_ID, 'n1');
|
||||
expect(collabView!.sortOrder).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { loginAttempts } from './db/schema.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { checkRateLimit, recordLoginAttempt, clearOldAttempts } from './rate-limit.js';
|
||||
|
||||
let db: Db;
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
});
|
||||
|
||||
const IP = '192.168.1.1';
|
||||
const EMAIL = 'test@test.com';
|
||||
|
||||
function addFailedAttempts(count: number, now: Date) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
recordLoginAttempt(db, IP, EMAIL, false, now);
|
||||
}
|
||||
}
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
it('should allow first attempt', () => {
|
||||
const result = checkRateLimit(db, IP, EMAIL);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow up to 4 failed attempts', () => {
|
||||
const now = new Date();
|
||||
addFailedAttempts(4, now);
|
||||
const result = checkRateLimit(db, IP, EMAIL, now);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should block after 5 failed attempts within window', () => {
|
||||
const now = new Date();
|
||||
addFailedAttempts(5, now);
|
||||
const result = checkRateLimit(db, IP, EMAIL, now);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.retryAfter).toBeDefined();
|
||||
expect(result.retryAfter!).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return correct retryAfter value', () => {
|
||||
const now = new Date();
|
||||
addFailedAttempts(5, now);
|
||||
const result = checkRateLimit(db, IP, EMAIL, now);
|
||||
// Lockout is 15 minutes (900 seconds)
|
||||
expect(result.retryAfter).toBe(900);
|
||||
});
|
||||
|
||||
it('should allow again after lockout expires', () => {
|
||||
const attemptTime = new Date('2024-01-01T00:00:00Z');
|
||||
addFailedAttempts(5, attemptTime);
|
||||
|
||||
// Check at attempt time — should be blocked
|
||||
const blocked = checkRateLimit(db, IP, EMAIL, attemptTime);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
// Check 16 minutes later — should be allowed
|
||||
const later = new Date(attemptTime.getTime() + 16 * 60 * 1000);
|
||||
const allowed = checkRateLimit(db, IP, EMAIL, later);
|
||||
expect(allowed.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordLoginAttempt', () => {
|
||||
it('should insert attempt record', () => {
|
||||
recordLoginAttempt(db, IP, EMAIL, false);
|
||||
const rows = db.select().from(loginAttempts).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ip).toBe(IP);
|
||||
expect(rows[0].email).toBe(EMAIL);
|
||||
});
|
||||
|
||||
it('should record success/failure flag correctly', () => {
|
||||
recordLoginAttempt(db, IP, EMAIL, true);
|
||||
recordLoginAttempt(db, IP, EMAIL, false);
|
||||
const rows = db.select().from(loginAttempts).all();
|
||||
expect(rows[0].success).toBe(true);
|
||||
expect(rows[1].success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearOldAttempts', () => {
|
||||
it('should remove attempts older than 2x lockout', () => {
|
||||
const old = new Date('2024-01-01T00:00:00Z');
|
||||
const recent = new Date('2024-01-01T01:00:00Z');
|
||||
recordLoginAttempt(db, IP, EMAIL, false, old);
|
||||
recordLoginAttempt(db, IP, EMAIL, false, recent);
|
||||
|
||||
// Clear at a time where old is > 2x lockout (30 min) ago but recent is not
|
||||
const clearTime = new Date(old.getTime() + 31 * 60 * 1000);
|
||||
clearOldAttempts(db, clearTime);
|
||||
|
||||
const rows = db.select().from(loginAttempts).all();
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { loginAttempts } from './db/schema.js';
|
||||
import { and, eq, gte, sql } from 'drizzle-orm';
|
||||
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const WINDOW_MS = 60 * 1000; // 1 minute
|
||||
@@ -72,6 +72,6 @@ export function recordLoginAttempt(db: Db, ip: string, email: string, success: b
|
||||
export function clearOldAttempts(db: Db, now?: Date): void {
|
||||
const cutoff = new Date((now ?? new Date()).getTime() - LOCKOUT_MS * 2);
|
||||
db.delete(loginAttempts)
|
||||
.where(sql`${loginAttempts.timestamp} < ${cutoff}`)
|
||||
.where(lt(loginAttempts.timestamp, cutoff))
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, tags, noteTags, users } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
|
||||
let db: Db;
|
||||
const USER_ID = 1;
|
||||
|
||||
function seedUser() {
|
||||
db.insert(users)
|
||||
.values({ email: 'test@test.com', displayName: 'Test', role: 'admin', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string) {
|
||||
db.insert(notes)
|
||||
.values({ id, userId: USER_ID, title: '', content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUser();
|
||||
});
|
||||
|
||||
describe('syncNoteTags', () => {
|
||||
it('should create new tags and associations', () => {
|
||||
seedNote('n1');
|
||||
syncNoteTags(db, 'n1', ['todo', 'urgent'], USER_ID);
|
||||
|
||||
const tagRows = db.select().from(tags).all();
|
||||
expect(tagRows).toHaveLength(2);
|
||||
|
||||
const associations = db.select().from(noteTags).all();
|
||||
expect(associations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should remove old associations and add new ones', () => {
|
||||
seedNote('n1');
|
||||
syncNoteTags(db, 'n1', ['old-tag'], USER_ID);
|
||||
syncNoteTags(db, 'n1', ['new-tag'], USER_ID);
|
||||
|
||||
const map = fetchTagsForNotes(db, ['n1']);
|
||||
expect(map.get('n1')).toEqual(['new-tag']);
|
||||
});
|
||||
|
||||
it('should handle empty tag list (removes all)', () => {
|
||||
seedNote('n1');
|
||||
syncNoteTags(db, 'n1', ['tag1', 'tag2'], USER_ID);
|
||||
syncNoteTags(db, 'n1', [], USER_ID);
|
||||
|
||||
const associations = db.select().from(noteTags).where(eq(noteTags.noteId, 'n1')).all();
|
||||
expect(associations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should deduplicate existing tags', () => {
|
||||
seedNote('n1');
|
||||
seedNote('n2');
|
||||
syncNoteTags(db, 'n1', ['shared-tag'], USER_ID);
|
||||
syncNoteTags(db, 'n2', ['shared-tag'], USER_ID);
|
||||
|
||||
// Should reuse the same tag row
|
||||
const tagRows = db.select().from(tags).all();
|
||||
expect(tagRows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTagsForNotes', () => {
|
||||
it('should return Map of noteId to tag names', () => {
|
||||
seedNote('n1');
|
||||
seedNote('n2');
|
||||
syncNoteTags(db, 'n1', ['alpha', 'beta'], USER_ID);
|
||||
syncNoteTags(db, 'n2', ['gamma'], USER_ID);
|
||||
|
||||
const map = fetchTagsForNotes(db, ['n1', 'n2']);
|
||||
expect(map.get('n1')).toHaveLength(2);
|
||||
expect(map.get('n1')).toContain('alpha');
|
||||
expect(map.get('n1')).toContain('beta');
|
||||
expect(map.get('n2')).toEqual(['gamma']);
|
||||
});
|
||||
|
||||
it('should return empty map for empty noteIds', () => {
|
||||
const map = fetchTagsForNotes(db, []);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user