Files
photo-live/services/api/test/downloads.e2e-spec.ts
T

461 lines
17 KiB
TypeScript
Raw Normal View History

// 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);
});
});