feat(gateway): split server lifecycle administration
This commit is contained in:
@@ -98,6 +98,38 @@ const installFixture = async (
|
||||
risk: 'CRITICAL',
|
||||
scope: 'GLOBAL',
|
||||
},
|
||||
{
|
||||
permission: 'admin.profiles.runtime',
|
||||
label: 'Profile 실행 관리',
|
||||
description: '실행 상태를 관리합니다.',
|
||||
risk: 'HIGH',
|
||||
scope: 'PROFILE',
|
||||
scopes: ['*'],
|
||||
},
|
||||
{
|
||||
permission: 'admin.profiles.settings',
|
||||
label: 'Profile 설정 관리',
|
||||
description: '설정을 관리합니다.',
|
||||
risk: 'HIGH',
|
||||
scope: 'PROFILE',
|
||||
scopes: ['*'],
|
||||
},
|
||||
{
|
||||
permission: 'admin.profiles.deploy',
|
||||
label: 'Profile 버전 배포',
|
||||
description: '버전을 배포합니다.',
|
||||
risk: 'CRITICAL',
|
||||
scope: 'PROFILE',
|
||||
scopes: ['*'],
|
||||
},
|
||||
{
|
||||
permission: 'admin.scenarios.reset',
|
||||
label: '시나리오 초기화',
|
||||
description: '시나리오를 초기화합니다.',
|
||||
risk: 'CRITICAL',
|
||||
scope: 'PROFILE',
|
||||
scopes: ['*'],
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (operation === 'admin.profiles.listScenarios') {
|
||||
@@ -212,7 +244,7 @@ const installFixture = async (
|
||||
test('reports clock-shift acceptance separately from actual application', async ({ page }) => {
|
||||
const fixture = await installFixture(page, { deferRequest: true, pendingProfileReads: 1 });
|
||||
await page.goto('admin/servers');
|
||||
await expect(page.getByRole('heading', { name: '서버 관리' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '서버 관리', level: 1 })).toBeVisible();
|
||||
|
||||
const duration = page.locator('input[type="number"][min="1"][max="1440"]');
|
||||
const accelerate = page.getByRole('button', { name: '가속', exact: true });
|
||||
@@ -291,13 +323,13 @@ test('renders an ignored terminal outcome without calling it applied', async ({
|
||||
await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('directs profile deployment to the centralized version page', async ({ page }) => {
|
||||
test('directs profile deployment to the selected server version tab', async ({ page }) => {
|
||||
await installFixture(page);
|
||||
await page.goto('admin/servers');
|
||||
|
||||
const releaseLink = page.getByRole('link', { name: '버전 업데이트 열기' });
|
||||
const releaseLink = page.getByRole('link', { name: '버전 업데이트', exact: true }).last();
|
||||
await expect(releaseLink).toBeVisible();
|
||||
await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/releases');
|
||||
await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/servers/hwe%3Adefault/version');
|
||||
await expect(page.getByRole('button', { name: '설치 적용' })).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
@@ -27,8 +27,7 @@ const login = async (page: Page, username: string, password: string): Promise<vo
|
||||
const hweRow = (page: Page) => page.locator('tbody tr').filter({ hasText: /^hwe섭/ });
|
||||
|
||||
const resetScenario = async (page: Page, scenarioId: string, sourceCommit: string): Promise<void> => {
|
||||
await page.goto('/gateway/admin/releases');
|
||||
await page.getByTestId('profile-select').selectOption('hwe:2');
|
||||
await page.goto('/gateway/admin/servers/hwe%3A2/scenario');
|
||||
await page.getByTestId('source-commit').check();
|
||||
await page.getByTestId('source-ref').fill(sourceCommit);
|
||||
await page.getByTestId('load-scenarios').click();
|
||||
|
||||
@@ -85,10 +85,9 @@ test('admin resets and opens hwe, then two users create generals and reach main'
|
||||
await login(page, adminUsername, await readPassword('admin'));
|
||||
await page.getByRole('link', { name: '관리자 페이지' }).click();
|
||||
await expect(page).toHaveURL(/\/gateway\/admin$/);
|
||||
await page.getByRole('link', { name: '버전 업데이트' }).first().click();
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
|
||||
await page.goto(`/gateway/admin/servers/${encodeURIComponent(profileKey)}/scenario`);
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/.+\/scenario$/);
|
||||
|
||||
await page.getByTestId('profile-select').selectOption(profileKey);
|
||||
const profileStatus = page.getByTestId('selected-profile-status');
|
||||
if (!skipReset) {
|
||||
await page.getByTestId('source-commit').check();
|
||||
|
||||
@@ -28,13 +28,30 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
|
||||
}
|
||||
if (
|
||||
operation === 'lobby.profiles' ||
|
||||
operation === 'admin.profiles.list' ||
|
||||
operation === 'admin.profiles.listScenarios' ||
|
||||
operation === 'admin.operations.list' ||
|
||||
operation === 'admin.releases.list'
|
||||
) {
|
||||
return response([]);
|
||||
}
|
||||
if (operation === 'admin.profiles.list') {
|
||||
return response(
|
||||
roles.some((role) => role.includes(':hwe:2'))
|
||||
? [
|
||||
{
|
||||
profileName: 'hwe:2',
|
||||
profile: 'hwe',
|
||||
scenario: '1010',
|
||||
status: 'RUNNING',
|
||||
buildStatus: 'SUCCEEDED',
|
||||
meta: { korName: '환상서버' },
|
||||
runtime: {},
|
||||
runtimeActions: [],
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
}
|
||||
if (operation === 'admin.releases.gatewayState') {
|
||||
return response({ id: 'gateway', updatedAt: '2026-08-01T00:00:00.000Z' });
|
||||
}
|
||||
@@ -42,7 +59,26 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
|
||||
return response({ enabled: true });
|
||||
}
|
||||
if (operation === 'admin.capabilities.list') {
|
||||
return response([]);
|
||||
return response(
|
||||
roles.includes('superuser')
|
||||
? [
|
||||
{ permission: 'admin.users.manage', scope: 'GLOBAL', scopes: ['*'] },
|
||||
{ permission: 'admin.profiles.runtime', scope: 'PROFILE', scopes: ['*'] },
|
||||
{ permission: 'admin.profiles.settings', scope: 'PROFILE', scopes: ['*'] },
|
||||
{ permission: 'admin.profiles.deploy', scope: 'PROFILE', scopes: ['*'] },
|
||||
{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['*'] },
|
||||
{ permission: 'admin.releases.manage', scope: 'GLOBAL', scopes: ['*'] },
|
||||
{ permission: 'admin.notice.manage', scope: 'GLOBAL', scopes: ['*'] },
|
||||
{ permission: 'admin.audit.read', scope: 'GLOBAL', scopes: ['*'] },
|
||||
]
|
||||
: [
|
||||
{
|
||||
permission: roles[0]?.split(':')[0],
|
||||
scope: 'PROFILE',
|
||||
scopes: ['hwe:2'],
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
throw new Error(`Unhandled tRPC operation: ${operation}`);
|
||||
});
|
||||
@@ -86,16 +122,16 @@ test('bootstrap superuser can navigate the administrator workspace from the lobb
|
||||
await writeFile(testInfo.outputPath('admin-overview-mobile-geometry.json'), JSON.stringify(geometry));
|
||||
await page.screenshot({ path: testInfo.outputPath('admin-overview-mobile-menu.png'), fullPage: true });
|
||||
|
||||
await navigation.getByRole('link', { name: '버전 업데이트' }).click();
|
||||
await navigation.getByRole('link', { name: 'Gateway 릴리스' }).click();
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
|
||||
await expect(page.getByRole('heading', { name: '버전 업데이트' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Gateway 릴리스' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('legacy server operations URL keeps query parameters and redirects to releases', async ({ page }) => {
|
||||
test('legacy server operations URL keeps query parameters and redirects to the server list', async ({ page }) => {
|
||||
await installGatewayFixture(page, ['superuser']);
|
||||
|
||||
await page.goto('admin/server-operations?operationId=legacy-operation');
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/releases\?operationId=legacy-operation$/);
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/servers\?operationId=legacy-operation$/);
|
||||
});
|
||||
|
||||
test('scoped administrators see the same navigation while ordinary users do not', async ({ browser }) => {
|
||||
@@ -104,6 +140,11 @@ test('scoped administrators see the same navigation while ordinary users do not'
|
||||
await installGatewayFixture(scopedPage, ['admin.profiles.manage:hwe:2']);
|
||||
await scopedPage.goto('lobby');
|
||||
await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
|
||||
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
|
||||
const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' });
|
||||
await expect(scopedNavigation.getByRole('link', { name: '환상서버 (hwe:2)' })).toBeVisible();
|
||||
await expect(scopedNavigation.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
|
||||
await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0);
|
||||
await scopedContext.close();
|
||||
|
||||
const userContext = await browser.newContext();
|
||||
|
||||
@@ -33,6 +33,7 @@ type FixtureState = {
|
||||
}>;
|
||||
runtimeRunning: boolean;
|
||||
requestBodies: Array<{ operation: string; body: unknown }>;
|
||||
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
|
||||
};
|
||||
|
||||
const profile = (runtimeRunning: boolean) => ({
|
||||
@@ -99,6 +100,18 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (name === 'admin.profiles.list') {
|
||||
return response([profile(state.runtimeRunning)]);
|
||||
}
|
||||
if (name === 'admin.capabilities.list') {
|
||||
return response(
|
||||
state.capabilities ?? [
|
||||
{ permission: 'admin.profiles.runtime', scope: 'PROFILE', scopes: ['*'] },
|
||||
{ permission: 'admin.profiles.settings', scope: 'PROFILE', scopes: ['*'] },
|
||||
{ permission: 'admin.profiles.deploy', scope: 'PROFILE', scopes: ['*'] },
|
||||
{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['*'] },
|
||||
{ permission: 'admin.reset.schedule', scope: 'PROFILE', scopes: ['*'] },
|
||||
{ permission: 'admin.releases.manage', scope: 'GLOBAL', scopes: ['*'] },
|
||||
]
|
||||
);
|
||||
}
|
||||
if (name === 'admin.operations.list') {
|
||||
return response(state.operations);
|
||||
}
|
||||
@@ -218,10 +231,11 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/releases');
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await expect(page.getByTestId('server-operations-page')).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
|
||||
await expect(page.getByTestId('source-help')).toContainText('실제로 시작될 때');
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/);
|
||||
await expect(page.getByTestId('source-current')).toBeChecked();
|
||||
await expect(page.getByTestId('source-help')).toContainText('현재 서버 커밋');
|
||||
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
|
||||
|
||||
const desktopGeometry = await page
|
||||
@@ -237,6 +251,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
});
|
||||
expect(desktopGeometry).toHaveLength(2);
|
||||
expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x);
|
||||
await page.getByTestId('source-commit').check();
|
||||
const sourceInput = page.getByTestId('source-ref');
|
||||
await sourceInput.focus();
|
||||
const focusedInputStyle = await sourceInput.evaluate((element) => {
|
||||
@@ -256,7 +271,6 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
);
|
||||
await page.screenshot({ path: testInfo.outputPath('desktop-operations.png'), fullPage: true });
|
||||
|
||||
await page.getByTestId('source-commit').check();
|
||||
await expect(page.getByTestId('source-help')).toContainText('전체 SHA로 고정');
|
||||
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
|
||||
await page.getByTestId('load-scenarios').click();
|
||||
@@ -288,31 +302,12 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('starts and stops all runtime roles through the operation controls', async ({ page }) => {
|
||||
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: false, requestBodies: [] };
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/releases');
|
||||
await page.getByTestId('start-server').click();
|
||||
await expect(page.getByText('시작 작업을 요청했습니다.')).toBeVisible();
|
||||
await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible();
|
||||
|
||||
await page.getByTestId('stop-server').click();
|
||||
await expect(page.getByText('정지 작업을 요청했습니다.')).toBeVisible();
|
||||
await expect(page.getByText('STOPPED', { exact: true }).first()).toBeVisible();
|
||||
|
||||
const serializedRequests = state.requestBodies.map((entry) => JSON.stringify(entry.body)).join('\n');
|
||||
expect(serializedRequests).toContain('"action":"START"');
|
||||
expect(serializedRequests).toContain('"action":"STOP"');
|
||||
});
|
||||
|
||||
test('separates DB-preserving profile deployment from DB reset', async ({ page }) => {
|
||||
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/releases');
|
||||
await page.goto('admin/servers/che%3A2/version');
|
||||
await expect(page.getByText('Game frontend')).toBeVisible();
|
||||
await page.getByTestId('request-deploy').click();
|
||||
|
||||
@@ -322,6 +317,33 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
|
||||
});
|
||||
|
||||
test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
operations: [],
|
||||
gatewayOperations: [],
|
||||
runtimeRunning: true,
|
||||
requestBodies: [],
|
||||
capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:2'] }],
|
||||
};
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await expect(page.getByTestId('source-current')).toBeChecked();
|
||||
await expect(page.getByTestId('source-branch')).toHaveCount(0);
|
||||
await expect(page.getByTestId('source-commit')).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
|
||||
await page.getByTestId('request-reset').click();
|
||||
await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible();
|
||||
await expect
|
||||
.poll(() => state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset'))
|
||||
.toBe(true);
|
||||
|
||||
const request = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset');
|
||||
expect(JSON.stringify(request?.body)).toContain('"sourceMode":"CURRENT"');
|
||||
expect(JSON.stringify(request?.body)).not.toContain('"sourceRef"');
|
||||
});
|
||||
|
||||
test('controls gateway deployment and rollback through the external controller queue', async ({ page }, testInfo) => {
|
||||
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
|
||||
await installFixture(page, state);
|
||||
@@ -375,7 +397,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/releases');
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await expect(page.getByText('FAILED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
|
||||
const failure = page.getByText(longError);
|
||||
|
||||
Reference in New Issue
Block a user