461 lines
17 KiB
TypeScript
461 lines
17 KiB
TypeScript
|
|
// services/api/test/downloads.e2e-spec.ts —— P4a Task 4 公开照片下载 e2e
|
|||
|
|
// (T12 收口)。与 likes-realtime.e2e-spec 同模式:进程内起 cn + ov 两个真实
|
|||
|
|
// 入口,相册/开关/上传全走 cn API(PATCH albums 开关 → album.updated 同步 →
|
|||
|
|
// ov 副本生效),OutboxSender(生产实现)指向 ov 入口真实 URL。覆盖(A18):
|
|||
|
|
// ① 开关矩阵:on/on → browse/original 双 200(variant 回显、expiresAt 未来、
|
|||
|
|
// URL 可取回真实字节、original 字节 == 上传原图、browse 字节 == browse
|
|||
|
|
// 变体对象);on/off → browse 200 / original 403 DOWNLOAD_DISABLED;
|
|||
|
|
// off/off 与 off/on(级联——downloadEnabled 关则 original 也 403,无旁路)
|
|||
|
|
// → 双 variant 均 403;
|
|||
|
|
// ② 水印相册:browse 下载 URL = 模式键 variants/{photoId}/{v}/watermark.jpg
|
|||
|
|
// (与公开列表同一 ovBrowseKey 推导),字节 == 水印变体对象;
|
|||
|
|
// ③ 可见性:隐藏照片(cn 隐藏 + 同步)→ 404 PHOTO_NOT_PUBLIC;幽灵 token →
|
|||
|
|
// 404 ALBUM_NOT_PUBLIC;幽灵照片 → 404 PHOTO_NOT_PUBLIC;variant 缺失/
|
|||
|
|
// 非法 → 400 VALIDATION_FAILED;
|
|||
|
|
// ④ 基础限速:DOWNLOAD_WINDOW_REQUESTS 压小(惰性 env)+ 清窗键 → 窗口超额
|
|||
|
|
// 429 RATE_LIMITED;
|
|||
|
|
// ⑤ 排除规则:全程下载调用后 visit_requests / album_stats 零行(下载不计数)。
|
|||
|
|
import { NestFactory } from '@nestjs/core';
|
|||
|
|
import { type NestExpressApplication } from '@nestjs/platform-express';
|
|||
|
|
import { Readable } from 'node:stream';
|
|||
|
|
import request from 'supertest';
|
|||
|
|
import sharp from 'sharp';
|
|||
|
|
import Redis from 'ioredis';
|
|||
|
|
import { PrismaClient } from '@photo/db-cn';
|
|||
|
|
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
|||
|
|
import { ErrorCodes } from '@photo/contracts';
|
|||
|
|
import {
|
|||
|
|
createStorageFromEnv,
|
|||
|
|
type MinioStorageAdapter,
|
|||
|
|
} from '@photo/storage';
|
|||
|
|
import { AppCnModule } from '../src/app-cn.module';
|
|||
|
|
import { AppOvModule } from '../src/app-ov.module';
|
|||
|
|
import { seedAdmin } from '../src/scripts/seed-admin';
|
|||
|
|
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
|
|||
|
|
import { configureApp } from '../src/shared/configure-app';
|
|||
|
|
import { PublishService } from '../../worker/src/publish.service';
|
|||
|
|
import { WatermarkRegenService } from '../../worker/src/watermark.service';
|
|||
|
|
import { resetDb } from './reset';
|
|||
|
|
import { resetOvDb } from './reset-ov';
|
|||
|
|
import { OutboxSender } from '../../sync/src/sender';
|
|||
|
|
|
|||
|
|
jest.setTimeout(180_000);
|
|||
|
|
|
|||
|
|
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
|
|||
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
|
|||
|
|
const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
|
|||
|
|
|
|||
|
|
/** 测试内生成真实 JPEG(无二进制入库)。 */
|
|||
|
|
async function makeJpeg(
|
|||
|
|
width: number,
|
|||
|
|
height: number,
|
|||
|
|
rgb: { r: number; g: number; b: number },
|
|||
|
|
): Promise<Buffer> {
|
|||
|
|
return sharp({ create: { width, height, channels: 3, background: rgb } })
|
|||
|
|
.jpeg({ quality: 85 })
|
|||
|
|
.toBuffer();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface SeededAlbum {
|
|||
|
|
albumId: string;
|
|||
|
|
token: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
describe('downloads (e2e)', () => {
|
|||
|
|
let cnApp: NestExpressApplication;
|
|||
|
|
let ovApp: NestExpressApplication;
|
|||
|
|
let cnBase: string;
|
|||
|
|
let ovBase: string;
|
|||
|
|
let prismaCn: PrismaClient;
|
|||
|
|
let prismaOv: OvPrismaClient;
|
|||
|
|
let storage: MinioStorageAdapter;
|
|||
|
|
let sender: OutboxSender;
|
|||
|
|
let adminToken: string;
|
|||
|
|
|
|||
|
|
/** 四开关组合相册 + 水印相册(各含已发布照片;on/on 另含隐藏目标)。 */
|
|||
|
|
let onOn: SeededAlbum;
|
|||
|
|
let onOff: SeededAlbum;
|
|||
|
|
let offOff: SeededAlbum;
|
|||
|
|
let offOn: SeededAlbum;
|
|||
|
|
let watermarked: SeededAlbum;
|
|||
|
|
let onOnPhoto: string;
|
|||
|
|
let onOnHidden: string;
|
|||
|
|
let onOffPhoto: string;
|
|||
|
|
let offOffPhoto: string;
|
|||
|
|
let offOnPhoto: string;
|
|||
|
|
let wmPhoto: string;
|
|||
|
|
/** 上传原图字节(original 下载对照基准)。 */
|
|||
|
|
let onOnOriginal: Buffer;
|
|||
|
|
|
|||
|
|
/** 排空发送器:循环 runOnce 直至无未投递事件。 */
|
|||
|
|
async function drainSender(): Promise<void> {
|
|||
|
|
for (let round = 0; round < 20; round++) {
|
|||
|
|
const stats = await sender.runOnce();
|
|||
|
|
if (stats.picked === 0) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
throw new Error('drainSender 超时:20 轮后 outbox 仍有未投递事件');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Redis ov 清限速窗键(跨 spec 残留 / 测试间重置计数)。 */
|
|||
|
|
async function clearDownloadWindowKeys(): Promise<void> {
|
|||
|
|
const redis = new Redis(
|
|||
|
|
process.env.REDIS_URL_OV ?? 'redis://127.0.0.1:6381',
|
|||
|
|
);
|
|||
|
|
try {
|
|||
|
|
let cursor = '0';
|
|||
|
|
do {
|
|||
|
|
const [next, keys] = await redis.scan(
|
|||
|
|
cursor,
|
|||
|
|
'MATCH',
|
|||
|
|
'download:window:*',
|
|||
|
|
'COUNT',
|
|||
|
|
1000,
|
|||
|
|
);
|
|||
|
|
cursor = next;
|
|||
|
|
if (keys.length > 0) {
|
|||
|
|
await redis.del(...keys);
|
|||
|
|
}
|
|||
|
|
} while (cursor !== '0');
|
|||
|
|
} finally {
|
|||
|
|
await redis.quit();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** cn 建相册(缺省开关 true/false)→ 可选 PATCH 开关 → 返回 {albumId, token}。 */
|
|||
|
|
async function createAlbum(
|
|||
|
|
title: string,
|
|||
|
|
switches?: { downloadEnabled?: boolean; originalDownloadEnabled?: boolean },
|
|||
|
|
): Promise<SeededAlbum> {
|
|||
|
|
const album = await request(cnBase)
|
|||
|
|
.post('/v1/albums')
|
|||
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|||
|
|
.send({
|
|||
|
|
translations: [{ locale: 'zh-CN', title }],
|
|||
|
|
defaultLocale: 'zh-CN',
|
|||
|
|
startAt: '2026-12-01T10:00:00.000Z',
|
|||
|
|
});
|
|||
|
|
expect(album.status).toBe(201);
|
|||
|
|
const seeded = {
|
|||
|
|
albumId: album.body.id as string,
|
|||
|
|
token: album.body.publicToken as string,
|
|||
|
|
};
|
|||
|
|
if (switches !== undefined) {
|
|||
|
|
// 开关经 cn PATCH(album.updated 事件 → ov 副本;比直改副本行诚实)。
|
|||
|
|
const patch = await request(cnBase)
|
|||
|
|
.patch(`/v1/albums/${seeded.albumId}`)
|
|||
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|||
|
|
.send(switches);
|
|||
|
|
expect(patch.status).toBe(200);
|
|||
|
|
}
|
|||
|
|
return seeded;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */
|
|||
|
|
async function uploadAndPublish(
|
|||
|
|
albumId: string,
|
|||
|
|
clientTaskId: string,
|
|||
|
|
jpeg: Buffer,
|
|||
|
|
): Promise<string> {
|
|||
|
|
const init = await request(cnBase)
|
|||
|
|
.post('/v1/uploads')
|
|||
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|||
|
|
.send({
|
|||
|
|
albumId,
|
|||
|
|
clientTaskId,
|
|||
|
|
filename: 'download.jpg',
|
|||
|
|
bytes: jpeg.length,
|
|||
|
|
contentType: 'image/jpeg',
|
|||
|
|
});
|
|||
|
|
expect(init.status).toBe(201);
|
|||
|
|
const { uploadId, photoId, credential } = init.body as {
|
|||
|
|
uploadId: string;
|
|||
|
|
photoId: string;
|
|||
|
|
credential: { url: string; method: string; headers: Record<string, string> };
|
|||
|
|
};
|
|||
|
|
const put = await fetch(credential.url, {
|
|||
|
|
method: credential.method,
|
|||
|
|
headers: credential.headers,
|
|||
|
|
body: new Uint8Array(jpeg),
|
|||
|
|
});
|
|||
|
|
expect(put.ok).toBe(true);
|
|||
|
|
const done = await request(cnBase)
|
|||
|
|
.post(`/v1/uploads/${uploadId}/complete`)
|
|||
|
|
.set('Authorization', `Bearer ${adminToken}`);
|
|||
|
|
expect(done.status).toBe(200);
|
|||
|
|
expect(await new PublishService(prismaCn, storage).processPhoto(photoId)).toBe(
|
|||
|
|
'published',
|
|||
|
|
);
|
|||
|
|
return photoId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 下载 GET(返回 supertest Response)。 */
|
|||
|
|
function download(
|
|||
|
|
album: SeededAlbum,
|
|||
|
|
photoId: string,
|
|||
|
|
variant: string,
|
|||
|
|
): Promise<request.Response> {
|
|||
|
|
return request(ovBase).get(
|
|||
|
|
`/v1/public/albums/${album.token}/photos/${photoId}/download?variant=${variant}`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** cn browse 变体对象字节(browse 下载对照基准)。 */
|
|||
|
|
async function browseObjectOf(photoId: string): Promise<Buffer> {
|
|||
|
|
const row = await prismaCn.photo_variants.findUniqueOrThrow({
|
|||
|
|
where: { photoId_variant: { photoId, variant: 'browse' } },
|
|||
|
|
});
|
|||
|
|
return readObject(row.objectKey);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 流式读取对象为单缓冲(与 worker readOriginal 同约定)。 */
|
|||
|
|
async function readObject(key: string): Promise<Buffer> {
|
|||
|
|
const stream = (await storage.getStream(key)) as Readable;
|
|||
|
|
const chunks: Buffer[] = [];
|
|||
|
|
for await (const chunk of stream) {
|
|||
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
|
|||
|
|
}
|
|||
|
|
return Buffer.concat(chunks);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
beforeAll(async () => {
|
|||
|
|
expect(SYNC_KEY).not.toBe('');
|
|||
|
|
await resetDb();
|
|||
|
|
await resetOvDb();
|
|||
|
|
await clearDownloadWindowKeys();
|
|||
|
|
|
|||
|
|
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
|
|||
|
|
rawBody: true,
|
|||
|
|
});
|
|||
|
|
configureApp(cnApp);
|
|||
|
|
await cnApp.listen(0);
|
|||
|
|
cnBase = await cnApp.getUrl();
|
|||
|
|
|
|||
|
|
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
|||
|
|
rawBody: true,
|
|||
|
|
});
|
|||
|
|
// main-ov 同形:契约帧适配器(WS 与 HTTP 同端口;本 spec 不用 WS,仅为
|
|||
|
|
// 与生产入口同形的装配)。
|
|||
|
|
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
|
|||
|
|
configureApp(ovApp);
|
|||
|
|
await ovApp.listen(0);
|
|||
|
|
ovBase = await ovApp.getUrl();
|
|||
|
|
|
|||
|
|
prismaCn = new PrismaClient();
|
|||
|
|
prismaOv = new OvPrismaClient();
|
|||
|
|
storage = createStorageFromEnv();
|
|||
|
|
sender = new OutboxSender({
|
|||
|
|
prisma: prismaCn,
|
|||
|
|
ovApiUrl: ovBase,
|
|||
|
|
internalKey: SYNC_KEY,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await seedAdmin(prismaCn, ADMIN_ACCOUNT, ADMIN_PASSWORD);
|
|||
|
|
const login = await request(cnBase).post('/v1/auth/password').send({
|
|||
|
|
account: ADMIN_ACCOUNT,
|
|||
|
|
password: ADMIN_PASSWORD,
|
|||
|
|
});
|
|||
|
|
expect(login.status).toBe(200);
|
|||
|
|
adminToken = login.body.sessionToken as string;
|
|||
|
|
|
|||
|
|
// —— 开关矩阵四相册(缺省 true/false;PATCH 显式设定目标组合)——
|
|||
|
|
onOn = await createAlbum('下载开原图开', {
|
|||
|
|
originalDownloadEnabled: true,
|
|||
|
|
});
|
|||
|
|
onOff = await createAlbum('下载开原图关');
|
|||
|
|
offOff = await createAlbum('下载关原图关', {
|
|||
|
|
downloadEnabled: false,
|
|||
|
|
originalDownloadEnabled: false,
|
|||
|
|
});
|
|||
|
|
offOn = await createAlbum('下载关原图开', {
|
|||
|
|
downloadEnabled: false,
|
|||
|
|
originalDownloadEnabled: true,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
onOnOriginal = await makeJpeg(960, 640, { r: 190, g: 90, b: 40 });
|
|||
|
|
onOnPhoto = await uploadAndPublish(onOn.albumId, 'dl-onon', onOnOriginal);
|
|||
|
|
onOnHidden = await uploadAndPublish(
|
|||
|
|
onOn.albumId,
|
|||
|
|
'dl-onon-hidden',
|
|||
|
|
await makeJpeg(800, 500, { r: 90, g: 190, b: 120 }),
|
|||
|
|
);
|
|||
|
|
onOffPhoto = await uploadAndPublish(
|
|||
|
|
onOff.albumId,
|
|||
|
|
'dl-onoff',
|
|||
|
|
await makeJpeg(900, 600, { r: 60, g: 120, b: 190 }),
|
|||
|
|
);
|
|||
|
|
offOffPhoto = await uploadAndPublish(
|
|||
|
|
offOff.albumId,
|
|||
|
|
'dl-offoff',
|
|||
|
|
await makeJpeg(700, 450, { r: 120, g: 60, b: 190 }),
|
|||
|
|
);
|
|||
|
|
offOnPhoto = await uploadAndPublish(
|
|||
|
|
offOn.albumId,
|
|||
|
|
'dl-offon',
|
|||
|
|
await makeJpeg(850, 550, { r: 60, g: 190, b: 190 }),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 隐藏目标:cn 隐藏 → photo.hidden(高优先级)→ ov 撤下。
|
|||
|
|
const hide = await request(cnBase)
|
|||
|
|
.patch(`/v1/photos/${onOnHidden}`)
|
|||
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|||
|
|
.send({ visibility: 'hidden' });
|
|||
|
|
expect(hide.status).toBe(200);
|
|||
|
|
|
|||
|
|
// —— 水印相册:POST watermark-jobs(enabled)→ 直驱 done → ov 有效位 ——
|
|||
|
|
watermarked = await createAlbum('下载水印相册');
|
|||
|
|
wmPhoto = await uploadAndPublish(
|
|||
|
|
watermarked.albumId,
|
|||
|
|
'dl-wm',
|
|||
|
|
await makeJpeg(900, 600, { r: 150, g: 150, b: 60 }),
|
|||
|
|
);
|
|||
|
|
const wmJob = await request(cnBase)
|
|||
|
|
.post(`/v1/albums/${watermarked.albumId}/watermark-jobs`)
|
|||
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|||
|
|
.send({ enabled: true, text: { content: '© Download e2e' } });
|
|||
|
|
expect(wmJob.status).toBe(202);
|
|||
|
|
expect(
|
|||
|
|
await new WatermarkRegenService(prismaCn, storage).processJob(
|
|||
|
|
wmJob.body.jobId as string,
|
|||
|
|
),
|
|||
|
|
).toBe('done');
|
|||
|
|
|
|||
|
|
await drainSender();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
afterAll(async () => {
|
|||
|
|
delete process.env.DOWNLOAD_WINDOW_REQUESTS;
|
|||
|
|
await ovApp.close();
|
|||
|
|
await cnApp.close();
|
|||
|
|
await prismaCn.$disconnect();
|
|||
|
|
await prismaOv.$disconnect();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('① on/on:双 variant 200、variant/expiresAt 正确、URL 真实字节(original == 上传原图)', async () => {
|
|||
|
|
const browse = await download(onOn, onOnPhoto, 'browse');
|
|||
|
|
expect(browse.status).toBe(200);
|
|||
|
|
expect(browse.body.variant).toBe('browse');
|
|||
|
|
expect(Date.parse(browse.body.expiresAt)).toBeGreaterThan(Date.now());
|
|||
|
|
// browse 键 = 内联 browse 键(无水印相册)且字节 == browse 变体对象。
|
|||
|
|
expect(browse.body.url).toContain(
|
|||
|
|
`/variants/${onOnPhoto}/1/browse.jpg`,
|
|||
|
|
);
|
|||
|
|
const browseBytes = Buffer.from(
|
|||
|
|
await (await fetch(browse.body.url)).arrayBuffer(),
|
|||
|
|
);
|
|||
|
|
expect(browseBytes.length).toBeGreaterThan(0);
|
|||
|
|
expect(browseBytes.equals(await browseObjectOf(onOnPhoto))).toBe(true);
|
|||
|
|
|
|||
|
|
const original = await download(onOn, onOnPhoto, 'original');
|
|||
|
|
expect(original.status).toBe(200);
|
|||
|
|
expect(original.body.variant).toBe('original');
|
|||
|
|
expect(Date.parse(original.body.expiresAt)).toBeGreaterThan(Date.now());
|
|||
|
|
expect(original.body.url).toContain(`/originals/${onOnPhoto}.jpg`);
|
|||
|
|
// A20 语义的下载面:原图零触碰——字节与上传时完全一致。
|
|||
|
|
const originalBytes = Buffer.from(
|
|||
|
|
await (await fetch(original.body.url)).arrayBuffer(),
|
|||
|
|
);
|
|||
|
|
expect(originalBytes.equals(onOnOriginal)).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('② on/off:browse 200;original 403 DOWNLOAD_DISABLED', async () => {
|
|||
|
|
const browse = await download(onOff, onOffPhoto, 'browse');
|
|||
|
|
expect(browse.status).toBe(200);
|
|||
|
|
expect(browse.body.variant).toBe('browse');
|
|||
|
|
expect((await fetch(browse.body.url)).ok).toBe(true);
|
|||
|
|
|
|||
|
|
const original = await download(onOff, onOffPhoto, 'original');
|
|||
|
|
expect(original.status).toBe(403);
|
|||
|
|
expect(original.body.code).toBe(ErrorCodes.DOWNLOAD_DISABLED);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('③ off/off 与 off/on(级联):双 variant 均 403 DOWNLOAD_DISABLED', async () => {
|
|||
|
|
for (const album of [offOff, offOn]) {
|
|||
|
|
const photoId = album === offOff ? offOffPhoto : offOnPhoto;
|
|||
|
|
const browse = await download(album, photoId, 'browse');
|
|||
|
|
expect(browse.status).toBe(403);
|
|||
|
|
expect(browse.body.code).toBe(ErrorCodes.DOWNLOAD_DISABLED);
|
|||
|
|
// 级联:downloadEnabled 关 → original 无旁路(即使 originalDownloadEnabled 开)。
|
|||
|
|
const original = await download(album, photoId, 'original');
|
|||
|
|
expect(original.status).toBe(403);
|
|||
|
|
expect(original.body.code).toBe(ErrorCodes.DOWNLOAD_DISABLED);
|
|||
|
|
}
|
|||
|
|
// ov 副本开关确已同步到位(矩阵前提非巧合)。
|
|||
|
|
const ovAlbum = await prismaOv.albums_ov.findUniqueOrThrow({
|
|||
|
|
where: { albumId: offOn.albumId },
|
|||
|
|
});
|
|||
|
|
expect(ovAlbum.downloadEnabled).toBe(false);
|
|||
|
|
expect(ovAlbum.originalDownloadEnabled).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('④ 水印相册:browse 下载 = 模式键 watermark.jpg,字节 == 水印变体对象', async () => {
|
|||
|
|
const res = await download(watermarked, wmPhoto, 'browse');
|
|||
|
|
expect(res.status).toBe(200);
|
|||
|
|
expect(res.body.url).toContain(
|
|||
|
|
`/variants/${wmPhoto}/1/watermark.jpg`,
|
|||
|
|
);
|
|||
|
|
const bytes = Buffer.from(
|
|||
|
|
await (await fetch(res.body.url)).arrayBuffer(),
|
|||
|
|
);
|
|||
|
|
const wmRow = await prismaCn.photo_variants.findUniqueOrThrow({
|
|||
|
|
where: { photoId_variant: { photoId: wmPhoto, variant: 'watermark' } },
|
|||
|
|
});
|
|||
|
|
expect(bytes.equals(await readObject(wmRow.objectKey))).toBe(true);
|
|||
|
|
// 与公开列表 browse 同键(同一 ovBrowseKey 推导的间接证明)。
|
|||
|
|
const list = await request(ovBase)
|
|||
|
|
.get(`/v1/public/albums/${watermarked.token}/photos?limit=10`);
|
|||
|
|
expect(list.body.items[0].browse.url).toContain(
|
|||
|
|
`/variants/${wmPhoto}/1/watermark.jpg`,
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('⑤ 可见性与参数校验:隐藏 404 / 幽灵 token 404 / 幽灵照片 404 / variant 非法 400', async () => {
|
|||
|
|
const hidden = await download(onOn, onOnHidden, 'browse');
|
|||
|
|
expect(hidden.status).toBe(404);
|
|||
|
|
expect(hidden.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
|||
|
|
|
|||
|
|
const ghostAlbum = await request(ovBase).get(
|
|||
|
|
`/v1/public/albums/${'g' + '0'.repeat(23)}/photos/${onOnPhoto}/download?variant=browse`,
|
|||
|
|
);
|
|||
|
|
expect(ghostAlbum.status).toBe(404);
|
|||
|
|
expect(ghostAlbum.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
|||
|
|
|
|||
|
|
const ghostPhoto = await download(onOn, 'p-ghost', 'browse');
|
|||
|
|
expect(ghostPhoto.status).toBe(404);
|
|||
|
|
expect(ghostPhoto.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
|||
|
|
|
|||
|
|
for (const query of ['', '?variant=', '?variant=thumb', '?variant=1']) {
|
|||
|
|
const res = await request(ovBase).get(
|
|||
|
|
`/v1/public/albums/${onOn.token}/photos/${onOnPhoto}/download${query}`,
|
|||
|
|
);
|
|||
|
|
expect(res.status).toBe(400);
|
|||
|
|
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('⑥ 基础限速:同 IP 窗口超额 → 429 RATE_LIMITED', async () => {
|
|||
|
|
// 清窗键重置计数(①-⑤ 已消耗本机 IP 预算)+ 压小上限。
|
|||
|
|
await clearDownloadWindowKeys();
|
|||
|
|
process.env.DOWNLOAD_WINDOW_REQUESTS = '3';
|
|||
|
|
try {
|
|||
|
|
for (let i = 0; i < 3; i++) {
|
|||
|
|
const ok = await download(onOn, onOnPhoto, 'browse');
|
|||
|
|
expect(ok.status).toBe(200);
|
|||
|
|
}
|
|||
|
|
const limited = await download(onOn, onOnPhoto, 'browse');
|
|||
|
|
expect(limited.status).toBe(429);
|
|||
|
|
expect(limited.body.code).toBe(ErrorCodes.RATE_LIMITED);
|
|||
|
|
// 拒绝不改写已签发凭证语义:前序 URL 仍可取回(限速只挡新签发)。
|
|||
|
|
} finally {
|
|||
|
|
delete process.env.DOWNLOAD_WINDOW_REQUESTS;
|
|||
|
|
await clearDownloadWindowKeys();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('⑦ 排除规则:下载不计数——visit_requests / album_stats 全程零行', async () => {
|
|||
|
|
expect(await prismaOv.visit_requests.count()).toBe(0);
|
|||
|
|
expect(await prismaOv.album_stats.count()).toBe(0);
|
|||
|
|
});
|
|||
|
|
});
|