feat: synchronize account icons across game profiles
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user