merge: 최신 main을 외교 권한 복수 임명 수정에 최종 통합한다

This commit is contained in:
2026-08-24 08:47:05 +00:00
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'`);
});
});