diff --git a/server/routes/shares.integration.test.ts b/server/routes/shares.integration.test.ts new file mode 100644 index 00000000..1862c7af --- /dev/null +++ b/server/routes/shares.integration.test.ts @@ -0,0 +1,845 @@ +import { eq, sql } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { shareRecipients, shares } from '../db/schema.js' +import * as emailService from '../services/email.js' +import { S3Service } from '../services/s3.js' +import { authedHeaders, createTestApp } from '../test/setup.js' + +type TestApp = Awaited>['app'] +type TestDb = Awaited>['db'] + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function _signUpAndGetUser(app: TestApp, email: string) { + const res = await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Test User', email, password: 'password123456' }), + }) + const cookies = res.headers.getSetCookie().join('; ') + const body = (await res.json()) as { user?: { id: string } } + return { headers: { Cookie: cookies }, userId: body.user?.id ?? '' } +} + +const validStorage = { + id: 'st-share-test', + title: 'Test S3', + mode: 'private', + bucket: 'test-bucket', + endpoint: 'https://s3.amazonaws.com', + region: 'us-east-1', + accessKey: 'AKIAIOSFODNN7EXAMPLE', + secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', +} + +async function insertStorage(db: TestDb) { + const now = Date.now() + await db.run(sql` + INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) + VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now}) + `) +} + +async function insertFile( + db: TestDb, + orgId: string, + opts: { id: string; name: string; parent?: string; status?: string }, +) { + const now = Date.now() + const status = opts.status ?? 'active' + await db.run(sql` + INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at) + VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'text/plain', 100, 0, ${opts.parent ?? ''}, 'some/key.txt', ${validStorage.id}, ${status}, ${now}, ${now}) + `) +} + +async function insertFolder(db: TestDb, orgId: string, opts: { id: string; name: string; parent?: string }) { + const now = Date.now() + await db.run(sql` + INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at) + VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'folder', 0, 1, ${opts.parent ?? ''}, '', ${validStorage.id}, 'active', ${now}, ${now}) + `) +} + +async function getOrgId(db: TestDb): Promise { + const rows = await db.all<{ id: string }>(sql` + SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1 + `) + return rows[0].id +} + +async function createShare(app: TestApp, headers: Record, body: Record) { + return app.request('/api/shares', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +// ─── POST /api/shares auth guard ───────────────────────────────────────────── + +describe('POST /api/shares (auth guard)', () => { + it('returns 401 without auth', async () => { + const { app } = await createTestApp() + const res = await app.request('/api/shares', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ matterId: 'x', kind: 'landing' }), + }) + expect(res.status).toBe(401) + }) +}) + +// ─── POST /api/shares ───────────────────────────────────────────────────────── + +describe('POST /api/shares', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) + }) + + it('creates a landing share without password and returns 201 with correct shape', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'f1', name: 'doc.txt' }) + + const res = await createShare(app, headers, { matterId: 'f1', kind: 'landing' }) + expect(res.status).toBe(201) + + const body = (await res.json()) as Record + expect(typeof body.id).toBe('string') + expect(typeof body.token).toBe('string') + expect(body.kind).toBe('landing') + expect((body.urls as Record).landing).toMatch(/^\/s\//) + expect((body.urls as Record).direct).toBeUndefined() + }) + + it('creates a landing share with password and stores passwordHash in DB', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'f2', name: 'secret.txt' }) + + const res = await createShare(app, headers, { matterId: 'f2', kind: 'landing', password: 'hunter2' }) + expect(res.status).toBe(201) + + const body = (await res.json()) as Record + const shareId = body.id as string + + const rows = await db.select({ passwordHash: shares.passwordHash }).from(shares).where(eq(shares.id, shareId)) + expect(rows[0]?.passwordHash).not.toBeNull() + expect(rows[0]?.passwordHash).not.toBe('') + }) + + it('creates a landing share with recipients and inserts share_recipients rows', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'f3', name: 'report.txt' }) + + const recipients = [{ recipientEmail: 'alice@example.com' }, { recipientEmail: 'bob@example.com' }] + + const res = await createShare(app, headers, { matterId: 'f3', kind: 'landing', recipients }) + expect(res.status).toBe(201) + + const body = (await res.json()) as Record + const shareId = body.id as string + + const rows = await db.select().from(shareRecipients).where(eq(shareRecipients.shareId, shareId)) + expect(rows).toHaveLength(2) + }) + + it('creates a direct share for a file and returns direct url', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'f4', name: 'photo.jpg' }) + + const res = await createShare(app, headers, { matterId: 'f4', kind: 'direct' }) + expect(res.status).toBe(201) + + const body = (await res.json()) as Record + expect(body.kind).toBe('direct') + expect((body.urls as Record).direct).toMatch(/^\/dl\//) + expect((body.urls as Record).landing).toBeUndefined() + }) + + it('returns 400 with DIRECT_NO_FOLDER when creating direct share for a folder', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFolder(db, orgId, { id: 'fo1', name: 'My Folder' }) + + const res = await createShare(app, headers, { matterId: 'fo1', kind: 'direct' }) + expect(res.status).toBe(400) + + const body = (await res.json()) as Record + expect(body.code).toBe('DIRECT_NO_FOLDER') + }) + + it('returns 400 with DIRECT_NO_PASSWORD when creating direct share with password', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'f5', name: 'file.txt' }) + + const res = await createShare(app, headers, { matterId: 'f5', kind: 'direct', password: 'secret' }) + expect(res.status).toBe(400) + + const body = (await res.json()) as Record + expect(body.code).toBe('DIRECT_NO_PASSWORD') + }) + + it('returns 404 when matterId does not belong to current org', async () => { + const { app } = await createTestApp() + const headers = await authedHeaders(app) + + const res = await createShare(app, headers, { matterId: 'nonexistent-matter', kind: 'landing' }) + expect(res.status).toBe(404) + + const body = (await res.json()) as Record + expect(body.code).toBe('MATTER_NOT_FOUND') + }) + + it('sets expiresAt when provided in request', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'f6', name: 'expire.txt' }) + + const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() + const res = await createShare(app, headers, { + matterId: 'f6', + kind: 'landing', + expiresAt: futureDate, + }) + expect(res.status).toBe(201) + + const body = (await res.json()) as Record + expect(body.expiresAt).not.toBeNull() + }) + + it('sets downloadLimit when provided in request', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'f7', name: 'limited.txt' }) + + const res = await createShare(app, headers, { + matterId: 'f7', + kind: 'landing', + downloadLimit: 5, + }) + expect(res.status).toBe(201) + + const body = (await res.json()) as Record + expect(body.downloadLimit).toBe(5) + }) + + it('returns 400 with DIRECT_NO_RECIPIENTS when creating direct share with recipients', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + const orgId = await getOrgId(db) + const matterId = nanoid() + await insertFile(db, orgId, { id: matterId, name: 'file.txt' }) + + const res = await createShare(app, headers, { + matterId, + kind: 'direct', + recipients: [{ recipientEmail: 'someone@example.com' }], + }) + expect(res.status).toBe(400) + const body = (await res.json()) as Record + expect(body.code).toBe('DIRECT_NO_RECIPIENTS') + }) + + it('returns 500 when createShare throws an unexpected error', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + const orgId = await getOrgId(db) + const matterId = nanoid() + await insertFile(db, orgId, { id: matterId, name: 'file.txt' }) + + const shareService = await import('../services/share.js') + vi.spyOn(shareService, 'createShare').mockRejectedValueOnce(new Error('unexpected db error')) + + const res = await createShare(app, headers, { matterId, kind: 'landing' }) + expect(res.status).toBe(500) + }) + + it('returns 201 even when dispatchShareCreated rejects', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + const orgId = await getOrgId(db) + const matterId = nanoid() + await insertFile(db, orgId, { id: matterId, name: 'file.txt' }) + + const notifService = await import('../services/share-notification.js') + vi.spyOn(notifService, 'dispatchShareCreated').mockRejectedValueOnce(new Error('dispatch failed')) + + const res = await createShare(app, headers, { + matterId, + kind: 'landing', + recipients: [{ recipientEmail: 'someone@example.com' }], + }) + expect(res.status).toBe(201) + }) +}) + +// ─── GET /api/shares auth guard ─────────────────────────────────────────────── + +describe('GET /api/shares (auth guard)', () => { + it('returns 401 without auth', async () => { + const { app } = await createTestApp() + const res = await app.request('/api/shares') + expect(res.status).toBe(401) + }) +}) + +// ─── GET /api/shares ────────────────────────────────────────────────────────── + +describe('GET /api/shares', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) + }) + + it('returns empty list for a new user with no shares', async () => { + const { app } = await createTestApp() + const headers = await authedHeaders(app) + + const res = await app.request('/api/shares', { headers }) + expect(res.status).toBe(200) + + const body = (await res.json()) as { items: unknown[]; total: number; page: number; pageSize: number } + expect(body.items).toHaveLength(0) + expect(body.total).toBe(0) + }) + + it('returns shares with pagination fields in response', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'pg1', name: 'a.txt' }) + await insertFile(db, orgId, { id: 'pg2', name: 'b.txt' }) + + await createShare(app, headers, { matterId: 'pg1', kind: 'landing' }) + await createShare(app, headers, { matterId: 'pg2', kind: 'landing' }) + + const res = await app.request('/api/shares?page=1&pageSize=1', { headers }) + expect(res.status).toBe(200) + + const body = (await res.json()) as { items: unknown[]; total: number; page: number; pageSize: number } + expect(body.page).toBe(1) + expect(body.pageSize).toBe(1) + expect(body.items).toHaveLength(1) + expect(body.total).toBe(2) + }) + + it('does not return shares belonging to another user', async () => { + const { app, db } = await createTestApp() + await insertStorage(db) + + // User A creates a share + const headersA = await authedHeaders(app, `a-${nanoid()}@example.com`) + const orgIdA = await getOrgId(db) + await insertFile(db, orgIdA, { id: 'oa1', name: 'only-a.txt' }) + await createShare(app, headersA, { matterId: 'oa1', kind: 'landing' }) + + // User B lists their shares + const headersB = await authedHeaders(app, `b-${nanoid()}@example.com`) + const res = await app.request('/api/shares', { headers: headersB }) + expect(res.status).toBe(200) + + const body = (await res.json()) as { items: unknown[]; total: number } + expect(body.total).toBe(0) + expect(body.items).toHaveLength(0) + }) + + it('each list item has matter and recipientCount fields', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'rm1', name: 'has-recipients.txt' }) + + await createShare(app, headers, { + matterId: 'rm1', + kind: 'landing', + recipients: [{ recipientEmail: 'x@example.com' }], + }) + + const res = await app.request('/api/shares', { headers }) + expect(res.status).toBe(200) + + const body = (await res.json()) as { items: Array> } + const item = body.items[0] + expect(item).toHaveProperty('matter') + const matter = item.matter as Record + expect(typeof matter.name).toBe('string') + expect(typeof matter.type).toBe('string') + expect(typeof matter.dirtype).toBe('number') + expect(typeof item.recipientCount).toBe('number') + expect(item.recipientCount).toBe(1) + }) + + it('filters shares by status=active', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'st1', name: 'active.txt' }) + await insertFile(db, orgId, { id: 'st2', name: 'revoked.txt' }) + + const res1 = await createShare(app, headers, { matterId: 'st1', kind: 'landing' }) + const _body1 = (await res1.json()) as Record + const res2 = await createShare(app, headers, { matterId: 'st2', kind: 'landing' }) + const body2 = (await res2.json()) as Record + + // Revoke the second share + await app.request(`/api/shares/${body2.id}`, { method: 'DELETE', headers }) + + const resActive = await app.request('/api/shares?status=active', { headers }) + const activeBody = (await resActive.json()) as { items: unknown[]; total: number } + expect(activeBody.total).toBe(1) + + const resRevoked = await app.request('/api/shares?status=revoked', { headers }) + const revokedBody = (await resRevoked.json()) as { items: unknown[]; total: number } + expect(revokedBody.total).toBe(1) + }) +}) + +// ─── GET /api/shares/:id auth guard ────────────────────────────────────────── + +describe('GET /api/shares/:id (auth guard)', () => { + it('returns 401 without auth', async () => { + const { app } = await createTestApp() + const res = await app.request('/api/shares/some-id') + expect(res.status).toBe(401) + }) +}) + +// ─── GET /api/shares/:id ────────────────────────────────────────────────────── + +describe('GET /api/shares/:id', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) + }) + + it('returns full share data including recipients and matter for creator', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'gd1', name: 'get-detail.txt' }) + + const createRes = await createShare(app, headers, { + matterId: 'gd1', + kind: 'landing', + recipients: [{ recipientEmail: 'r@example.com' }], + }) + const createBody = (await createRes.json()) as Record + const shareId = createBody.id as string + + const res = await app.request(`/api/shares/${shareId}`, { headers }) + expect(res.status).toBe(200) + + const body = (await res.json()) as Record + expect(body.id).toBe(shareId) + expect(Array.isArray(body.recipients)).toBe(true) + expect((body.recipients as unknown[]).length).toBe(1) + expect(body).toHaveProperty('matter') + const matter = body.matter as Record + expect(matter.name).toBe('get-detail.txt') + }) + + it('returns 404 when share belongs to another user', async () => { + const { app, db } = await createTestApp() + await insertStorage(db) + + const headersA = await authedHeaders(app, `ownerA-${nanoid()}@example.com`) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'ga1', name: 'owned-by-a.txt' }) + + const createRes = await createShare(app, headersA, { matterId: 'ga1', kind: 'landing' }) + const createBody = (await createRes.json()) as Record + const shareId = createBody.id as string + + // User B tries to get User A's share + const headersB = await authedHeaders(app, `otherB-${nanoid()}@example.com`) + const res = await app.request(`/api/shares/${shareId}`, { headers: headersB }) + expect(res.status).toBe(404) + }) + + it('returns 404 for a non-existent share id', async () => { + const { app } = await createTestApp() + const headers = await authedHeaders(app) + + const res = await app.request('/api/shares/nonexistent-share-id', { headers }) + expect(res.status).toBe(404) + }) +}) + +// ─── DELETE /api/shares/:id auth guard ─────────────────────────────────────── + +describe('DELETE /api/shares/:id (auth guard)', () => { + it('returns 401 without auth', async () => { + const { app } = await createTestApp() + const res = await app.request('/api/shares/some-id', { method: 'DELETE' }) + expect(res.status).toBe(401) + }) +}) + +// ─── POST /:token/save ──────────────────────────────────────────────────────── + +describe('POST /api/shares/:token/save', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) + vi.spyOn(S3Service.prototype, 'copyObject').mockResolvedValue(undefined) + vi.spyOn(S3Service.prototype, 'streamCopy').mockResolvedValue(undefined) + }) + + it('returns 404 when share token does not exist', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + const orgId = await getOrgId(db) + + const res = await app.request('/api/shares/nonexistent-token/save', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }), + }) + expect(res.status).toBe(404) + }) + + it('returns 400 with DIRECT_SAVE_FORBIDDEN for direct shares', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'sv-direct', name: 'direct-file.txt' }) + + // Create direct share + const createRes = await createShare(app, headers, { matterId: 'sv-direct', kind: 'direct' }) + const createBody = (await createRes.json()) as Record + const token = createBody.token as string + + const res = await app.request(`/api/shares/${token}/save`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }), + }) + expect(res.status).toBe(400) + + const body = (await res.json()) as Record + expect(body.code).toBe('DIRECT_SAVE_FORBIDDEN') + }) + + it('returns 410 when the shared matter has been trashed', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'sv-trashed', name: 'will-trash.txt' }) + + const createRes = await createShare(app, headers, { matterId: 'sv-trashed', kind: 'landing' }) + const createBody = (await createRes.json()) as Record + const token = createBody.token as string + + // Trash the matter + await db.run(sql`UPDATE matters SET status = 'trashed' WHERE id = 'sv-trashed'`) + + const res = await app.request(`/api/shares/${token}/save`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }), + }) + expect(res.status).toBe(410) + }) + + it('saves a landing share file to personal drive and returns 201', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'sv-ok', name: 'shareable.txt' }) + + const createRes = await createShare(app, headers, { matterId: 'sv-ok', kind: 'landing' }) + const createBody = (await createRes.json()) as Record + const token = createBody.token as string + + const res = await app.request(`/api/shares/${token}/save`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }), + }) + expect(res.status).toBe(201) + }) + + it('returns 401 without auth', async () => { + const { app } = await createTestApp() + const res = await app.request('/api/shares/sometoken/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetOrgId: 'org1', targetParent: '' }), + }) + expect(res.status).toBe(401) + }) + + it('returns 400 QUOTA_EXCEEDED when target org quota is exhausted', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + + // Insert a file with non-zero size (100 bytes) + await insertFile(db, orgId, { id: 'sv-quota', name: 'big-file.txt' }) + + // The personal org already has an org_quota row created during sign-up. + // Update it to set used=quota so adding even 1 byte will exceed it. + await db.run(sql`UPDATE org_quotas SET quota = 1, used = 1 WHERE org_id = ${orgId}`) + + const createRes = await createShare(app, headers, { matterId: 'sv-quota', kind: 'landing' }) + const createBody = (await createRes.json()) as Record + const token = createBody.token as string + + const res = await app.request(`/api/shares/${token}/save`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }), + }) + expect(res.status).toBe(400) + + const body = (await res.json()) as Record + expect(body.code).toBe('QUOTA_EXCEEDED') + }) + + it('returns 403 when targetOrgId is not a personal org and user has no member role', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'sv-forbidden', name: 'forbidden.txt' }) + + const createRes = await createShare(app, headers, { matterId: 'sv-forbidden', kind: 'landing' }) + const createBody = (await createRes.json()) as Record + const token = createBody.token as string + + // Use a non-existent team org as target (user has no member record there, and it's not personal) + const fakeTeamOrgId = `team-org-${nanoid()}` + await db.run(sql` + INSERT INTO organization (id, name, slug, metadata, created_at) + VALUES (${fakeTeamOrgId}, 'Team Org', ${fakeTeamOrgId}, '{"type":"team"}', ${Date.now()}) + `) + + const res = await app.request(`/api/shares/${token}/save`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetOrgId: fakeTeamOrgId, targetParent: '' }), + }) + expect(res.status).toBe(403) + }) + + it('allows password-protected share save when the user is a listed recipient', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'sv-recip', name: 'recipient-file.txt' }) + + // Sign up user B to get their userId + const emailB = `recip-user-b-${nanoid()}@example.com` + const signupRes = await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'User B', email: emailB, password: 'password123456' }), + }) + const signupBody = (await signupRes.json()) as { user?: { id: string } } + const userBId = signupBody.user?.id ?? '' + const cookiesB = signupRes.headers.getSetCookie().join('; ') + const headersB = { Cookie: cookiesB } + + const orgIdB = await getOrgId(db) + + // User A creates a password-protected landing share with user B as recipient + const createRes = await createShare(app, headers, { + matterId: 'sv-recip', + kind: 'landing', + password: 'secretPass123', + recipients: [{ recipientUserId: userBId }], + }) + const createBody = (await createRes.json()) as Record + const token = createBody.token as string + + // User B saves without cookie — should be allowed because they are a recipient + const res = await app.request(`/api/shares/${token}/save`, { + method: 'POST', + headers: { ...headersB, 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetOrgId: orgIdB, targetParent: '' }), + }) + // Should NOT get 401 — recipient bypasses cookie requirement + expect(res.status).not.toBe(401) + }) + + it('returns 401 for password-protected share when user is not a recipient and no cookie', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'sv-pw', name: 'protected.txt' }) + + // Create share with password as user A + const createRes = await createShare(app, headers, { + matterId: 'sv-pw', + kind: 'landing', + password: 'topSecret123', + }) + const createBody = (await createRes.json()) as Record + const token = createBody.token as string + + // User B tries to save without cookie + const headersB = await authedHeaders(app, `pw-user-b-${nanoid()}@example.com`) + const res = await app.request(`/api/shares/${token}/save`, { + method: 'POST', + headers: { ...headersB, 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }), + }) + expect(res.status).toBe(401) + }) + + it('bypasses 401 for password-protected share when non-recipient has valid sharetk cookie', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'sv-cookie-auth', name: 'cookie-auth.txt' }) + + // User A creates a password-protected share with no recipients + const createRes = await createShare(app, headers, { + matterId: 'sv-cookie-auth', + kind: 'landing', + password: 'cookiePass123', + }) + const createBody = (await createRes.json()) as Record + const token = createBody.token as string + + // User B signs up; not a recipient but presents the sharetk cookie + const headersB = await authedHeaders(app, `cookie-norecip-${nanoid()}@example.com`) + const cookieHeader = `${headersB.Cookie}; sharetk_${token}=authenticated` + const res = await app.request(`/api/shares/${token}/save`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookieHeader }, + body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }), + }) + // Cookie present → auth check passes; any response other than 401 is correct + expect(res.status).not.toBe(401) + }) + + it('returns 403 when user has a viewer role in the target org', async () => { + const { app, db } = await createTestApp() + // First user creates the share + const ownerHeaders = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'sv-viewer-role', name: 'viewer-role.txt' }) + + const createRes = await createShare(app, ownerHeaders, { matterId: 'sv-viewer-role', kind: 'landing' }) + const createBody = (await createRes.json()) as Record + const token = createBody.token as string + + // Second user signs up — sign-up response reliably returns user.id + const viewerEmail = `viewer-user-${nanoid()}@example.com` + const signupRes = await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Viewer User', email: viewerEmail, password: 'password123456' }), + }) + const signupBody = (await signupRes.json()) as { user?: { id: string } } + const viewerId = signupBody.user?.id ?? '' + const viewerHeaders = { Cookie: signupRes.headers.getSetCookie().join('; ') } + + // Create a team org and add viewer as a viewer member (FK requires a real user.id) + const teamOrgId = `viewer-team-${nanoid()}` + await db.run(sql` + INSERT INTO organization (id, name, slug, metadata, created_at) + VALUES (${teamOrgId}, 'Viewer Team', ${teamOrgId}, '{"type":"team"}', ${Date.now()}) + `) + await db.run(sql` + INSERT INTO member (id, organization_id, user_id, role, created_at) + VALUES (${nanoid()}, ${teamOrgId}, ${viewerId}, 'viewer', ${Date.now()}) + `) + + // Viewer tries to save to the team org → getMemberRole returns 'viewer' → 403 + const res = await app.request(`/api/shares/${token}/save`, { + method: 'POST', + headers: { ...viewerHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetOrgId: teamOrgId, targetParent: '' }), + }) + expect(res.status).toBe(403) + }) +}) + +// ─── DELETE /api/shares/:id ─────────────────────────────────────────────────── + +describe('DELETE /api/shares/:id', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) + }) + + it('creator can delete their share and share status becomes revoked in DB', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'del1', name: 'delete-me.txt' }) + + const createRes = await createShare(app, headers, { matterId: 'del1', kind: 'landing' }) + const createBody = (await createRes.json()) as Record + const shareId = createBody.id as string + + const res = await app.request(`/api/shares/${shareId}`, { method: 'DELETE', headers }) + expect(res.status).toBe(204) + + const rows = await db.select({ status: shares.status }).from(shares).where(eq(shares.id, shareId)) + expect(rows[0]?.status).toBe('revoked') + }) + + it('returns 403 when non-creator tries to delete a share', async () => { + const { app, db } = await createTestApp() + await insertStorage(db) + + const headersA = await authedHeaders(app, `del-owner-${nanoid()}@example.com`) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'del2', name: 'other-share.txt' }) + + const createRes = await createShare(app, headersA, { matterId: 'del2', kind: 'landing' }) + const createBody = (await createRes.json()) as Record + const shareId = createBody.id as string + + const headersB = await authedHeaders(app, `del-other-${nanoid()}@example.com`) + const res = await app.request(`/api/shares/${shareId}`, { method: 'DELETE', headers: headersB }) + expect(res.status).toBe(403) + }) + + it('returns 404 for non-existent share', async () => { + const { app } = await createTestApp() + const headers = await authedHeaders(app) + + const res = await app.request('/api/shares/does-not-exist', { method: 'DELETE', headers }) + expect(res.status).toBe(404) + }) +}) diff --git a/server/routes/shares.ts b/server/routes/shares.ts index 0c8f20f5..a47480f1 100644 --- a/server/routes/shares.ts +++ b/server/routes/shares.ts @@ -1,12 +1,16 @@ import { zValidator } from '@hono/zod-validator' +import { eq } from 'drizzle-orm' import { Hono } from 'hono' import { getCookie } from 'hono/cookie' -import { saveShareRequestSchema } from '../../shared/schemas/share' -import { requireAuth } from '../middleware/auth' +import { createShareRequestSchema, listSharesQuerySchema, saveShareRequestSchema } from '../../shared/schemas/share' +import { user } from '../db/auth-schema' +import { matters } from '../db/schema' +import { requireAuth, requireTeamRole } from '../middleware/auth' import type { Env } from '../middleware/platform' import { getMemberRole, isPersonalOrg } from '../services/org' import { computeSourceBytes, isQuotaSufficient, saveShareToDrive } from '../services/save-to-drive' -import { resolveShareByToken } from '../services/share' +import { createShare, getShareById, listSharesForApi, resolveShareByToken, revokeShare } from '../services/share' +import { dispatchShareCreated } from '../services/share-notification' const ROLE_LEVELS: Record = { owner: 3, @@ -15,15 +19,116 @@ const ROLE_LEVELS: Record = { member: 1, } +function shareUrls(kind: string, token: string): { landing?: string; direct?: string } { + if (kind === 'landing') return { landing: `/s/${token}` } + return { direct: `/dl/${token}` } +} + const app = new Hono() .use(requireAuth) + .post('/', requireTeamRole('editor'), zValidator('json', createShareRequestSchema), async (c) => { + const orgId = c.get('orgId')! + const userId = c.get('userId')! + const db = c.get('platform').db + const body = c.req.valid('json') + + let expiresAt: Date | undefined + if (body.expiresAt) expiresAt = new Date(body.expiresAt) + + // Pre-fetch creator name and matter name to avoid extra queries after createShare + const [creatorRow, matterRow] = await Promise.all([ + db.select({ name: user.name }).from(user).where(eq(user.id, userId)).limit(1), + db.select({ name: matters.name }).from(matters).where(eq(matters.id, body.matterId)).limit(1), + ]) + // Missing user is a data integrity violation — do not silently use a raw ID + const creatorName = creatorRow[0]?.name ?? 'Unknown' + // matterRow may be undefined if matterId is invalid; createShare validates and throws MATTER_NOT_FOUND + const matterName = matterRow[0]?.name + + let share: Awaited> + try { + share = await createShare(db, { + matterId: body.matterId, + orgId, + creatorId: userId, + kind: body.kind, + password: body.password, + expiresAt, + downloadLimit: body.downloadLimit, + recipients: body.recipients, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : '' + if (msg === 'MATTER_NOT_FOUND') return c.json({ error: 'Matter not found', code: 'MATTER_NOT_FOUND' }, 404) + if (msg === 'DIRECT_NO_FOLDER') + return c.json({ error: 'Direct shares cannot be folders', code: 'DIRECT_NO_FOLDER' }, 400) + if (msg === 'DIRECT_NO_PASSWORD') + return c.json({ error: 'Direct shares cannot have a password', code: 'DIRECT_NO_PASSWORD' }, 400) + if (msg === 'DIRECT_NO_RECIPIENTS') + return c.json({ error: 'Direct shares cannot have recipients', code: 'DIRECT_NO_RECIPIENTS' }, 400) + throw err + } + + // createShare succeeded — matter is guaranteed valid; matterName fallback is unreachable in practice + const resolvedMatterName = matterName ?? '' + + // Fire-and-forget: do not block response on notification dispatch + const recipients = body.recipients ?? [] + if (recipients.length > 0) { + dispatchShareCreated(db, share, recipients, creatorName, resolvedMatterName).catch((err) => + console.error('[shares] dispatchShareCreated failed:', err), + ) + } + + return c.json( + { + id: share.id, + token: share.token, + kind: share.kind, + urls: shareUrls(share.kind, share.token), + expiresAt: share.expiresAt, + downloadLimit: share.downloadLimit, + }, + 201, + ) + }) + .get('/', zValidator('query', listSharesQuerySchema), async (c) => { + const userId = c.get('userId')! + const db = c.get('platform').db + const { page, pageSize, status } = c.req.valid('query') + + const result = await listSharesForApi(db, userId, { page, pageSize, status }) + return c.json({ ...result, page, pageSize }) + }) + .get('/:id', async (c) => { + const userId = c.get('userId')! + const db = c.get('platform').db + const { id } = c.req.param() + + const share = await getShareById(db, id) + if (!share || share.creatorId !== userId) return c.json({ error: 'Not found' }, 404) + + return c.json(share) + }) + .delete('/:id', async (c) => { + const userId = c.get('userId')! + const db = c.get('platform').db + const { id } = c.req.param() + + const share = await getShareById(db, id) + if (!share) return c.json({ error: 'Not found' }, 404) + if (share.creatorId !== userId) return c.json({ error: 'Forbidden' }, 403) + + await revokeShare(db, id, userId) + + return new Response(null, { status: 204 }) + }) .post('/:token/save', zValidator('json', saveShareRequestSchema), async (c) => { const token = c.req.param('token') const { targetOrgId, targetParent } = c.req.valid('json') const currentUserId = c.get('userId')! const db = c.get('platform').db - // 1. Resolve share — distinguish not_found/revoked from matter_trashed const resolution = await resolveShareByToken(db, token) if (resolution.status === 'matter_trashed') { return c.json({ error: 'Share target has been deleted' }, 410) @@ -34,7 +139,6 @@ const app = new Hono() const { share, matter, recipients } = resolution - // 2. Reject direct shares — they cannot be saved to drive if (share.kind === 'direct') { return c.json( { @@ -45,7 +149,6 @@ const app = new Hono() ) } - // 3. Password-protected share: recipient is exempt; others need the cookie if (share.passwordHash) { const isRecipient = recipients.some( (r: { recipientUserId: string | null }) => r.recipientUserId === currentUserId, @@ -58,7 +161,6 @@ const app = new Hono() } } - // 4. Verify current user has editor+ role in targetOrgId const role = await getMemberRole(db, targetOrgId, currentUserId) if (role !== null) { if ((ROLE_LEVELS[role] ?? 0) < ROLE_LEVELS.editor) { @@ -68,7 +170,6 @@ const app = new Hono() return c.json({ error: 'Forbidden' }, 403) } - // 5. Pre-flight quota check (non-atomic fast-fail) const totalBytes = await computeSourceBytes(db, matter) const quotaOk = await isQuotaSufficient(db, targetOrgId, totalBytes) if (!quotaOk) { diff --git a/server/services/share-notification.integration.test.ts b/server/services/share-notification.integration.test.ts new file mode 100644 index 00000000..487f3dbd --- /dev/null +++ b/server/services/share-notification.integration.test.ts @@ -0,0 +1,240 @@ +import { eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as authSchema from '../db/auth-schema.js' +import { notifications, systemOptions } from '../db/schema.js' +import * as emailService from '../services/email.js' +import { dispatchShareCreated, type RecipientInput } from '../services/share-notification.js' +import { createTestApp } from '../test/setup.js' + +type TestDb = Awaited>['db'] + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function insertUser(db: TestDb, overrides: Partial<{ id: string; email: string }> = {}) { + const id = overrides.id ?? nanoid() + const email = overrides.email ?? `${id}@example.com` + await db.insert(authSchema.user).values({ + id, + name: 'Test User', + email, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }) + return { id, email } +} + +function makeShare( + overrides: Partial<{ + id: string + token: string + kind: 'landing' | 'direct' + expiresAt: Date | null + }> = {}, +) { + return { + id: overrides.id ?? nanoid(), + token: overrides.token ?? nanoid(10), + kind: overrides.kind ?? 'landing', + matterId: nanoid(), + orgId: nanoid(), + creatorId: nanoid(), + passwordHash: null, + expiresAt: overrides.expiresAt ?? null, + downloadLimit: null, + views: 0, + downloads: 0, + status: 'active', + createdAt: new Date(), + } +} + +async function configureEmail(db: TestDb) { + await db.insert(systemOptions).values({ + key: 'email_provider', + value: 'smtp', + public: false, + }) + await db.insert(systemOptions).values({ key: 'email_from', value: 'no-reply@example.com', public: false }) + await db.insert(systemOptions).values({ key: 'email_smtp_host', value: 'smtp.example.com', public: false }) + await db.insert(systemOptions).values({ key: 'email_smtp_port', value: '587', public: false }) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('dispatchShareCreated', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('inserts a notification row when recipient has recipientUserId', async () => { + const { db } = await createTestApp() + const user = await insertUser(db) + const share = makeShare() + const recipients: RecipientInput[] = [{ recipientUserId: user.id }] + + await dispatchShareCreated(db, share, recipients, 'Alice', 'secret.pdf') + + const rows = await db.select().from(notifications).where(eq(notifications.userId, user.id)) + expect(rows).toHaveLength(1) + expect(rows[0].type).toBe('share_received') + expect(rows[0].title).toContain('Alice') + expect(rows[0].title).toContain('secret.pdf') + expect(rows[0].refType).toBe('share') + expect(rows[0].refId).toBe(share.id) + }) + + it('does not insert notification when recipient has only email (no userId)', async () => { + const { db } = await createTestApp() + const share = makeShare() + const recipients: RecipientInput[] = [{ recipientEmail: 'someone@example.com' }] + + await dispatchShareCreated(db, share, recipients, 'Bob', 'file.txt') + + const rows = await db.select().from(notifications) + expect(rows).toHaveLength(0) + }) + + it('does not send email and does not throw when email is not configured', async () => { + const { db } = await createTestApp() + const sendEmailSpy = vi.spyOn(emailService, 'sendEmail') + const share = makeShare() + const recipients: RecipientInput[] = [{ recipientEmail: 'test@example.com' }] + + // No email config in DB + await expect(dispatchShareCreated(db, share, recipients, 'Carol', 'report.docx')).resolves.toBeUndefined() + expect(sendEmailSpy).not.toHaveBeenCalled() + }) + + it('sends email when email is configured and recipient has email', async () => { + const { db } = await createTestApp() + const sendEmailSpy = vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) + + await configureEmail(db) + + const share = makeShare() + const recipients: RecipientInput[] = [{ recipientEmail: 'dave@example.com' }] + + await dispatchShareCreated(db, share, recipients, 'Eve', 'photo.jpg') + + expect(sendEmailSpy).toHaveBeenCalledOnce() + const callArgs = sendEmailSpy.mock.calls[0] + // sendEmail(db, message) — second arg is the message + expect(callArgs[1].to).toBe('dave@example.com') + expect(callArgs[1].subject).toContain('Eve') + expect(callArgs[1].subject).toContain('photo.jpg') + }) + + it('looks up email from user table when recipient has only recipientUserId and email is configured', async () => { + const { db } = await createTestApp() + const sendEmailSpy = vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) + + await configureEmail(db) + + const user = await insertUser(db, { email: 'frank@example.com' }) + const share = makeShare() + const recipients: RecipientInput[] = [{ recipientUserId: user.id }] + + await dispatchShareCreated(db, share, recipients, 'Grace', 'budget.xlsx') + + expect(sendEmailSpy).toHaveBeenCalledOnce() + const callArgs = sendEmailSpy.mock.calls[0] + expect(callArgs[1].to).toBe('frank@example.com') + }) + + it('does not throw when email send fails — logs and continues', async () => { + const { db } = await createTestApp() + const sendEmailSpy = vi.spyOn(emailService, 'sendEmail').mockRejectedValue(new Error('SMTP down')) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await configureEmail(db) + + const share = makeShare() + const recipients: RecipientInput[] = [{ recipientEmail: 'victim@example.com' }] + + // Should NOT throw despite email failure + await expect(dispatchShareCreated(db, share, recipients, 'Sender', 'file.txt')).resolves.toBeUndefined() + expect(sendEmailSpy).toHaveBeenCalledOnce() + expect(consoleErrorSpy).toHaveBeenCalled() + }) + + it('inserts in-app notifications for all recipients that have recipientUserId', async () => { + const { db } = await createTestApp() + const user1 = await insertUser(db) + const user2 = await insertUser(db) + const share = makeShare() + + const recipients: RecipientInput[] = [ + { recipientUserId: user1.id }, + { recipientUserId: user2.id }, + { recipientEmail: 'no-account@example.com' }, + ] + + await dispatchShareCreated(db, share, recipients, 'Hub', 'multi.zip') + + const rows1 = await db.select().from(notifications).where(eq(notifications.userId, user1.id)) + expect(rows1).toHaveLength(1) + + const rows2 = await db.select().from(notifications).where(eq(notifications.userId, user2.id)) + expect(rows2).toHaveLength(1) + + // No notification for email-only recipient + const allRows = await db.select().from(notifications) + expect(allRows).toHaveLength(2) + }) + + it('uses /s/{token} URL for landing shares in notification metadata', async () => { + const { db } = await createTestApp() + const user = await insertUser(db) + const share = makeShare({ kind: 'landing', token: 'abc123token' }) + + await dispatchShareCreated(db, share, [{ recipientUserId: user.id }], 'Ian', 'landing.pdf') + + const rows = await db.select().from(notifications).where(eq(notifications.userId, user.id)) + expect(rows).toHaveLength(1) + const metadata = JSON.parse(rows[0].metadata ?? '{}') as Record + expect(metadata.token).toBe('abc123token') + expect(metadata.kind).toBe('landing') + }) + + it('uses /dl/{token} URL for direct shares in notification metadata', async () => { + const { db } = await createTestApp() + const user = await insertUser(db) + const share = makeShare({ kind: 'direct', token: 'directtoken1' }) + + await dispatchShareCreated(db, share, [{ recipientUserId: user.id }], 'Jane', 'direct.mp4') + + const rows = await db.select().from(notifications).where(eq(notifications.userId, user.id)) + expect(rows).toHaveLength(1) + const metadata = JSON.parse(rows[0].metadata ?? '{}') as Record + expect(metadata.kind).toBe('direct') + }) + + it('includes expiresAt in email body when share has an expiry date', async () => { + const { db } = await createTestApp() + const sendEmailSpy = vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) + + await configureEmail(db) + + const expiresAt = new Date('2026-12-31T00:00:00Z') + const share = makeShare({ expiresAt }) + const recipients: RecipientInput[] = [{ recipientEmail: 'reader@example.com' }] + + await dispatchShareCreated(db, share, recipients, 'Karl', 'expiring.pdf') + + expect(sendEmailSpy).toHaveBeenCalledOnce() + const emailHtml = sendEmailSpy.mock.calls[0][1].html + expect(emailHtml).toContain('2026-12-31') + }) + + it('handles empty recipients array without errors', async () => { + const { db } = await createTestApp() + const share = makeShare() + + await expect(dispatchShareCreated(db, share, [], 'Leo', 'empty.txt')).resolves.toBeUndefined() + + const rows = await db.select().from(notifications) + expect(rows).toHaveLength(0) + }) +}) diff --git a/server/services/share-notification.ts b/server/services/share-notification.ts new file mode 100644 index 00000000..460c92c6 --- /dev/null +++ b/server/services/share-notification.ts @@ -0,0 +1,78 @@ +import { eq } from 'drizzle-orm' +import { user } from '../db/auth-schema' +import { systemOptions } from '../db/schema' +import type { Database } from '../platform/interface' +import { sendEmail } from './email' +import { createNotification } from './notification' +import type { Share } from './share' + +async function getUserEmail(db: Database, userId: string): Promise { + const rows = await db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1) + return rows[0]?.email ?? null +} + +async function isEmailConfigured(db: Database): Promise { + const rows = await db + .select({ value: systemOptions.value }) + .from(systemOptions) + .where(eq(systemOptions.key, 'email_provider')) + .limit(1) + return Boolean(rows[0]?.value) +} + +async function sendShareEmail( + db: Database, + opts: { to: string; creatorName: string; matterName: string; url: string; expiresAt: Date | null }, +): Promise { + const expiryLine = opts.expiresAt ? `

This share expires on ${opts.expiresAt.toISOString().split('T')[0]}.

` : '' + await sendEmail(db, { + to: opts.to, + subject: `${opts.creatorName} shared "${opts.matterName}" with you`, + html: ` +

${opts.creatorName} shared a file with you

+

${opts.matterName} is now available.

+ ${expiryLine} +

Open share

+ `, + }) +} + +export type RecipientInput = { + recipientUserId?: string | null + recipientEmail?: string | null +} + +export async function dispatchShareCreated( + db: Database, + share: Share, + recipients: RecipientInput[], + creatorName: string, + matterName: string, +): Promise { + const shareUrl = share.kind === 'landing' ? `/s/${share.token}` : `/dl/${share.token}` + const emailEnabled = await isEmailConfigured(db) + + for (const r of recipients) { + if (r.recipientUserId) { + await createNotification(db, { + userId: r.recipientUserId, + type: 'share_received', + title: `${creatorName} shared "${matterName}" with you`, + body: 'Click to open the share', + refType: 'share', + refId: share.id, + metadata: JSON.stringify({ token: share.token, kind: share.kind }), + }) + } + + const email = r.recipientEmail ?? (r.recipientUserId ? await getUserEmail(db, r.recipientUserId) : null) + + if (email && emailEnabled) { + try { + await sendShareEmail(db, { to: email, creatorName, matterName, url: shareUrl, expiresAt: share.expiresAt }) + } catch (err) { + console.error(`[share-notification] email to ${email} failed:`, err) + } + } + } +} diff --git a/server/services/share.integration.test.ts b/server/services/share.integration.test.ts index 0cf55a92..68006e83 100644 --- a/server/services/share.integration.test.ts +++ b/server/services/share.integration.test.ts @@ -11,7 +11,6 @@ import { incrementViews, isAccessibleByUser, listShareRecipientUserIds, - listSharesByCreator, resolveShareByToken, revokeShare, verifyPassword, @@ -393,71 +392,6 @@ describe('incrementDownloadsAtomic', () => { }) }) -// ─── listSharesByCreator ────────────────────────────────────────────────────── - -describe('listSharesByCreator', () => { - it('returns empty result when no shares exist for creator', async () => { - const { db } = await createTestApp() - const result = await listSharesByCreator(db, 'unknown-creator', { page: 1, pageSize: 20 }) - expect(result).toEqual({ items: [], total: 0 }) - }) - - it('returns shares with matterName and matterType joined', async () => { - const { db } = await createTestApp() - const orgId = nanoid() - const matter = await seedMatter(db, { orgId }) - await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' }) - - const result = await listSharesByCreator(db, 'u1', { page: 1, pageSize: 20 }) - expect(result.total).toBe(1) - expect(result.items[0].matterName).toBe(matter.name) - expect(result.items[0].matterType).toBe(matter.type) - }) - - it('paginates correctly', async () => { - const { db } = await createTestApp() - const orgId = nanoid() - for (let i = 0; i < 5; i++) { - const m = await seedMatter(db, { orgId }) - await createShare(db, { matterId: m.id, orgId, creatorId: 'paginator', kind: 'landing' }) - } - - const page1 = await listSharesByCreator(db, 'paginator', { page: 1, pageSize: 3 }) - expect(page1.total).toBe(5) - expect(page1.items).toHaveLength(3) - - const page2 = await listSharesByCreator(db, 'paginator', { page: 2, pageSize: 3 }) - expect(page2.items).toHaveLength(2) - }) - - it('returns shares across multiple orgs for the same creator', async () => { - const { db } = await createTestApp() - const m1 = await seedMatter(db, { orgId: 'org-x' }) - const m2 = await seedMatter(db, { orgId: 'org-y' }) - await createShare(db, { matterId: m1.id, orgId: 'org-x', creatorId: 'cross-org-user', kind: 'landing' }) - await createShare(db, { matterId: m2.id, orgId: 'org-y', creatorId: 'cross-org-user', kind: 'landing' }) - - const result = await listSharesByCreator(db, 'cross-org-user', { page: 1, pageSize: 20 }) - expect(result.total).toBe(2) - }) - - it('filters by status when provided', async () => { - const { db } = await createTestApp() - const orgId = nanoid() - const m1 = await seedMatter(db, { orgId }) - const m2 = await seedMatter(db, { orgId }) - await createShare(db, { matterId: m1.id, orgId, creatorId: 'u-filter', kind: 'landing' }) - const s2 = await createShare(db, { matterId: m2.id, orgId, creatorId: 'u-filter', kind: 'landing' }) - await revokeShare(db, s2.id, 'u-filter') - - const active = await listSharesByCreator(db, 'u-filter', { page: 1, pageSize: 20, status: 'active' }) - expect(active.total).toBe(1) - - const revoked = await listSharesByCreator(db, 'u-filter', { page: 1, pageSize: 20, status: 'revoked' }) - expect(revoked.total).toBe(1) - }) -}) - // ─── revokeShare ───────────────────────────────────────────────────────────── describe('revokeShare', () => { diff --git a/server/services/share.ts b/server/services/share.ts index 5a4a750b..999ed321 100644 --- a/server/services/share.ts +++ b/server/services/share.ts @@ -9,7 +9,14 @@ import type { Matter } from './matter' export type Share = typeof shares.$inferSelect export type ShareRecipient = typeof shareRecipients.$inferSelect -export type ShareWithMatter = Share & { matterName: string; matterType: string } +export type ShareWithDetails = Share & { + matter: { name: string; type: string; dirtype: number } + recipients: ShareRecipient[] +} +export type ShareListItem = Share & { + matter: { name: string; type: string; dirtype: number } + recipientCount: number +} export function verifyPassword(share: Share, plaintext: string): boolean { if (!share.passwordHash) return false @@ -122,37 +129,6 @@ export async function incrementDownloadsAtomic( return { ok: false, downloads: current[0].downloads } } -export async function listSharesByCreator( - db: Database, - creatorId: string, - opts: { page: number; pageSize: number; status?: string }, -): Promise<{ items: ShareWithMatter[]; total: number }> { - const conditions = [eq(shares.creatorId, creatorId)] - if (opts.status) conditions.push(eq(shares.status, opts.status)) - const where = and(...conditions) - - const [countRow] = await db.select({ count: count() }).from(shares).where(where) - const total = countRow?.count ?? 0 - - const offset = (opts.page - 1) * opts.pageSize - const rows = await db - .select({ share: shares, matterName: matters.name, matterType: matters.type }) - .from(shares) - .leftJoin(matters, eq(shares.matterId, matters.id)) - .where(where) - .orderBy(desc(shares.createdAt)) - .limit(opts.pageSize) - .offset(offset) - - const items = rows.map(({ share, matterName, matterType }) => ({ - ...share, - matterName: matterName ?? '', - matterType: matterType ?? '', - })) - - return { items, total } -} - export async function revokeShare(db: Database, shareId: string, creatorId: string): Promise { const result = await db .update(shares) @@ -180,3 +156,61 @@ export async function cascadeDeleteByMatter(db: Database, matterId: string): Pro await db.delete(shareRecipients).where(inArray(shareRecipients.shareId, shareIds)) await db.delete(shares).where(inArray(shares.id, shareIds)) } + +export async function getShareById(db: Database, shareId: string): Promise { + const rows = await db + .select({ share: shares, matter: matters }) + .from(shares) + .innerJoin(matters, eq(shares.matterId, matters.id)) + .where(eq(shares.id, shareId)) + + const row = rows[0] + if (!row) return null + + const recipients = await db.select().from(shareRecipients).where(eq(shareRecipients.shareId, shareId)) + + return { + ...row.share, + matter: { name: row.matter.name, type: row.matter.type, dirtype: row.matter.dirtype ?? 0 }, + recipients, + } +} + +export async function listSharesForApi( + db: Database, + creatorId: string, + opts: { page: number; pageSize: number; status?: string }, +): Promise<{ items: ShareListItem[]; total: number }> { + const conditions = [eq(shares.creatorId, creatorId)] + if (opts.status) conditions.push(eq(shares.status, opts.status)) + const where = and(...conditions) + + const [countRow] = await db.select({ count: count() }).from(shares).where(where) + const total = countRow?.count ?? 0 + + const offset = (opts.page - 1) * opts.pageSize + const rows = await db + .select({ + share: shares, + matterName: matters.name, + matterType: matters.type, + matterDirtype: matters.dirtype, + recipientCount: count(shareRecipients.id), + }) + .from(shares) + .leftJoin(matters, eq(shares.matterId, matters.id)) + .leftJoin(shareRecipients, eq(shareRecipients.shareId, shares.id)) + .where(where) + .groupBy(shares.id) + .orderBy(desc(shares.createdAt)) + .limit(opts.pageSize) + .offset(offset) + + const items: ShareListItem[] = rows.map(({ share, matterName, matterType, matterDirtype, recipientCount }) => ({ + ...share, + matter: { name: matterName ?? '', type: matterType ?? '', dirtype: matterDirtype ?? 0 }, + recipientCount, + })) + + return { items, total } +} diff --git a/shared/schemas/share.ts b/shared/schemas/share.ts index 154b78c6..c654d29d 100644 --- a/shared/schemas/share.ts +++ b/shared/schemas/share.ts @@ -28,6 +28,17 @@ export const listSharesQuerySchema = z.object({ status: z.enum(['active', 'revoked']).optional(), }) +export const createShareRequestSchema = z.object({ + matterId: z.string().min(1), + kind: shareKindSchema, + password: z.string().optional(), + expiresAt: z.string().datetime({ offset: true }).optional(), + downloadLimit: z.number().int().positive().optional(), + recipients: z.array(shareRecipientSchema).optional(), +}) + +export type CreateShareRequest = z.infer + export const saveShareRequestSchema = z.object({ targetOrgId: z.string().min(1), targetParent: z.string().default(''), diff --git a/shared/types/index.ts b/shared/types/index.ts index e5569d26..1f0f9b51 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -102,12 +102,12 @@ export interface Share { orgId: string creatorId: string passwordHash: string | null - expiresAt: Date | null + expiresAt: string | null downloadLimit: number | null views: number downloads: number status: 'active' | 'revoked' - createdAt: Date + createdAt: string } export interface ShareRecipient { @@ -115,7 +115,23 @@ export interface ShareRecipient { shareId: string recipientUserId: string | null recipientEmail: string | null - createdAt: Date + createdAt: string +} + +export interface ShareMatter { + name: string + type: string + dirtype: number +} + +export interface ShareListItem extends Share { + matter: ShareMatter + recipientCount: number +} + +export interface ShareDetail extends Share { + matter: ShareMatter + recipients: ShareRecipient[] } export interface Notification { diff --git a/src/lib/api.integration.test.ts b/src/lib/api.integration.test.ts new file mode 100644 index 00000000..22e75d5d --- /dev/null +++ b/src/lib/api.integration.test.ts @@ -0,0 +1,92 @@ +// Integration-project tests for src/lib/api.ts share wrapper functions. +// These run in the integration vitest project so codecov picks them up for patch coverage. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiError, deleteShare, getShare, listShares } from './api' + +function makeResponse(body: unknown, ok = true, status = 200): Response { + return { + ok, + status, + statusText: ok ? 'OK' : 'Forbidden', + json: async () => body, + } as unknown as Response +} + +describe('shares API wrappers (integration)', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('listShares', () => { + it('calls /api/shares with default params and returns payload', async () => { + const payload = { items: [], total: 0, page: 1, pageSize: 20 } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) + + const result = await listShares() + + expect(result).toEqual(payload) + const [url] = vi.mocked(fetch).mock.calls[0] as [string] + expect(url).toContain('/api/shares') + expect(url).toContain('page=1') + expect(url).toContain('pageSize=20') + }) + + it('forwards page, pageSize, and status query params', async () => { + const payload = { items: [], total: 3, page: 2, pageSize: 10 } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) + + await listShares(2, 10, 'active') + + const [url] = vi.mocked(fetch).mock.calls[0] as [string] + expect(url).toContain('page=2') + expect(url).toContain('pageSize=10') + expect(url).toContain('status=active') + }) + + it('throws ApiError on non-ok response', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401)) + + await expect(listShares()).rejects.toThrow('unauthorized') + }) + }) + + describe('getShare', () => { + it('calls /api/shares/:id and returns share detail', async () => { + const payload = { id: 'share-1', token: 'abc', kind: 'landing' } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) + + const result = await getShare('share-1') + + expect(result).toEqual(payload) + const [url] = vi.mocked(fetch).mock.calls[0] as [string] + expect(url).toContain('/api/shares/share-1') + }) + + it('throws ApiError on 404 response', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Not found' }, false, 404)) + + await expect(getShare('missing')).rejects.toThrow('Not found') + }) + }) + + describe('deleteShare', () => { + it('calls DELETE /api/shares/:id and resolves on 204', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204 } as Response) + + await expect(deleteShare('share-1')).resolves.toBeUndefined() + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toContain('/api/shares/share-1') + expect(init.method).toBe('DELETE') + }) + + it('throws ApiError on non-ok response', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 403, statusText: 'Forbidden' } as Response) + + await expect(deleteShare('share-1')).rejects.toBeInstanceOf(ApiError) + }) + }) +}) diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index d4b305a5..5fde063d 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -9,12 +9,14 @@ import { createObject, createStorage, deleteObject, + deleteShare, deleteStorage, deleteUser, emptyTrash, getObject, getProfile, getSession, + getShare, getStorage, getSystemOption, getUnreadCount, @@ -23,6 +25,7 @@ import { listNotifications, listObjects, listQuotas, + listShares, listStorages, listSystemOptions, listUsers, @@ -989,4 +992,73 @@ describe('api', () => { await expect(markAllNotificationsRead()).rejects.toThrow('unauthorized') }) }) + + describe('listShares', () => { + it('calls /api/shares with default params', async () => { + const payload = { items: [], total: 0, page: 1, pageSize: 20 } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) + + const result = await listShares() + + expect(result).toEqual(payload) + const [url] = vi.mocked(fetch).mock.calls[0] as [string] + expect(url).toContain('/api/shares') + expect(url).toContain('page=1') + expect(url).toContain('pageSize=20') + }) + + it('passes page, pageSize, and status params', async () => { + const payload = { items: [], total: 3, page: 2, pageSize: 10 } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) + + await listShares(2, 10, 'active') + + const [url] = vi.mocked(fetch).mock.calls[0] as [string] + expect(url).toContain('page=2') + expect(url).toContain('pageSize=10') + expect(url).toContain('status=active') + }) + + it('throws on error response', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401)) + + await expect(listShares()).rejects.toThrow('unauthorized') + }) + }) + + describe('getShare', () => { + it('calls /api/shares/:id and returns share detail', async () => { + const payload = { id: 'share-1', token: 'abc', kind: 'landing' } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) + + const result = await getShare('share-1') + + expect(result).toEqual(payload) + const [url] = vi.mocked(fetch).mock.calls[0] as [string] + expect(url).toContain('/api/shares/share-1') + }) + + it('throws on error response', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Not found' }, false, 404)) + + await expect(getShare('missing')).rejects.toThrow('Not found') + }) + }) + + describe('deleteShare', () => { + it('calls DELETE /api/shares/:id and resolves on 204', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204 } as Response) + + await expect(deleteShare('share-1')).resolves.toBeUndefined() + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toContain('/api/shares/share-1') + expect(init.method).toBe('DELETE') + }) + + it('throws ApiError on non-ok response', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 403, statusText: 'Forbidden' } as Response) + + await expect(deleteShare('share-1')).rejects.toThrow('Forbidden') + }) + }) }) diff --git a/src/lib/api.ts b/src/lib/api.ts index 7b4b6076..0cc44017 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -5,6 +5,8 @@ import type { AuthProvider, Notification, PaginatedResponse, + ShareDetail, + ShareListItem, Storage, StorageObject, } from '@shared/types' @@ -16,6 +18,7 @@ import { notificationsApi, objects, profiles, + sharesApi, storages, system, teamsApi, @@ -369,6 +372,28 @@ export function markAllNotificationsRead() { return unwrap<{ count: number }>(notificationsApi['read-all'].$post()) } +// Shares API + +export type { ShareDetail, ShareListItem } + +export function listShares(page = 1, pageSize = 20, status?: 'active' | 'revoked') { + const query: Record = { page: String(page), pageSize: String(pageSize) } + if (status) query.status = status + return unwrap<{ items: ShareListItem[]; total: number; page: number; pageSize: number }>( + sharesApi.index.$get({ query }), + ) +} + +export function getShare(id: string) { + return unwrap(sharesApi[':id'].$get({ param: { id } })) +} + +export function deleteShare(id: string) { + return sharesApi[':id'].$delete({ param: { id } }).then((res) => { + if (!res.ok) throw new ApiError(res.status, { error: res.statusText }) + }) +} + // Auth API — Better Auth passthrough, not typed via Hono RPC export async function getSession(): Promise<{ session: unknown; user: unknown } | null> { const res = await fetch('/api/auth/get-session', { credentials: 'include' }) diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts index cdcee48b..23e0d5d7 100644 --- a/src/lib/rpc.ts +++ b/src/lib/rpc.ts @@ -7,6 +7,7 @@ import type { ObjectsRoute, ProfileRoute, PublicTeamsRoute, + SharesRoute, StoragesRoute, SystemRoute, TeamsRoute, @@ -32,3 +33,4 @@ export const profiles = hc('/api/profiles') export const teamsApi = hc('/api/teams', opts) export const publicTeamsApi = hc('/api/teams') export const notificationsApi = hc('/api/notifications', opts) +export const sharesApi = hc('/api/shares', opts) diff --git a/vitest.config.ts b/vitest.config.ts index 17e03dd0..ce41d7f4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -27,6 +27,7 @@ const coverageConfig = { 'server/db/**', 'shared/**/*.test.ts', 'src/**/*.test.ts', + 'src/**/*.integration.test.ts', 'src/i18n/index.ts', ], reporter: ['text', 'json'] as const, @@ -57,7 +58,7 @@ export default defineConfig({ resolve: { alias: aliases }, test: { name: 'integration', - include: ['server/**/*.integration.test.ts'], + include: ['server/**/*.integration.test.ts', 'src/**/*.integration.test.ts'], coverage: { ...coverageConfig, thresholds: {