255 lines
12 KiB
TypeScript
255 lines
12 KiB
TypeScript
import { constants, publicEncrypt } from 'node:crypto';
|
|
import { readFile } from 'node:fs/promises';
|
|
|
|
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
|
import { expect, test } from '@playwright/test';
|
|
import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api';
|
|
import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api';
|
|
import { Pool } from 'pg';
|
|
|
|
const gatewayUrl = process.env.SAMMO_LIFECYCLE_GATEWAY_URL ?? 'http://127.0.0.1:15001/gateway/api/trpc';
|
|
const gameUrl = process.env.SAMMO_LIFECYCLE_GAME_URL ?? 'http://127.0.0.1:15015/hwe/api/trpc';
|
|
const profile = process.env.SAMMO_LIFECYCLE_PROFILE_KEY?.trim() || 'hwe:default';
|
|
const sampleImage = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
|
'base64'
|
|
);
|
|
|
|
const requiredEnv = (name: string): string => {
|
|
const value = process.env[name]?.trim();
|
|
if (!value) throw new Error(`${name} is required`);
|
|
return value;
|
|
};
|
|
|
|
const readPassword = async (account: 'admin' | 'user_a' | 'user_b'): Promise<string> =>
|
|
(await readFile(`${requiredEnv('SAMMO_LIFECYCLE_SECRET_ROOT')}/${account}_password`, 'utf8')).trim();
|
|
|
|
const createGatewayClient = (session: { token?: string }) =>
|
|
createTRPCProxyClient<GatewayAppRouter>({
|
|
links: [
|
|
httpBatchLink({
|
|
url: gatewayUrl,
|
|
headers: () => (session.token ? { 'x-session-token': session.token } : {}),
|
|
}),
|
|
],
|
|
});
|
|
|
|
const createGameClient = (session: { token?: string }) =>
|
|
createTRPCProxyClient<GameAppRouter>({
|
|
links: [
|
|
httpBatchLink({
|
|
url: gameUrl,
|
|
headers: () => (session.token ? { authorization: `Bearer ${session.token}` } : {}),
|
|
}),
|
|
],
|
|
});
|
|
|
|
const loginGame = async (username: string, password: string) => {
|
|
const gatewaySession: { token?: string } = {};
|
|
const gateway = createGatewayClient(gatewaySession);
|
|
const passwordKey = await gateway.auth.passwordKey.query();
|
|
const credential = {
|
|
keyId: passwordKey.keyId,
|
|
ciphertext: publicEncrypt(
|
|
{
|
|
key: passwordKey.publicKeyPem,
|
|
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
oaepHash: 'sha256',
|
|
},
|
|
Buffer.from(password, 'utf8')
|
|
).toString('base64'),
|
|
};
|
|
const login = await gateway.auth.login.mutate({ username, credential });
|
|
if (login.status === 'otp') {
|
|
throw new Error('Live lifecycle fixture requires a currently verified KakaoTalk login.');
|
|
}
|
|
gatewaySession.token = login.sessionToken;
|
|
const issued = await gateway.auth.issueGameSession.mutate({
|
|
sessionToken: login.sessionToken,
|
|
profile,
|
|
});
|
|
const gameSession: { token?: string } = {};
|
|
const game = createGameClient(gameSession);
|
|
gameSession.token = (await game.auth.exchangeGatewayToken.mutate({ gatewayToken: issued.gameToken })).accessToken;
|
|
return { gateway, game };
|
|
};
|
|
|
|
const monthOrdinal = (year: number, month: number): number => year * 12 + month - 1;
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const createDatabasePool = (): Pool =>
|
|
new Pool({
|
|
host: process.env.POSTGRES_HOST ?? '127.0.0.1',
|
|
port: Number(process.env.POSTGRES_PORT ?? 5432),
|
|
database: process.env.POSTGRES_DB ?? 'sammo',
|
|
user: process.env.POSTGRES_USER ?? 'sammo',
|
|
password: requiredEnv('POSTGRES_PASSWORD'),
|
|
options: '-c search_path=hwe',
|
|
});
|
|
|
|
test('two users enlist, the engine advances three months, and only a chief uploads a nation image', async () => {
|
|
test.setTimeout(300_000);
|
|
|
|
const adminUsername = requiredEnv('SAMMO_LIFECYCLE_ADMIN_USERNAME');
|
|
const [{ game: admin }, { game: userA }, { game: userB }] = await Promise.all([
|
|
loginGame(adminUsername, await readPassword('admin')),
|
|
loginGame('guiusera', await readPassword('user_a')),
|
|
loginGame('guiuserb', await readPassword('user_b')),
|
|
]);
|
|
|
|
const db = createDatabasePool();
|
|
|
|
try {
|
|
const initialWorldResult = await db.query<{ current_year: number; current_month: number }>(
|
|
'SELECT current_year, current_month FROM world_state LIMIT 1'
|
|
);
|
|
const initialWorld = initialWorldResult.rows[0];
|
|
if (!initialWorld) throw new Error('HWE world_state is missing.');
|
|
const initialOrdinal = monthOrdinal(initialWorld.current_year, initialWorld.current_month);
|
|
|
|
const reserveEnlist = async (game: typeof userA, destNationId: number) => {
|
|
const context = await game.general.me.query();
|
|
if (!context?.general) throw new Error('User general is missing.');
|
|
if (context.general.nationId > 0) return context.general.id;
|
|
const snapshot = await game.turns.reserved.getGeneral.query({ generalId: context.general.id });
|
|
await game.turns.reserved.setGeneral.mutate({
|
|
generalId: context.general.id,
|
|
turnIndex: 0,
|
|
action: 'che_임관',
|
|
args: { destNationId },
|
|
expectedRevision: snapshot.revision,
|
|
});
|
|
return context.general.id;
|
|
};
|
|
|
|
const [generalAId, generalBId] = await Promise.all([reserveEnlist(userA, 1), reserveEnlist(userB, 1)]);
|
|
|
|
const runNextMonth = async () => {
|
|
const before = await admin.turnDaemon.status.query({ timeoutMs: 5_000 });
|
|
if (!before?.lastTurnTime) throw new Error('Turn daemon did not report the last monthly boundary.');
|
|
const beforeWorldResult = await db.query<{ current_year: number; current_month: number }>(
|
|
'SELECT current_year, current_month FROM world_state LIMIT 1'
|
|
);
|
|
const beforeWorld = beforeWorldResult.rows[0];
|
|
if (!beforeWorld) throw new Error('HWE world_state is missing before turn execution.');
|
|
const expectedOrdinal = monthOrdinal(beforeWorld.current_year, beforeWorld.current_month) + 1;
|
|
const lastBoundary = new Date(before.lastTurnTime).getTime();
|
|
const targetTime = new Date(Math.floor(lastBoundary / 3_600_000) * 3_600_000 + 3_900_000).toISOString();
|
|
await admin.turnDaemon.run.mutate({
|
|
reason: 'manual',
|
|
targetTime,
|
|
budget: { budgetMs: 90_000, maxGenerals: 10_000, catchUpCap: 1 },
|
|
});
|
|
|
|
const deadline = Date.now() + 90_000;
|
|
while (Date.now() < deadline) {
|
|
const after = await admin.turnDaemon.status.query({ timeoutMs: 5_000 });
|
|
if (after?.lastError) throw new Error(`Turn daemon failed: ${after.lastError}`);
|
|
const worldResult = await db.query<{ current_year: number; current_month: number }>(
|
|
'SELECT current_year, current_month FROM world_state LIMIT 1'
|
|
);
|
|
const world = worldResult.rows[0];
|
|
if (world && monthOrdinal(world.current_year, world.current_month) >= expectedOrdinal) {
|
|
return;
|
|
}
|
|
await sleep(250);
|
|
}
|
|
throw new Error('Turn daemon did not complete the requested monthly boundary.');
|
|
};
|
|
|
|
for (let index = 0; index < 3; index += 1) {
|
|
await runNextMonth();
|
|
}
|
|
|
|
const progressedWorldResult = await db.query<{ current_year: number; current_month: number }>(
|
|
'SELECT current_year, current_month FROM world_state LIMIT 1'
|
|
);
|
|
const progressedWorld = progressedWorldResult.rows[0];
|
|
if (!progressedWorld) throw new Error('HWE world_state disappeared.');
|
|
expect(monthOrdinal(progressedWorld.current_year, progressedWorld.current_month) - initialOrdinal).toBe(3);
|
|
|
|
let enlisted = await db.query<{ id: number; nation_id: number; officer_level: number }>(
|
|
'SELECT id, nation_id, officer_level FROM general WHERE id = ANY($1::int[]) ORDER BY id',
|
|
[[generalAId, generalBId]]
|
|
);
|
|
expect(enlisted.rows).toHaveLength(2);
|
|
if (enlisted.rows.some((general) => general.nation_id === 0)) {
|
|
const candidateResult = await db.query<{ id: number }>(
|
|
`SELECT n.id
|
|
FROM nation n
|
|
LEFT JOIN general g ON g.nation_id = n.id
|
|
WHERE n.id > 0
|
|
GROUP BY n.id, n.level
|
|
HAVING COUNT(g.id) < 10
|
|
ORDER BY n.level DESC, n.id
|
|
LIMIT 1`
|
|
);
|
|
const candidate = candidateResult.rows[0];
|
|
if (!candidate) throw new Error('No nation has room for an opening-period enlistment.');
|
|
if (enlisted.rows.find((general) => general.id === generalAId)?.nation_id === 0) {
|
|
await reserveEnlist(userA, candidate.id);
|
|
}
|
|
if (enlisted.rows.find((general) => general.id === generalBId)?.nation_id === 0) {
|
|
await reserveEnlist(userB, candidate.id);
|
|
}
|
|
await runNextMonth();
|
|
enlisted = await db.query<{ id: number; nation_id: number; officer_level: number }>(
|
|
'SELECT id, nation_id, officer_level FROM general WHERE id = ANY($1::int[]) ORDER BY id',
|
|
[[generalAId, generalBId]]
|
|
);
|
|
}
|
|
expect(enlisted.rows.every((general) => general.nation_id > 0)).toBe(true);
|
|
|
|
const waitForPaused = async (paused: boolean) => {
|
|
const deadline = Date.now() + 15_000;
|
|
while (Date.now() < deadline) {
|
|
const status = await admin.turnDaemon.status.query({ timeoutMs: 5_000 });
|
|
if (status?.lastError) throw new Error(`Turn daemon failed: ${status.lastError}`);
|
|
if (status?.paused === paused) return;
|
|
await sleep(200);
|
|
}
|
|
throw new Error(`Turn daemon did not become ${paused ? 'paused' : 'resumed'}.`);
|
|
};
|
|
|
|
const setOfficerLevels = async (aLevel: number, bLevel: number) => {
|
|
const connection = await db.connect();
|
|
try {
|
|
await connection.query('BEGIN');
|
|
await connection.query('UPDATE general SET officer_level = $2 WHERE id = $1', [generalAId, aLevel]);
|
|
await connection.query('UPDATE general SET officer_level = $2 WHERE id = $1', [generalBId, bLevel]);
|
|
await connection.query('COMMIT');
|
|
} catch (error) {
|
|
await connection.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
};
|
|
|
|
// Appointment is fixture setup for the authorization boundary below. Enlistment and
|
|
// month advancement above are performed exclusively by the real turn daemon.
|
|
await admin.turnDaemon.pause.mutate({ reason: 'chief upload authorization fixture' });
|
|
await waitForPaused(true);
|
|
try {
|
|
await setOfficerLevels(5, 1);
|
|
|
|
await expect(userB.board.uploadImage.mutate({ dataUrl: sampleImage.toString('base64') })).rejects.toThrow(
|
|
'권한이 부족합니다. 수뇌부가 아닙니다.'
|
|
);
|
|
|
|
const uploaded = await userA.board.uploadImage.mutate({ dataUrl: sampleImage.toString('base64') });
|
|
expect(uploaded.url).toMatch(/^https:\/\/sam-image\.hided\.net\/uploads\/core2026\/[a-f0-9]{32}\.webp$/);
|
|
expect(uploaded.format).toBe('webp');
|
|
expect(uploaded.width).toBe(1);
|
|
expect(uploaded.height).toBe(1);
|
|
expect(uploaded.size).toBeGreaterThan(0);
|
|
} finally {
|
|
await setOfficerLevels(1, 1);
|
|
await admin.turnDaemon.resume.mutate({ reason: 'chief upload authorization fixture complete' });
|
|
await waitForPaused(false);
|
|
}
|
|
} finally {
|
|
await db.end();
|
|
}
|
|
});
|