From cd0f50f89d8cb2a8086947d10c4da6e28f46efc4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 08:45:47 +0000 Subject: [PATCH] =?UTF-8?q?fix(migration):=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=95=84=EC=9D=B4=EC=BD=98=EC=9D=84=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검토한 회원 번호의 이미지가 계속 유효성 조건을 어길 때만 업로드에서 제외한다. 기존 Ref 경로가 현재 선택인 경우 기본 아이콘으로 정리하고 최신 Core 선택은 보존한다. --- docs/legacy-db-migration.md | 8 + tools/legacy-db-migration/README.md | 6 + .../migration-plan.example.json | 3 +- tools/legacy-db-migration/src/config.ts | 21 ++- tools/legacy-db-migration/src/gateway.ts | 35 +++- .../src/legacyUserIcons.ts | 175 ++++++++++++++---- tools/legacy-db-migration/src/plan.ts | 3 +- .../test/legacyUserIcons.postgres.test.ts | 33 +++- .../test/legacyUserIcons.test.ts | 78 +++++++- 9 files changed, 313 insertions(+), 49 deletions(-) diff --git a/docs/legacy-db-migration.md b/docs/legacy-db-migration.md index cec07c57..4337b856 100644 --- a/docs/legacy-db-migration.md +++ b/docs/legacy-db-migration.md @@ -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 diff --git a/tools/legacy-db-migration/README.md b/tools/legacy-db-migration/README.md index 4dc02326..1c8f1464 100644 --- a/tools/legacy-db-migration/README.md +++ b/tools/legacy-db-migration/README.md @@ -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 diff --git a/tools/legacy-db-migration/migration-plan.example.json b/tools/legacy-db-migration/migration-plan.example.json index 565b79f1..25bab94b 100644 --- a/tools/legacy-db-migration/migration-plan.example.json +++ b/tools/legacy-db-migration/migration-plan.example.json @@ -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" }, diff --git a/tools/legacy-db-migration/src/config.ts b/tools/legacy-db-migration/src/config.ts index 6fb8398a..979f3d2d 100644 --- a/tools/legacy-db-migration/src/config.ts +++ b/tools/legacy-db-migration/src/config.ts @@ -169,7 +169,11 @@ const resolveUserIcons = async ( label: string ): Promise => { 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 => { diff --git a/tools/legacy-db-migration/src/gateway.ts b/tools/legacy-db-migration/src/gateway.ts index c7b28db6..de2d1ccf 100644 --- a/tools/legacy-db-migration/src/gateway.ts +++ b/tools/legacy-db-migration/src/gateway.ts @@ -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, - preparedIcons: ReadonlyMap + preparedIcons: ReadonlyMap, + rejectedIcons: ReadonlyMap ): Promise => { 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 => { 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, diff --git a/tools/legacy-db-migration/src/legacyUserIcons.ts b/tools/legacy-db-migration/src/legacyUserIcons.ts index dcd866c6..faf661a4 100644 --- a/tools/legacy-db-migration/src/legacyUserIcons.ts +++ b/tools/legacy-db-migration/src/legacyUserIcons.ts @@ -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; + rejected: Map; 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 => { 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(); - 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 => { + 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[], diff --git a/tools/legacy-db-migration/src/plan.ts b/tools/legacy-db-migration/src/plan.ts index 92973e6d..816dbfbd 100644 --- a/tools/legacy-db-migration/src/plan.ts +++ b/tools/legacy-db-migration/src/plan.ts @@ -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 => { @@ -90,6 +90,7 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise 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(); diff --git a/tools/legacy-db-migration/test/legacyUserIcons.test.ts b/tools/legacy-db-migration/test/legacyUserIcons.test.ts index 07df2d30..5ed5ad6d 100644 --- a/tools/legacy-db-migration/test/legacyUserIcons.test.ts +++ b/tools/legacy-db-migration/test/legacyUserIcons.test.ts @@ -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(), + }); + + 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'`); + }); });