fix(migration): Ref 전용 아이콘을 업로드 API로 이관한다

전용 아이콘 바이트를 모두 검증한 뒤 sam-image 서명 API에 결정적 경로로 등록한다. API 반환 경로를 계정과 아이콘 소유 목록에 연결하고 Core에서 바뀐 현재 선택은 보존한다.
This commit is contained in:
2026-08-24 08:33:19 +00:00
parent 1e79611089
commit 0c5dee1af8
14 changed files with 891 additions and 29 deletions
@@ -17,6 +17,8 @@ const writeFixture = async (mode = 0o600): Promise<string> => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'sammo-legacy-plan-'));
workDirectories.push(directory);
await writeFile(path.join(directory, 'mysql-password'), 'secret-value\n', { mode: 0o600 });
await writeFile(path.join(directory, 'image-upload-secret'), `${'u'.repeat(32)}\n`, { mode: 0o600 });
const iconDirectory = await mkdtemp(path.join(directory, 'icons-'));
const configPath = path.join(directory, 'migration-plan.json');
await writeFile(
configPath,
@@ -30,6 +32,12 @@ const writeFixture = async (mode = 0o600): Promise<string> => {
user: 'migration_reader',
passwordFile: './mysql-password',
},
userIcons: {
sourceDirectory: iconDirectory,
uploadBaseUrl: 'https://sam-image.hided.net',
publicBaseUrl: 'https://sam-image.hided.net/icons',
uploadSecretFile: './image-upload-secret',
},
targetUrlEnv: 'TEST_GATEWAY_DATABASE_URL',
},
}),
@@ -51,6 +59,11 @@ describe('legacy migration plan config', () => {
expect(source.username).toBe('migration_reader');
expect(source.password).toBe('secret-value');
expect(plan.stages[0]!.sourceIdentity.key).toBe('fixture-cutover:gateway');
expect(plan.stages[0]!.userIcons).toMatchObject({
uploadBaseUrl: 'https://sam-image.hided.net',
publicBaseUrl: 'https://sam-image.hided.net/icons',
uploadSecret: 'u'.repeat(32),
});
});
it('rejects a config readable by group or other users', async () => {
+26 -1
View File
@@ -3,7 +3,13 @@ import type { Pool as PgPool, PoolClient, QueryResult } from 'pg';
import { describe, expect, it, vi } from 'vitest';
import { upsertRows } from '../src/db.js';
import { mapMember, MEMBER_PRESERVED_COLUMNS, migrateGateway, preflightMemberConflicts } from '../src/gateway.js';
import {
mapMember,
MEMBER_PRESERVED_COLUMNS,
migrateGateway,
normalizeLegacyIconPicture,
preflightMemberConflicts,
} from '../src/gateway.js';
const memberRow = (overrides: Record<string, unknown> = {}) => ({
NO: 7,
@@ -44,6 +50,25 @@ describe('legacy gateway member migration', () => {
});
});
it('removes only the Ref cache marker while preserving the original icon metadata', () => {
const mapped = mapMember(
memberRow({ PICTURE: 'users/core/' + 'a'.repeat(32) + '.png?=20260809', IMGSVR: 0 }),
new Date('2026-08-17T00:00:00.000Z'),
null
);
expect(normalizeLegacyIconPicture('legacy.png?=20260809')).toBe('legacy.png');
expect(normalizeLegacyIconPicture('literal.png?other')).toBe('literal.png?other');
expect(mapped).toMatchObject({
picture: 'users/core/' + 'a'.repeat(32) + '.png',
image_server: 0,
});
expect((mapped.legacy_data as { value: unknown }).value).toMatchObject({
picture: 'users/core/' + 'a'.repeat(32) + '.png?=20260809',
imageServer: 0,
});
});
it('preserves target-owned credentials and OAuth state on a repeated member upsert', async () => {
const query = vi.fn(async (_sql: string, _values?: unknown[]) => ({ rows: [], rowCount: 0 }));
const client = { query } as unknown as PoolClient;
@@ -0,0 +1,78 @@
import { Pool } from 'pg';
import { describe, expect, it } from 'vitest';
import { legacyUserId } from '../src/identity.js';
import { syncImportedUserIcons, type PreparedLegacyUserIcon } from '../src/legacyUserIcons.js';
const databaseUrl = process.env.LEGACY_ICON_TEST_DATABASE_URL;
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),
});
describe.skipIf(!databaseUrl)('legacy user icon PostgreSQL synchronization', () => {
it('updates only an unchanged Ref selection and preserves newer Core state', async () => {
const pool = new Pool({ connectionString: databaseUrl });
const client = await pool.connect();
const icons = [
imported(700_001, 'first.png?=20260809', `users/core2026/${'1'.repeat(32)}.png`),
imported(700_002, 'second.png?=20260809', `users/core2026/${'2'.repeat(32)}.png`),
imported(700_003, 'third.png?=20260809', `users/core2026/${'3'.repeat(32)}.png`),
];
try {
await client.query('BEGIN');
for (const [index, icon] of icons.entries()) {
const currentPicture =
index === 0
? icon.sourcePicture
: index === 1
? `users/core2026/${'8'.repeat(32)}.png`
: 'default.jpg';
await client.query(
`INSERT INTO "app_user"
("id", "login_id", "display_name", "password_hash", "password_salt",
"updated_at", "picture", "image_server")
VALUES ($1, $2, $3, 'hash', 'salt', CURRENT_TIMESTAMP, $4, $5)`,
[icon.userId, `icon-test-${index}`, `아이콘테스트-${index}`, currentPicture, index === 0 ? 1 : 0]
);
}
await expect(syncImportedUserIcons(client, icons, new Date('2026-08-24T00:00:00Z'))).resolves.toEqual({
currentLinked: 1,
libraryInserted: 3,
libraryRetired: 1,
targetPreserved: 2,
});
const accounts = await client.query<{ id: string; picture: string; image_server: number }>(
`SELECT "id", "picture", "image_server" FROM "app_user"
WHERE "id" = ANY($1::text[]) ORDER BY "login_id"`,
[icons.map((icon) => icon.userId)]
);
expect(accounts.rows.map(({ picture, image_server: imageServer }) => ({ picture, imageServer }))).toEqual([
{ picture: icons[0]!.picture, imageServer: 0 },
{ picture: `users/core2026/${'8'.repeat(32)}.png`, imageServer: 0 },
{ picture: 'default.jpg', imageServer: 0 },
]);
const library = await client.query<{ picture: string; retired_at: Date | null }>(
`SELECT "picture", "retired_at" FROM "user_icon"
WHERE "user_id" = ANY($1::text[]) ORDER BY "picture"`,
[icons.map((icon) => icon.userId)]
);
expect(library.rows).toHaveLength(3);
expect(library.rows.filter((row) => row.retired_at !== null)).toHaveLength(1);
} finally {
await client.query('ROLLBACK');
client.release();
await pool.end();
}
});
});
@@ -0,0 +1,182 @@
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'));
});
});