Secure actor-owned game API routes
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
type MessageView,
|
||||
} from '../../messages/store.js';
|
||||
import { publishRealtimeEvent } from '../../realtime/publisher.js';
|
||||
import { getOwnedGeneral } from '../shared/general.js';
|
||||
|
||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||
|
||||
@@ -30,15 +31,7 @@ export const messagesRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
|
||||
const sequence = input.sequence ?? -1;
|
||||
const nationId = general.nationId;
|
||||
@@ -138,15 +131,7 @@ export const messagesRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
|
||||
const nationId = general.nationId;
|
||||
const mailboxes = {
|
||||
@@ -190,15 +175,7 @@ export const messagesRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
|
||||
const src = await buildTargetFromGeneral(ctx.db, general);
|
||||
const now = new Date();
|
||||
|
||||
@@ -13,3 +13,25 @@ export const getMyGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>) =>
|
||||
}
|
||||
return general;
|
||||
};
|
||||
|
||||
export const getOwnedGeneral = async (
|
||||
ctx: Pick<GameApiContext, 'db' | 'auth'>,
|
||||
generalId: number
|
||||
) => {
|
||||
if (!ctx.auth?.user.id) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found.' });
|
||||
}
|
||||
if (general.userId !== ctx.auth.user.id) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'General is not owned by the authenticated user.',
|
||||
});
|
||||
}
|
||||
return general;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { getOwnedGeneral } from '../shared/general.js';
|
||||
|
||||
export const troopRouter = router({
|
||||
join: authedProcedure
|
||||
@@ -12,9 +13,10 @@ export const troopRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'troopJoin',
|
||||
generalId: input.generalId,
|
||||
generalId: general.id,
|
||||
troopId: input.troopId,
|
||||
});
|
||||
if (!result) {
|
||||
@@ -45,9 +47,10 @@ export const troopRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'troopExit',
|
||||
generalId: input.generalId,
|
||||
generalId: general.id,
|
||||
});
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
|
||||
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
|
||||
|
||||
@@ -10,8 +11,27 @@ const zTurnRunBudget = z.object({
|
||||
catchUpCap: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const turnDaemonAdminProcedure = authedProcedure.use(({ ctx, next }) => {
|
||||
const roles = ctx.auth?.user.roles ?? [];
|
||||
const profileName = ctx.profile.name;
|
||||
const canManageProfile =
|
||||
roles.includes('superuser') ||
|
||||
roles.includes('admin') ||
|
||||
roles.includes('admin.superuser') ||
|
||||
roles.includes('admin.profiles.manage') ||
|
||||
roles.includes('admin.profiles.manage:*') ||
|
||||
roles.includes(`admin.profiles.manage:${profileName}`);
|
||||
if (!canManageProfile) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Profile administration permission is required.',
|
||||
});
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
export const turnDaemonRouter = router({
|
||||
run: procedure
|
||||
run: turnDaemonAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
reason: zRunReason,
|
||||
@@ -28,7 +48,7 @@ export const turnDaemonRouter = router({
|
||||
});
|
||||
return { accepted: true, requestId };
|
||||
}),
|
||||
pause: procedure
|
||||
pause: turnDaemonAdminProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
@@ -43,7 +63,7 @@ export const turnDaemonRouter = router({
|
||||
});
|
||||
return { accepted: true, requestId };
|
||||
}),
|
||||
resume: procedure
|
||||
resume: turnDaemonAdminProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
@@ -58,7 +78,7 @@ export const turnDaemonRouter = router({
|
||||
});
|
||||
return { accepted: true, requestId };
|
||||
}),
|
||||
status: procedure
|
||||
status: turnDaemonAdminProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
shiftGeneralTurns,
|
||||
shiftNationTurns,
|
||||
} from '../../turns/reservedTurns.js';
|
||||
import { getOwnedGeneral } from '../shared/general.js';
|
||||
|
||||
const buildShiftAmountSchema = (maxTurns: number) =>
|
||||
z
|
||||
@@ -34,7 +35,7 @@ export const turnsRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [worldState, general] = await Promise.all([
|
||||
ctx.db.worldState.findFirst(),
|
||||
ctx.db.general.findUnique({ where: { id: input.generalId } }),
|
||||
getOwnedGeneral(ctx, input.generalId),
|
||||
]);
|
||||
|
||||
if (!worldState) {
|
||||
@@ -44,13 +45,6 @@ export const turnsRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
|
||||
const [city, nation, nationGenerals] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
@@ -85,15 +79,7 @@ export const turnsRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
await getOwnedGeneral(ctx, input.generalId);
|
||||
|
||||
return listGeneralTurns(ctx.db, input.generalId);
|
||||
}),
|
||||
@@ -104,15 +90,7 @@ export const turnsRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
@@ -142,15 +120,7 @@ export const turnsRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
await getOwnedGeneral(ctx, input.generalId);
|
||||
|
||||
const turns = await setGeneralTurn(
|
||||
ctx.db,
|
||||
@@ -169,15 +139,7 @@ export const turnsRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
await getOwnedGeneral(ctx, input.generalId);
|
||||
|
||||
const turns = await shiftGeneralTurns(ctx.db, input.generalId, input.amount);
|
||||
return { ok: true, turns };
|
||||
@@ -196,15 +158,7 @@ export const turnsRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
@@ -236,15 +190,7 @@ export const turnsRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await ctx.db.general.findUnique({
|
||||
where: { id: input.generalId },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found.',
|
||||
});
|
||||
}
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { WorldStateRow } from '../../context.js';
|
||||
import {
|
||||
type WorldStateRow,
|
||||
zWorldStateConfig,
|
||||
zWorldStateMeta,
|
||||
} from '../../context.js';
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
import { loadWorldMap } from '../../maps/worldMap.js';
|
||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||
import { getOwnedGeneral } from '../shared/general.js';
|
||||
|
||||
const toWorldStateSnapshot = (row: WorldStateRow) => ({
|
||||
scenarioCode: row.scenarioCode,
|
||||
currentYear: row.currentYear,
|
||||
currentMonth: row.currentMonth,
|
||||
tickSeconds: row.tickSeconds,
|
||||
config: row.config,
|
||||
meta: row.meta,
|
||||
config: zWorldStateConfig.parse(row.config),
|
||||
meta: zWorldStateMeta.parse(row.meta),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -34,6 +39,9 @@ export const worldRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (input.generalId !== undefined) {
|
||||
await getOwnedGeneral(ctx, input.generalId);
|
||||
}
|
||||
const map = await loadWorldMap(ctx, input);
|
||||
if (!map) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -26,7 +26,7 @@ const profile: GameProfile = {
|
||||
const buildGeneralRow = (overrides?: Partial<GeneralRow>): GeneralRow => {
|
||||
const base: GeneralRow = {
|
||||
id: 1,
|
||||
userId: null,
|
||||
userId: 'user-1',
|
||||
name: '테스트',
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
@@ -123,7 +123,7 @@ const buildContext = (options?: {
|
||||
},
|
||||
profile.name
|
||||
);
|
||||
const auth: GameSessionTokenPayload = options?.auth ?? {
|
||||
const defaultAuth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: profile.name,
|
||||
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
|
||||
@@ -133,10 +133,11 @@ const buildContext = (options?: {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: 'Tester',
|
||||
roles: [],
|
||||
roles: ['admin'],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
const auth = options && 'auth' in options ? (options.auth ?? null) : defaultAuth;
|
||||
return {
|
||||
db: db as unknown as DatabaseClient,
|
||||
turnDaemon: transport,
|
||||
@@ -172,8 +173,8 @@ describe('appRouter', () => {
|
||||
currentYear: 1,
|
||||
currentMonth: 2,
|
||||
tickSeconds: 600,
|
||||
config: { seed: 123 },
|
||||
meta: { label: 'sample' },
|
||||
config: { maxUserCnt: 500, hiddenSeed: 'config-secret' },
|
||||
meta: { otherTextInfo: 'sample', hiddenSeed: 'meta-secret' },
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
@@ -182,9 +183,50 @@ describe('appRouter', () => {
|
||||
|
||||
expect(response?.scenarioCode).toBe('default');
|
||||
expect(response?.currentYear).toBe(1);
|
||||
expect(response?.config).toEqual({ maxUserCnt: 500 });
|
||||
expect(response?.meta).toEqual({ otherTextInfo: 'sample' });
|
||||
expect(response?.updatedAt).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('requires profile administration permission for turn daemon control', async () => {
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: profile.name,
|
||||
issuedAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: '2026-01-02T00:00:00.000Z',
|
||||
sessionId: 'session-user',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: 'Tester',
|
||||
roles: ['user'],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
const caller = appRouter.createCaller(buildContext({ auth }));
|
||||
|
||||
await expect(caller.turnDaemon.run({ reason: 'manual' })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(caller.turnDaemon.pause()).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(caller.turnDaemon.resume()).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(caller.turnDaemon.status()).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unauthenticated turn daemon control', async () => {
|
||||
const caller = appRouter.createCaller(buildContext({ auth: null }));
|
||||
|
||||
await expect(caller.turnDaemon.pause()).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns status from transport', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport({
|
||||
state: 'paused',
|
||||
@@ -238,4 +280,99 @@ describe('appRouter', () => {
|
||||
expect(response[0]?.action).toBe('che_포상');
|
||||
expect(response[0]?.index).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects another user general across actor-owned routers', async () => {
|
||||
const general = buildGeneralRow({ id: 15, userId: 'user-2' });
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const caller = appRouter.createCaller(buildContext({ general, transport }));
|
||||
|
||||
await expect(caller.turns.getCommandTable({ generalId: general.id })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(caller.turns.reserved.getGeneral({ generalId: general.id })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(caller.turns.reserved.getNation({ generalId: general.id })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(
|
||||
caller.turns.reserved.setGeneral({
|
||||
generalId: general.id,
|
||||
turnIndex: 0,
|
||||
action: '휴식',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(
|
||||
caller.turns.reserved.shiftGeneral({
|
||||
generalId: general.id,
|
||||
amount: 1,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(
|
||||
caller.turns.reserved.setNation({
|
||||
generalId: general.id,
|
||||
turnIndex: 0,
|
||||
action: '휴식',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(
|
||||
caller.turns.reserved.shiftNation({
|
||||
generalId: general.id,
|
||||
amount: 1,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(caller.messages.getRecent({ generalId: general.id })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(
|
||||
caller.messages.getOld({
|
||||
generalId: general.id,
|
||||
to: 1,
|
||||
type: 'private',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(
|
||||
caller.messages.send({
|
||||
generalId: general.id,
|
||||
mailbox: 0,
|
||||
text: 'test',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(caller.troop.join({ generalId: general.id, troopId: 3 })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(caller.troop.exit({ generalId: general.id })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(
|
||||
caller.world.getMap({
|
||||
generalId: general.id,
|
||||
showMe: true,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated general-scoped map views', async () => {
|
||||
const general = buildGeneralRow({ id: 16 });
|
||||
const caller = appRouter.createCaller(buildContext({ general, auth: null }));
|
||||
|
||||
await expect(caller.world.getMap({ generalId: general.id })).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,10 +99,14 @@ export const appRouter = router({
|
||||
password: input.password,
|
||||
displayName: input.displayName,
|
||||
});
|
||||
await ctx.users.updateRoles(created.id, ['superuser']);
|
||||
const session = await ctx.sessions.createSession(created);
|
||||
const bootstrappedUser = {
|
||||
...created,
|
||||
roles: ['superuser'],
|
||||
};
|
||||
await ctx.users.updateRoles(created.id, bootstrappedUser.roles);
|
||||
const session = await ctx.sessions.createSession(bootstrappedUser);
|
||||
return {
|
||||
user: toPublicUser(created),
|
||||
user: toPublicUser(bootstrappedUser),
|
||||
sessionToken: session.sessionToken,
|
||||
issuedAt: session.issuedAt,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createGatewayApiContext } from '../src/context.js';
|
||||
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
|
||||
|
||||
const buildCaller = () => {
|
||||
const users = createInMemoryUserRepository();
|
||||
@@ -87,13 +88,47 @@ const buildCaller = () => {
|
||||
orchestrator,
|
||||
profileStatus,
|
||||
requestHeaders: {},
|
||||
prisma: {} as unknown as GatewayPrismaClient,
|
||||
prisma: {
|
||||
appUser: {
|
||||
findFirst: async () => null,
|
||||
},
|
||||
} as unknown as GatewayPrismaClient,
|
||||
})
|
||||
);
|
||||
return { caller, oauthSessions };
|
||||
};
|
||||
|
||||
describe('gateway auth flow', () => {
|
||||
it('carries the bootstrap superuser role into game sessions', async () => {
|
||||
const previousToken = process.env.GATEWAY_BOOTSTRAP_TOKEN;
|
||||
process.env.GATEWAY_BOOTSTRAP_TOKEN = 'bootstrap-test-token';
|
||||
try {
|
||||
const { caller } = buildCaller();
|
||||
const bootstrap = await caller.auth.bootstrapLocal({
|
||||
token: 'bootstrap-test-token',
|
||||
username: 'admin',
|
||||
password: 'secretpass',
|
||||
displayName: 'Admin',
|
||||
});
|
||||
|
||||
expect(bootstrap.user.roles).toEqual(['superuser']);
|
||||
|
||||
const issued = await caller.auth.issueGameSession({
|
||||
sessionToken: bootstrap.sessionToken,
|
||||
profile: 'che:default',
|
||||
});
|
||||
const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');
|
||||
|
||||
expect(payload?.user.roles).toEqual(['superuser']);
|
||||
} finally {
|
||||
if (previousToken === undefined) {
|
||||
delete process.env.GATEWAY_BOOTSTRAP_TOKEN;
|
||||
} else {
|
||||
process.env.GATEWAY_BOOTSTRAP_TOKEN = previousToken;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('registers and issues a game session', async () => {
|
||||
const { caller, oauthSessions } = buildCaller();
|
||||
const oauthSession = await oauthSessions.createSession({
|
||||
|
||||
@@ -75,12 +75,15 @@ For profile alignment:
|
||||
- Wait for PM2 to report the processes as `online`.
|
||||
|
||||
5) **Verify API <-> daemon**
|
||||
- Call `turnDaemon.status` over tRPC and expect a non-null status.
|
||||
- Exchange a gateway session carrying `superuser`, `admin`, or
|
||||
`admin.profiles.manage[:<profile>]` for a game access token.
|
||||
- Call `turnDaemon.status` with that administrator token and expect a
|
||||
non-null status.
|
||||
- Send a mutation command that expects a result (e.g., `troop.join`) and
|
||||
verify `commandResult` is received.
|
||||
|
||||
6) **Verify realtime events**
|
||||
- Trigger a run via `turnDaemon.run`.
|
||||
- Trigger a run via `turnDaemon.run` with the administrator token.
|
||||
- Subscribe to `sammo:${profileName}:realtime:events` and wait for
|
||||
`turnCompleted`.
|
||||
|
||||
@@ -94,7 +97,9 @@ For profile alignment:
|
||||
Mandatory checks:
|
||||
|
||||
- PM2 reports both processes as online.
|
||||
- `turnDaemon.status` responds within the timeout.
|
||||
- Authenticated profile administration is required for every
|
||||
`turnDaemon.run/pause/resume/status` call.
|
||||
- `turnDaemon.status` responds within the timeout for the administrator token.
|
||||
- A command with `commandResult` returns a success response.
|
||||
- A `turnCompleted` realtime event is observed after a `run` command.
|
||||
|
||||
|
||||
@@ -69,5 +69,5 @@ These are loaded from `.env.ci` and can be overridden per run.
|
||||
- `auth.bootstrapLocal` only works when no users exist; the test resets the DB
|
||||
to satisfy this precondition.
|
||||
- `profiles.installNow` seeds the scenario and auto-creates the admin general.
|
||||
- The test runs turn processing via `turnDaemon.run` and validates founding
|
||||
rules at the third turn.
|
||||
- The test runs turn processing via administrator-authorized
|
||||
`turnDaemon.run` and validates founding rules at the third turn.
|
||||
|
||||
@@ -293,6 +293,15 @@ describe('auction integration flow', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
||||
sessionToken: adminSessionRef.value ?? '',
|
||||
profile: 'che:2',
|
||||
});
|
||||
const adminAccess = await gameClient.auth.exchangeGatewayToken.mutate({
|
||||
gatewayToken: adminGatewayToken.gameToken,
|
||||
});
|
||||
gameAccessRef.value = adminAccess.accessToken;
|
||||
|
||||
for (const user of demoUsers) {
|
||||
const login = await gatewayClient.auth.login.mutate({
|
||||
username: user.username,
|
||||
|
||||
@@ -192,10 +192,14 @@ const cleanupPm2 = async (manager: Pm2ProcessManager, names: string[]) => {
|
||||
for (const name of names) {
|
||||
try {
|
||||
await manager.stop(name);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Cleanup is best-effort when the process is already stopped.
|
||||
}
|
||||
try {
|
||||
await manager.delete(name);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Cleanup is best-effort when the process is already deleted.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -237,7 +241,9 @@ const waitForHttpOk = async (url: string, timeoutMs = 15_000) => {
|
||||
if (status >= 200 && status < 300) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// The service can refuse connections while PM2 is still starting it.
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
throw new Error(`health check timeout: ${url}`);
|
||||
@@ -445,10 +451,12 @@ describe('pm2 orchestrator e2e', () => {
|
||||
const gatewayUrl = `http://localhost:${gatewayServer.config.port}`;
|
||||
const gameUrl = `http://localhost:${apiPort}`;
|
||||
const adminSessionRef: { value?: string } = {};
|
||||
const adminGameAccessRef: { value?: string } = {};
|
||||
const accessTokenRef: { value?: string } = {};
|
||||
const gatewayClient = createGatewayClient(gatewayUrl, gatewayServer.config.trpcPath, adminSessionRef);
|
||||
const gameTrpcPath = process.env.GAME_TRPC_PATH ?? process.env.TRPC_PATH ?? '/trpc';
|
||||
const gameClientPublic = createGameClient(gameUrl, gameTrpcPath, { value: undefined });
|
||||
const gameClientAdmin = createGameClient(gameUrl, gameTrpcPath, adminGameAccessRef);
|
||||
const gameClientAuthed = createGameClient(gameUrl, gameTrpcPath, accessTokenRef);
|
||||
|
||||
const bootstrap = await gatewayClient.auth.bootstrapLocal.mutate({
|
||||
@@ -496,7 +504,16 @@ describe('pm2 orchestrator e2e', () => {
|
||||
|
||||
await waitForHttpOk(`${gameUrl}/healthz`);
|
||||
|
||||
await waitForTurnDaemonStatus(gameClientPublic);
|
||||
const adminGameSession = await gatewayClient.auth.issueGameSession.mutate({
|
||||
sessionToken: adminSessionRef.value ?? '',
|
||||
profile: profileName,
|
||||
});
|
||||
const adminAccess = await gameClientPublic.auth.exchangeGatewayToken.mutate({
|
||||
gatewayToken: adminGameSession.gameToken,
|
||||
});
|
||||
adminGameAccessRef.value = adminAccess.accessToken;
|
||||
|
||||
await waitForTurnDaemonStatus(gameClientAdmin);
|
||||
|
||||
const login = await gatewayClient.auth.login.mutate({
|
||||
username: 'e2e-user',
|
||||
@@ -547,7 +564,7 @@ describe('pm2 orchestrator e2e', () => {
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const runStatus = await runTurn(gameClientPublic);
|
||||
const runStatus = await runTurn(gameClientAdmin);
|
||||
expect(runStatus.lastRunAt).toBeTruthy();
|
||||
|
||||
const realtimeEvent = await ssePromise;
|
||||
|
||||
Reference in New Issue
Block a user