mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
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>
This commit is contained in:
co-authored by
Alex Chen
parent
0138e7779e
commit
00f48cf355
@@ -350,8 +350,8 @@ describe('background jobs API', () => {
|
||||
async function seedStorage(db: TestDb): Promise<void> {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('route-storage', 'Route Storage', 'private', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('route-storage', 'Route Storage', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,12 +76,12 @@ async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (
|
||||
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
|
||||
egress_credit_per_unit, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'remote-download-storage', 'Remote Download Storage', 'private', 'test-bucket',
|
||||
'remote-download-storage', 'Remote Download Storage', 'test-bucket',
|
||||
'https://s3.example.com', 'auto', 'test-access-key', 'test-secret-key',
|
||||
'$UID/$RAW_NAME', '', 0, 0, 'active', 0, ${100 * 1024 * 1024}, 1, ${now}, ${now}
|
||||
)
|
||||
|
||||
@@ -999,7 +999,6 @@ describe('DELETE /api/image-hosting/config', () => {
|
||||
await db.insert(schema.storages).values({
|
||||
id: storageId,
|
||||
title: 'Test Storage',
|
||||
mode: 's3',
|
||||
bucket: 'test',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'auto',
|
||||
|
||||
@@ -15,7 +15,6 @@ beforeEach(() => {
|
||||
const validStorage = {
|
||||
id: 'st-ihost-1',
|
||||
title: 'Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -29,8 +28,8 @@ type TestAuth = Awaited<ReturnType<typeof createTestApp>>['auth']
|
||||
async function insertStorage(db: TestDb) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT 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})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -117,8 +117,8 @@ async function signUp(app: ReturnType<typeof createApp>, db: Awaited<ReturnType<
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof buildAppWithDb>>['db'], id: string) {
|
||||
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 (${id}, 'CF S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
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 (${id}, 'CF S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ afterEach(() => {
|
||||
const validStorage = {
|
||||
id: 'st-1',
|
||||
title: 'Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -103,12 +102,12 @@ async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
const metered = opts.metered ? 1 : 0
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (
|
||||
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
|
||||
egress_credit_per_unit, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
${validStorage.id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket},
|
||||
${validStorage.id}, ${validStorage.title}, ${validStorage.bucket},
|
||||
${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey},
|
||||
'', '', 0, 0, 'active', ${metered}, ${100 * 1024 ** 2}, 1, ${now}, ${now}
|
||||
)
|
||||
@@ -839,8 +838,8 @@ describe('Matter service', () => {
|
||||
const { db } = await createTestApp()
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
|
||||
const matter = await createMatter(db, {
|
||||
@@ -862,8 +861,8 @@ describe('Matter service', () => {
|
||||
const { db } = await createTestApp()
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
|
||||
const matter = await createMatter(db, {
|
||||
@@ -886,8 +885,8 @@ describe('Matter service', () => {
|
||||
const { db } = await createTestApp()
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
|
||||
await createMatter(db, {
|
||||
@@ -937,8 +936,8 @@ describe('Matter service', () => {
|
||||
const { db } = await createTestApp()
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
const matter = await createMatter(db, {
|
||||
orgId: 'org-1',
|
||||
@@ -956,8 +955,8 @@ describe('Matter service', () => {
|
||||
const { db } = await createTestApp()
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
const matter = await createMatter(db, {
|
||||
orgId: 'org-1',
|
||||
@@ -986,8 +985,8 @@ describe('Matter service', () => {
|
||||
const { db } = await createTestApp()
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
const source = await createMatter(db, {
|
||||
orgId: 'org-1',
|
||||
@@ -1428,7 +1427,6 @@ describe('Objects API — quota enforcement', () => {
|
||||
const validStorage = {
|
||||
id: 'st-quota',
|
||||
title: 'Quota S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -1439,8 +1437,8 @@ describe('Objects API — quota enforcement', () => {
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db'], used = 0) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT 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},
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket},
|
||||
${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey},
|
||||
${validStorage.secretKey}, '', '', 0, ${used}, 'active', ${now}, ${now})
|
||||
`)
|
||||
@@ -2067,12 +2065,12 @@ describe('object multipart upload API with S3-compatible storage', () => {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (
|
||||
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
|
||||
egress_credit_per_unit, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'multipart-live-storage', 'Multipart Live Storage', 'private', 'test-bucket',
|
||||
'multipart-live-storage', 'Multipart Live Storage', 'test-bucket',
|
||||
${endpoint}, 'auto', 'test-access-key', 'test-secret-key',
|
||||
'$UID/$RAW_NAME', '', 0, 0, 'active', 0, ${100 * 1024 * 1024}, 1, ${now}, ${now}
|
||||
)
|
||||
|
||||
@@ -37,8 +37,8 @@ async function signUpAndGetIds(app: ReturnType<typeof createApp>, db: Awaited<Re
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof buildApp>>['db']) {
|
||||
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 (${STORAGE_ID}, 'CF S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
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})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ beforeEach(() => {
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT 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}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -601,8 +601,8 @@ describe('GET /r/:token — two-org isolation', () => {
|
||||
|
||||
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 (${STORAGE_ID}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
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}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
await insertImageHosting(db, orgId, { id: 'ih-iso1', token: 'ih_isolationtest' })
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ async function signUpAndGetIds(app: ReturnType<typeof createApp>, db: Awaited<Re
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof buildApp>>['db']) {
|
||||
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 (${STORAGE_ID}, 'CF S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
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})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
const validStorage = {
|
||||
id: 'st-share-test',
|
||||
title: 'Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -26,8 +25,8 @@ const validStorage = {
|
||||
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})
|
||||
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 (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -1060,8 +1059,8 @@ describe('Public share routes', () => {
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT 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}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
const validStorage = {
|
||||
id: 'st-audit-test',
|
||||
title: 'Audit Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -29,8 +28,8 @@ const validStorage = {
|
||||
async function insertStorage(db: TestDb, id = validStorage.id) {
|
||||
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 (${id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
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 (${id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -396,7 +395,6 @@ describe('Storage audit events', () => {
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'New Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -412,8 +410,6 @@ describe('Storage audit events', () => {
|
||||
expect(evt?.targetName).toBe('New Storage')
|
||||
// Must NOT store secret keys or access keys in metadata
|
||||
assertNoSecrets(evt?.metadata ?? null)
|
||||
const meta = JSON.parse(evt?.metadata ?? '{}') as { mode: string }
|
||||
expect(meta.mode).toBe('private')
|
||||
})
|
||||
|
||||
it('records storage_update when admin updates a storage', async () => {
|
||||
@@ -426,7 +422,6 @@ describe('Storage audit events', () => {
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Original Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -460,7 +455,6 @@ describe('Storage audit events', () => {
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Deletable Storage',
|
||||
mode: 'private',
|
||||
bucket: 'del-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
|
||||
@@ -35,7 +35,6 @@ async function adminHeaders(app: ReturnType<typeof buildApp>) {
|
||||
|
||||
const validStorage = {
|
||||
title: 'CF Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'cf-test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
|
||||
@@ -6,7 +6,6 @@ import { adminHeaders, authedHeaders, createTestApp } from '../../test/setup.js'
|
||||
|
||||
const validStorage = {
|
||||
title: 'Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -57,7 +56,6 @@ describe('Admin Storages API', () => {
|
||||
expect(res.status).toBe(201)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.title).toBe('Test S3')
|
||||
expect(body.mode).toBe('private')
|
||||
expect(body.bucket).toBe('test-bucket')
|
||||
expect(body.status).toBe('active')
|
||||
expect(body.capacity).toBe(0)
|
||||
@@ -228,7 +226,6 @@ async function insertStorage(
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
opts: {
|
||||
id: string
|
||||
mode: 'private' | 'public'
|
||||
status?: string
|
||||
capacity?: number
|
||||
used?: number
|
||||
@@ -241,99 +238,75 @@ async function insertStorage(
|
||||
const status = opts.status ?? 'active'
|
||||
const title = `Storage ${opts.id}`
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${opts.id}, ${title}, ${opts.mode}, 'bucket', 'https://s3.example.com', 'us-east-1', 'key', 'secret', '$UID/$RAW_NAME', '', ${capacity}, ${used}, ${status}, ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${opts.id}, ${title}, 'bucket', 'https://s3.example.com', 'us-east-1', 'key', 'secret', '$UID/$RAW_NAME', '', ${capacity}, ${used}, ${status}, ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
describe('selectStorage service', () => {
|
||||
it('returns the single active storage when capacity is unlimited (0) [spec: storages/select-active]', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, used: 0 })
|
||||
await insertStorage(db, { id: 's1', capacity: 0, used: 0 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
|
||||
it('returns storage when used is below capacity', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 50 })
|
||||
await insertStorage(db, { id: 's1', capacity: 100, used: 50 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
|
||||
it('skips storage where used equals capacity', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 100, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', capacity: 200, used: 50, createdAt: 2 })
|
||||
await insertStorage(db, { id: 's1', capacity: 100, used: 100, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', capacity: 200, used: 50, createdAt: 2 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s2')
|
||||
})
|
||||
|
||||
it('skips storage where used exceeds capacity', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 110, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', capacity: 0, used: 0, createdAt: 2 })
|
||||
await insertStorage(db, { id: 's1', capacity: 100, used: 110, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', capacity: 0, used: 0, createdAt: 2 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s2')
|
||||
})
|
||||
|
||||
it('picks the oldest active storage first (sequential fill order)', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, used: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', capacity: 0, used: 0, createdAt: 2 })
|
||||
await insertStorage(db, { id: 's1', capacity: 0, used: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', capacity: 0, used: 0, createdAt: 2 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
|
||||
it('ignores disabled storages', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', status: 'disabled', capacity: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', status: 'active', capacity: 0, createdAt: 2 })
|
||||
await insertStorage(db, { id: 's1', status: 'disabled', capacity: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', status: 'active', capacity: 0, createdAt: 2 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s2')
|
||||
})
|
||||
|
||||
it('ignores storages of a different mode', async () => {
|
||||
it('throws when no active storage exists', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'public', capacity: 0 })
|
||||
|
||||
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
|
||||
await expect(createStorageRepo(db).select()).rejects.toThrow('No available storage')
|
||||
})
|
||||
|
||||
it('throws when no active storage exists for the requested mode', async () => {
|
||||
it('throws when all storages are at full capacity', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', capacity: 50, used: 50 })
|
||||
await insertStorage(db, { id: 's2', capacity: 100, used: 100 })
|
||||
|
||||
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
|
||||
})
|
||||
|
||||
it('throws when all storages of the mode are at full capacity', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 50, used: 50 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', capacity: 100, used: 100 })
|
||||
|
||||
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
|
||||
})
|
||||
|
||||
it('returns a public storage when mode is public', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'public', capacity: 0 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('public')
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
|
||||
it('does not return a public storage when private mode is requested', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'public', capacity: 0, createdAt: 2 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
expect(storage.id).toBe('s1')
|
||||
await expect(createStorageRepo(db).select()).rejects.toThrow('No available storage')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@ const storageSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
mode: z.string(),
|
||||
bucket: z.string(),
|
||||
endpoint: z.string(),
|
||||
region: z.string(),
|
||||
|
||||
@@ -42,12 +42,12 @@ async function insertStorage(db: Database) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (
|
||||
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
|
||||
egress_credit_per_unit, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
${STORAGE_ID}, 'Cloud Traffic S3', 'private', 'test-bucket', 'https://s3.amazonaws.com',
|
||||
${STORAGE_ID}, 'Cloud Traffic S3', 'test-bucket', 'https://s3.amazonaws.com',
|
||||
'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', true, ${100 * 1024 ** 2}, 1, ${now}, ${now}
|
||||
)
|
||||
`)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { S3Service } from '../adapters/gateways/s3.js'
|
||||
import { createTeamInviteRepo } from '../adapters/repos/team-invite.js'
|
||||
import * as authSchema from '../db/auth-schema.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import { createTestApp, seedBusinessLicense } from '../test/setup.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
type TestApp = Awaited<ReturnType<typeof createTestApp>>['app']
|
||||
@@ -659,12 +658,35 @@ describe('GET /api/teams/:teamId/activity — isolation', () => {
|
||||
|
||||
// ─── Org logo (PUT/DELETE /:teamId/logo) ─────────────────────────────────────
|
||||
|
||||
async function insertPublicStorage(db: TestDb) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('st-logo', 'Public', 'public', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKID', 'secret', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
const CLOUD_LOGO_URL = 'https://avatars.zpan.cloud/team/logo.png'
|
||||
|
||||
// Stub the Cloud avatar service and capture each request so tests can assert the
|
||||
// /avatars/team/:id path, the image content type, and the bearer auth that reach
|
||||
// Cloud. `seedBusinessLicense` makes the instance Cloud-paired.
|
||||
function stubCloudAvatarFetch() {
|
||||
const calls: { url: string; method: string; contentType: string | null; authorization: string | null }[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const u = String(url)
|
||||
if (u.includes('/avatars/')) {
|
||||
const headers = new Headers(init?.headers)
|
||||
calls.push({
|
||||
url: u,
|
||||
method: init?.method ?? 'GET',
|
||||
contentType: headers.get('content-type'),
|
||||
authorization: headers.get('authorization'),
|
||||
})
|
||||
if (init?.method === 'DELETE') return new Response(null, { status: 204 })
|
||||
return new Response(JSON.stringify({ url: CLOUD_LOGO_URL, key: 'avatars/team/logo' }), {
|
||||
status: 201,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return new Response('unexpected fetch', { status: 404 })
|
||||
}),
|
||||
)
|
||||
return calls
|
||||
}
|
||||
|
||||
function makeFile(type: string, bytes = 16): File {
|
||||
@@ -674,7 +696,7 @@ function makeFile(type: string, bytes = 16): File {
|
||||
describe('PUT /api/teams/:teamId/logo', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(S3Service.prototype, 'putObject').mockResolvedValue(16)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns 401 without auth', async () => {
|
||||
@@ -690,7 +712,6 @@ describe('PUT /api/teams/:teamId/logo', () => {
|
||||
const { headers, userId } = await signUpAndGetUser(app, `m-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'member')
|
||||
await insertPublicStorage(db)
|
||||
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/png'))
|
||||
@@ -698,33 +719,37 @@ describe('PUT /api/teams/:teamId/logo', () => {
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 400 when mime is invalid (gif)', async () => {
|
||||
it('returns 400 for an unsupported mime, before any Cloud call', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'owner')
|
||||
await insertPublicStorage(db)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/gif'))
|
||||
form.set('file', makeFile('application/pdf'))
|
||||
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'PUT', headers, body: form })
|
||||
expect(res.status).toBe(400)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 413 when file > 2 MiB', async () => {
|
||||
it('returns 413 when file > 1 MiB, before any Cloud call', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'owner')
|
||||
await insertPublicStorage(db)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/png', 3 * 1024 * 1024))
|
||||
form.set('file', makeFile('image/png', 2 * 1024 * 1024))
|
||||
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'PUT', headers, body: form })
|
||||
expect(res.status).toBe(413)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 503 when no public storage is configured', async () => {
|
||||
it('returns 503 cloud_required when the instance is not paired to Cloud', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
@@ -736,32 +761,37 @@ describe('PUT /api/teams/:teamId/logo', () => {
|
||||
expect(res.status).toBe(503)
|
||||
})
|
||||
|
||||
it('uploads + writes organization.logo + returns URL (owner)', async () => {
|
||||
it('hosts the logo on Cloud + writes organization.logo + returns URL (owner)', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'owner')
|
||||
await insertPublicStorage(db)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/jpeg'))
|
||||
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'PUT', headers, body: form })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { url: string }
|
||||
expect(body.url).toContain(`_system/org-logos/${orgId}`)
|
||||
expect(body.url).toContain('.jpg')
|
||||
expect(S3Service.prototype.putObject).toHaveBeenCalledTimes(1)
|
||||
expect(body.url).toBe(CLOUD_LOGO_URL)
|
||||
|
||||
const put = calls.find((c) => c.method === 'PUT')
|
||||
expect(put?.url).toContain(`/avatars/team/${orgId}`)
|
||||
expect(put?.contentType).toBe('image/jpeg')
|
||||
expect(put?.authorization).toBe('Bearer test-refresh-token')
|
||||
|
||||
const rows = await db.all<{ logo: string | null }>(sql`SELECT logo FROM organization WHERE id = ${orgId}`)
|
||||
expect(rows[0]?.logo).toBe(body.url)
|
||||
expect(rows[0]?.logo).toBe(CLOUD_LOGO_URL)
|
||||
})
|
||||
|
||||
it('succeeds for admin role (not just owner)', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const { headers, userId } = await signUpAndGetUser(app, `a-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'admin')
|
||||
await insertPublicStorage(db)
|
||||
stubCloudAvatarFetch()
|
||||
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/png'))
|
||||
@@ -773,7 +803,7 @@ describe('PUT /api/teams/:teamId/logo', () => {
|
||||
describe('DELETE /api/teams/:teamId/logo', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(S3Service.prototype, 'deleteObject').mockResolvedValue(undefined)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns 401 without auth', async () => {
|
||||
@@ -792,34 +822,37 @@ describe('DELETE /api/teams/:teamId/logo', () => {
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('clears organization.logo + removes all mime variants from S3', async () => {
|
||||
it('clears organization.logo + deletes the Cloud logo', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'owner')
|
||||
await insertPublicStorage(db)
|
||||
|
||||
await db.run(sql`UPDATE organization SET logo = 'https://example.com/old.png' WHERE id = ${orgId}`)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'DELETE', headers })
|
||||
expect(res.status).toBe(204)
|
||||
|
||||
const rows = await db.all<{ logo: string | null }>(sql`SELECT logo FROM organization WHERE id = ${orgId}`)
|
||||
expect(rows[0]?.logo).toBeNull()
|
||||
expect(S3Service.prototype.deleteObject).toHaveBeenCalledTimes(3)
|
||||
const del = calls.find((c) => c.method === 'DELETE')
|
||||
expect(del?.url).toContain(`/avatars/team/${orgId}`)
|
||||
})
|
||||
|
||||
it('succeeds when no public storage (DB cleared, S3 skipped)', async () => {
|
||||
it('succeeds when the instance is not paired to Cloud (DB cleared, Cloud delete skipped)', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'owner')
|
||||
await db.run(sql`UPDATE organization SET logo = 'https://example.com/old.png' WHERE id = ${orgId}`)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'DELETE', headers })
|
||||
expect(res.status).toBe(204)
|
||||
const rows = await db.all<{ logo: string | null }>(sql`SELECT logo FROM organization WHERE id = ${orgId}`)
|
||||
expect(rows[0]?.logo).toBeNull()
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
expired,
|
||||
forbidden,
|
||||
type InviteLinkInfo,
|
||||
internalError,
|
||||
noStorage,
|
||||
notFound,
|
||||
type PendingInvitation,
|
||||
@@ -134,9 +135,11 @@ function failureError(failure: { status: 400 | 404; error: string }) {
|
||||
}
|
||||
|
||||
// Maps the image-upload gateway outcome ({ status, error }) to its error factory.
|
||||
function imageUploadError(status: 400 | 413 | 503, error: string) {
|
||||
function imageUploadError(status: 400 | 403 | 413 | 500 | 503, error: string) {
|
||||
if (status === 413) return payloadTooLarge(error)
|
||||
if (status === 503) return noStorage(error)
|
||||
if (status === 403) return forbidden(error)
|
||||
if (status === 500) return internalError(error)
|
||||
return badRequest(error)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { S3Service } from '../adapters/gateways/s3.js'
|
||||
import { buildBreadcrumb } from '../domain/breadcrumb.js'
|
||||
import { authedHeaders, createTestApp } from '../test/setup.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
import { authedHeaders, createTestApp, seedBusinessLicense } from '../test/setup.js'
|
||||
|
||||
async function adminHeaders(app: ReturnType<typeof import('../app')['createApp']>) {
|
||||
// Sign up first user (gets promoted to admin via hook)
|
||||
@@ -374,12 +371,36 @@ describe('User entitlements API (admin)', () => {
|
||||
|
||||
// ─── User avatar (PUT/DELETE /api/users/me/avatar) ────────────────────────────
|
||||
|
||||
async function insertPublicStorage(db: TestDb) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('st-me', 'Public', 'public', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKID', 'secret', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
const CLOUD_AVATAR_URL = 'https://avatars.zpan.cloud/user/u1.webp'
|
||||
|
||||
// Stub the Cloud avatar service and capture each avatar request so tests can
|
||||
// assert the /avatars/:scope/:id path, the image content type, and the bearer
|
||||
// auth that reach Cloud. `seedBusinessLicense` makes the instance Cloud-paired
|
||||
// (active license binding, refresh token 'test-refresh-token').
|
||||
function stubCloudAvatarFetch() {
|
||||
const calls: { url: string; method: string; contentType: string | null; authorization: string | null }[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const u = String(url)
|
||||
if (u.includes('/avatars/')) {
|
||||
const headers = new Headers(init?.headers)
|
||||
calls.push({
|
||||
url: u,
|
||||
method: init?.method ?? 'GET',
|
||||
contentType: headers.get('content-type'),
|
||||
authorization: headers.get('authorization'),
|
||||
})
|
||||
if (init?.method === 'DELETE') return new Response(null, { status: 204 })
|
||||
return new Response(JSON.stringify({ url: CLOUD_AVATAR_URL, key: 'avatars/user/u1' }), {
|
||||
status: 201,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return new Response('unexpected fetch', { status: 404 })
|
||||
}),
|
||||
)
|
||||
return calls
|
||||
}
|
||||
|
||||
function makeFile(type: string, bytes = 16): File {
|
||||
@@ -389,7 +410,7 @@ function makeFile(type: string, bytes = 16): File {
|
||||
describe('PUT /api/users/me/avatar', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(S3Service.prototype, 'putObject').mockResolvedValue(16)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns 401 without auth [spec: avatar/auth-required]', async () => {
|
||||
@@ -420,27 +441,31 @@ describe('PUT /api/users/me/avatar', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when mime is not PNG/JPG/WebP [spec: avatar/mime-validated]', async () => {
|
||||
it('returns 400 for an unsupported mime, before any Cloud call [spec: avatar/mime-validated]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await insertPublicStorage(db)
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/gif'))
|
||||
form.set('file', makeFile('application/pdf'))
|
||||
const res = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form })
|
||||
expect(res.status).toBe(400)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 413 when file exceeds 2 MiB [spec: avatar/size-limit]', async () => {
|
||||
it('returns 413 when the file exceeds 1 MiB, before any Cloud call [spec: avatar/size-limit]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await insertPublicStorage(db)
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/png', 3 * 1024 * 1024))
|
||||
form.set('file', makeFile('image/png', 2 * 1024 * 1024))
|
||||
const res = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form })
|
||||
expect(res.status).toBe(413)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 503 when no public storage is configured [spec: avatar/needs-storage]', async () => {
|
||||
it('returns 503 cloud_required when the instance is not paired to Cloud [spec: avatar/needs-cloud]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const form = new FormData()
|
||||
@@ -449,47 +474,33 @@ describe('PUT /api/users/me/avatar', () => {
|
||||
expect(res.status).toBe(503)
|
||||
})
|
||||
|
||||
it('uploads the file to S3, writes user.image, returns the URL [spec: avatar/upload]', async () => {
|
||||
it('hosts the avatar on Cloud, writes user.image, returns the URL [spec: avatar/upload]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await insertPublicStorage(db)
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/webp'))
|
||||
|
||||
const res = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { url: string }
|
||||
expect(body.url).toContain('_system/avatars/')
|
||||
expect(body.url).toContain('.webp')
|
||||
expect(S3Service.prototype.putObject).toHaveBeenCalledTimes(1)
|
||||
expect(body.url).toBe(CLOUD_AVATAR_URL)
|
||||
|
||||
const put = calls.find((c) => c.method === 'PUT')
|
||||
expect(put?.url).toMatch(/\/avatars\/user\//)
|
||||
expect(put?.contentType).toBe('image/webp')
|
||||
expect(put?.authorization).toBe('Bearer test-refresh-token')
|
||||
|
||||
const rows = await db.all<{ image: string | null }>(sql`SELECT image FROM user LIMIT 1`)
|
||||
expect(rows[0]?.image).toBe(body.url)
|
||||
})
|
||||
|
||||
it('is idempotent — re-PUT with same mime returns the same URL [spec: avatar/idempotent]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await insertPublicStorage(db)
|
||||
const headers = await authedHeaders(app)
|
||||
|
||||
const form1 = new FormData()
|
||||
form1.set('file', makeFile('image/png'))
|
||||
const res1 = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form1 })
|
||||
const body1 = (await res1.json()) as { url: string }
|
||||
|
||||
const form2 = new FormData()
|
||||
form2.set('file', makeFile('image/png'))
|
||||
const res2 = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form2 })
|
||||
const body2 = (await res2.json()) as { url: string }
|
||||
|
||||
expect(body1.url).toBe(body2.url)
|
||||
expect(rows[0]?.image).toBe(CLOUD_AVATAR_URL)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/users/me/avatar', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(S3Service.prototype, 'deleteObject').mockResolvedValue(undefined)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns 401 without auth', async () => {
|
||||
@@ -498,30 +509,34 @@ describe('DELETE /api/users/me/avatar', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('clears user.image and removes all mime variants from S3 [spec: avatar/delete]', async () => {
|
||||
it('clears user.image and deletes the Cloud avatar [spec: avatar/delete]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await insertPublicStorage(db)
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
await db.run(sql`UPDATE user SET image = 'https://example.com/old.png'`)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const res = await app.request('/api/users/me/avatar', { method: 'DELETE', headers })
|
||||
expect(res.status).toBe(204)
|
||||
|
||||
const rows = await db.all<{ image: string | null }>(sql`SELECT image FROM user LIMIT 1`)
|
||||
expect(rows[0]?.image).toBeNull()
|
||||
// 3 mime variants attempted (png, jpg, webp)
|
||||
expect(S3Service.prototype.deleteObject).toHaveBeenCalledTimes(3)
|
||||
|
||||
const del = calls.find((c) => c.method === 'DELETE')
|
||||
expect(del?.url).toMatch(/\/avatars\/user\//)
|
||||
})
|
||||
|
||||
it('succeeds when no public storage exists (DB cleared, S3 skipped) [spec: avatar/delete-no-storage]', async () => {
|
||||
it('succeeds when the instance is not paired to Cloud (DB cleared, Cloud delete skipped) [spec: avatar/delete-unbound]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await db.run(sql`UPDATE user SET image = 'https://example.com/old.png'`)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const res = await app.request('/api/users/me/avatar', { method: 'DELETE', headers })
|
||||
expect(res.status).toBe(204)
|
||||
const rows = await db.all<{ image: string | null }>(sql`SELECT image FROM user LIMIT 1`)
|
||||
expect(rows[0]?.image).toBeNull()
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+12
-2
@@ -1,7 +1,15 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { badRequest, noStorage, notFound, payloadTooLarge, unsupportedMediaType } from '../usecases/ports'
|
||||
import {
|
||||
badRequest,
|
||||
forbidden,
|
||||
internalError,
|
||||
noStorage,
|
||||
notFound,
|
||||
payloadTooLarge,
|
||||
unsupportedMediaType,
|
||||
} from '../usecases/ports'
|
||||
import { getUserQuota } from '../usecases/quota'
|
||||
import {
|
||||
getPublicProfile,
|
||||
@@ -52,9 +60,11 @@ function failureError(failure: { status: 400 | 404; error: string }) {
|
||||
}
|
||||
|
||||
// Maps the image-upload gateway outcome ({ status, error }) to its error factory.
|
||||
function imageUploadError(status: 400 | 413 | 503, error: string) {
|
||||
function imageUploadError(status: 400 | 403 | 413 | 500 | 503, error: string) {
|
||||
if (status === 413) return payloadTooLarge(error)
|
||||
if (status === 503) return noStorage(error)
|
||||
if (status === 403) return forbidden(error)
|
||||
if (status === 500) return internalError(error)
|
||||
return badRequest(error)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ type TestApp = Awaited<ReturnType<typeof createTestApp>>
|
||||
const storage = {
|
||||
id: 'dav-storage',
|
||||
title: 'DAV Storage',
|
||||
mode: 'private',
|
||||
bucket: 'dav-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -48,8 +47,8 @@ function streamBody(text: string): ReadableStream {
|
||||
async function seedStorage(db: TestApp['db']) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${storage.id}, ${storage.title}, ${storage.mode}, ${storage.bucket}, ${storage.endpoint}, ${storage.region}, ${storage.accessKey}, ${storage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT 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}, ${storage.title}, ${storage.bucket}, ${storage.endpoint}, ${storage.region}, ${storage.accessKey}, ${storage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -2044,7 +2043,6 @@ describe('WebDAV over real HTTP (npm client)', () => {
|
||||
const e2eStorage = {
|
||||
id: 'webdav-e2e-storage',
|
||||
title: 'WebDAV E2E Storage',
|
||||
mode: 'private',
|
||||
bucket: 'webdav-e2e-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
|
||||
Reference in New Issue
Block a user