Files
zpan/server/services/save-to-drive.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

217 lines
7.1 KiB
TypeScript

import { env } from 'cloudflare:workers'
import { nanoid } from 'nanoid'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DirType } from '../../shared/constants'
import { matters } from '../db/schema'
import { createCloudflarePlatform } from '../platform/cloudflare'
import { S3Service } from './s3'
import { saveShareToDrive } from './save-to-drive'
import { createShare, resolveShareByToken, revokeShareByToken } from './share'
function buildDb() {
return createCloudflarePlatform(env).db
}
async function seedStorage(db: ReturnType<typeof buildDb>, id: string) {
await db.run(
`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 ('${id}', 'CF Test S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIA...', 'secret...', '', '', 0, 0, 'active', ${Date.now()}, ${Date.now()})`,
)
}
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-file-${nanoid(6)}.pdf`,
type: dirtype !== DirType.FILE ? 'folder' : 'application/pdf',
size: 1024,
dirtype,
parent: '',
object: dirtype !== DirType.FILE ? '' : `objects/${nanoid()}.pdf`,
storageId: 'cf-storage-1',
status: 'active',
trashedAt: null,
createdAt: now,
updatedAt: now,
}
await db.insert(matters).values(matter)
return matter
}
// ─── resolveShareByToken on D1 ────────────────────────────────────────────────
describe('[CF] resolveShareByToken', () => {
it('returns ok for an active landing share', async () => {
const db = buildDb()
const orgId = `org-${nanoid(6)}`
await seedStorage(db, 'cf-storage-1')
const matter = await seedMatter(db, orgId)
const share = await createShare(db, {
matterId: matter.id,
orgId,
creatorId: 'cf-user-1',
kind: 'landing',
})
const result = await resolveShareByToken(db, share.token)
expect(result.status).toBe('ok')
if (result.status !== 'ok') return
expect(result.share.id).toBe(share.id)
})
it('returns revoked when share is revoked', async () => {
const db = buildDb()
const orgId = `org-${nanoid(6)}`
await seedStorage(db, 'cf-storage-1')
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 result = await resolveShareByToken(db, share.token)
expect(result.status).toBe('revoked')
})
it('returns matter_trashed when matter is trashed', async () => {
const db = buildDb()
const orgId = `org-${nanoid(6)}`
await seedStorage(db, 'cf-storage-1')
const matter = await seedMatter(db, orgId)
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'cf-user-3', kind: 'landing' })
await db.run(`UPDATE matters SET status = 'trashed' WHERE id = '${matter.id}'`)
const result = await resolveShareByToken(db, share.token)
expect(result.status).toBe('matter_trashed')
})
})
// ─── saveShareToDrive on D1 ───────────────────────────────────────────────────
describe('[CF] saveShareToDrive — stream copy via D1', () => {
beforeEach(() => {
vi.spyOn(S3Service.prototype, 'copyObject').mockResolvedValue(undefined)
vi.spyOn(S3Service.prototype, 'streamCopy').mockResolvedValue(undefined)
})
it('saves a single file to the target org on D1', async () => {
const db = buildDb()
const srcOrgId = `src-${nanoid(6)}`
const dstOrgId = `dst-${nanoid(6)}`
await seedStorage(db, 'cf-storage-1')
const matter = await seedMatter(db, srcOrgId)
const share = await createShare(db, { matterId: matter.id, orgId: srcOrgId, creatorId: 'cf-u1', kind: 'landing' })
if (share.status === 'revoked') throw new Error('test setup failed')
const result = await saveShareToDrive(db, {
share,
matter,
currentUserId: 'cf-u2',
targetOrgId: dstOrgId,
targetParent: '',
})
expect(result.saved).toHaveLength(1)
expect(result.skipped).toHaveLength(0)
expect(result.saved[0].orgId).toBe(dstOrgId)
expect(result.saved[0].status).toBe('active')
})
it('does not increment downloads counter after save', async () => {
const db = buildDb()
const srcOrgId = `src-${nanoid(6)}`
const dstOrgId = `dst-${nanoid(6)}`
await seedStorage(db, 'cf-storage-1')
const matter = await seedMatter(db, srcOrgId)
const share = await createShare(db, { matterId: matter.id, orgId: srcOrgId, creatorId: 'cf-u3', kind: 'landing' })
if (share.status === 'revoked') throw new Error('test setup failed')
const downloadsBefore = share.downloads
await saveShareToDrive(db, {
share,
matter,
currentUserId: 'cf-u4',
targetOrgId: dstOrgId,
targetParent: '',
})
const _rows = await db.select().from(matters).where(
// Check that the original share downloads didn't change
)
// Verify by re-querying the share
const updatedShareRows = await db.all<{ downloads: number }>(
`SELECT downloads FROM shares WHERE id = '${share.id}'`,
)
expect(updatedShareRows[0]?.downloads).toBe(downloadsBefore)
})
it('recursively saves a folder tree on D1', async () => {
const db = buildDb()
const srcOrgId = `src-${nanoid(6)}`
const dstOrgId = `dst-${nanoid(6)}`
await seedStorage(db, 'cf-storage-1')
// Create folder structure
const now = new Date()
const folder = {
id: nanoid(),
orgId: srcOrgId,
alias: nanoid(10),
name: 'cf-album',
type: 'folder',
size: 0,
dirtype: DirType.USER_FOLDER,
parent: '',
object: '',
storageId: 'cf-storage-1',
status: 'active',
trashedAt: null,
createdAt: now,
updatedAt: now,
}
await db.insert(matters).values(folder)
const file1 = {
id: nanoid(),
orgId: srcOrgId,
alias: nanoid(10),
name: 'photo1.jpg',
type: 'image/jpeg',
size: 500,
dirtype: DirType.FILE,
parent: 'cf-album',
object: `objects/${nanoid()}.jpg`,
storageId: 'cf-storage-1',
status: 'active',
trashedAt: null,
createdAt: now,
updatedAt: now,
}
await db.insert(matters).values(file1)
const share = await createShare(db, { matterId: folder.id, orgId: srcOrgId, creatorId: 'cf-u5', kind: 'landing' })
if (share.status === 'revoked') throw new Error('test setup failed')
const result = await saveShareToDrive(db, {
share,
matter: folder,
currentUserId: 'cf-u6',
targetOrgId: dstOrgId,
targetParent: '',
})
// 1 root folder + 1 file = 2
expect(result.saved).toHaveLength(2)
expect(result.skipped).toHaveLength(0)
})
})