mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
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:
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user