feat: synchronize account icons across game profiles

This commit is contained in:
2026-07-31 11:21:08 +00:00
parent c8adeeb47b
commit 5f20413552
87 changed files with 5755 additions and 280 deletions
@@ -0,0 +1,157 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const userId = 'e72680fd-0aed-4fdd-80d9-24f78d55676c';
const assertDedicatedSchema = (): void => {
const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA;
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) {
throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.');
}
};
integration('account icon daily PostgreSQL CAS', () => {
let db: GatewayPrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let initialized = false;
beforeAll(async () => {
assertDedicatedSchema();
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
initialized = true;
closeDb = () => connector.disconnect();
await db.appUser.deleteMany({ where: { id: userId } });
await db.appUser.create({
data: {
id: userId,
loginId: 'icon-cas-integration',
displayName: '아이콘 CAS 통합',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['user'],
sanctions: {},
picture: 'old.png',
imageServer: 1,
iconUpdatedAt: new Date('2026-07-30T09:00:00.000Z'),
},
});
});
afterAll(async () => {
if (initialized) {
await db.appUser.deleteMany({ where: { id: userId } });
}
await closeDb?.();
});
it('allows exactly one concurrent update in the same KST day', async () => {
const users = createPostgresUserRepository(db);
const updatedAt = new Date('2026-07-31T15:00:00.000Z');
const kstDayStart = new Date('2026-07-31T15:00:00.000Z');
const results = await Promise.all([
users.updateIconForDay(userId, 'first.png', 1, updatedAt, kstDayStart, true),
users.updateIconForDay(userId, 'second.png', 1, updatedAt, kstDayStart, true),
]);
expect(results.filter(Boolean)).toHaveLength(1);
await expect(db.appUser.findUniqueOrThrow({ where: { id: userId } })).resolves.toMatchObject({
picture: expect.stringMatching(/^(first|second)\.png$/),
imageServer: 1,
iconUpdatedAt: updatedAt,
});
});
it('allows a default icon to upload again while revisions stay strictly increasing', async () => {
const users = createPostgresUserRepository(db);
const frozenNow = new Date('2026-08-01T03:00:00.000Z');
const kstDayStart = new Date('2026-07-31T15:00:00.000Z');
await db.appUser.update({
where: { id: userId },
data: {
picture: 'old.png',
imageServer: 1,
iconUpdatedAt: new Date('2026-07-30T14:59:59.000Z'),
iconRevision: new Date('2026-08-01T03:00:00.000Z'),
},
});
const deletedRevision = await users.updateIconForDay(userId, 'default.jpg', 0, frozenNow, kstDayStart, false);
const uploadedRevision = await users.updateIconForDay(userId, 'again.png', 1, frozenNow, kstDayStart, true);
expect(deletedRevision).toBe('2026-08-01T03:00:00.001Z');
expect(uploadedRevision).toBe('2026-08-01T03:00:00.002Z');
await expect(db.appUser.findUniqueOrThrow({ where: { id: userId } })).resolves.toMatchObject({
picture: 'again.png',
iconUpdatedAt: frozenNow,
iconRevision: new Date('2026-08-01T03:00:00.002Z'),
});
});
it('uses UTC 15:00 as the KST date boundary', async () => {
const users = createPostgresUserRepository(db);
await db.appUser.update({
where: { id: userId },
data: {
picture: 'same-day.png',
imageServer: 1,
iconUpdatedAt: new Date('2026-07-31T14:59:59.000Z'),
iconRevision: new Date('2026-07-31T14:59:59.000Z'),
},
});
await expect(
users.updateIconForDay(
userId,
'blocked.png',
1,
new Date('2026-07-31T14:59:59.999Z'),
new Date('2026-07-30T15:00:00.000Z'),
true
)
).resolves.toBeNull();
await expect(
users.updateIconForDay(
userId,
'allowed.png',
1,
new Date('2026-07-31T15:00:00.000Z'),
new Date('2026-07-31T15:00:00.000Z'),
true
)
).resolves.toBeTruthy();
});
it('serializes administrator reset revisions in a dedicated column', async () => {
const users = createPostgresUserRepository(db);
const frozenNow = new Date('2026-08-02T00:00:00.000Z');
await db.appUser.update({
where: { id: userId },
data: {
picture: 'custom.png',
imageServer: 1,
iconRevision: frozenNow,
profileIconResetAt: null,
sanctions: { warningCount: 1 },
},
});
const first = await users.resetProfileIcon(userId, frozenNow);
const second = await users.resetProfileIcon(userId, frozenNow);
expect(first).toBe('2026-08-02T00:00:00.001Z');
expect(second).toBe('2026-08-02T00:00:00.002Z');
await expect(db.appUser.findUniqueOrThrow({ where: { id: userId } })).resolves.toMatchObject({
profileIconResetAt: new Date('2026-08-02T00:00:00.002Z'),
iconRevision: new Date('2026-08-02T00:00:00.002Z'),
sanctions: { warningCount: 1 },
});
});
});
@@ -0,0 +1,103 @@
import { createHmac, randomUUID } from 'node:crypto';
import fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { registerAccountIconInternalRoute } from '../src/auth/accountIconInternalRoute.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
const secret = 'gateway-test-secret';
const token = createHmac('sha256', secret).update('sammo:account-icon-source:v1').digest('hex');
describe('account icon internal route', () => {
const app = fastify();
const users = createInMemoryUserRepository();
let userId = '';
beforeEach(async () => {
if (!app.hasRoute({ method: 'GET', url: '/internal/account-icons/:userId' })) {
registerAccountIconInternalRoute(app, { users, secret });
}
const user = await users.createUser({
username: `internal-${randomUUID()}`,
password: 'password',
displayName: `내부-${randomUUID()}`,
});
userId = user.id;
await users.updateIcon(userId, 'latest.png', 1, new Date('2026-07-31T09:00:00.000Z'));
});
afterEach(async () => {
if (userId) {
await users.deleteUser(userId);
}
});
it('requires the purpose-derived token and exposes only the projection', async () => {
const unauthorized = await app.inject({
method: 'GET',
url: `/internal/account-icons/${userId}`,
headers: { 'x-sammo-internal-token': secret },
});
expect(unauthorized.statusCode).toBe(401);
const response = await app.inject({
method: 'GET',
url: `/internal/account-icons/${userId}`,
headers: { 'x-sammo-internal-token': token },
});
expect(response.statusCode).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.json()).toEqual({
revision: '2026-07-31T09:00:00.000Z',
picture: 'latest.png',
imageServer: 1,
});
expect(Object.keys(response.json()).sort()).toEqual(['imageServer', 'picture', 'revision']);
});
it('returns 404 for a missing account without leaking account fields', async () => {
const response = await app.inject({
method: 'GET',
url: `/internal/account-icons/${randomUUID()}`,
headers: { 'x-sammo-internal-token': token },
});
expect(response.statusCode).toBe(404);
expect(response.json()).toEqual({ ok: false, error: 'not_found' });
});
it('returns the durable reset marker even after a newer ordinary icon change', async () => {
const resetRevision = await users.resetProfileIcon(userId, new Date('2099-07-31T09:00:00.001Z'));
await users.updateIcon(userId, 'newer.png', 1, new Date('2099-07-31T09:00:00.002Z'));
const response = await app.inject({
method: 'POST',
url: '/internal/account-icon-resets',
headers: { 'x-sammo-internal-token': token },
payload: { userIds: [userId, randomUUID()] },
});
expect(response.statusCode).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.json()).toEqual({
resets: [
{
userId,
resetRevision,
current: {
revision: '2099-07-31T09:00:00.002Z',
picture: 'newer.png',
imageServer: 1,
},
},
],
});
const invalid = await app.inject({
method: 'POST',
url: '/internal/account-icon-resets',
headers: { 'x-sammo-internal-token': token },
payload: { userIds: [userId], extra: true },
});
expect(invalid.statusCode).toBe(400);
});
});
+40 -3
View File
@@ -33,7 +33,7 @@ const buildCaller = async (
const session = await sessions.createSession({ ...admin, roles: adminRoles });
const createdInputs: GatewayOperationCreateInput[] = [];
const createdRuntimeActions: Array<Record<string, unknown>> = [];
const flushes: Array<{ userId: string; reason?: string }> = [];
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
const profile = {
profileName: 'che:2',
profile: 'che',
@@ -79,8 +79,12 @@ const buildCaller = async (
users,
sessions,
flushPublisher: {
publishUserFlush: async (userId, reason) => {
flushes.push({ userId, reason });
publishUserFlush: async (userId, reason, metadata) => {
flushes.push({
userId,
reason,
...(metadata?.iconRevision ? { iconRevision: metadata.iconRevision } : {}),
});
},
},
gameTokenSecret: 'test-secret',
@@ -397,4 +401,37 @@ describe('admin role non-escalation', () => {
{ userId: target.id, reason: 'admin-server-restriction' },
]);
});
it('keeps profile icon reset revisions monotonic and outside generic sanction patches', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'icon-reset-target',
password: 'secretpass',
displayName: 'Icon Reset Target',
});
const frozenNow = new Date(target.createdAt);
await harness.users.updateIcon(target.id, 'custom.png', 1, frozenNow);
const first = await harness.users.resetProfileIcon(target.id, frozenNow);
const second = await harness.users.resetProfileIcon(target.id, frozenNow);
expect(new Date(first!).getTime()).toBe(new Date(target.createdAt).getTime() + 1);
expect(new Date(second!).getTime()).toBe(new Date(first!).getTime() + 1);
await expect(
harness.caller.admin.users.updateSanctions({
userId: target.id,
patch: {
profileIconResetAt: null,
},
} as never)
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
const result = await harness.caller.admin.users.resetProfileIcon({ userId: target.id });
expect(new Date(result.profileIconResetAt).getTime()).toBeGreaterThan(new Date(second!).getTime());
expect((await harness.users.findById(target.id))?.profileIconResetAt).toBe(result.profileIconResetAt);
expect(harness.flushes.at(-1)).toEqual({
userId: target.id,
reason: 'admin-profile-icon-reset',
iconRevision: result.profileIconResetAt,
});
});
});
+199 -4
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
@@ -16,14 +16,25 @@ import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { decryptGameSessionToken, type UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: number } = {}) => {
const buildCaller = (
options: {
userIconDir?: string;
localAccountGraceDays?: number;
flushError?: Error;
profileListError?: Error;
} = {}
) => {
const users = createInMemoryUserRepository();
const sessions = new InMemoryGatewaySessionService({
sessionTtlSeconds: 3600,
gameSessionTtlSeconds: 600,
});
const flushPublisher = {
publishUserFlush: async () => {},
publishUserFlush: vi.fn(async () => {
if (options.flushError) {
throw options.flushError;
}
}),
};
const oauthSessions = new InMemoryOAuthSessionStore();
const kakaoClient = {
@@ -139,6 +150,11 @@ const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: nu
color: '#fff',
}))
);
if (options.profileListError) {
profileStatus.listLobbyProfiles = async () => {
throw options.profileListError;
};
}
const passwordEnvelope = createPasswordEnvelopeService();
const requestHeaders: Record<string, string> = {};
const sealPassword = (password: string) => {
@@ -187,6 +203,7 @@ const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: nu
oauthSessions,
users,
sessions,
flushPublisher,
sealPassword,
setSessionHeader: (sessionToken: string) => {
requestHeaders['x-session-token'] = sessionToken;
@@ -576,6 +593,7 @@ describe('gateway auth flow', () => {
roles: ['user', 'latest-role'],
picture: 'latest-owner.webp',
imageServer: 3,
iconUpdatedAt: '2026-07-30T12:00:00.000Z',
canUseGeneralPicture: false,
});
expect(payload?.sanctions).toMatchObject({
@@ -668,7 +686,9 @@ describe('account self service', () => {
it('validates and stores a legacy-sized account icon with a daily change limit', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-'));
try {
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
const { caller, users, sessions, flushPublisher } = buildCaller({
userIconDir: iconDir,
});
const user = await users.createUser({
username: 'icon-self',
password: 'current-password',
@@ -692,7 +712,9 @@ describe('account self service', () => {
const updated = await users.findById(user.id);
expect(result.iconUrl).toMatch(/^http:\/\/localhost\/user-icons\/[a-f0-9]{16}\.png$/);
expect(result.profiles.map((profile) => profile.profileName)).toEqual(['che:default', 'hwe:default']);
expect(updated?.imageServer).toBe(1);
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-changed');
expect(await fs.stat(path.join(iconDir, updated?.picture ?? 'missing'))).toBeTruthy();
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
@@ -701,4 +723,177 @@ describe('account self service', () => {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
it('atomically allows only one icon change per KST day and removes the losing file', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
try {
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
const user = await users.createUser({
username: 'icon-race',
password: 'current-password',
});
const session = await sessions.createSession(user);
const png = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: '#556677',
},
})
.png()
.toBuffer();
const attempts = await Promise.allSettled(
[1, 2].map(() =>
caller.account.changeIcon({
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
})
)
);
expect(attempts.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
expect(attempts.filter(({ status }) => status === 'rejected')).toHaveLength(1);
expect(await fs.readdir(iconDir)).toHaveLength(1);
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
it('flushes an account icon deletion with selectable running profiles', async () => {
const { caller, users, sessions, flushPublisher } = buildCaller();
const user = await users.createUser({
username: 'icon-delete',
password: 'current-password',
});
await users.updateIcon(user.id, 'old.png', 1, new Date('2026-07-30T12:00:00.000Z'));
const session = await sessions.createSession(user);
const result = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
const updated = await users.findById(user.id);
expect(result).toMatchObject({
ok: true,
iconUrl: null,
profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
});
expect(updated).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-deleted');
});
it('uses the Asia/Seoul day boundary and preserves Ref delete-to-upload behavior', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-kst-'));
const png = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: '#667788',
},
})
.png()
.toBuffer();
try {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-31T14:59:59.000Z'));
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
const user = await users.createUser({
username: 'icon-kst',
password: 'current-password',
});
await users.updateIcon(user.id, 'old.png', 1, new Date('2026-07-31T00:00:00.000Z'));
const session = await sessions.createSession(user);
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
});
vi.setSystemTime(new Date('2026-07-31T15:00:00.000Z'));
const deleted = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
expect(deleted.revision).toBe('2026-07-31T15:00:00.000Z');
const changed = await caller.account.changeIcon({
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
});
expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime());
expect((await users.findById(user.id))?.picture).not.toBe('default.jpg');
} finally {
vi.useRealTimers();
await fs.rm(iconDir, { recursive: true, force: true });
}
});
it('does not commit an icon when profile discovery fails before mutation', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-profile-failure-'));
try {
const { caller, users, sessions } = buildCaller({
userIconDir: iconDir,
profileListError: new Error('profile unavailable'),
});
const user = await users.createUser({
username: 'icon-profile-failure',
password: 'current-password',
});
const session = await sessions.createSession(user);
const png = await sharp({
create: { width: 64, height: 64, channels: 4, background: '#778899' },
})
.png()
.toBuffer();
await expect(
caller.account.changeIcon({
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
})
).rejects.toThrow('profile unavailable');
expect(await fs.readdir(iconDir)).toEqual([]);
expect(await users.findById(user.id)).toMatchObject({
picture: 'default.jpg',
imageServer: 0,
});
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
it('returns a recoverable success when flush publication fails after commit', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-flush-failure-'));
try {
const { caller, users, sessions } = buildCaller({
userIconDir: iconDir,
flushError: new Error('redis unavailable'),
});
const user = await users.createUser({
username: 'icon-flush-failure',
password: 'current-password',
});
const session = await sessions.createSession(user);
const png = await sharp({
create: { width: 64, height: 64, channels: 4, background: '#8899aa' },
})
.png()
.toBuffer();
const changed = await caller.account.changeIcon({
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
});
expect(changed.flushPublished).toBe(false);
expect((await users.findById(user.id))?.picture).not.toBe('default.jpg');
await expect(caller.account.prepareIconSync({ sessionToken: session.sessionToken })).resolves.toMatchObject(
{
projection: {
revision: changed.revision,
imageServer: 1,
},
profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
}
);
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
});
@@ -129,6 +129,7 @@ const createHarness = (
workspaceRoot: '/srv/sammo',
redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
},
reconcileIntervalMs: 60_000,
scheduleIntervalMs: 60_000,
@@ -102,6 +102,7 @@ describe('buildProcessDefinitions', () => {
workspaceRoot: '/srv/sammo/main',
redisKeyPrefix: 'sammo:gateway',
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
};
it('runs a built profile from its commit worktree', () => {
@@ -114,6 +115,7 @@ describe('buildProcessDefinitions', () => {
GAME_PROFILE_NAME: 'che:2',
GAME_TRPC_PATH: '/che/api/trpc',
GAME_API_EVENTS_PATH: '/che/api/events',
GATEWAY_INTERNAL_API_URL: 'http://127.0.0.1:13000',
GAME_UPLOAD_PATH: '/che/api/uploads',
});
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
@@ -62,6 +62,7 @@ const createHarness = (
workspaceRoot: '/srv/sammo',
redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
},
reconcileIntervalMs: 60_000,
scheduleIntervalMs: 60_000,
@@ -49,9 +49,7 @@ describe('password credential compatibility', () => {
password: 'current-password',
});
const userSalt = 'ref-user-salt';
const browserHash = createHash('sha512')
.update(`${globalSalt}current-password${globalSalt}`)
.digest('hex');
const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex');
user.passwordSalt = userSalt;
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');