feat: 오프너의 천하통일 서버 정리를 허용한다
시나리오 초기화 권한 범위에서 실제 game DB의 통일 상태를 재검증한 뒤 완료 서버만 STOPPED로 정리한다. 런타임 전체 권한은 확대하지 않고 DB와 완료 기록을 보존한다.
This commit is contained in:
@@ -62,7 +62,8 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [
|
||||
{
|
||||
permission: 'admin.scenarios.reset',
|
||||
label: '시나리오 초기화',
|
||||
description: '지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화합니다.',
|
||||
description:
|
||||
'지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화하고, 천하통일 서버를 닫아 정리합니다.',
|
||||
risk: 'CRITICAL',
|
||||
scope: 'PROFILE',
|
||||
},
|
||||
@@ -123,6 +124,7 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
|
||||
if (path.endsWith('.profiles.requestAction') && rawInput && typeof rawInput === 'object') {
|
||||
const action = (rawInput as { action?: unknown }).action;
|
||||
if (action === 'RESET_SCHEDULED') return 'admin.reset.schedule';
|
||||
if (action === 'CLOSE_COMPLETED') return 'admin.scenarios.reset';
|
||||
if (action === 'RESUME') return 'admin.resume.when-stopped';
|
||||
if (action === 'UPDATE_RUNTIME_SETTINGS') return 'admin.profiles.runtime';
|
||||
if (action === 'OPEN_SURVEY') return 'admin.survey.open';
|
||||
|
||||
@@ -45,6 +45,7 @@ const zServerAction = z.enum([
|
||||
'RESUME',
|
||||
'PAUSE',
|
||||
'STOP',
|
||||
'CLOSE_COMPLETED',
|
||||
'ACCELERATE',
|
||||
'DELAY',
|
||||
'UPDATE_RUNTIME_SETTINGS',
|
||||
@@ -2227,6 +2228,7 @@ export const adminRouter = router({
|
||||
canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESUME_WHEN_STOPPED, profile.profileName);
|
||||
const canOpenSurvey =
|
||||
canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
|
||||
const canResetScenario = hasScopedPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, profile.profileName);
|
||||
|
||||
if (input.action === 'RESUME') {
|
||||
if (profile.status !== 'STOPPED' && profile.status !== 'PAUSED') {
|
||||
@@ -2257,6 +2259,28 @@ export const adminRouter = router({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Stop is allowed only while the profile runtime is available.',
|
||||
});
|
||||
} else if (input.action === 'CLOSE_COMPLETED') {
|
||||
if (!canResetScenario) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Scenario reset permission is required.',
|
||||
});
|
||||
}
|
||||
if (!gatewayProfileCapabilities(profile.status).runtimeExpected) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Completed cleanup is allowed only while the profile runtime is available.',
|
||||
});
|
||||
}
|
||||
const [runtimeSettings] =
|
||||
(await ctx.orchestrator.listRuntimeSettings?.([profile.profileName])) ?? [];
|
||||
const isUnited = profile.status === 'COMPLETED' || Number(runtimeSettings?.isUnited ?? 0) !== 0;
|
||||
if (!isUnited) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Only a unified game can be closed by a scenario opener.',
|
||||
});
|
||||
}
|
||||
} else if (input.action === 'OPEN_SURVEY') {
|
||||
if (!canOpenSurvey) {
|
||||
throw new TRPCError({
|
||||
@@ -2323,6 +2347,7 @@ export const adminRouter = router({
|
||||
RESUME: 'RUNNING',
|
||||
PAUSE: 'PAUSED',
|
||||
STOP: 'STOPPED',
|
||||
CLOSE_COMPLETED: 'STOPPED',
|
||||
SHUTDOWN: 'DISABLED',
|
||||
} as const;
|
||||
const mappedStatus = statusMap[input.action as keyof typeof statusMap];
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface ProfileRuntimeSnapshot extends ProfileRuntimeState {
|
||||
|
||||
export interface ProfileRuntimeSettingsSnapshot {
|
||||
profileName: string;
|
||||
/** Ref game_env.isunited compatibility value. Any non-zero value means unification has begun. */
|
||||
isUnited: number;
|
||||
turnTermMinutes: number;
|
||||
blockGeneralCreate: 0 | 1 | 2;
|
||||
autorunUser: {
|
||||
@@ -933,6 +935,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
);
|
||||
return {
|
||||
profileName,
|
||||
isUnited: Number(meta.isunited ?? meta.isUnited ?? 0),
|
||||
turnTermMinutes: Math.max(1, Math.round(row.tickSeconds / 60)),
|
||||
blockGeneralCreate,
|
||||
autorunUser:
|
||||
|
||||
@@ -31,6 +31,7 @@ const buildCaller = async (
|
||||
initialProfileStatus?: GatewayProfileRecord['status'];
|
||||
profileScenario?: string | null;
|
||||
profileMeta?: GatewayProfileRecord['meta'];
|
||||
gameIsUnited?: number;
|
||||
releaseCommitSha?: string;
|
||||
initialOperation?: GatewayOperationRecord;
|
||||
profileLogVisibilityAfterPolls?: number;
|
||||
@@ -291,6 +292,7 @@ const buildCaller = async (
|
||||
listRuntimeSettings: async () => [
|
||||
{
|
||||
profileName: 'che:2',
|
||||
isUnited: options.gameIsUnited ?? 0,
|
||||
turnTermMinutes: 20,
|
||||
blockGeneralCreate: 2,
|
||||
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
|
||||
@@ -405,6 +407,7 @@ describe('admin profile navigation API', () => {
|
||||
|
||||
expect(result[0]?.runtimeSettings).toEqual({
|
||||
profileName: 'che:2',
|
||||
isUnited: 0,
|
||||
turnTermMinutes: 20,
|
||||
blockGeneralCreate: 2,
|
||||
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
|
||||
@@ -1427,6 +1430,63 @@ describe('admin runtime clock action API', () => {
|
||||
expect(harness.getReconcileCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('lets a scoped scenario opener close a unified game without runtime authority', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation, {
|
||||
adminRoles: ['user', 'admin.scenarios.reset:che:2'],
|
||||
firstUserIsAdmin: false,
|
||||
initialProfileStatus: 'RUNNING',
|
||||
gameIsUnited: 2,
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.profiles.requestAction({
|
||||
profileName: 'che:2',
|
||||
action: 'CLOSE_COMPLETED',
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(harness.updatedStatuses).toEqual(['STOPPED']);
|
||||
expect(harness.getReconcileCount()).toBe(1);
|
||||
expect(harness.auditEvents.at(-1)).toMatchObject({ capability: 'admin.scenarios.reset' });
|
||||
});
|
||||
|
||||
it('does not let a scenario opener close a game before unification', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation, {
|
||||
adminRoles: ['user', 'admin.scenarios.reset:che:2'],
|
||||
firstUserIsAdmin: false,
|
||||
initialProfileStatus: 'RUNNING',
|
||||
gameIsUnited: 0,
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.profiles.requestAction({
|
||||
profileName: 'che:2',
|
||||
action: 'CLOSE_COMPLETED',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Only a unified game can be closed by a scenario opener.',
|
||||
});
|
||||
expect(harness.updatedStatuses).toEqual([]);
|
||||
expect(harness.getReconcileCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not turn runtime authority into scenario-opener cleanup authority', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation, {
|
||||
adminRoles: ['user', 'admin.profiles.runtime:che:2'],
|
||||
firstUserIsAdmin: false,
|
||||
initialProfileStatus: 'RUNNING',
|
||||
gameIsUnited: 2,
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.profiles.requestAction({
|
||||
profileName: 'che:2',
|
||||
action: 'CLOSE_COMPLETED',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
expect(harness.updatedStatuses).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates a first-class clock action owned by the authenticated administrator', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user