feat: harden content asset access

This commit is contained in:
Codex
2026-06-29 18:25:47 +08:00
parent ee578af1f6
commit 79d0d786a0
13 changed files with 762 additions and 67 deletions

View File

@@ -3148,11 +3148,29 @@ async function testTenantContentAssetsAndImports() {
regionId: ids.region,
subjectId: ids.subject,
categoryId: ids.category,
metadata: { source: 'api-integration' },
metadata: { source: 'api-integration', providerManagedAccess: true },
},
});
assert.equal(asset.item?.visibility, 'svip', 'tenant admin should create svip asset');
const lockedCdnAsset = await request('/api/tenant-content/assets', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
title: '集成测试未授权 CDN SVIP 资料',
assetType: 'pdf',
storageProvider: 'external_url',
cdnUrl: 'https://example.test/resources/locked-without-provider-managed.pdf',
fileName: 'locked-without-provider-managed.pdf',
mimeType: 'application/pdf',
visibility: 'svip',
regionId: ids.region,
subjectId: ids.subject,
categoryId: ids.category,
metadata: { source: 'api-integration' },
},
});
const adminAssets = await request('/api/tenant-content/assets', {
userId: TENANT_ADMIN_USER_ID,
query: { assetType: 'pdf', regionId: ids.region },
@@ -3178,6 +3196,31 @@ async function testTenantContentAssetsAndImports() {
assert.equal(download.access?.svip, true, 'asset download should report svip access');
assert.equal(download.download?.url, 'https://example.test/resources/integration.pdf', 'external asset download should use cdn url');
assert.equal(download.download?.signatureMode, 'public-or-provider-managed', 'external asset should be marked provider managed');
assert.equal(download.download?.expiresInSec, 300, 'locked student asset downloads should use short TTL');
const deniedLockedCdn = await request('/api/catalog/assets/download', {
query: { assetId: lockedCdnAsset.item.id },
expectStatus: 409,
});
assert.equal(deniedLockedCdn.code, 'ASSET_CDN_ACCESS_NOT_ALLOWED', 'locked cdn asset should require explicit provider-managed access');
const accessEventsAfterLockedDeny = await request('/api/tenant-content/assets/access-events', {
userId: TENANT_ADMIN_USER_ID,
query: { assetId: lockedCdnAsset.item.id },
});
assert.ok(
accessEventsAfterLockedDeny.items?.some(item => item.result === 'denied' && item.denyCode === 'ASSET_CDN_ACCESS_NOT_ALLOWED'),
'locked cdn denial should be recorded as an asset access event',
);
const accessEventsAfterDownload = await request('/api/tenant-content/assets/access-events', {
userId: TENANT_ADMIN_USER_ID,
query: { assetId: asset.item.id },
});
assert.ok(
accessEventsAfterDownload.items?.some(item => item.accessType === 'download' && item.result === 'granted'),
'successful student asset download should be recorded as an access event',
);
const localAsset = await request('/api/tenant-content/assets', {
userId: TENANT_ADMIN_USER_ID,
@@ -3233,6 +3276,7 @@ async function testTenantContentAssetsAndImports() {
assert.equal(localPreview.preview?.method, 'GET', 'asset preview should sign GET');
assert.equal(localPreview.preview?.signatureMode, 'local-placeholder', 'local asset preview should use local placeholder signer');
assert.ok(localPreview.preview?.url?.includes('disposition=inline'), 'asset preview should request inline disposition');
assert.equal(localPreview.preview?.expiresInSec, 300, 'student inline previews should use short TTL');
const adminPreview = await request('/api/tenant-content/assets/sign-preview', {
userId: TENANT_ADMIN_USER_ID,
@@ -3241,6 +3285,15 @@ async function testTenantContentAssetsAndImports() {
});
assert.ok(adminPreview.preview?.url?.includes('disposition=inline'), 'admin preview should request inline disposition');
const localAccessEvents = await request('/api/tenant-content/assets/access-events', {
userId: TENANT_ADMIN_USER_ID,
query: { assetId: localAsset.item.id },
});
assert.ok(
localAccessEvents.items?.some(item => item.accessType === 'preview' && item.expiresInSec === 300),
'student preview access event should include short TTL',
);
const failedAsset = await request('/api/tenant-content/assets', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',

View File

@@ -1354,22 +1354,30 @@ async function main() {
`
insert into public.content_assets (
id, tenant_id, asset_key, title, asset_type, storage_provider,
bucket, object_key, file_name, mime_type, visibility, status, source
bucket, object_key, file_name, mime_type, file_size_bytes,
checksum_sha256, visibility, status, upload_status, verified_at,
verified_size_bytes, verified_checksum_sha256, source
)
values (
$1, $2, 'smoke-video-asset', '烟测视频对象', 'video', 'local_dev',
'tenant-assets', $3, 'smoke.mp4', 'video/mp4',
'svip', 'active', 'smoke-seed'
'tenant-assets', $3, 'smoke.mp4', 'video/mp4', 8192,
$4, 'svip', 'active', 'verified', now(), 8192, $4, 'smoke-seed'
)
on conflict (id)
do update set storage_provider = excluded.storage_provider,
bucket = excluded.bucket,
object_key = excluded.object_key,
file_size_bytes = excluded.file_size_bytes,
checksum_sha256 = excluded.checksum_sha256,
visibility = excluded.visibility,
status = 'active',
upload_status = 'verified',
verified_at = coalesce(public.content_assets.verified_at, now()),
verified_size_bytes = excluded.verified_size_bytes,
verified_checksum_sha256 = excluded.verified_checksum_sha256,
updated_at = now()
`,
[ids.videoAsset, tenantId, `${tenantId}/videos/smoke.mp4`],
[ids.videoAsset, tenantId, `${tenantId}/videos/smoke.mp4`, 'c'.repeat(64)],
);
await client.query(