fix(migration): 검증 실패 아이콘을 명시적으로 제외한다

검토한 회원 번호의 이미지가 계속 유효성 조건을 어길 때만 업로드에서 제외한다. 기존 Ref 경로가 현재 선택인 경우 기본 아이콘으로 정리하고 최신 Core 선택은 보존한다.
This commit is contained in:
2026-08-24 08:45:47 +00:00
parent c88a8de9bc
commit cd0f50f89d
9 changed files with 313 additions and 49 deletions
+8
View File
@@ -86,6 +86,14 @@ is not silently resurrected. The original Ref path, `IMGSVR`, returned path and
byte SHA-256 remain in `legacy_data`. Picture collisions across owners fail the
transaction.
Historical bytes that violate the Ref validation contract block preflight.
Operators may list a reviewed member in `excludedMemberNumbers`; the importer
then proves the file is still invalid and records the reason. It never uploads
that byte or creates a `user_icon` row. If the target still selects the rejected
Ref path it moves only that selection to `default.jpg`; a newer Core selection
is preserved. A stale exclusion whose file has become valid also blocks the
plan, so this cannot become a general skip-errors switch.
Kakao members retain `oauth_id`, email and metadata. A non-empty provider ID is
required before an imported row is marked Kakao-verified. A parseable legacy
`token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
+6
View File
@@ -44,6 +44,12 @@ is mandatory. Mount Ref's `d_pic` directory read-only as `sourceDirectory`, and
mount the Core2026 sam-image upload secret as a mode-0600 `uploadSecretFile`.
The two URL fields normally point to `https://sam-image.hided.net` and its
`/icons` path. The importer never adds account images to the image Git tree.
An invalid historical file blocks the plan by default. After byte-level review,
its member number may be listed in `excludedMemberNumbers`; the exclusion is
accepted only while that exact member still has invalid image geometry/format.
A valid file or stale/missing member exclusion fails closed. An unchanged
invalid Ref selection is reset to the default icon instead of publishing bad
bytes; a newer Core selection is preserved.
```sh
mkdir -p tools/legacy-db-migration/secrets
@@ -14,7 +14,8 @@
"sourceDirectory": "/run/sammo-migration/user-icons",
"uploadBaseUrl": "https://sam-image.hided.net",
"publicBaseUrl": "https://sam-image.hided.net/icons",
"uploadSecretFile": "/run/secrets/image_upload_core2026_secret"
"uploadSecretFile": "/run/secrets/image_upload_core2026_secret",
"excludedMemberNumbers": []
},
"targetUrlEnv": "GATEWAY_DATABASE_URL"
},
+19 -2
View File
@@ -169,7 +169,11 @@ const resolveUserIcons = async (
label: string
): Promise<LegacyUserIconTransferConfig> => {
const record = asRecord(value, label);
rejectUnknownKeys(record, ['sourceDirectory', 'uploadBaseUrl', 'publicBaseUrl', 'uploadSecretFile'], label);
rejectUnknownKeys(
record,
['sourceDirectory', 'uploadBaseUrl', 'publicBaseUrl', 'uploadSecretFile', 'excludedMemberNumbers'],
label
);
const sourceDirectory = path.resolve(configDirectory, requiredString(record, 'sourceDirectory', label));
const sourceInfo = await lstat(sourceDirectory);
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
@@ -180,7 +184,20 @@ const resolveUserIcons = async (
const secretPath = path.resolve(configDirectory, requiredString(record, 'uploadSecretFile', label));
const uploadSecret = (await readSecureText(secretPath, `${label}.uploadSecretFile`)).replace(/\r?\n$/u, '');
if (uploadSecret.length < 32) throw new Error(`${label}.uploadSecretFile must contain at least 32 characters`);
return { sourceDirectory, uploadBaseUrl, publicBaseUrl, uploadSecret };
const excludedMemberNumbers = record.excludedMemberNumbers ?? [];
if (
!Array.isArray(excludedMemberNumbers) ||
excludedMemberNumbers.some((value) => !Number.isSafeInteger(value) || Number(value) <= 0)
) {
throw new Error(`${label}.excludedMemberNumbers must contain only positive safe integers`);
}
return {
sourceDirectory,
uploadBaseUrl,
publicBaseUrl,
uploadSecret,
excludedMemberNumbers: excludedMemberNumbers as number[],
};
};
export const loadMigrationPlan = async (configPathInput: string): Promise<ResolvedMigrationPlan> => {
+27 -8
View File
@@ -31,9 +31,11 @@ import {
normalizeLegacyIconPicture,
prepareLegacyUserIcons,
syncImportedUserIcons,
syncRejectedUserIcons,
type LegacyUserIconPreparation,
type LegacyUserIconTransferConfig,
type PreparedLegacyUserIcon,
type RejectedLegacyUserIcon,
} from './legacyUserIcons.js';
import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
@@ -119,7 +121,8 @@ export const mapMember = (
row: SourceRow,
migratedAt: Date,
lastLoginAt: Date | null,
importedIcon?: PreparedLegacyUserIcon
importedIcon?: PreparedLegacyUserIcon,
rejectedIcon?: RejectedLegacyUserIcon
): TargetRow => {
const memberNo = toNumber(row.NO, 'member.NO');
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
@@ -139,6 +142,7 @@ export const mapMember = (
picture: rawPicture,
imageServer,
...(importedIcon ? { importedPicture: importedIcon.picture, importedPictureSha256: importedIcon.sha256 } : {}),
...(rejectedIcon ? { rejectedPictureReason: rejectedIcon.reason } : {}),
tokenValidUntil: toNullableString(row.token_valid_until),
regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`),
blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`),
@@ -157,8 +161,8 @@ export const mapMember = (
oauth_id: oauthId,
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
oauth_info: jsonParameter(oauthInfo),
picture: importedIcon?.picture ?? normalizeLegacyIconPicture(rawPicture),
image_server: importedIcon?.imageServer ?? imageServer,
picture: importedIcon?.picture ?? (rejectedIcon ? 'default.jpg' : normalizeLegacyIconPicture(rawPicture)),
image_server: importedIcon?.imageServer ?? (rejectedIcon ? 0 : imageServer),
icon_updated_at: null,
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
terms_accepted_at: null,
@@ -196,21 +200,24 @@ const processMembers = async (
apply: boolean,
migratedAt: Date,
counts: Record<string, number>,
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>,
rejectedIcons: ReadonlyMap<number, RejectedLegacyUserIcon>
): Promise<void> => {
const lastLogins = await loadLastLogins(source);
for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) {
const mapped = rows.map((row) => {
const memberNo = toNumber(row.NO, 'member.NO');
const importedIcon = preparedIcons.get(memberNo);
const rejectedIcon = rejectedIcons.get(memberNo);
const sourcePicture = toNullableString(row.PICTURE) ?? 'default.jpg';
if (
(sourcePicture !== 'default.jpg' && !importedIcon) ||
(importedIcon && importedIcon.sourcePicture !== sourcePicture)
(sourcePicture !== 'default.jpg' && !importedIcon && !rejectedIcon) ||
(importedIcon && importedIcon.sourcePicture !== sourcePicture) ||
(rejectedIcon && rejectedIcon.sourcePicture !== sourcePicture)
) {
throw new Error(`member.${memberNo}.PICTURE changed after user-icon preflight`);
}
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null, importedIcon);
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null, importedIcon, rejectedIcon);
});
if (target) {
await preflightMemberConflicts(target, mapped);
@@ -228,6 +235,17 @@ const processMembers = async (
counts.user_icon_library_inserted = (counts.user_icon_library_inserted ?? 0) + synced.libraryInserted;
counts.user_icon_library_retired = (counts.user_icon_library_retired ?? 0) + synced.libraryRetired;
counts.user_icon_target_preserved = (counts.user_icon_target_preserved ?? 0) + synced.targetPreserved;
const rejected = await syncRejectedUserIcons(
target,
rows
.map((row) => rejectedIcons.get(toNumber(row.NO, 'member.NO')))
.filter((icon): icon is RejectedLegacyUserIcon => Boolean(icon)),
migratedAt
);
counts.user_icon_rejected_current_reset =
(counts.user_icon_rejected_current_reset ?? 0) + rejected.currentReset;
counts.user_icon_rejected_target_preserved =
(counts.user_icon_rejected_target_preserved ?? 0) + rejected.targetPreserved;
}
counts.member = (counts.member ?? 0) + mapped.length;
}
@@ -351,10 +369,11 @@ export const migrateGateway = async (
counts.user_icon_legacy_file = prepared.counts.legacyFiles;
counts.user_icon_existing_upload = prepared.counts.existingUploads;
counts.user_icon_uploaded = prepared.counts.uploaded;
counts.user_icon_rejected = prepared.counts.rejected;
};
const run = async (runId: string | null, prepared: LegacyUserIconPreparation): Promise<void> => {
recordIconCounts(prepared);
await processMembers(source, client, apply, migratedAt, counts, prepared.icons);
await processMembers(source, client, apply, migratedAt, counts, prepared.icons, prepared.rejected);
progress.member = {
strategy: 'rescan',
startAfterId: null,
+141 -34
View File
@@ -27,6 +27,7 @@ export interface LegacyUserIconTransferConfig {
uploadBaseUrl: string;
publicBaseUrl: string;
uploadSecret: string;
excludedMemberNumbers: readonly number[];
}
export interface PreparedLegacyUserIcon {
@@ -44,14 +45,25 @@ export interface PreparedLegacyUserIcon {
export interface LegacyUserIconPreparation {
icons: Map<number, PreparedLegacyUserIcon>;
rejected: Map<number, RejectedLegacyUserIcon>;
counts: {
custom: number;
legacyFiles: number;
existingUploads: number;
uploaded: number;
rejected: number;
};
}
export interface RejectedLegacyUserIcon {
memberNo: number;
userId: string;
sourcePicture: string;
normalizedSourcePicture: string;
sourceImageServer: number;
reason: string;
}
export interface LegacyUserIconSyncCounts {
currentLinked: number;
libraryInserted: number;
@@ -59,6 +71,11 @@ export interface LegacyUserIconSyncCounts {
targetPreserved: number;
}
export interface RejectedLegacyUserIconSyncCounts {
currentReset: number;
targetPreserved: number;
}
interface ValidatedImage {
body: Buffer;
extension: string;
@@ -66,6 +83,8 @@ interface ValidatedImage {
sha256: string;
}
class LegacyUserIconValidationError extends Error {}
const encodedPicturePath = (picture: string): string => picture.split('/').map(encodeURIComponent).join('/');
export const normalizeLegacyIconPicture = (picture: string): string => picture.replace(LEGACY_CACHE_SUFFIX, '');
@@ -82,21 +101,29 @@ const iconCreatedAt = (sourcePicture: string, fallback: Date): Date => {
const validateImage = async (body: Buffer, label: string): Promise<ValidatedImage> => {
if (body.length === 0 || body.length > MAX_ICON_BYTES) {
throw new Error(`${label} must be non-empty and at most 50 KiB`);
throw new LegacyUserIconValidationError(`${label} must be non-empty and at most 50 KiB`);
}
let metadata: { mediaType?: string; format?: string; width?: number; height?: number };
let metadata: {
mediaType?: string;
format?: string;
width?: number;
height?: number;
pageHeight?: number;
pages?: number;
};
try {
metadata = await sharp(body, { animated: true }).metadata();
} catch (error) {
throw new Error(`${label} is not a decodable image`, { cause: error });
throw new LegacyUserIconValidationError(`${label} is not a decodable image`, { cause: error });
}
const detected = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
const extension = detected === 'jpeg' ? 'jpg' : detected;
if (!extension || !CONTENT_TYPES[extension]) {
throw new Error(`${label} must be avif, webp, jpeg, png, or gif`);
throw new LegacyUserIconValidationError(`${label} must be avif, webp, jpeg, png, or gif`);
}
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
throw new Error(`${label} must be a square image from 64x64 through 128x128`);
const frameHeight = metadata.pages && metadata.pages > 1 ? metadata.pageHeight : metadata.height;
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || frameHeight !== metadata.width) {
throw new LegacyUserIconValidationError(`${label} must be a square image from 64x64 through 128x128`);
}
return {
body,
@@ -119,7 +146,7 @@ const readLegacyIcon = async (directory: string, picture: string, memberNo: numb
throw new Error(`member.${memberNo}.PICTURE must resolve to a regular non-symlink file`);
}
if (info.size === 0 || info.size > MAX_ICON_BYTES) {
throw new Error(`member.${memberNo}.PICTURE must be non-empty and at most 50 KiB`);
throw new LegacyUserIconValidationError(`member.${memberNo}.PICTURE must be non-empty and at most 50 KiB`);
}
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
@@ -248,22 +275,59 @@ export const prepareLegacyUserIcons = async (
throw new Error('Gateway source has custom icons but gateway.userIcons is not configured');
}
if (!config) {
return { icons: new Map(), counts: { custom: 0, legacyFiles: 0, existingUploads: 0, uploaded: 0 } };
return {
icons: new Map(),
rejected: new Map(),
counts: { custom: 0, legacyFiles: 0, existingUploads: 0, uploaded: 0, rejected: 0 },
};
}
if (config.uploadSecret.length < 32) {
throw new Error('gateway.userIcons.uploadSecretFile must contain at least 32 characters');
}
const fetchImpl = options.fetchImpl ?? fetch;
const now = options.now ?? Date.now;
const exclusions = new Set(config.excludedMemberNumbers);
if (exclusions.size !== config.excludedMemberNumbers.length) {
throw new Error('gateway.userIcons.excludedMemberNumbers must not contain duplicates');
}
const validated = await mapWithConcurrency(customRows, options.concurrency ?? 8, async (row) => {
const memberNo = toNumber(row.NO, 'member.NO');
const sourcePicture = toStringValue(row.PICTURE, `member.${memberNo}.PICTURE`);
const normalizedSourcePicture = normalizeLegacyIconPicture(sourcePicture);
const sourceImageServer = toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`);
const fallbackCreatedAt = toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`);
if (REMOTE_PICTURE.test(normalizedSourcePicture)) {
const image = await fetchExistingIcon(config, normalizedSourcePicture, memberNo, fetchImpl);
try {
if (REMOTE_PICTURE.test(normalizedSourcePicture)) {
const image = await fetchExistingIcon(config, normalizedSourcePicture, memberNo, fetchImpl);
if (exclusions.has(memberNo)) {
throw new Error(`member.${memberNo} is configured as excluded but its icon is valid`);
}
return {
kind: 'icon' as const,
base: {
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture,
sourceImageServer,
imageServer: 0 as const,
createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt),
source: 'existing-upload' as const,
sha256: image.sha256,
},
image,
picture: normalizedSourcePicture,
};
}
if (sourceImageServer !== 1) {
throw new Error(`member.${memberNo}.PICTURE has an unsupported IMGSVR value`);
}
const image = await readLegacyIcon(config.sourceDirectory, normalizedSourcePicture, memberNo);
if (exclusions.has(memberNo)) {
throw new Error(`member.${memberNo} is configured as excluded but its icon is valid`);
}
return {
kind: 'icon' as const,
base: {
memberNo,
userId: legacyUserId(memberNo),
@@ -272,42 +336,44 @@ export const prepareLegacyUserIcons = async (
sourceImageServer,
imageServer: 0 as const,
createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt),
source: 'existing-upload' as const,
source: 'legacy-file' as const,
sha256: image.sha256,
},
image,
picture: normalizedSourcePicture,
picture: `users/core2026/${deterministicUploadName(memberNo, normalizedSourcePicture, image)}`,
};
} catch (error) {
if (!(error instanceof LegacyUserIconValidationError) || !exclusions.has(memberNo)) throw error;
return {
kind: 'rejected' as const,
rejected: {
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture,
sourceImageServer,
reason: error.message,
} satisfies RejectedLegacyUserIcon,
};
}
if (sourceImageServer !== 1) {
throw new Error(`member.${memberNo}.PICTURE has an unsupported IMGSVR value`);
}
const image = await readLegacyIcon(config.sourceDirectory, normalizedSourcePicture, memberNo);
return {
base: {
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture,
sourceImageServer,
imageServer: 0 as const,
createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt),
source: 'legacy-file' as const,
sha256: image.sha256,
},
image,
picture: `users/core2026/${deterministicUploadName(memberNo, normalizedSourcePicture, image)}`,
};
});
const missingExclusions = [...exclusions].filter(
(memberNo) => !validated.some((result) => result.kind === 'rejected' && result.rejected.memberNo === memberNo)
);
if (missingExclusions.length) {
throw new Error(`Configured user-icon exclusions were not rejected: ${missingExclusions.join(', ')}`);
}
const validIcons = validated.filter((result) => result.kind === 'icon');
const rejectedIcons = validated.filter((result) => result.kind === 'rejected').map((result) => result.rejected);
const pictures = new Map<string, number>();
for (const icon of validated) {
for (const icon of validIcons) {
const owner = pictures.get(icon.picture);
if (owner !== undefined && owner !== icon.base.memberNo) {
throw new Error('Legacy user icon picture is shared by multiple source accounts');
}
pictures.set(icon.picture, icon.base.memberNo);
}
const prepared = await mapWithConcurrency(validated, options.concurrency ?? 8, async (icon) => {
const prepared = await mapWithConcurrency(validIcons, options.concurrency ?? 8, async (icon) => {
const picture =
apply && icon.base.source === 'legacy-file'
? await uploadLegacyIcon(
@@ -323,15 +389,56 @@ export const prepareLegacyUserIcons = async (
});
return {
icons: new Map(prepared.map((icon) => [icon.memberNo, icon])),
rejected: new Map(rejectedIcons.map((icon) => [icon.memberNo, icon])),
counts: {
custom: prepared.length,
custom: validated.length,
legacyFiles: prepared.filter((icon) => icon.source === 'legacy-file').length,
existingUploads: prepared.filter((icon) => icon.source === 'existing-upload').length,
uploaded: apply ? prepared.filter((icon) => icon.source === 'legacy-file').length : 0,
rejected: rejectedIcons.length,
},
};
};
export const syncRejectedUserIcons = async (
target: PoolClient,
rejected: readonly RejectedLegacyUserIcon[],
migratedAt: Date
): Promise<RejectedLegacyUserIconSyncCounts> => {
if (rejected.length === 0) return { currentReset: 0, targetPreserved: 0 };
const accounts = await target.query<{ id: string; picture: string; image_server: number }>(
`SELECT "id", "picture", "image_server" FROM "app_user" WHERE "id" = ANY($1::text[]) FOR UPDATE`,
[rejected.map((icon) => icon.userId)]
);
const byId = new Map(accounts.rows.map((account) => [account.id, account]));
const counts = { currentReset: 0, targetPreserved: 0 };
for (const icon of rejected) {
const account = byId.get(icon.userId);
if (!account) throw new Error(`Imported member account is missing for member.${icon.memberNo}`);
const sourceMatchesCurrent =
account.picture === icon.sourcePicture || account.picture === icon.normalizedSourcePicture;
if (sourceMatchesCurrent) {
const reset = await target.query(
`UPDATE "app_user"
SET "picture" = 'default.jpg', "image_server" = 0,
"icon_revision" = GREATEST(
COALESCE("icon_revision", "icon_updated_at", "created_at"),
$2::timestamptz
)
WHERE "id" = $1 AND "picture" = $3 AND "image_server" = $4`,
[icon.userId, migratedAt, account.picture, account.image_server]
);
if (reset.rowCount !== 1) {
throw new Error(`Target account icon changed concurrently for member.${icon.memberNo}`);
}
counts.currentReset += 1;
} else {
counts.targetPreserved += 1;
}
}
return counts;
};
export const syncImportedUserIcons = async (
target: PoolClient,
icons: readonly PreparedLegacyUserIcon[],
+2 -1
View File
@@ -24,7 +24,7 @@ export interface PlanRunSummary {
interface StagePreflight {
battleResults?: { seasons: number; files: number; bytes: number };
battleResultManifests?: readonly BattleResultSeasonManifest[];
userIcons?: { custom: number; legacyFiles: number; existingUploads: number };
userIcons?: { custom: number; legacyFiles: number; existingUploads: number; rejected: number };
}
const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePreflight> => {
@@ -90,6 +90,7 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePrefl
custom: prepared.counts.custom,
legacyFiles: prepared.counts.legacyFiles,
existingUploads: prepared.counts.existingUploads,
rejected: prepared.counts.rejected,
},
};
}
@@ -2,7 +2,7 @@ import { Pool } from 'pg';
import { describe, expect, it } from 'vitest';
import { legacyUserId } from '../src/identity.js';
import { syncImportedUserIcons, type PreparedLegacyUserIcon } from '../src/legacyUserIcons.js';
import { syncImportedUserIcons, syncRejectedUserIcons, type PreparedLegacyUserIcon } from '../src/legacyUserIcons.js';
const databaseUrl = process.env.LEGACY_ICON_TEST_DATABASE_URL;
@@ -69,6 +69,37 @@ describe.skipIf(!databaseUrl)('legacy user icon PostgreSQL synchronization', ()
);
expect(library.rows).toHaveLength(3);
expect(library.rows.filter((row) => row.retired_at !== null)).toHaveLength(1);
const rejectedUserId = legacyUserId(700_004);
await client.query(
`INSERT INTO "app_user"
("id", "login_id", "display_name", "password_hash", "password_salt",
"updated_at", "picture", "image_server")
VALUES ($1, 'icon-test-rejected', '아이콘테스트-제외', 'hash', 'salt',
CURRENT_TIMESTAMP, 'invalid.gif?=20260809', 1)`,
[rejectedUserId]
);
await expect(
syncRejectedUserIcons(
client,
[
{
memberNo: 700_004,
userId: rejectedUserId,
sourcePicture: 'invalid.gif?=20260809',
normalizedSourcePicture: 'invalid.gif',
sourceImageServer: 1,
reason: 'not square',
},
],
new Date('2026-08-24T00:00:00Z')
)
).resolves.toEqual({ currentReset: 1, targetPreserved: 0 });
const rejectedAccount = await client.query<{ picture: string; image_server: number }>(
`SELECT "picture", "image_server" FROM "app_user" WHERE "id" = $1`,
[rejectedUserId]
);
expect(rejectedAccount.rows[0]).toEqual({ picture: 'default.jpg', image_server: 0 });
} finally {
await client.query('ROLLBACK');
client.release();
@@ -11,6 +11,7 @@ import { legacyUserId } from '../src/identity.js';
import {
prepareLegacyUserIcons,
syncImportedUserIcons,
syncRejectedUserIcons,
type LegacyUserIconTransferConfig,
type PreparedLegacyUserIcon,
} from '../src/legacyUserIcons.js';
@@ -36,6 +37,7 @@ const createFixture = async (): Promise<{ config: LegacyUserIconTransferConfig;
uploadBaseUrl: 'https://upload.test',
publicBaseUrl: 'https://public.test/icons',
uploadSecret: 's'.repeat(32),
excludedMemberNumbers: [],
},
png,
};
@@ -57,7 +59,13 @@ describe('legacy user icon transfer', () => {
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.counts).toEqual({
custom: 1,
legacyFiles: 1,
existingUploads: 0,
uploaded: 0,
rejected: 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();
@@ -123,7 +131,13 @@ describe('legacy user icon transfer', () => {
{ fetchImpl }
);
expect(result.counts).toEqual({ custom: 1, legacyFiles: 0, existingUploads: 1, uploaded: 0 });
expect(result.counts).toEqual({
custom: 1,
legacyFiles: 0,
existingUploads: 1,
uploaded: 0,
rejected: 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}`);
@@ -135,6 +149,40 @@ describe('legacy user icon transfer', () => {
);
});
it('permits only an explicitly reviewed member exclusion whose bytes remain invalid', async () => {
const { config } = await createFixture();
const invalidGif = await sharp({
create: { width: 64, height: 65, channels: 4, background: '#336699ff' },
})
.gif()
.toBuffer();
await writeFile(path.join(config.sourceDirectory, 'invalid.gif'), invalidGif);
config.excludedMemberNumbers = [7];
const result = await prepareLegacyUserIcons([sourceRow({ PICTURE: 'invalid.gif?=20260809' })], config, true, {
fetchImpl: vi.fn<typeof fetch>(),
});
expect(result.counts).toEqual({
custom: 1,
legacyFiles: 0,
existingUploads: 0,
uploaded: 0,
rejected: 1,
});
expect(result.rejected.get(7)?.reason).toContain('square image');
expect(result.icons.size).toBe(0);
});
it('rejects a stale exclusion when the configured member icon is valid', async () => {
const { config } = await createFixture();
config.excludedMemberNumbers = [7];
await expect(prepareLegacyUserIcons([sourceRow()], config, false)).rejects.toThrow(
'configured as excluded but its icon is valid'
);
});
it('links an unchanged Ref selection while preserving newer Core selections', async () => {
const imported = (memberNo: number, sourcePicture: string, picture: string): PreparedLegacyUserIcon => ({
memberNo,
@@ -179,4 +227,30 @@ describe('legacy user icon transfer', () => {
expect(inserts).toHaveLength(3);
expect(inserts[2]?.[1]?.[3]).toEqual(new Date('2026-08-24T00:00:00Z'));
});
it('resets only the still-selected invalid Ref icon', async () => {
const userId = legacyUserId(7);
const rejected = {
memberNo: 7,
userId,
sourcePicture: 'invalid.gif?=20260809',
normalizedSourcePicture: 'invalid.gif',
sourceImageServer: 1,
reason: 'not square',
};
const query = vi.fn(async (sql: string, _parameters?: readonly unknown[]) => {
if (sql.includes('FROM "app_user"')) {
return {
rows: [{ id: userId, picture: rejected.sourcePicture, image_server: 1 }],
rowCount: 1,
} as QueryResult;
}
return { rows: [], rowCount: 1 } as unknown as QueryResult;
});
await expect(
syncRejectedUserIcons({ query } as unknown as PoolClient, [rejected], new Date('2026-08-24T00:00:00Z'))
).resolves.toEqual({ currentReset: 1, targetPreserved: 0 });
expect(String(query.mock.calls[1]?.[0])).toContain(`SET "picture" = 'default.jpg'`);
});
});