fix(migration): Ref 전용 아이콘을 업로드 API로 이관한다
전용 아이콘 바이트를 모두 검증한 뒤 sam-image 서명 API에 결정적 경로로 등록한다. API 반환 경로를 계정과 아이콘 소유 목록에 연결하고 Core에서 바뀐 현재 선택은 보존한다.
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user