Files
core2026/tools/legacy-db-migration/test/legacyUserIcons.test.ts
T
Hide_D 0c5dee1af8 fix(migration): Ref 전용 아이콘을 업로드 API로 이관한다
전용 아이콘 바이트를 모두 검증한 뒤 sam-image 서명 API에 결정적 경로로 등록한다. API 반환 경로를 계정과 아이콘 소유 목록에 연결하고 Core에서 바뀐 현재 선택은 보존한다.
2026-08-24 08:33:19 +00:00

183 lines
7.8 KiB
TypeScript

import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createHash, createHmac } from 'node:crypto';
import type { PoolClient, QueryResult } from 'pg';
import sharp from 'sharp';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { legacyUserId } from '../src/identity.js';
import {
prepareLegacyUserIcons,
syncImportedUserIcons,
type LegacyUserIconTransferConfig,
type PreparedLegacyUserIcon,
} from '../src/legacyUserIcons.js';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
);
});
const createFixture = async (): Promise<{ config: LegacyUserIconTransferConfig; png: Buffer }> => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'sammo-user-icons-'));
temporaryDirectories.push(directory);
const png = await sharp({ create: { width: 64, height: 64, channels: 4, background: '#336699ff' } })
.png()
.toBuffer();
await writeFile(path.join(directory, 'legacy.png'), png);
return {
config: {
sourceDirectory: directory,
uploadBaseUrl: 'https://upload.test',
publicBaseUrl: 'https://public.test/icons',
uploadSecret: 's'.repeat(32),
},
png,
};
};
const sourceRow = (overrides: Record<string, unknown> = {}) => ({
NO: 7,
PICTURE: 'legacy.png?=20260809',
IMGSVR: 1,
REG_DATE: '2020-01-01 00:00:00',
...overrides,
});
describe('legacy user icon transfer', () => {
it('validates a Ref file and derives a deterministic API path without writing during dry-run', async () => {
const { config } = await createFixture();
const fetchImpl = vi.fn<typeof fetch>();
const first = await prepareLegacyUserIcons([sourceRow()], config, false, { fetchImpl });
const second = await prepareLegacyUserIcons([sourceRow()], config, false, { fetchImpl });
expect(first.counts).toEqual({ custom: 1, legacyFiles: 1, existingUploads: 0, uploaded: 0 });
expect(first.icons.get(7)?.picture).toMatch(/^users\/core2026\/[a-f0-9]{32}\.png$/u);
expect(second.icons.get(7)?.picture).toBe(first.icons.get(7)?.picture);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('uploads through the signed API and accepts only the exact returned path', async () => {
const { config, png } = await createFixture();
const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
const pathname = new URL(String(input)).pathname;
const headers = new Headers(init?.headers);
const expires = headers.get('x-image-expires')!;
const requestId = headers.get('x-image-request-id')!;
const contentType = headers.get('content-type')!;
const expectedSignature = createHmac('sha256', config.uploadSecret)
.update(
`${expires}.${requestId}.${pathname}.${contentType}.${createHash('sha256').update(png).digest('hex')}`
)
.digest('hex');
expect(init?.method).toBe('PUT');
expect(Buffer.from(init?.body as Uint8Array)).toEqual(png);
expect(headers.get('x-image-client')).toBe('core2026');
expect(headers.get('x-image-signature')).toBe(expectedSignature);
return new Response(JSON.stringify({ path: pathname.replace('/v1/uploads/user-icons/', 'icons/users/') }), {
status: 201,
headers: { 'content-type': 'application/json' },
});
});
const result = await prepareLegacyUserIcons([sourceRow()], config, true, {
fetchImpl,
now: () => Date.parse('2026-08-24T00:00:00.000Z'),
});
expect(result.counts.uploaded).toBe(1);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(result.icons.get(7)?.picture).toMatch(/^users\/core2026\/[a-f0-9]{32}\.png$/u);
});
it('validates all source files before starting any permanent upload', async () => {
const { config } = await createFixture();
const fetchImpl = vi.fn<typeof fetch>();
await expect(
prepareLegacyUserIcons(
[sourceRow(), sourceRow({ NO: 8, PICTURE: 'missing.png?=20260809' })],
config,
true,
{ fetchImpl }
)
).rejects.toThrow();
expect(fetchImpl).not.toHaveBeenCalled();
});
it('verifies an existing sam-image object without uploading it again', async () => {
const { config, png } = await createFixture();
const picture = `users/core/${'a'.repeat(32)}.png`;
const fetchImpl = vi.fn<typeof fetch>(async () => new Response(png, { status: 200 }));
const result = await prepareLegacyUserIcons(
[sourceRow({ PICTURE: `${picture}?=20260809`, IMGSVR: 0 })],
config,
true,
{ fetchImpl }
);
expect(result.counts).toEqual({ custom: 1, legacyFiles: 0, existingUploads: 1, uploaded: 0 });
expect(result.icons.get(7)?.picture).toBe(picture);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(`https://public.test/icons/${picture}`);
});
it('fails closed when custom icons exist without an API transfer configuration', async () => {
await expect(prepareLegacyUserIcons([sourceRow()], undefined, false)).rejects.toThrow(
'gateway.userIcons is not configured'
);
});
it('links an unchanged Ref selection while preserving newer Core selections', async () => {
const imported = (memberNo: number, sourcePicture: string, picture: string): PreparedLegacyUserIcon => ({
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture: sourcePicture.replace(/\?=[0-9]{8}$/u, ''),
sourceImageServer: 1,
picture,
imageServer: 0,
createdAt: new Date('2026-08-09T00:00:00.000Z'),
source: 'legacy-file',
sha256: 'a'.repeat(64),
});
const icons = [
imported(7, 'first.png?=20260809', `users/core2026/${'1'.repeat(32)}.png`),
imported(8, 'second.png?=20260809', `users/core2026/${'2'.repeat(32)}.png`),
imported(9, 'third.png?=20260809', `users/core2026/${'3'.repeat(32)}.png`),
];
const query = vi.fn(async (sql: string, _parameters?: readonly unknown[]) => {
if (sql.includes('FROM "app_user"')) {
return {
rows: [
{ id: legacyUserId(7), picture: 'first.png?=20260809', image_server: 1 },
{ id: legacyUserId(8), picture: `users/core2026/${'8'.repeat(32)}.png`, image_server: 0 },
{ id: legacyUserId(9), picture: 'default.jpg', image_server: 0 },
],
rowCount: 3,
} as QueryResult;
}
if (sql.includes('JOIN unnest')) return { rows: [], rowCount: 0 } as unknown as QueryResult;
return { rows: [], rowCount: 1 } as unknown as QueryResult;
});
const target = { query } as unknown as PoolClient;
await expect(syncImportedUserIcons(target, icons, new Date('2026-08-24T00:00:00Z'))).resolves.toEqual({
currentLinked: 1,
libraryInserted: 3,
libraryRetired: 1,
targetPreserved: 2,
});
const inserts = query.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO "user_icon"'));
expect(inserts).toHaveLength(3);
expect(inserts[2]?.[1]?.[3]).toEqual(new Date('2026-08-24T00:00:00Z'));
});
});