전용 아이콘 바이트를 모두 검증한 뒤 sam-image 서명 API에 결정적 경로로 등록한다. API 반환 경로를 계정과 아이콘 소유 목록에 연결하고 Core에서 바뀐 현재 선택은 보존한다.
183 lines
7.8 KiB
TypeScript
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'));
|
|
});
|
|
});
|