Files
zpan/server/services/share.cf-test.ts
T
saltboandClaude Opus 4.7 0c20475d14 refactor(shares): unify share API under /api/shares with RESTful paths
Collapse the split /api/share/* (public) and /api/shares/* (authed) into a
single /api/shares resource mounted as two sub-apps (publicShares before
authMiddleware, authedShares after). All action verbs are removed from paths
and replaced with nouns:

  POST  /api/share/:token/verify       → POST /api/shares/:token/sessions
  GET   /api/share/:token/children     → GET  /api/shares/:token/objects
  GET   /api/share/:token/download[/x] → GET  /api/shares/:token/objects/:ref
  POST  /api/shares/:token/save        → POST /api/shares/:token/objects
  GET   /api/shares/:id, DELETE /:id   → GET/DELETE /api/shares/:token

Token is now the canonical external identifier; the internal matter id is
never exposed. GET /api/shares/:token handles both visitor and creator views
and returns creator-only fields (id, orgId, recipients, …) only when the
viewer is the creator. /dl/:token and /s/:token short-links are preserved
unchanged.

Drive-by hardening:
- Remove passwordHash from the shared Share wire type; list endpoint uses
  explicit column projection so the hash cannot leak.
- revokeShareByToken returns boolean; DELETE handler maps a lost race to 404
  instead of propagating an unhandled 500.
- Save-to-drive password gate now uses the shared checkAccessGate helper,
  fixing a looseness where any non-empty sharetk cookie bypassed the check.

Frontend: rpc.ts splits into publicSharesApi / authedSharesApi; api.ts
wrappers take token (not id); new buildShareObjectUrl for download URL
construction; ShareView replaces ShareLandingResponse / ShareDetail.

2188 node tests + 43 CF tests pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:07:57 -04:00

121 lines
3.7 KiB
TypeScript

import { env } from 'cloudflare:workers'
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { DirType } from '../../shared/constants'
import { matters } from '../db/schema'
import { createCloudflarePlatform } from '../platform/cloudflare'
import {
cascadeDeleteByMatter,
createShare,
incrementDownloadsAtomic,
resolveShareByToken,
revokeShareByToken,
} from './share'
function buildDb() {
return createCloudflarePlatform(env).db
}
async function seedMatter(db: ReturnType<typeof buildDb>, orgId: string, dirtype = DirType.FILE) {
const now = new Date()
const matter = {
id: nanoid(),
orgId,
alias: nanoid(10),
name: `cf-test-${nanoid(6)}`,
type: dirtype !== DirType.FILE ? 'folder' : 'application/pdf',
size: 0,
dirtype,
parent: '',
object: dirtype !== DirType.FILE ? '' : `objects/${nanoid()}`,
storageId: 'storage-1',
status: 'active',
trashedAt: null,
createdAt: now,
updatedAt: now,
}
await db.insert(matters).values(matter)
return matter
}
// ─── Atomic counter race tests on D1 ─────────────────────────────────────────
describe('[CF] incrementDownloadsAtomic — race conditions on D1', () => {
it('enforces download limit under 50 concurrent calls (limit=10)', async () => {
const db = buildDb()
const orgId = `org-${nanoid(6)}`
const matter = await seedMatter(db, orgId)
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'cf-user-1',
kind: 'landing',
downloadLimit: 10,
})
const results = await Promise.all(Array.from({ length: 50 }, () => incrementDownloadsAtomic(db, share.id)))
const successCount = results.filter((r) => r.ok).length
expect(successCount).toBe(10)
})
it('returns ok=false for all calls when share is revoked', async () => {
const db = buildDb()
const orgId = `org-${nanoid(6)}`
const matter = await seedMatter(db, orgId)
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'cf-user-2',
kind: 'landing',
})
await revokeShareByToken(db, share.token, 'cf-user-2')
const results = await Promise.all(Array.from({ length: 5 }, () => incrementDownloadsAtomic(db, share.id)))
expect(results.every((r) => !r.ok)).toBe(true)
})
it('returns ok=false for all calls when share is expired', async () => {
const db = buildDb()
const orgId = `org-${nanoid(6)}`
const matter = await seedMatter(db, orgId)
const pastDate = new Date(Date.now() - 5000)
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'cf-user-3',
kind: 'landing',
expiresAt: pastDate,
})
const results = await Promise.all(Array.from({ length: 5 }, () => incrementDownloadsAtomic(db, share.id)))
expect(results.every((r) => !r.ok)).toBe(true)
})
})
// ─── cascadeDeleteByMatter on D1 ─────────────────────────────────────────────
describe('[CF] cascadeDeleteByMatter on D1', () => {
it('removes shares and recipients atomically', async () => {
const db = buildDb()
const orgId = `org-${nanoid(6)}`
const matter = await seedMatter(db, orgId)
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'cf-cascade-user',
kind: 'landing',
recipients: [{ recipientEmail: 'cascade@example.com' }],
})
await cascadeDeleteByMatter(db, matter.id)
// Share should be gone after cascade deletion
expect((await resolveShareByToken(db, share.token)).status).toBe('not_found')
})
})