00b96f244d
- AGC:直传对象存储抛错(能力作用域拒绝、网络不可达、请求被取消)时统一转成中文可操作文案,不再把英文插件错误原样暴露给作者。 - 测试:新增直传抛错的单测,确认失败时不再登记素材。 - 文档:记录用 tauri-plugin-http 同一套 urlpattern 逻辑验证 `https://*.aliyuncs.com/*` 命中 dev/生产 OSS 桶且拒绝无关主机,并说明桌面端真实执行仍需在装有 AGC 的机器上补跑。
200 lines
6.6 KiB
TypeScript
200 lines
6.6 KiB
TypeScript
// @vitest-environment jsdom
|
||
/**
|
||
* AGC 素材直传(封面 / 截图)的三步链路边界。
|
||
*
|
||
* 这里只替换平台 API 调用与对象存储直传实现,验证「凭证 → 直传 → confirm」的字段口径、
|
||
* 地址白名单与错误文案,不触达真实 Tauri HTTP 插件或 OSS。
|
||
*/
|
||
import { beforeEach, expect, test, vi } from 'vitest';
|
||
|
||
const requestClientApiMock = vi.hoisted(() => vi.fn());
|
||
|
||
vi.mock('../src/services/clientApi', () => ({
|
||
requestClientApi: (...args: unknown[]) => requestClientApiMock(...args),
|
||
}));
|
||
|
||
import {
|
||
resolvePlatformAssetUploadUrl,
|
||
uploadPlatformMediaAsset,
|
||
} from '../src/services/assetDirectUpload';
|
||
|
||
const TICKET = {
|
||
upload: {
|
||
bucket: 'genarrative-assets',
|
||
host: 'https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/',
|
||
objectKey:
|
||
'generated-character-drafts/game-distribution/cover/42/cover.png',
|
||
legacyPublicPath: '/generated-character-drafts/game-distribution/cover/42',
|
||
formFields: {
|
||
key: 'generated-character-drafts/game-distribution/cover/42/cover.png',
|
||
policy: 'policy-value',
|
||
OSSAccessKeyId: 'ak-value',
|
||
signature: 'signature-value',
|
||
success_action_status: '204',
|
||
'x-oss-meta-asset_kind': 'game_distribution_cover',
|
||
},
|
||
},
|
||
};
|
||
|
||
const CONFIRMED = {
|
||
assetObject: {
|
||
assetObjectId: 'asset_cover_1',
|
||
objectKey: TICKET.upload.objectKey,
|
||
assetKind: 'game_distribution_cover',
|
||
},
|
||
};
|
||
|
||
function buildCoverFile() {
|
||
return new File(['cover-bytes'], 'cover.png', { type: 'image/png' });
|
||
}
|
||
|
||
beforeEach(() => {
|
||
requestClientApiMock.mockReset();
|
||
requestClientApiMock
|
||
.mockResolvedValueOnce(TICKET)
|
||
.mockResolvedValueOnce(CONFIRMED);
|
||
});
|
||
|
||
test('封面按凭证、直传、confirm 三步上传并返回素材标识', async () => {
|
||
const uploadFetch = vi.fn(async () => new Response(null, { status: 204 }));
|
||
const file = buildCoverFile();
|
||
|
||
const uploaded = await uploadPlatformMediaAsset({
|
||
file,
|
||
assetKind: 'game_distribution_cover',
|
||
pathSegments: ['game-distribution', 'cover', '42'],
|
||
entityId: 'game-distribution-cover',
|
||
metadata: { game_distribution_media: 'cover' },
|
||
fetchImpl: uploadFetch,
|
||
});
|
||
|
||
expect(uploaded).toEqual({
|
||
assetObjectId: 'asset_cover_1',
|
||
objectKey: TICKET.upload.objectKey,
|
||
});
|
||
|
||
const ticketCall = requestClientApiMock.mock.calls[0];
|
||
expect(ticketCall?.[0]).toBe('/api/assets/direct-upload-tickets');
|
||
const ticketBody = JSON.parse(String((ticketCall?.[1] as RequestInit).body));
|
||
expect(ticketBody).toEqual(
|
||
expect.objectContaining({
|
||
legacyPrefix: 'generated-character-drafts',
|
||
pathSegments: ['game-distribution', 'cover', '42'],
|
||
fileName: 'cover.png',
|
||
contentType: 'image/png',
|
||
access: 'private',
|
||
maxSizeBytes: file.size,
|
||
metadata: {
|
||
asset_kind: 'game_distribution_cover',
|
||
game_distribution_media: 'cover',
|
||
},
|
||
}),
|
||
);
|
||
|
||
const uploadCall = uploadFetch.mock.calls[0] as unknown as [
|
||
string,
|
||
RequestInit,
|
||
];
|
||
expect(uploadCall[0]).toBe(TICKET.upload.host);
|
||
expect(uploadCall[1].method).toBe('POST');
|
||
const formData = uploadCall[1].body as FormData;
|
||
expect(formData.get('policy')).toBe('policy-value');
|
||
expect(formData.get('signature')).toBe('signature-value');
|
||
expect(formData.get('success_action_status')).toBe('204');
|
||
const uploadedFile = formData.get('file');
|
||
expect(uploadedFile).toBeInstanceOf(File);
|
||
expect((uploadedFile as File).name).toBe('cover.png');
|
||
|
||
const confirmCall = requestClientApiMock.mock.calls[1];
|
||
expect(confirmCall?.[0]).toBe('/api/assets/objects/confirm');
|
||
expect(JSON.parse(String((confirmCall?.[1] as RequestInit).body))).toEqual({
|
||
bucket: 'genarrative-assets',
|
||
objectKey: TICKET.upload.objectKey,
|
||
contentType: 'image/png',
|
||
contentLength: file.size,
|
||
assetKind: 'game_distribution_cover',
|
||
accessPolicy: 'private',
|
||
entityId: 'game-distribution-cover',
|
||
});
|
||
});
|
||
|
||
test('直传地址不属于平台素材存储时失败关闭,且不发送文件', async () => {
|
||
requestClientApiMock.mockReset();
|
||
requestClientApiMock
|
||
.mockResolvedValueOnce({
|
||
upload: { ...TICKET.upload, host: 'https://evil.example.com/upload' },
|
||
})
|
||
.mockResolvedValueOnce(CONFIRMED);
|
||
const uploadFetch = vi.fn();
|
||
|
||
await expect(
|
||
uploadPlatformMediaAsset({
|
||
file: buildCoverFile(),
|
||
assetKind: 'game_distribution_cover',
|
||
pathSegments: ['game-distribution', 'cover', '42'],
|
||
entityId: 'game-distribution-cover',
|
||
fetchImpl: uploadFetch as never,
|
||
}),
|
||
).rejects.toThrow('素材上传地址不属于平台素材存储,已终止上传');
|
||
|
||
expect(uploadFetch).not.toHaveBeenCalled();
|
||
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
test('对象存储拒绝上传时给出可重试文案', async () => {
|
||
const uploadFetch = vi.fn(
|
||
async () => new Response('denied', { status: 403 }),
|
||
);
|
||
|
||
await expect(
|
||
uploadPlatformMediaAsset({
|
||
file: buildCoverFile(),
|
||
assetKind: 'game_distribution_screenshot',
|
||
pathSegments: ['game-distribution', 'screenshot', '42'],
|
||
entityId: 'game-distribution-screenshot',
|
||
fetchImpl: uploadFetch,
|
||
}),
|
||
).rejects.toThrow('上传素材到对象存储失败(HTTP 403),请重试');
|
||
|
||
// 直传失败时不得再登记素材,避免留下没有实体的素材记录。
|
||
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
test('直传网络/作用域错误转成可操作文案', async () => {
|
||
const uploadFetch = vi.fn(async () => {
|
||
throw new Error('url not allowed on the configured scope');
|
||
});
|
||
|
||
await expect(
|
||
uploadPlatformMediaAsset({
|
||
file: buildCoverFile(),
|
||
assetKind: 'game_distribution_cover',
|
||
pathSegments: ['game-distribution', 'cover', '42'],
|
||
entityId: 'game-distribution-cover',
|
||
fetchImpl: uploadFetch,
|
||
}),
|
||
).rejects.toThrow(
|
||
'上传素材失败:无法访问素材存储,请检查网络后重试(url not allowed on the configured scope)',
|
||
);
|
||
|
||
// 直传抛错时不得继续登记素材。
|
||
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
test('本地回环与阿里云 OSS 之外的主机一律拒绝', () => {
|
||
expect(resolvePlatformAssetUploadUrl('http://127.0.0.1:9000/bucket')).toBe(
|
||
'http://127.0.0.1:9000/bucket',
|
||
);
|
||
expect(
|
||
resolvePlatformAssetUploadUrl(
|
||
'https://genarrative-assets.oss-cn-beijing.aliyuncs.com/',
|
||
),
|
||
).toBe('https://genarrative-assets.oss-cn-beijing.aliyuncs.com/');
|
||
expect(() =>
|
||
resolvePlatformAssetUploadUrl('http://oss.example.com/'),
|
||
).toThrow('素材上传地址不属于平台素材存储,已终止上传');
|
||
expect(() => resolvePlatformAssetUploadUrl(' ')).toThrow(
|
||
'素材上传地址为空,请稍后重试',
|
||
);
|
||
});
|