feat(shares): authenticated CRUD API + notification dispatch (v2.3.0 T3) (#311)

* feat(shares): authenticated CRUD API + notification dispatch (T3)

- POST/GET/GET-by-id/DELETE /api/shares endpoints with requireAuth + requireTeamRole('editor') on create
- Shares list returns matter: {name, type, dirtype} and recipientCount per item
- Creator-only access on GET/:id (404 for non-creator) and DELETE (403 for non-creator)
- share-notification service: in-app notification always sent to recipientUserId; email sent conditionally if isEmailConfigured; email failures are caught and logged, never block the 201 response
- createShareRequestSchema added to shared/schemas/share.ts for HTTP boundary validation
- ShareListItem, ShareDetail, ShareMatter types added to shared/types/index.ts; timestamps use string to match JSON wire format
- sharesApi RPC client added to src/lib/rpc.ts; listShares/getShare/deleteShare helpers added to src/lib/api.ts
- Removed dead listSharesByCreator (superseded by listSharesForApi)
- 44 new integration tests; 2026 tests total pass

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* test(shares): add api.ts wrapper tests + DIRECT_NO_RECIPIENTS coverage

- listShares, getShare, deleteShare unit tests in src/lib/api.test.ts
- DIRECT_NO_RECIPIENTS test case in shares.integration.test.ts
- Closes codecov/patch gap (was 87%, target ~94%)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(shares): cover throw-err and dispatch-catch paths in shares route

- Add test for unknown createShare error (line 69: `throw err`)
- Add test for dispatchShareCreated rejection (line 79: `.catch()` console.error)
- shares.ts now at 100% line coverage in integration project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: cover src/lib/api.ts share wrappers in integration project

Add src/lib/api.integration.test.ts with 7 tests for listShares,
getShare, and deleteShare, and extend the vitest integration project
to pick up src/**/*.integration.test.ts so codecov patch coverage
for src/lib/api.ts is reported correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add branch coverage for shares save handler edge cases

Cover two previously uncovered branches in POST /:token/save:
- Line 158: non-recipient with valid sharetk cookie bypasses 401 check
- Line 166: viewer-role member of target org gets 403 (via real DB
  membership insert using sign-up response user ID)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jasper Van
2026-04-20 08:46:55 -04:00
committed by GitHub
parent 5bea716460
commit d974ced139
13 changed files with 1561 additions and 110 deletions
+845
View File
@@ -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<ReturnType<typeof createTestApp>>['app']
type TestDb = Awaited<ReturnType<typeof createTestApp>>['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<string> {
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<string, string>, body: Record<string, unknown>) {
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<string, unknown>
expect(typeof body.id).toBe('string')
expect(typeof body.token).toBe('string')
expect(body.kind).toBe('landing')
expect((body.urls as Record<string, string>).landing).toMatch(/^\/s\//)
expect((body.urls as Record<string, string>).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<string, unknown>
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<string, unknown>
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<string, unknown>
expect(body.kind).toBe('direct')
expect((body.urls as Record<string, string>).direct).toMatch(/^\/dl\//)
expect((body.urls as Record<string, string>).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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<Record<string, unknown>> }
const item = body.items[0]
expect(item).toHaveProperty('matter')
const matter = item.matter as Record<string, unknown>
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<string, unknown>
const res2 = await createShare(app, headers, { matterId: 'st2', kind: 'landing' })
const body2 = (await res2.json()) as Record<string, unknown>
// 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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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)
})
})
+109 -8
View File
@@ -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<string, number> = {
owner: 3,
@@ -15,15 +19,116 @@ const ROLE_LEVELS: Record<string, number> = {
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<Env>()
.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<ReturnType<typeof createShare>>
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<Env>()
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<Env>()
)
}
// 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<Env>()
}
}
// 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<Env>()
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) {
@@ -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<ReturnType<typeof createTestApp>>['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<string, unknown>
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<string, unknown>
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)
})
})
+78
View File
@@ -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<string | null> {
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<boolean> {
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<void> {
const expiryLine = opts.expiresAt ? `<p>This share expires on ${opts.expiresAt.toISOString().split('T')[0]}.</p>` : ''
await sendEmail(db, {
to: opts.to,
subject: `${opts.creatorName} shared "${opts.matterName}" with you`,
html: `
<h2>${opts.creatorName} shared a file with you</h2>
<p><strong>${opts.matterName}</strong> is now available.</p>
${expiryLine}
<p><a href="${opts.url}">Open share</a></p>
`,
})
}
export type RecipientInput = {
recipientUserId?: string | null
recipientEmail?: string | null
}
export async function dispatchShareCreated(
db: Database,
share: Share,
recipients: RecipientInput[],
creatorName: string,
matterName: string,
): Promise<void> {
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)
}
}
}
}
-66
View File
@@ -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', () => {
+66 -32
View File
@@ -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<void> {
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<ShareWithDetails | null> {
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 }
}
+11
View File
@@ -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<typeof createShareRequestSchema>
export const saveShareRequestSchema = z.object({
targetOrgId: z.string().min(1),
targetParent: z.string().default(''),
+19 -3
View File
@@ -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 {
+92
View File
@@ -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)
})
})
})
+72
View File
@@ -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')
})
})
})
+25
View File
@@ -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<string, string> = { 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<ShareDetail>(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' })
+2
View File
@@ -7,6 +7,7 @@ import type {
ObjectsRoute,
ProfileRoute,
PublicTeamsRoute,
SharesRoute,
StoragesRoute,
SystemRoute,
TeamsRoute,
@@ -32,3 +33,4 @@ export const profiles = hc<ProfileRoute>('/api/profiles')
export const teamsApi = hc<TeamsRoute>('/api/teams', opts)
export const publicTeamsApi = hc<PublicTeamsRoute>('/api/teams')
export const notificationsApi = hc<NotificationsRoute>('/api/notifications', opts)
export const sharesApi = hc<SharesRoute>('/api/shares', opts)
+2 -1
View File
@@ -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: {