Files
zpan/server/http/share-public.cf-test.ts
T
agent-kanban-local[bot]andAlex Chen 00f48cf355 feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode (#467)
* feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode

Host user avatars and org logos on the ZPan Cloud avatar service
(zpan-cloud-sdk ^2.4.0) instead of a public S3/R2 bucket, then remove the
now-dead storages.mode / public-bucket concept entirely (#456 parts 2-3).

- image-upload gateway: upload/delete via SDK uploadAvatar/deleteAvatar against
  a bound Cloud client; validate mime (AVATAR_CONTENT_TYPES) + size
  (MAX_AVATAR_BYTES) before the call; map cloud error codes to 400/403/413/500;
  unbound instance returns 503 cloud_required (delete is a best-effort no-op).
- licensing-cloud: createAvatarUploadClient builds the client with a plain-object
  bearer header so both the image content-type and Authorization survive hono's
  per-request header merge (a Headers instance would be dropped).
- drop storages.mode (migration via drizzle-kit), StorageRepo.select() no longer
  takes a mode, remove StorageMode / Storage.mode / mode schema+audit+UI+i18n and
  the PUBLIC_IMAGES bucket + PUBLIC_IMAGES_URL wiring.

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

* ci(deploy): drop dead PUBLIC_IMAGES R2 provisioning from CF deploy

The Cloud avatar migration removed the PUBLIC_IMAGES binding from
wrangler.toml, so the deploy workflow's R2 public-images steps are dead and
must go too — otherwise every CF deploy keeps re-provisioning a public-read
zpan-public-images bucket (the footgun #456 eliminates) and sets an unused
PUBLIC_IMAGES_URL secret. Removes the bucket-create, managed-public-URL, and
secret steps (steps.r2 was only consumed by the secret step). Also drops a
stale storage-modes line from the v2.0 roadmap.

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

---------

Co-authored-by: Alex Chen <alex-chen@mails.agent-kanban.dev>
2026-06-20 00:16:07 -04:00

142 lines
6.2 KiB
TypeScript

import { env } from 'cloudflare:workers'
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { createShareRepo } from '../adapters/repos/share'
import { createApp } from '../app'
import { createAuth } from '../auth'
import { createCloudflarePlatform } from '../platform/cloudflare'
// ─── CF routing regression guard ─────────────────────────────────────────────
// Verifies that public share JSON endpoints live under /api/* (Worker-handled)
// and NOT under /s/* (CF Assets serves the SPA there). These assertions catch
// any accidental re-mount before the bug reaches a preview deployment.
describe('[CF] Routing regression — share routes must be under /api/* or /r/*', () => {
it('/s/:token returns 404 (not routed) — JSON share API is not mounted at /s', async () => {
const platform = createCloudflarePlatform(env)
const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET)
const app = createApp(platform, auth)
const res = await app.request('/s/any-token')
// 404 means no Worker route handles /s/* — correct: CF Assets owns this path.
// If this becomes 200 with JSON it means a /s/* route was accidentally added.
expect(res.status).toBe(404)
const ct = res.headers.get('content-type') ?? ''
expect(ct).not.toContain('application/json')
})
it('/api/shares/:token returns JSON (not SPA) — correct path for share API', async () => {
const platform = createCloudflarePlatform(env)
const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET)
const app = createApp(platform, auth)
const res = await app.request('/api/shares/nonexistent')
expect(res.status).toBe(404)
const ct = res.headers.get('content-type') ?? ''
expect(ct).toContain('application/json')
})
})
const STORAGE_ID = 'st-cf-share'
async function buildApp() {
const platform = createCloudflarePlatform(env)
const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET)
return { app: createApp(platform, auth), db: platform.db }
}
async function signUpAndGetIds(app: ReturnType<typeof createApp>, db: Awaited<ReturnType<typeof buildApp>>['db']) {
const email = `cf-share-${Date.now()}@example.com`
const signUpRes = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'CF Test', email, password: 'password123456' }),
})
const cookies = signUpRes.headers.getSetCookie().join('; ')
const orgRows = await db.all<{ id: string }>(
sql`SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' ORDER BY created_at DESC LIMIT 1`,
)
const userRows = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email} LIMIT 1`)
return { headers: { Cookie: cookies }, orgId: orgRows[0].id, userId: userRows[0].id }
}
async function insertStorage(db: Awaited<ReturnType<typeof buildApp>>['db']) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'CF S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
async function insertFile(
db: Awaited<ReturnType<typeof buildApp>>['db'],
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}-cf-alias`}, ${opts.name}, 'text/plain', 512, 0, ${opts.parent ?? ''}, 'keys/file.txt', ${STORAGE_ID}, 'active', ${now}, ${now})
`)
}
describe('[CF] Public share routes — no requireAuth', () => {
it('GET /api/shares/:token returns share metadata without auth', async () => {
const { app, db } = await buildApp()
const { orgId, userId } = await signUpAndGetIds(app, db)
await insertStorage(db)
await insertFile(db, orgId, { id: `cf-f1-${Date.now()}`, name: 'cf-file.txt' })
const rows = await db.all<{ id: string }>(sql`SELECT id FROM matters WHERE name = 'cf-file.txt' LIMIT 1`)
const matterId = rows[0].id
const share = await createShareRepo(db).create({ matterId, orgId, creatorId: userId, kind: 'landing' })
const res = await app.request(`/api/shares/${share.token}`)
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.kind).toBe('landing')
const matter = body.matter as Record<string, unknown>
expect(matter.name).toBe('cf-file.txt')
})
it('GET /r/unknown-prefix returns 404 for unknown token without auth', async () => {
const { app } = await buildApp()
const res = await app.request('/r/nonexistent-cf-token')
expect(res.status).toBe(404)
})
})
describe('[CF] Concurrent downloads — atomic limit enforcement', () => {
it('with limit=5 and 20 concurrent requests, exactly 5 succeed', async () => {
const { app, db } = await buildApp()
const { orgId, userId } = await signUpAndGetIds(app, db)
await insertStorage(db)
const fileId = `cf-dlc-${Date.now()}`
await insertFile(db, orgId, { id: fileId, name: 'concurrent.bin' })
// Spy on presignDownload to return a fake URL without hitting real S3
const share = await createShareRepo(db).create({
matterId: fileId,
orgId,
creatorId: userId,
kind: 'direct',
downloadLimit: 5,
})
// Fire 20 concurrent requests
const results = await Promise.all(
Array.from({ length: 20 }, () => app.request(`/r/${share.token}`, { redirect: 'manual' }).then((r) => r.status)),
)
// Note: presignDownload will throw in CF test env (no real S3),
// so successful requests (that passed the limit gate) may get 500.
// We count 302 + 500 as "passed the gate", and 410 as "rejected by limit".
const passed = results.filter((s) => s !== 410).length
const rejected = results.filter((s) => s === 410).length
expect(passed).toBe(5)
expect(rejected).toBe(15)
})
})