Repository navigation
Test Specs IV9
생성: 2026-10-13 · 기준: v07 ← Test Scenarios · IV 허브 · 기능 TC · TC-IAM
Playwright(TypeScript) spec 소스입니다. 앵커는 #<시나리오번호 소문자> 입니다. IV 프로젝트(이미 설치된 Playwright)의 tests/iv9/ 에 복사하고, 아래 IV9 전용 헬퍼 2개를 lib/ 에 추가하면 실행됩니다. 나머지 공용 헬퍼(ui.ts, fixtures.ts, data.ts, projects.ts 등)는 Test-Specs-Common.
npx playwright test tests/iv9/IV9_S13.spec.ts # 1건 (UI: 녹화 results/v07/recordings/v07_IV9_S13.webm)
npx playwright test tests/iv9 # 전체
IAM_URL=https://<iam 주소> npx playwright test tests/iv9/IV9_S06.spec.ts # API 시나리오의 IAM 주소 지정| 환경변수 | 설명 |
|---|---|
BASE_URL |
웹 콘솔 주소(UI 시나리오, playwright.config 기본값) |
IAM_URL |
mc-iam-manager 외부 주소(API 시나리오, iam nginx 443) |
ADMIN_ID / ADMIN_PW
|
플랫폼 관리자 계정(lib/data.ts) |
RUN_TAG |
결과·녹화 폴더 구분(기본 v07) |
| 파일 | 줄 | 해시 |
|---|---|---|
lib/iv9.ts |
236 | dc57737b |
lib/iam-api.ts |
39 | 03bb64c6 |
IV9_S01 tests/iv9/IV9_S01.spec.ts
|
46 | f240c994 |
IV9_S02 tests/iv9/IV9_S02.spec.ts
|
28 | 6a0b1399 |
IV9_S03 tests/iv9/IV9_S03.spec.ts
|
41 | 2c8b38a0 |
IV9_S04 tests/iv9/IV9_S04.spec.ts
|
32 | ab4f58aa |
IV9_S05 tests/iv9/IV9_S05.spec.ts
|
43 | e20d32ca |
IV9_S06 tests/iv9/IV9_S06.spec.ts
|
28 | 6338c449 |
IV9_S07 tests/iv9/IV9_S07.spec.ts
|
29 | 9eca8861 |
IV9_S08 tests/iv9/IV9_S08.spec.ts
|
26 | 897d2353 |
IV9_S09 tests/iv9/IV9_S09.spec.ts
|
28 | f8f709fe |
IV9_S10 tests/iv9/IV9_S10.spec.ts
|
35 | 9d6b4bf8 |
IV9_S11 tests/iv9/IV9_S11.spec.ts
|
30 | 35c03091 |
IV9_S12 tests/iv9/IV9_S12.spec.ts
|
44 | 84eaec86 |
IV9_S13 tests/iv9/IV9_S13.spec.ts
|
51 | 3679d729 |
IV9_S14 tests/iv9/IV9_S14.spec.ts
|
30 | f986b496 |
IV9_S15 tests/iv9/IV9_S15.spec.ts
|
28 | b70c5605 |
IV9_S16 tests/iv9/IV9_S16.spec.ts
|
75 | d615639a |
IV9_S17 tests/iv9/IV9_S17.spec.ts
|
29 | e0522bef |
IV9_S18 tests/iv9/IV9_S18.spec.ts
|
28 | a2527a2f |
IV9_S19 tests/iv9/IV9_S19.spec.ts
|
36 | 445db2d5 |
IV9_S20 tests/iv9/IV9_S20.spec.ts
|
72 | f5937c38 |
IV9_S21 tests/iv9/IV9_S21.spec.ts
|
76 | 7feaf7e8 |
lib/iv9.ts · 236줄 · 해시 dc57737b
import type { Locator, Page } from '@playwright/test';
import { test, expect } from './fixtures';
// IV9 (mc-iam-manager TC) 공용 헬퍼. 셀렉터는 2026-10-13 stage 프로브로 확인.
// - Workspaces 화면: 사이드 메뉴에 별도 항목이 없고 'Workspaces' 그룹의 href(/webconsole/operations/manage/workspaces)로 연다.
// 목록 #Workspaceslist-table(Tabulator, 1열 rowSelection 체크박스), 상단 'Add Workspace' → #workspace-modal-add
// 목록 카드 ⌄ 메뉴: Delete / Edit. Delete 는 체크된 행을 지우고 location.reload()
// 행 클릭 → 하단 #workspace-info-card 탭(Details / Projects / Members / Cost Analytics)
// Members 탭: + (addUserModalInit) → #user-modal-add (#user-modal-add-userselector TomSelect, #user-modal-add-roleselector)
// ⌄ 'Unassign Members' → #commonDefaultModal Confirm
// - Roles 화면: 행 클릭 → Role Detail, 'Add Role' → Create New Role(#role-name-input, #role-description-input, #save-create-role-btn)
// 목록 카드 ⌄ 메뉴 Delete → #commonDefaultModal('Would you like to delete this role ?')
export const URL_WORKSPACES = '/webconsole/operations/manage/workspaces';
export const URL_CLOUD_OVERVIEW = '/webconsole/settings/environment/cloudsps/cloudoverview';
/** 이 시나리오에서 만들고 지워도 되는 이름(그 외는 절대 삭제하지 않는다) */
export const IV9_WS = 'iv9ws';
export const IV9_WS2 = 'iv9ws2';
export const IV9_ROLE = 'iv9role';
const DELETABLE_WS = [IV9_WS, IV9_WS2];
const DELETABLE_ROLES = [IV9_ROLE];
/**
* 역할/멤버 할당에 쓰는 테스트 사용자.
* stage 에 녹화용 rec####a/b 사용자가 없어(2026-10-13 Users 목록 9명) 실제 사람이 아닌 테스트 계정 pending01(승인 대기, 역할 없음)을 쓴다.
* 금지 대상: mcmp, mzc-noh, iam01/workflow01/app01/monitor01/cost01/cost02.
*/
export const IV9_USER = { id: 'pending01', name: 'pending 01', email: 'pending01@mcmp.test' };
const exactName = (s: string) => new RegExp(`^\\s*${s}\\s*$`);
export const tabRow = (page: Page, tableSel: string, field: string, value: string): Locator =>
page.locator(`${tableSel} .tabulator-row`).filter({ has: page.locator(`[tabulator-field="${field}"]`, { hasText: exactName(value) }) });
export const toastTexts = async (page: Page) =>
(await page.locator('#toast-container .toast, [class*="toast"]').allInnerTexts()).map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
// ---------------- Workspaces ----------------
export const wsRow = (page: Page, name: string) => tabRow(page, '#Workspaceslist-table', 'name', name);
export async function gotoWorkspaces(page: Page) {
await page.goto(URL_WORKSPACES);
for (let attempt = 1; ; attempt++) {
if (await wsRow(page, 'ws01').first().waitFor({ timeout: 30_000 }).then(() => true, () => false)) break;
if (attempt >= 3) throw new Error('Workspaces 목록(ws01)이 90초 동안 표시되지 않음');
test.info().annotations.push({ type: 'list-retry', description: `시도 ${attempt}: Workspaces 목록 미표시 → 새로고침` });
await page.reload();
}
// 페이지 크기 최대(행이 2페이지로 밀리지 않도록)
const sizeSel = page.locator('#Workspaceslist-table select.tabulator-page-size, #Workspaceslist-table ~ * select.tabulator-page-size').first();
if (await sizeSel.count()) {
const vals = (await sizeSel.locator('option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).value))).filter((v) => /^\d+$/.test(v));
const max = vals.sort((a, b) => Number(b) - Number(a))[0];
if (max) await sizeSel.selectOption(max);
}
await page.waitForTimeout(1000);
}
/** Add Workspace 팝업으로 생성. 이미 있으면 false */
export async function createWorkspace(page: Page, name: string, description: string) {
if (await wsRow(page, name).count()) return false;
await page.getByRole('link', { name: 'Add Workspace' }).click();
const m = page.locator('#workspace-modal-add');
await expect(m).toBeVisible();
await m.locator('#workspace-modal-add-name').fill(name);
await m.locator('#workspace-modal-add-description').fill(description);
await m.getByRole('button', { name: 'Confirm' }).click();
await expect(m, 'Add Workspace 팝업 닫힘(생성 성공)').toBeHidden({ timeout: 30_000 });
return true;
}
/** 카드 안에서 해당 항목을 가진 ⌄ 드롭다운을 펼치고 항목 클릭(정렬 드롭다운 등 다른 ⌄ 와 구분) */
async function openDropdownItem(scope: Locator, item: RegExp) {
const dd = scope.locator('.dropdown').filter({ has: scope.page().locator('.dropdown-item', { hasText: item }) }).first();
await dd.locator('[data-bs-toggle="dropdown"]').first().click();
await dd.locator('.dropdown-item', { hasText: item }).first().click();
}
/** 목록 행을 체크(rowSelection)한다 */
async function checkRow(row: Locator) {
const cb = row.locator('input[type="checkbox"]').first();
if (!(await cb.isChecked())) await cb.click();
await expect(cb).toBeChecked();
}
/** 체크 → 목록 카드 ⌄ → Delete → Confirm. iv9ws/iv9ws2 만 허용 */
export async function deleteWorkspace(page: Page, name: string) {
if (!DELETABLE_WS.includes(name)) throw new Error(`삭제 금지 Workspace: ${name}`);
await gotoWorkspaces(page);
if (!(await wsRow(page, name).count())) return false;
// 다른 행이 체크돼 있지 않은지 확인(다른 Workspace 삭제 방지)
const checked = page.locator('#Workspaceslist-table .tabulator-row input[type="checkbox"]:checked');
expect(await checked.count(), '삭제 전 체크된 Workspace 없음').toBe(0);
await checkRow(wsRow(page, name).first());
expect(await checked.count(), `체크된 Workspace 는 ${name} 하나`).toBe(1);
const card = page.locator('.card').filter({ has: page.locator('#Workspaceslist-table') }).first();
await openDropdownItem(card, /^\s*Delete\s*$/);
const modal = page.locator('#commonDefaultModal');
await expect(modal).toBeVisible();
test.info().annotations.push({ type: 'confirm-modal', description: (await modal.innerText()).replace(/\s+/g, ' ').trim() });
const apis: string[] = [];
const onResp = async (r: import('@playwright/test').Response) => {
if (/workspace/i.test(r.url()) && r.request().method() !== 'GET' && r.request().method() !== 'OPTIONS')
apis.push(`${r.status()} ${r.request().method()} ${r.url().replace(/^https?:\/\/[^/]+/, '')} ${(r.request().postData() || '').slice(0, 120)} → ${(await r.text().catch(() => '')).slice(0, 200)}`);
};
page.on('response', onResp);
await modal.locator('#commonDefaultModal-confirm-btn').click();
await page.waitForLoadState('load');
await page.waitForTimeout(4000);
page.off('response', onResp);
test.info().annotations.push({ type: 'workspace-delete-api', description: apis.join(' | ') || '(요청 없음)' });
await gotoWorkspaces(page);
await expect(wsRow(page, name), `${name} 삭제됨`).toHaveCount(0, { timeout: 30_000 });
return true;
}
/** Workspace 행 클릭 → 하단 상세 카드 표시 */
export async function openWorkspace(page: Page, name: string) {
await wsRow(page, name).first().locator('[tabulator-field="name"]').click();
await expect(page.locator('#workspace-details-name')).toHaveText(exactName(name), { timeout: 20_000 });
}
/** Members 표의 사용자 행(role 을 주면 역할까지 일치). 주의: 2026-10-13 stage 에서 Members 표는 워크스페이스와 무관하게 전체 매핑을 보여 준다 */
export const memberRow = (page: Page, username: string, role?: string) => {
const r = tabRow(page, '#WorkspacesUserInfo-table', 'username', username);
return role ? r.filter({ has: page.locator('[tabulator-field="role"]', { hasText: exactName(role) }) }) : r;
};
export async function openMembersTab(page: Page) {
await page.locator('#workspace-info-card').getByRole('tab', { name: 'Members' }).click();
await page.waitForTimeout(2500);
// 페이지 크기 최대(기본 5행 → 다른 사용자가 2페이지로 밀림)
const sizeSel = page.locator('#workspace-members select.tabulator-page-size').first();
if (await sizeSel.count()) {
const vals = (await sizeSel.locator('option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).value))).filter((v) => /^\d+$/.test(v));
const max = vals.sort((a, b) => Number(b) - Number(a))[0];
if (max) await sizeSel.selectOption(max);
await page.waitForTimeout(800);
}
}
/** Members 표의 사용자 이름 목록 */
export const memberNames = async (page: Page) =>
(await page.locator('#WorkspacesUserInfo-table .tabulator-row [tabulator-field="username"]').allInnerTexts()).map((s) => s.trim());
/** Members 탭 + → 사용자·역할 선택 → Confirm */
export async function addMember(page: Page, username: string, role: string) {
await page.locator('#workspace-members a.btn-action[onclick*="addUserModalInit"]').click();
const m = page.locator('#user-modal-add');
await expect(m).toBeVisible({ timeout: 20_000 });
// TomSelect: 입력창 클릭 → 사용자 이름 입력 → 옵션 클릭
await m.locator('.ts-control').first().click();
await m.locator('.ts-control input').first().fill(username);
await page.locator('.ts-dropdown .option', { hasText: exactName(username) }).first().click();
await page.keyboard.press('Escape').catch(() => {});
// Escape 가 모달까지 닫으면 안 된다 → 모달 유지 확인
await expect(m).toBeVisible();
await m.locator('#user-modal-add-roleselector').selectOption({ label: role });
await m.getByRole('button', { name: 'Confirm' }).click();
await expect(m, 'Add/Assign user 팝업 닫힘').toBeHidden({ timeout: 30_000 });
}
/** Members 탭에서 해당 사용자 행만 체크 → ⌄ Unassign Members → Confirm */
export async function unassignMember(page: Page, username: string, role?: string) {
const forbidden = ['mcmp', 'mzc-noh', 'iam01', 'workflow01', 'app01', 'monitor01', 'cost01', 'cost02', 'infra01'];
if (forbidden.includes(username)) throw new Error(`해제 금지 사용자: ${username}`);
const checked = page.locator('#WorkspacesUserInfo-table .tabulator-row input[type="checkbox"]:checked');
expect(await checked.count(), 'Unassign 전 체크된 멤버 없음').toBe(0);
await checkRow(memberRow(page, username, role).first());
expect(await checked.count(), `체크된 멤버는 ${username} 하나`).toBe(1);
const pane = page.locator('#workspace-members');
await openDropdownItem(pane, /Unassign Members/);
const modal = page.locator('#commonDefaultModal');
await expect(modal).toBeVisible();
test.info().annotations.push({ type: 'confirm-modal', description: (await modal.innerText()).replace(/\s+/g, ' ').trim() });
const resp = page.waitForResponse((r) => r.url().includes('/api/mc-iam-manager/removeWorkspaceRole'), { timeout: 30_000 }).catch(() => null);
await modal.locator('#commonDefaultModal-confirm-btn').click();
const r = await resp;
const desc = r ? `${r.status()} ${r.request().postData()} → ${(await r.text().catch(() => '')).slice(0, 200)}` : '(요청 없음)';
test.info().annotations.push({ type: 'unassign-api', description: desc });
await page.waitForTimeout(2000);
return r?.ok() ?? false;
}
// ---------------- Roles ----------------
export const roleRow = (page: Page, name: string) => page.locator('#roles-table .tabulator-row').filter({ has: page.getByRole('gridcell', { name, exact: true }) });
export async function gotoRoles(page: Page, url: string) {
await page.goto(url);
for (let attempt = 1; ; attempt++) {
if (await roleRow(page, 'admin').first().waitFor({ timeout: 30_000 }).then(() => true, () => false)) break;
if (attempt >= 3) throw new Error('Roles 목록(admin)이 90초 동안 표시되지 않음');
test.info().annotations.push({ type: 'list-retry', description: `시도 ${attempt}: Roles 목록 미표시 → 새로고침` });
await page.reload();
}
const sizeSel = page.locator('select.tabulator-page-size').first();
if (await sizeSel.count()) {
const vals = (await sizeSel.locator('option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).value))).filter((v) => /^\d+$/.test(v));
const max = vals.sort((a, b) => Number(b) - Number(a))[0];
if (max) await sizeSel.selectOption(max);
}
await page.waitForTimeout(1000);
}
/** 역할 행 클릭 → Role Detail 표시 */
export async function openRole(page: Page, name: string) {
await roleRow(page, name).first().getByRole('gridcell', { name, exact: true }).click();
await expect(page.locator('#role-detail-rolename-text'), `Role Detail [${name}]`).toHaveText(name, { timeout: 20_000 });
}
/** 선택된 역할 Delete (iv9role 만 허용) */
export async function deleteRole(page: Page, url: string, name: string) {
if (!DELETABLE_ROLES.includes(name)) throw new Error(`삭제 금지 Role: ${name}`);
await gotoRoles(page, url);
if (!(await roleRow(page, name).count())) return false;
await openRole(page, name);
const card = page.locator('.card').filter({ has: page.locator('#roles-table') }).first();
await openDropdownItem(card, /^\s*Delete\s*$/);
const modal = page.locator('#commonDefaultModal');
await expect(modal).toBeVisible();
test.info().annotations.push({ type: 'confirm-modal', description: (await modal.innerText()).replace(/\s+/g, ' ').trim() });
// 확인 직전 Role Detail 이 여전히 삭제 대상인지 재확인
await expect(page.locator('#role-detail-rolename-text')).toHaveText(name);
await modal.locator('#commonDefaultModal-confirm-btn').click();
await expect(roleRow(page, name), `${name} 삭제됨`).toHaveCount(0, { timeout: 30_000 });
return true;
}
// ---------------- Users > User Info > Workspace 카드 ----------------
/** Users 화면에서 사용자 행 클릭 → User Info 표시 → Workspace 카드 이름 목록 반환 */
export async function userWorkspaces(page: Page, u: { name: string; email: string }) {
await page.getByRole('gridcell', { name: u.name, exact: true }).first().click();
await expect(page.getByText(`[${u.name}]`).first(), 'User Info 표시').toBeVisible({ timeout: 20_000 });
await page.waitForTimeout(2500); // Workspace 카드는 별도 요청으로 채워진다
const names = (await page.locator('#workspace-roles-names').innerText()).split('\n').map((s) => s.trim()).filter((s) => s && s !== '-');
return names;
}lib/iam-api.ts · 39줄 · 해시 03bb64c6
import { expect, request as pwRequest, type APIRequestContext, type TestInfo } from '@playwright/test';
import { ADMIN } from './data';
// mc-iam-manager REST API 헬퍼 (IV9 API 시나리오용)
// IAM_URL: 외부에서 접근하는 IAM 주소(iam nginx 443). 기본 stage. 계정은 lib/data.ts ADMIN(mcmp).
export const IAM_URL = process.env.IAM_URL ?? '<IAM 주소>';
export async function iamContext(): Promise<APIRequestContext> {
return pwRequest.newContext({ baseURL: IAM_URL, ignoreHTTPSErrors: true });
}
export type Tokens = { access_token: string; refresh_token: string; expires_in?: number };
export async function iamLogin(api: APIRequestContext, id = ADMIN.id, pw = ADMIN.pw): Promise<Tokens> {
const res = await api.post('/api/auth/login', { data: { id, password: pw } });
expect(res.status(), `로그인 실패 HTTP ${res.status()}`).toBe(200);
const t = await res.json();
expect(t.access_token, 'access_token 없음').toBeTruthy();
return t;
}
export const bearer = (t: Tokens | string) => ({ Authorization: `Bearer ${typeof t === 'string' ? t : t.access_token}` });
// 응답을 리포트에 첨부(토큰·비밀값은 가림)
export async function attachRes(testInfo: TestInfo, name: string, status: number, body: unknown) {
const text = JSON.stringify(body, null, 1).replace(/("(access_token|refresh_token|id_token|kc_client_secret|password)"\s*:\s*")[^"]*/g, '$1***');
await testInfo.attach(name, { body: `HTTP ${status}\n${text}`, contentType: 'text/plain' });
}
export async function call(api: APIRequestContext, testInfo: TestInfo, name: string, method: 'get' | 'post' | 'put' | 'delete', path: string, opts: { token?: Tokens | string; data?: unknown } = {}) {
const res = await api[method](path, { headers: opts.token ? bearer(opts.token) : {}, data: opts.data });
let body: any = null;
try { body = await res.json(); } catch { body = await res.text().catch(() => ''); }
await attachRes(testInfo, name, res.status(), body);
return { status: res.status(), body };
}
// JWT exp(초)
export const jwtExp = (token: string) => JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()).exp as number;tests/iv9/IV9_S01.spec.ts · 46줄 · 해시 f240c994 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { URL_CLOUD_OVERVIEW } from '../../lib/iv9';
// IV9_S01 : TC-IAM-001 서비스 상태 확인 (Cloud Overview > Readyz 영역 > 프레임워크별 Readyz 버튼)
// TC 대상 endpoint 는 GET /readyz. 화면의 Readyz 표에는 Readyz 버튼이 있는 프레임워크만 표시되며, 각 결과(OK/ERROR)를 기록한다.
// 화면 표시: 정상='✅ Ready'(=OK), 실패='❌ ERROR'(+ 사유 문구).
// 판정: 모든 Readyz 버튼이 결과(OK 또는 ERROR)를 표시하면 통과(각 프레임워크의 상태 자체는 기록 대상).
test('IV9_S01 TC-IAM-001 서비스 상태 확인 (Cloud Overview Readyz)', async ({ page }) => {
await test.step('1. admin 로그인', async () => {
await loginAsAdmin(page);
});
await test.step('2. 좌측 메뉴 Cloud SPs > Cloud Overview 이동', async () => {
await clickMenu(page, 'Cloud SPs', 'Cloud Overview');
await expect(page).toHaveURL(new RegExp(URL_CLOUD_OVERVIEW));
await expect(page.getByText('Framework health check and initialization')).toBeVisible({ timeout: 30_000 });
});
const rows = page.locator('tr[id^="readyz-row-"]').filter({ has: page.getByRole('button', { name: 'Readyz' }) });
const results: string[] = [];
await test.step('3. Readyz 영역에서 프레임워크별 Readyz 버튼 클릭 → OK/Error 표시 확인', async () => {
await expect(rows.first()).toBeVisible({ timeout: 30_000 });
const n = await rows.count();
expect(n, 'Readyz 버튼이 있는 프레임워크 수').toBeGreaterThan(0);
const allNames = (await page.locator('tr[id^="readyz-row-"] td.fw-medium').allInnerTexts()).map((s) => s.trim());
test.info().annotations.push({ type: 'readyz-frameworks', description: `표시된 프레임워크 ${allNames.length}개: ${allNames.join(', ')}` });
if (!allNames.includes('mc-iam-manager')) {
test.info().annotations.push({ type: 'note', description: 'Readyz 표에 mc-iam-manager 행이 없음(콘솔 자체가 iam 인증으로 동작 중 = 로그인 성공으로 간접 확인)' });
}
for (let i = 0; i < n; i++) {
const row = rows.nth(i);
const name = (await row.locator('td.fw-medium').innerText()).trim();
const status = row.locator(`[id="readyz-status-${name}"]`);
await row.getByRole('button', { name: 'Readyz' }).click();
await expect(status, `${name} 결과 표시`).toContainText(/Ready|ERROR/i, { timeout: 60_000 });
const text = (await row.innerText()).replace(/\s+/g, ' ').replace(name, '').trim();
const st = /Ready/i.test(await status.innerText()) ? 'OK(Ready)' : 'Error';
results.push(`${name}: ${st} (${text.slice(0, 120)})`);
}
});
await test.step('4. 프레임워크별 OK/Error 결과 기록', async () => {
for (const r of results) test.info().annotations.push({ type: 'readyz', description: r });
await page.locator('#readyz-row-mc-application-manager, tr[id^="readyz-row-"]').first().scrollIntoViewIfNeeded();
await page.waitForTimeout(2000);
expect(results.length).toBeGreaterThan(0);
});
});tests/iv9/IV9_S02.spec.ts · 28줄 · 해시 6a0b1399 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';
// IV9_S02 : 초기 관리자 계정 설정 (POST /api/initial-admin) — TC-IAM-002
// TC 문구의 POST /api/init 은 현재 경로 POST /api/initial-admin (인증 없이 호출, Keycloak realm·client·관리자 생성).
// 설치 후 mc-iam-manager-post-initial 이 기동할 때마다 같은 호출을 한다(멱등) → 설치된 서버에서는 재호출이 성공하고 기존 관리자가 유지되는지 확인한다.
test('IV9_S02 초기 관리자 계정 설정 (initial-admin 호출 성공 → 플랫폼 관리자 로그인·역할 유지)', async ({}, testInfo) => {
const api = await iamContext();
await test.step('1. POST /api/initial-admin (본문 없음) → 성공 응답', async () => {
const r = await call(api, testInfo, 'initial-admin', 'post', '/api/initial-admin', { data: {} });
expect(r.status, JSON.stringify(r.body).slice(0, 200)).toBe(200);
expect(r.body?.error ?? false).toBe(false);
});
const t = await test.step('2. 플랫폼 관리자(.env 의 PLATFORMADMIN) 로그인 성공', () => iamLogin(api));
await test.step('3. 관리자 토큰에 platformAdmin 역할 + 플랫폼 관리자 전용 API 호출 성공', async () => {
const claims = JSON.parse(Buffer.from(t.access_token.split('.')[1], 'base64url').toString());
expect(claims.realm_access?.roles ?? [], 'realm 역할에 platformAdmin 없음').toContain('platformAdmin');
// /api/setup/* 는 PlatformAdminMiddleware 로 보호된다
const r = await call(api, testInfo, 'check-user-roles', 'get', `/api/setup/check-user-roles?username=${claims.preferred_username}`, { token: t });
expect(r.status).toBe(200);
// 참고: GET /api/users/me/platform-roles 는 Keycloak realm 역할로 부여된 platformAdmin 을 반영하지 않는다(null)
const me = await call(api, testInfo, 'my-platform-roles', 'get', '/api/users/me/platform-roles', { token: t });
if (!me.body || (Array.isArray(me.body) && !me.body.length)) testInfo.annotations.push({ type: '개선 메모', description: '플랫폼 관리자(mcmp)의 /api/users/me/platform-roles 가 null — Keycloak realm 역할(platformAdmin)이 IAM 역할 조회에 반영되지 않음' });
});
});tests/iv9/IV9_S03.spec.ts · 41줄 · 해시 2c8b38a0 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';
// IV9_S03 : 역할·메뉴·API 권한 일괄 초기화 — TC-IAM-003
// TC 문구의 POST /api/init/platform 은 현재 없다. 같은 일을 하는 API(설치 스크립트 1_setup_auto.sh 와 같은 순서):
// POST /api/setup/initial-menus(메뉴 + 역할-메뉴 권한 연쇄 등록) → GET /api/setup/initial-role-menu-permission-yaml → POST /api/setup/sync-mcmp-apis
// initial-menus 는 메뉴가 이미 있으면 건너뛴다(force 없이 기존 데이터 보호) → 설치된 서버에서는 '건너뜀' 응답과 데이터 유지가 기대 결과.
test('IV9_S03 역할·메뉴·API 권한 일괄 초기화 (initial-menus · role-menu 권한 · mcmp-api 동기화 → 데이터 존재)', async ({}, testInfo) => {
const api = await iamContext();
const t = await iamLogin(api);
const count = async (path: string) => {
const r = await call(api, testInfo, path, 'post', path, { token: t, data: {} });
return Array.isArray(r.body) ? r.body.length : (r.body?.length ?? Object.keys(r.body ?? {}).length);
};
const before = { menus: await count('/api/menus/list'), roles: await count('/api/roles/list') };
await test.step('1. POST /api/setup/initial-menus → 성공(이미 있으면 건너뜀)', async () => {
const r = await call(api, testInfo, 'initial-menus', 'post', '/api/setup/initial-menus', { token: t, data: {} });
expect(r.status, JSON.stringify(r.body).slice(0, 200)).toBeLessThan(300);
});
await test.step('2. GET /api/setup/initial-role-menu-permission-yaml → 성공', async () => {
const r = await call(api, testInfo, 'role-menu-permission', 'get', '/api/setup/initial-role-menu-permission-yaml', { token: t });
expect(r.status, JSON.stringify(r.body).slice(0, 200)).toBeLessThan(300);
});
await test.step('3. POST /api/setup/sync-mcmp-apis → 성공', async () => {
const r = await call(api, testInfo, 'sync-mcmp-apis', 'post', '/api/setup/sync-mcmp-apis', { token: t, data: {} });
expect(r.status, JSON.stringify(r.body).slice(0, 200)).toBe(200);
});
await test.step('4. 역할·메뉴·MCMP API 데이터 존재, 기존 메뉴·역할 수 유지', async () => {
const after = { menus: await count('/api/menus/list'), roles: await count('/api/roles/list') };
const apis = await call(api, testInfo, 'mcmp-apis', 'post', '/api/mcmp-apis/list', { token: t, data: {} });
testInfo.annotations.push({ type: 'counts', description: `menus ${before.menus}→${after.menus}, roles ${before.roles}→${after.roles}, mcmp-apis HTTP ${apis.status}` });
expect(after.menus).toBeGreaterThan(0);
expect(after.roles).toBeGreaterThanOrEqual(before.roles);
expect(after.menus).toBeGreaterThanOrEqual(before.menus);
expect(apis.status).toBe(200);
});
});tests/iv9/IV9_S04.spec.ts · 32줄 · 해시 ab4f58aa · 시나리오 보기
import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';
// IV9_S04 : 프로젝트 동기화 — TC-IAM-004
// TC 문구의 POST /api/sync/projects 는 현재 POST /api/setup/sync-projects (mc-infra-manager namespace ↔ IAM project).
// 차이 조회(GET /api/setup/projects/sync-diff) → 동기화 → 차이 재조회로 확인한다.
// 응답: { missingProjects: [IAM 에 없는 namespace], unassignedProjects: [workspace 미할당 project] }
const missing = (d: any) => (d?.missingProjects ?? []).map((x: any) => x.nsId);
test('IV9_S04 프로젝트 동기화 (sync-diff → sync-projects → 차이 해소)', async ({}, testInfo) => {
const api = await iamContext();
const t = await iamLogin(api);
const before = await test.step('1. GET /api/setup/projects/sync-diff → 동기화 전 차이 조회', async () => {
const r = await call(api, testInfo, 'sync-diff-before', 'get', '/api/setup/projects/sync-diff', { token: t });
expect(r.status, JSON.stringify(r.body).slice(0, 200)).toBe(200);
return r.body;
});
await test.step('2. POST /api/setup/sync-projects → "Project synchronization successful"', async () => {
const r = await call(api, testInfo, 'sync-projects', 'post', '/api/setup/sync-projects', { token: t, data: {} });
expect(r.status, JSON.stringify(r.body).slice(0, 200)).toBe(200);
expect(r.body?.message).toContain('Project synchronization successful');
});
await test.step('3. sync-diff 재조회 → infra-manager 에만 있는 namespace 가 남지 않음', async () => {
const r = await call(api, testInfo, 'sync-diff-after', 'get', '/api/setup/projects/sync-diff', { token: t });
expect(r.status).toBe(200);
testInfo.annotations.push({ type: 'diff', description: `IAM 에 없는 namespace: 전 [${missing(before).join(', ')}] → 후 [${missing(r.body).join(', ')}], workspace 미할당 project 후 ${(r.body?.unassignedProjects ?? []).length}개` });
expect(missing(r.body), 'IAM 에 없는 namespace 가 남음').toEqual([]);
});
});tests/iv9/IV9_S05.spec.ts · 43줄 · 해시 e20d32ca · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { URL_ROLES } from '../../lib/forms';
// IV9_S05 : TC-IAM-010 로그아웃 (상단 프로필 버튼 > Logout)
// 2026-10-13 확인: Logout 클릭 → /auth/logout('Logout' + [Back to Login]) 화면. 로그아웃 후 콘솔 주소를 열면 /auth/unauthorized(#cookieError, [Back to Login]).
// 판정: 로그아웃 후 콘솔 화면에 다시 들어갈 수 없어야 하고(인증 화면으로 이동), 로그인 화면으로 돌아갈 수 있어야 한다.
test('IV9_S05 TC-IAM-010 로그아웃', async ({ page }) => {
await test.step('1. admin 로그인', async () => {
await loginAsAdmin(page);
await expect(page).toHaveURL(/\/webconsole\//);
});
await test.step('2. 상단 프로필 버튼 클릭 → 사용자 메뉴 표시', async () => {
await page.getByRole('link', { name: 'Open user menu' }).click();
await expect(page.locator('#logoutbtn')).toBeVisible();
});
await test.step('3. Logout 클릭 → 로그아웃 화면 표시', async () => {
await page.locator('#logoutbtn').click();
await page.waitForURL((u) => u.pathname.startsWith('/auth/'), { timeout: 30_000 });
test.info().annotations.push({ type: 'after-logout', description: new URL(page.url()).pathname });
await expect(page.getByRole('link', { name: 'Back to Login' }).or(page.getByPlaceholder('Your ID')).first()).toBeVisible();
});
await test.step('4. 로그인 화면으로 이동 확인', async () => {
const back = page.getByRole('link', { name: 'Back to Login' });
if (await back.isVisible().catch(() => false)) await back.click();
await expect(page).toHaveURL(/\/auth\/login/);
await expect(page.getByPlaceholder('Your ID')).toBeVisible();
await page.waitForTimeout(1500);
});
await test.step('5. 콘솔 주소(Roles) 직접 열기 → 콘솔 화면 차단, 로그인 필요 화면으로 이동', async () => {
await page.goto(URL_ROLES);
await page.waitForTimeout(3000);
const path = new URL(page.url()).pathname + new URL(page.url()).hash;
test.info().annotations.push({ type: 'console-url-after-logout', description: `${URL_ROLES} → ${path}` });
expect(new URL(page.url()).pathname, '로그아웃 후 콘솔 화면 접근 불가').toMatch(/^\/auth\//);
if (!/\/auth\/login/.test(page.url())) {
test.info().annotations.push({ type: 'note', description: `로그인 화면으로 바로 이동하지 않고 ${path} 화면 표시 → [Back to Login] 으로 로그인 화면 이동` });
await page.getByRole('link', { name: 'Back to Login' }).click();
}
await expect(page).toHaveURL(/\/auth\/login/);
await expect(page.getByPlaceholder('Your ID')).toBeVisible();
});
});tests/iv9/IV9_S06.spec.ts · 28줄 · 해시 6338c449 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin, jwtExp } from '../../lib/iam-api';
// IV9_S06 : 토큰 갱신 (POST /api/auth/refresh) — TC-IAM-011 (TC 문구의 /api/refresh 는 현재 /api/auth/refresh)
test('IV9_S06 토큰 갱신 (refresh_token → 새 access_token 발급, 잘못된 토큰은 401)', async ({}, testInfo) => {
const api = await iamContext();
const t = await test.step('1. 로그인 → access_token·refresh_token 발급', () => iamLogin(api));
const n = await test.step('2. POST /api/auth/refresh {refresh_token} → 200, 새 access_token', async () => {
await new Promise((r) => setTimeout(r, 1500)); // exp 가 달라지도록 1초 이상 간격
const r = await call(api, testInfo, 'refresh', 'post', '/api/auth/refresh', { data: { refresh_token: t.refresh_token } });
expect(r.status).toBe(200);
expect(r.body?.access_token).toBeTruthy();
expect(r.body.access_token).not.toBe(t.access_token);
expect(jwtExp(r.body.access_token)).toBeGreaterThanOrEqual(jwtExp(t.access_token));
return r.body;
});
await test.step('3. 새 access_token 으로 API 호출 성공 (GET /api/users/me)', async () => {
const r = await call(api, testInfo, 'me', 'get', '/api/users/me', { token: n.access_token });
expect(r.status).toBe(200);
});
await test.step('4. 잘못된 refresh_token → 401, 빈 값 → 400', async () => {
expect((await call(api, testInfo, 'refresh-bad', 'post', '/api/auth/refresh', { data: { refresh_token: 'invalid.token.value' } })).status).toBe(401);
expect((await call(api, testInfo, 'refresh-empty', 'post', '/api/auth/refresh', { data: {} })).status).toBe(400);
});
});tests/iv9/IV9_S07.spec.ts · 29줄 · 해시 9eca8861 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';
// IV9_S07 : 토큰 검증 및 만료 임박 자동 갱신 (POST /api/auth/validate) — TC-IAM-012 (TC 문구의 /api/validate 는 현재 /api/auth/validate)
// 요청: Authorization: Bearer <access_token> + 본문 {refresh_token}. 유효하면 valid:true, 만료됐으면 refresh 로 새 토큰과 valid:false(핸들러 구현).
// 2단계는 서명이 깨진 토큰으로 '검증 실패 → 자동 갱신' 경로를 확인한다(실제 만료는 access·refresh TTL 이 모두 1800초라 재현 불가).
test('IV9_S07 토큰 검증 (유효 토큰 valid:true · 무효 토큰은 refresh 로 재발급 · 헤더 없으면 401)', async ({}, testInfo) => {
const api = await iamContext();
const t = await iamLogin(api);
await test.step('1. 유효한 access_token 검증 → 200, valid:true', async () => {
const r = await call(api, testInfo, 'validate-ok', 'post', '/api/auth/validate', { token: t, data: { refresh_token: t.refresh_token } });
expect(r.status).toBe(200);
expect(r.body?.valid).toBe(true);
});
await test.step('2. 무효(만료 가정) access_token + 유효 refresh_token → 200, valid:false, 새 토큰 발급', async () => {
const broken = t.access_token.slice(0, -4) + 'AAAA';
const r = await call(api, testInfo, 'validate-refresh', 'post', '/api/auth/validate', { token: broken, data: { refresh_token: t.refresh_token } });
expect(r.status).toBe(200);
expect(r.body?.valid).toBe(false);
expect(JSON.stringify(r.body)).toContain('access_token');
});
await test.step('3. Authorization 헤더 없음 → 401, refresh_token 없음 → 400', async () => {
expect((await call(api, testInfo, 'validate-noauth', 'post', '/api/auth/validate', { data: { refresh_token: t.refresh_token } })).status).toBe(401);
expect((await call(api, testInfo, 'validate-norefresh', 'post', '/api/auth/validate', { token: t, data: {} })).status).toBe(400);
});
});tests/iv9/IV9_S08.spec.ts · 26줄 · 해시 897d2353 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { URL_USERS } from '../../lib/forms';
import { gotoUsersAll } from '../../lib/groups';
// IV9_S08 : TC-IAM-014 사용자 목록 조회 (Organizations > Users)
test('IV9_S08 TC-IAM-014 사용자 목록 조회', async ({ page }) => {
await test.step('1. admin 로그인', async () => {
await loginAsAdmin(page);
});
await test.step('2. 좌측 메뉴 Organizations > Users 이동', async () => {
await clickMenu(page, 'Organizations', 'Users');
await expect(page).toHaveURL(new RegExp(URL_USERS));
await expect(page.getByText('List of Users')).toBeVisible({ timeout: 30_000 });
});
await test.step('3. 사용자 목록 표시 확인(전체 행 수, admin mcmp 포함)', async () => {
await gotoUsersAll(page); // 목록이 늦게 뜨는 경우 재시도 + 페이지 크기 최대
const rows = page.locator('.tabulator-row');
const n = await rows.count();
const names = (await rows.allInnerTexts()).map((s) => s.replace(/\s+/g, ' ').trim());
test.info().annotations.push({ type: 'users', description: `${n}명: ${names.join(' / ').slice(0, 900)}` });
expect(n, '사용자 1명 이상').toBeGreaterThan(0);
// admin mcmp: 화면에는 이름 'mcmp iammanager' / 이메일 iammanager@cloud-barista.org 로 표시
await expect(rows.filter({ hasText: /mcmp/ }).first(), 'admin mcmp 행').toBeVisible();
});
});tests/iv9/IV9_S09.spec.ts · 28줄 · 해시 f8f709fe · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { gotoUsersAll } from '../../lib/groups';
import { IV9_USER } from '../../lib/iv9';
// IV9_S09 : TC-IAM-015 사용자 상세 조회 (Users > 사용자 행 클릭 > User Info)
// 대상: 테스트 계정 pending01 (조회만, 변경 없음)
test('IV9_S09 TC-IAM-015 사용자 상세 조회', async ({ page }) => {
const u = IV9_USER;
await test.step('1. admin 로그인 후 Organizations > Users 이동', async () => {
await loginAsAdmin(page);
await clickMenu(page, 'Organizations', 'Users');
await gotoUsersAll(page);
});
await test.step(`2. 사용자 행(${u.id}) 클릭`, async () => {
await page.getByRole('gridcell', { name: u.name, exact: true }).first().click();
});
await test.step('3. User Info 영역에 사용자 정보(이름/이메일/상태/역할) 표시 확인', async () => {
const info = page.locator('.card').filter({ hasText: 'User Info' }).filter({ hasText: u.email }).last();
await expect(page.getByText(`[${u.name}]`).first(), 'User Info 제목에 사용자 표시').toBeVisible({ timeout: 20_000 });
await expect(info, 'User Info 에 이메일 표시').toContainText(u.email);
const text = (await info.innerText()).replace(/\s+/g, ' ');
test.info().annotations.push({ type: 'user-info', description: text.slice(0, 600) });
expect(text).toMatch(/First Name/);
expect(text).toMatch(/Status/);
await page.waitForTimeout(1500);
});
});tests/iv9/IV9_S10.spec.ts · 35줄 · 해시 9d6b4bf8 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { gotoUsersAll } from '../../lib/groups';
// IV9_S10 : TC-IAM-018 사용자 동기화
// mc-iam-manager 에 사용자 동기화 API 경로가 없다. Users 화면에 동기화 기능(버튼/메뉴)이 있는지 확인하고, 없으면 근거(스크린샷)를 남기고 SKIP.
test('IV9_S10 TC-IAM-018 사용자 동기화', async ({ page }) => {
await test.step('1. admin 로그인 후 Organizations > Users 이동', async () => {
await loginAsAdmin(page);
await clickMenu(page, 'Organizations', 'Users');
await gotoUsersAll(page);
});
let found: string[] = [];
await test.step('2. Users 화면의 버튼/메뉴에서 사용자 동기화(Sync) 기능 찾기', async () => {
// 목록 카드의 ⌄ 메뉴가 있으면 펼쳐서 메뉴 항목까지 확인
const toggles = page.locator('.card-header [data-bs-toggle="dropdown"]');
for (let i = 0; i < (await toggles.count()); i++) {
await toggles.nth(i).click().catch(() => {});
await page.waitForTimeout(500);
}
const labels = await page.evaluate(() =>
[...document.querySelectorAll('button, a.btn, .btn-action, .dropdown-item, [role=button]')]
.filter((e) => !(e as HTMLElement).closest('aside, .navbar-vertical'))
.map((e) => `${(e.textContent || '').trim().replace(/\s+/g, ' ')}|${e.getAttribute('title') || ''}|${e.getAttribute('onclick') || ''}`),
);
test.info().annotations.push({ type: 'users-controls', description: [...new Set(labels.map((l) => l.split('|')[0] || l))].filter(Boolean).join(', ').slice(0, 800) });
found = labels.filter((l) => /sync/i.test(l));
await page.keyboard.press('Escape');
await page.screenshot({ path: test.info().outputPath('users-no-sync.png'), fullPage: true });
await test.info().attach('users-no-sync.png', { path: test.info().outputPath('users-no-sync.png'), contentType: 'image/png' });
});
test.info().annotations.push({ type: 'sync-ui', description: found.length ? `발견: ${found.join(' / ')}` : 'Users 화면에 사용자 동기화(Sync) 버튼/메뉴 없음' });
test.skip(found.length === 0, 'Users 화면에 사용자 동기화 UI 없음 + mc-iam-manager 에 사용자 동기화 API 경로 없음(기능 미제공)');
expect(found.length).toBeGreaterThan(0); // UI 가 생기면 이후 단계를 추가해야 함
});tests/iv9/IV9_S11.spec.ts · 30줄 · 해시 35c03091 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';
// IV9_S11 : 회사(법인) 등록 (POST /api/company) — TC-IAM-019
// 회사는 시스템에 1개(realm_name 유일). 설치(post-initial) 때 'Default Company'(realm mciam)가 등록된다.
// 설치된 서버: 등록 결과 조회 + 같은 realm 으로 다시 등록하면 409(중복 거부). 회사가 없는 서버에서만 실제 등록을 수행한다.
test('IV9_S11 회사(법인) 등록 (필수값 검증 400 · 등록 정보 조회 · 중복 등록 409)', async ({}, testInfo) => {
const api = await iamContext();
const t = await iamLogin(api);
await test.step('1. 필수값(realm_name 등) 없이 등록 → 400', async () => {
const r = await call(api, testInfo, 'create-missing', 'post', '/api/company', { token: t, data: { name: 'iv9-company' } });
expect(r.status).toBe(400);
});
const company = await test.step('2. GET /api/company → 등록된 회사 정보(이름·realm·status active)', async () => {
const r = await call(api, testInfo, 'get-company', 'get', '/api/company', { token: t });
expect(r.status, '등록된 회사 없음(설치 초기화 미완료)').toBe(200);
expect(r.body?.realm_name).toBeTruthy();
expect(r.body?.status).toBe('active');
return r.body;
});
await test.step(`3. 같은 realm(${company.realm_name})으로 다시 등록 → 409 중복 거부`, async () => {
const r = await call(api, testInfo, 'create-dup', 'post', '/api/company', { token: t, data: { name: 'iv9-dup', realm_name: company.realm_name, kc_client_id: company.kc_client_id, kc_client_secret: 'iv9-dummy' } });
expect(r.status).toBe(409);
const after = await call(api, testInfo, 'get-company-after', 'get', '/api/company', { token: t });
expect(after.body?.name, '기존 회사 정보가 바뀜').toBe(company.name);
});
});tests/iv9/IV9_S12.spec.ts · 44줄 · 해시 84eaec86 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';
// IV9_S12 : 회사 정보 수정·비활성화·활성화 (PUT / DELETE / POST /activate /api/company) — TC-IAM-020
// 설명만 바꿨다가 원래 값으로 되돌리고, 비활성화 직후 다시 활성화한다(회사 상태는 로그인 등에 쓰이지 않음 — 2026-10-13 소스 확인).
test('IV9_S12 회사 정보 수정·비활성화·활성화 (수정 → inactive → active, 원래 값 복원)', async ({}, testInfo) => {
const api = await iamContext();
const t = await iamLogin(api);
const orig = (await call(api, testInfo, 'get-orig', 'get', '/api/company', { token: t })).body;
expect(orig?.name, '등록된 회사 없음').toBeTruthy();
const stamp = `iv9 edited ${Date.now()}`;
try {
await test.step('1. PUT /api/company (설명 변경) → 200, 조회 결과 반영', async () => {
const r = await call(api, testInfo, 'update', 'put', '/api/company', { token: t, data: { name: orig.name, description: stamp } });
expect(r.status).toBe(200);
expect((await call(api, testInfo, 'get-updated', 'get', '/api/company', { token: t })).body?.description).toBe(stamp);
});
await test.step('2. PUT name 없이 → 400', async () => {
expect((await call(api, testInfo, 'update-noname', 'put', '/api/company', { token: t, data: { description: 'x' } })).status).toBe(400);
});
await test.step('3. DELETE /api/company → status inactive', async () => {
const r = await call(api, testInfo, 'deactivate', 'delete', '/api/company', { token: t });
expect(r.status).toBe(200);
expect((await call(api, testInfo, 'get-inactive', 'get', '/api/company', { token: t })).body?.status).toBe('inactive');
});
await test.step('4. POST /api/company/activate → status active', async () => {
const r = await call(api, testInfo, 'activate', 'post', '/api/company/activate', { token: t, data: {} });
expect(r.status).toBe(200);
expect((await call(api, testInfo, 'get-active', 'get', '/api/company', { token: t })).body?.status).toBe('active');
});
} finally {
await test.step('5. 정리: 원래 설명·활성 상태로 복원', async () => {
await call(api, testInfo, 'restore', 'put', '/api/company', { token: t, data: { name: orig.name, description: orig.description ?? '' } });
await call(api, testInfo, 'restore-activate', 'post', '/api/company/activate', { token: t, data: {} });
const now = (await call(api, testInfo, 'get-restored', 'get', '/api/company', { token: t })).body;
expect(now?.description ?? '').toBe(orig.description ?? '');
expect(now?.status).toBe('active');
});
}
});tests/iv9/IV9_S13.spec.ts · 51줄 · 해시 3679d729 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { URL_ROLES } from '../../lib/forms';
import { IV9_ROLE, deleteRole, gotoRoles, openRole, roleRow } from '../../lib/iv9';
// IV9_S13 : TC-IAM-033 역할 생성 (Roles > Add Role > Create New Role > Role Name/Description > Save Role)
// 테스트 전용 역할 iv9role 을 만들고, 마지막 정리 단계에서 삭제한다(실패해도 finally 로 삭제 시도).
test('IV9_S13 TC-IAM-033 역할 생성', async ({ page, dialogs }) => {
const desc = `iv9 test role ${Date.now()}`;
try {
await test.step('1. admin 로그인 후 Workspaces > Roles 이동', async () => {
await loginAsAdmin(page);
await clickMenu(page, 'Workspaces', 'Roles');
await gotoRoles(page, URL_ROLES);
if (await roleRow(page, IV9_ROLE).count()) {
test.info().annotations.push({ type: 'preexisting', description: `${IV9_ROLE} 잔존(이전 실행) → 먼저 삭제` });
await deleteRole(page, URL_ROLES, IV9_ROLE);
}
});
await test.step('2. Add Role 클릭 → Create New Role 영역 표시', async () => {
await page.getByRole('link', { name: 'Add Role' }).click();
await expect(page.locator('#create_role')).toBeVisible();
await expect(page.getByText('Create New Role')).toBeVisible();
});
await test.step(`3. Role Name(${IV9_ROLE}) · Description 입력`, async () => {
await page.locator('#role-name-input').fill(IV9_ROLE);
await page.locator('#role-description-input').fill(desc);
});
await test.step('4. Save Role 클릭 → 저장 결과 확인', async () => {
const before = dialogs.length;
await page.locator('#save-create-role-btn').click();
await expect.poll(() => dialogs.slice(before).join(' | '), { timeout: 30_000 }).not.toBe('');
test.info().annotations.push({ type: 'save-alert', description: dialogs.slice(before).join(' | ') });
expect(dialogs.slice(before).join(' | ')).toMatch(/created successfully/i);
});
await test.step(`5. 역할 목록에 ${IV9_ROLE} 표시 · 상세 확인`, async () => {
await gotoRoles(page, URL_ROLES);
await expect(roleRow(page, IV9_ROLE).first(), `목록에 ${IV9_ROLE}`).toBeVisible({ timeout: 30_000 });
await openRole(page, IV9_ROLE);
await expect(page.locator('#role-detail-desc-view')).toContainText(desc);
await page.waitForTimeout(1500);
});
} finally {
await test.step(`6. (정리) ${IV9_ROLE} 삭제`, async () => {
const deleted = await deleteRole(page, URL_ROLES, IV9_ROLE);
test.info().annotations.push({ type: 'cleanup', description: deleted ? `${IV9_ROLE} 삭제 완료` : `${IV9_ROLE} 없음(삭제 불필요)` });
await gotoRoles(page, URL_ROLES);
await expect(roleRow(page, IV9_ROLE), `${IV9_ROLE} 정리됨`).toHaveCount(0);
});
}
});tests/iv9/IV9_S14.spec.ts · 30줄 · 해시 f986b496 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { URL_ROLES } from '../../lib/forms';
import { gotoRoles, roleRow } from '../../lib/iv9';
// IV9_S14 : TC-IAM-034 역할 목록 조회 (Workspaces > Roles, Refresh)
// 기본 역할 5종(admin, operator, viewer, billadmin, billviewer) 표시 확인. 2026-10-13: 목록은 트리(계층)가 아닌 표 형태로 표시됨 → 기록.
const BASE_ROLES = ['admin', 'operator', 'viewer', 'billadmin', 'billviewer'];
test('IV9_S14 TC-IAM-034 역할 목록 조회', async ({ page }) => {
await test.step('1. admin 로그인', async () => {
await loginAsAdmin(page);
});
await test.step('2. 좌측 메뉴 Workspaces > Roles 이동', async () => {
await clickMenu(page, 'Workspaces', 'Roles');
await expect(page).toHaveURL(new RegExp(URL_ROLES));
await gotoRoles(page, URL_ROLES);
});
await test.step('3. 목록 Refresh 클릭', async () => {
await page.locator('a.btn-action[onclick*="refreshRolesList"]').click();
await page.waitForTimeout(3000);
await expect(roleRow(page, 'admin').first()).toBeVisible({ timeout: 30_000 });
});
await test.step('4. 역할 목록 표시 확인(admin, operator, viewer, billadmin, billviewer)', async () => {
for (const r of BASE_ROLES) await expect(roleRow(page, r).first(), `역할 ${r}`).toBeVisible();
const rows = (await page.locator('#roles-table .tabulator-row').allInnerTexts()).map((s) => s.replace(/\s+/g, ' ').trim());
const tree = await page.locator('#roles-table .tabulator-data-tree-control, #roles-table .tabulator-data-tree-branch').count();
test.info().annotations.push({ type: 'roles', description: `${rows.length}개: ${rows.join(' / ')}` });
test.info().annotations.push({ type: 'display', description: tree ? `계층(트리) 표시: 트리 컨트롤 ${tree}개` : '계층(트리) 표시 아님 — 평면 표(Role Master ID / Name / Platform access / Workspace access / CSP Role)' });
});
});tests/iv9/IV9_S15.spec.ts · 28줄 · 해시 b70c5605 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { URL_ROLES } from '../../lib/forms';
import { gotoRoles, openRole } from '../../lib/iv9';
// IV9_S15 : TC-IAM-035 역할 상세 조회 (Roles > 역할 클릭 > Role Detail). 조회만, 변경 없음.
test('IV9_S15 TC-IAM-035 역할 상세 조회', async ({ page }) => {
await test.step('1. admin 로그인 후 Workspaces > Roles 이동', async () => {
await loginAsAdmin(page);
await clickMenu(page, 'Workspaces', 'Roles');
await gotoRoles(page, URL_ROLES);
});
await test.step('2. 목록에서 역할(viewer) 클릭', async () => {
await openRole(page, 'viewer');
});
await test.step('3. 하단 Role Detail 정보(Name, Description, Platform/Workspace access, CSP Role) 표시 확인', async () => {
const card = page.locator('#role-detail-card');
await expect(card).toBeVisible();
await expect(page.locator('#role-detail-name-view')).toHaveText('viewer');
const text = (await card.innerText()).replace(/\s+/g, ' ');
test.info().annotations.push({ type: 'role-detail', description: text.slice(0, 500) });
expect(text).toMatch(/Platform access/);
expect(text).toMatch(/Workspace access/);
expect(text).toMatch(/CSP Role/);
await card.scrollIntoViewIfNeeded();
await page.waitForTimeout(1500);
});
});tests/iv9/IV9_S16.spec.ts · 75줄 · 해시 d615639a · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { WORKSPACE } from '../../lib/data';
import { gotoUsersAll } from '../../lib/groups';
import { IV9_USER, gotoWorkspaces, memberRow, openMembersTab, openWorkspace, unassignMember, userWorkspaces } from '../../lib/iv9';
// IV9_S16 : TC-IAM-038 사용자에게 워크스페이스 역할 할당 (Users > 사용자 선택 > User Info > Workspace > Actions + > 역할 선택)
// 대상: 테스트 계정 pending01 (stage 에 rec####a/b 사용자가 없어 대체, 2026-10-13). ws01 에 viewer 를 할당하고 마지막에 해제한다.
// 2026-10-13 확인: Add Role Mapping 팝업에는 Workspace 선택란이 없고, 상단 Workspace 선택값(ws01)에 할당된다.
// Users 화면 Workspace 카드의 삭제(휴지통) 버튼은 API 를 호출하지 않는 미구현 기능(소스 TODO) → 정리는 Workspaces > ws01 > Members > Unassign Members 로 한다.
const ROLE = 'viewer';
test('IV9_S16 TC-IAM-038 사용자에게 워크스페이스 역할 할당', async ({ page, dialogs }) => {
const u = IV9_USER;
let assignedByTest = false;
try {
await test.step(`1. admin 로그인(상단 Workspace=${WORKSPACE}) 후 Organizations > Users 이동`, async () => {
await loginAsAdmin(page);
await expect(page.locator('#select-current-workspace')).toHaveValue(/.+/);
await clickMenu(page, 'Organizations', 'Users');
await gotoUsersAll(page);
});
let before: string[] = [];
await test.step(`2. 사용자(${u.id}) 선택 → User Info 의 Workspace 영역 확인`, async () => {
before = await userWorkspaces(page, u);
test.info().annotations.push({ type: 'workspaces-before', description: `${u.id}: ${before.join(', ') || '(없음)'}` });
});
if (before.includes(WORKSPACE)) {
test.info().annotations.push({ type: 'preexisting', description: `${u.id} 는 이미 ${WORKSPACE} 역할 보유 → 할당 생략(해제도 하지 않음)` });
} else {
await test.step(`3. Workspace 영역 Actions + 클릭 → Role Type=Workspace Role, Role=${ROLE} 선택 → Add Role`, async () => {
await page.locator('#workspace-roles-actions button[title="Add Workspace role"]').click();
const m = page.locator('#add-role-mapping-modal');
await expect(m).toBeVisible();
await expect(m.locator('#role-mapping-type')).toHaveValue('workspace');
await m.locator('#role-mapping-role').selectOption({ label: ROLE });
const n = dialogs.length;
const apis: string[] = [];
const onResp = async (r: import('@playwright/test').Response) => {
if (r.url().includes('/api/mc-iam-manager/') && r.request().method() === 'POST' && /role/i.test(r.url()) && !/list/i.test(r.url()))
apis.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')} ${(r.request().postData() || '').slice(0, 150)}`);
};
page.on('response', onResp);
assignedByTest = true; // 클릭 이후에는 결과와 관계없이 정리 단계를 수행
await m.getByRole('button', { name: 'Add Role' }).click();
await expect(m, 'Add Role Mapping 팝업 닫힘(저장 완료)').toBeHidden({ timeout: 30_000 });
await page.waitForTimeout(2000);
page.off('response', onResp);
test.info().annotations.push({ type: 'add-role-api', description: apis.join(' | ') || '(요청 없음)' });
// 화면 소스의 성공 알림(Utils.showAlert)은 빈 함수라 메시지가 표시되지 않는다(2026-10-13 확인) → 기록만
test.info().annotations.push({ type: 'add-message', description: dialogs.slice(n).join(' | ') || '(성공 메시지 표시 없음)' });
});
}
await test.step(`4. Workspace 영역에 ${WORKSPACE} 표시 확인(목록 다시 열어 저장 확인)`, async () => {
await gotoUsersAll(page);
const after = await userWorkspaces(page, u);
test.info().annotations.push({ type: 'workspaces-after', description: `${u.id}: ${after.join(', ') || '(없음)'}` });
expect(after, `${u.id} Workspace 에 ${WORKSPACE}`).toContain(WORKSPACE);
await page.locator('#workspace-roles-names').scrollIntoViewIfNeeded();
await page.waitForTimeout(1500);
});
} finally {
if (assignedByTest) {
await test.step(`5. (정리) Workspaces > ${WORKSPACE} > Members 에서 ${u.id}(${ROLE}) Unassign → Users 에서 해제 확인`, async () => {
await gotoWorkspaces(page);
await openWorkspace(page, WORKSPACE);
await openMembersTab(page);
if (await memberRow(page, u.id, ROLE).count()) await unassignMember(page, u.id, ROLE);
await gotoUsersAll(page);
const ws = await userWorkspaces(page, u);
test.info().annotations.push({ type: 'cleanup', description: `${u.id} Workspace(정리 후): ${ws.join(', ') || '(없음)'}` });
expect(ws, `${u.id} 의 ${WORKSPACE} 역할 해제됨`).not.toContain(WORKSPACE);
});
}
}
});tests/iv9/IV9_S17.spec.ts · 29줄 · 해시 e0522bef · 시나리오 보기
import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';
// IV9_S17 : MCMP API 동기화 (POST /api/setup/sync-mcmp-apis) — TC-IAM-048
// api.yaml(프레임워크별 서비스·액션 정의)을 IAM DB 로 동기화한다. 설치 스크립트가 기동마다 호출하는 멱등 동작.
test('IV9_S17 MCMP API 동기화 (sync-mcmp-apis → 서비스·액션 목록 존재·유지)', async ({}, testInfo) => {
const api = await iamContext();
const t = await iamLogin(api);
// 응답: { Services: {서비스명: {Version, BaseURL, Auth}}, ServiceActions: {서비스명: {액션명: {...}}} }
const summary = (b: any) => ({ services: Object.keys(b?.Services ?? {}).length, actions: Object.values(b?.ServiceActions ?? {}).reduce((n: number, a: any) => n + Object.keys(a ?? {}).length, 0) });
const before = summary((await call(api, testInfo, 'list-before', 'post', '/api/mcmp-apis/list', { token: t, data: {} })).body);
await test.step('1. POST /api/setup/sync-mcmp-apis → "Successfully triggered MCMP API sync"', async () => {
const r = await call(api, testInfo, 'sync', 'post', '/api/setup/sync-mcmp-apis', { token: t, data: {} });
expect(r.status, JSON.stringify(r.body).slice(0, 200)).toBe(200);
expect(r.body?.message).toContain('MCMP API sync');
});
await test.step('2. POST /api/mcmp-apis/list → 서비스·액션 목록 존재(동기화 전 이상)', async () => {
const r = await call(api, testInfo, 'list-after', 'post', '/api/mcmp-apis/list', { token: t, data: {} });
expect(r.status).toBe(200);
const after = summary(r.body);
testInfo.annotations.push({ type: 'mcmp-apis', description: `서비스 ${before.services} → ${after.services}, 액션 ${before.actions} → ${after.actions}` });
expect(after.services).toBeGreaterThan(0);
expect(after.actions).toBeGreaterThan(0);
expect(after.services).toBeGreaterThanOrEqual(before.services);
expect(after.actions).toBeGreaterThanOrEqual(before.actions);
});
});tests/iv9/IV9_S18.spec.ts · 28줄 · 해시 a2527a2f · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { URL_WORKSPACES, gotoWorkspaces, wsRow } from '../../lib/iv9';
// IV9_S18 : TC-IAM-049 워크스페이스 목록 조회
// 2026-10-13: 좌측 'Workspaces' 그룹에는 Projects/Roles/CSP Roles 만 있고 Workspaces 목록 화면 링크가 없다
// → 그룹 클릭이 화면을 열지 않으면 그룹 주소(/webconsole/operations/manage/workspaces)로 이동(기록).
test('IV9_S18 TC-IAM-049 워크스페이스 목록 조회', async ({ page }) => {
await test.step('1. admin 로그인', async () => {
await loginAsAdmin(page);
});
await test.step('2. Workspaces 화면 이동', async () => {
await page.locator('.nav-link[name="sidebar_workspaces"]').first().click();
await page.waitForTimeout(2000);
if (!new URL(page.url()).pathname.endsWith(URL_WORKSPACES)) {
test.info().annotations.push({ type: 'note', description: `좌측 메뉴 'Workspaces' 클릭으로 목록 화면이 열리지 않음(하위 메뉴만 펼침) → ${URL_WORKSPACES} 로 이동` });
await gotoWorkspaces(page);
}
await expect(page).toHaveURL(new RegExp(`${URL_WORKSPACES}$`));
});
await test.step('3. 워크스페이스 목록 표시 확인(ws01 포함)', async () => {
await expect(wsRow(page, 'ws01').first(), 'ws01 행').toBeVisible({ timeout: 30_000 });
const rows = (await page.locator('#Workspaceslist-table .tabulator-row').allInnerTexts()).map((s) => s.replace(/\s+/g, ' ').trim());
const summary = (await page.locator('.card').filter({ hasText: 'Workspaces Summary' }).first().innerText()).replace(/\s+/g, ' ');
test.info().annotations.push({ type: 'workspaces', description: `${rows.length}개: ${rows.join(' / ')}` });
test.info().annotations.push({ type: 'summary', description: summary.slice(0, 200) });
});
});tests/iv9/IV9_S19.spec.ts · 36줄 · 해시 445db2d5 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { IV9_WS, createWorkspace, deleteWorkspace, gotoWorkspaces, openWorkspace, toastTexts, wsRow } from '../../lib/iv9';
// IV9_S19 : TC-IAM-050 워크스페이스 생성 (Workspaces > Add Workspace > 이름·설명 > Confirm)
// 테스트 전용 iv9ws 를 만들고 마지막 정리 단계에서 삭제한다(실패해도 finally 로 삭제 시도).
test('IV9_S19 TC-IAM-050 워크스페이스 생성', async ({ page }) => {
const desc = `iv9 test workspace ${Date.now()}`;
try {
await test.step('1. admin 로그인 후 Workspaces 화면 이동', async () => {
await loginAsAdmin(page);
await gotoWorkspaces(page);
if (await wsRow(page, IV9_WS).count()) {
test.info().annotations.push({ type: 'preexisting', description: `${IV9_WS} 잔존(이전 실행) → 먼저 삭제` });
await deleteWorkspace(page, IV9_WS);
}
});
await test.step(`2. Add Workspace 클릭 → 이름(${IV9_WS})·설명 입력 → Confirm`, async () => {
expect(await createWorkspace(page, IV9_WS, desc)).toBe(true);
await page.waitForTimeout(1000);
test.info().annotations.push({ type: 'toast', description: (await toastTexts(page)).join(' | ') || '(없음)' });
});
await test.step(`3. 목록에 ${IV9_WS} 표시 · 상세(이름/설명) 확인`, async () => {
await expect(wsRow(page, IV9_WS).first(), `목록에 ${IV9_WS}`).toBeVisible({ timeout: 30_000 });
await openWorkspace(page, IV9_WS);
await expect(page.locator('#workspace-details-description')).toContainText(desc);
await page.waitForTimeout(1500);
});
} finally {
await test.step(`4. (정리) ${IV9_WS} 삭제`, async () => {
const deleted = await deleteWorkspace(page, IV9_WS);
test.info().annotations.push({ type: 'cleanup', description: deleted ? `${IV9_WS} 삭제 완료` : `${IV9_WS} 없음(삭제 불필요)` });
await expect(wsRow(page, 'ws01').first(), 'ws01 유지').toBeVisible();
});
}
});tests/iv9/IV9_S20.spec.ts · 72줄 · 해시 f5937c38 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoUsersAll } from '../../lib/groups';
import {
IV9_USER, IV9_WS2, addMember, createWorkspace, deleteWorkspace, gotoWorkspaces, memberNames, memberRow, openMembersTab, openWorkspace,
unassignMember, userWorkspaces, wsRow,
} from '../../lib/iv9';
// IV9_S20 : TC-IAM-051 워크스페이스에 사용자 추가 (Workspaces > 워크스페이스 선택 > Members 탭 > + > 사용자·역할 > Confirm)
// 임시 워크스페이스 iv9ws2 를 만들고 테스트 사용자 pending01(stage 에 rec#### 사용자가 없어 대체)을 viewer 로 추가한 뒤 정리한다.
// 정리 순서: 멤버 해제(Unassign Members) → 워크스페이스 삭제. 멤버가 남아 있으면 삭제 API 가 500 으로 실패한다(2026-10-13 확인).
// 2026-10-13 확인(제품): Members 표가 선택한 워크스페이스와 무관하게 모든 워크스페이스의 사용자-역할 매핑을 보여 준다
// (mc-iam-manager listUsersAndRolesByWorkspaces 가 경로의 workspaceId 를 쓰지 않음).
// 이 TC 는 '사용자 추가' 기능을 판정하므로 추가 후 대상 사용자 표시로 확인하고, 다른 워크스페이스 멤버가 섞여 보이는 현상은 결함 메모(annotation)로만 남긴다(2026-10-13 사용자 결정).
const ROLE = 'viewer';
test('IV9_S20 TC-IAM-051 워크스페이스에 사용자 추가', async ({ page }) => {
const u = IV9_USER;
let created = false;
try {
await test.step(`1. admin 로그인 후 Workspaces 화면에서 임시 워크스페이스 ${IV9_WS2} 생성`, async () => {
await loginAsAdmin(page);
await gotoWorkspaces(page);
if (await wsRow(page, IV9_WS2).count()) {
test.info().annotations.push({ type: 'preexisting', description: `${IV9_WS2} 잔존(이전 실행) → 정리 후 다시 생성` });
await openWorkspace(page, IV9_WS2);
await openMembersTab(page);
if (await memberRow(page, u.id, ROLE).count()) await unassignMember(page, u.id, ROLE);
await deleteWorkspace(page, IV9_WS2);
}
created = await createWorkspace(page, IV9_WS2, 'iv9 temp workspace for member test');
await expect(wsRow(page, IV9_WS2).first()).toBeVisible({ timeout: 30_000 });
});
await test.step(`2. ${IV9_WS2} 행 클릭 → Members 탭 열기`, async () => {
await openWorkspace(page, IV9_WS2);
await openMembersTab(page);
const before = await memberNames(page);
test.info().annotations.push({ type: 'members-before', description: `${IV9_WS2} Members(추가 전): ${before.join(', ') || '(없음)'}` });
if (before.length) test.info().annotations.push({ type: '결함 메모', description: `새 워크스페이스 Members 에 다른 워크스페이스 멤버 ${before.length}명 표시(${before.join(', ')}) — 목록 API 가 workspaceId 를 무시` });
});
await test.step(`3. + 클릭 → 사용자(${u.id})·역할(${ROLE}) 선택 → Confirm`, async () => {
await addMember(page, u.id, ROLE);
});
await test.step(`4. Members 목록에 ${u.id}(${ROLE}) 표시 확인`, async () => {
await openMembersTab(page);
const row = memberRow(page, u.id, ROLE).first();
await expect(row, `멤버 ${u.id}(${ROLE})`).toBeVisible({ timeout: 30_000 });
test.info().annotations.push({ type: 'member', description: (await row.innerText()).replace(/\s+/g, ' ').trim() });
});
await test.step(`5. Users > ${u.id} > User Info 의 Workspace 에 ${IV9_WS2} 표시 확인`, async () => {
await gotoUsersAll(page);
const ws = await userWorkspaces(page, u);
test.info().annotations.push({ type: 'user-workspaces', description: `${u.id}: ${ws.join(', ') || '(없음)'}` });
expect(ws, `${u.id} 의 Workspace 에 ${IV9_WS2}`).toContain(IV9_WS2);
});
} finally {
await test.step(`6. (정리) ${u.id} 멤버 해제 → 임시 워크스페이스 ${IV9_WS2} 삭제`, async () => {
await gotoWorkspaces(page);
if (await wsRow(page, IV9_WS2).count()) {
await openWorkspace(page, IV9_WS2);
await openMembersTab(page);
if (await memberRow(page, u.id, ROLE).count()) await unassignMember(page, u.id, ROLE);
await deleteWorkspace(page, IV9_WS2);
}
test.info().annotations.push({ type: 'cleanup', description: `${IV9_WS2} ${created ? '생성분 ' : ''}삭제 완료` });
await expect(wsRow(page, IV9_WS2)).toHaveCount(0);
await expect(wsRow(page, 'ws01').first(), 'ws01 유지').toBeVisible();
await gotoUsersAll(page);
const ws = await userWorkspaces(page, u);
expect(ws, `${u.id} 의 Workspace 에서 ${IV9_WS2} 제거됨`).not.toContain(IV9_WS2);
});
}
});tests/iv9/IV9_S21.spec.ts · 76줄 · 해시 7feaf7e8 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { PROJECTS_URL, PROTECTED, TEMP_PROJECT, ensureTempProject, openProjects, projectRow, protectedStillThere, selectProject } from '../../lib/projects';
import { toastTexts } from '../../lib/iv9';
// IV9_S21 : TC-IAM-053 프로젝트 수정 (Workspaces > Projects > 프로젝트 선택 > Edit > 설명 변경 > Save)
// 대상: 임시 Project ptmp(lib/projects.ts ensureTempProject). default / p1 / system 은 수정하지 않는다.
// 이 테스트가 ptmp 를 새로 만들었으면 마지막에 정리한다(ws01 Unassign → Delete). 원래 있던 ptmp 는 그대로 둔다.
test('IV9_S21 TC-IAM-053 프로젝트 수정', async ({ page }) => {
const newDesc = `iv9 edited ${new Date().toISOString().replace(/[-:]/g, '').slice(0, 15)}`;
let created = false;
try {
await test.step('1. admin 로그인 후 Workspaces > Projects 이동', async () => {
await openProjects(page);
});
await test.step(`2. 임시 Project ${TEMP_PROJECT} 준비(없으면 Add Project 로 생성)`, async () => {
expect(PROTECTED).not.toContain(TEMP_PROJECT);
created = await ensureTempProject(page);
test.info().annotations.push({ type: 'temp-project', description: created ? `${TEMP_PROJECT} 생성(테스트 후 삭제)` : `${TEMP_PROJECT} 기존 것 사용` });
});
await test.step(`3. ${TEMP_PROJECT} 행 클릭 → 상세 표시 → Edit 클릭`, async () => {
await selectProject(page, TEMP_PROJECT);
await expect(page.locator('#detail-name')).toHaveText(TEMP_PROJECT);
test.info().annotations.push({ type: 'desc-before', description: (await page.locator('#detail-description').innerText()).trim() });
await page.locator('#project-detail-panel').getByRole('button', { name: 'Edit', exact: true }).click();
await expect(page.locator('#modal-project-edit')).toBeVisible();
await expect(page.locator('#edit-project-name')).toHaveValue(TEMP_PROJECT);
});
await test.step(`4. Description 을 '${newDesc}' 로 변경 → Save`, async () => {
const m = page.locator('#modal-project-edit');
await m.locator('#edit-project-description').fill(newDesc);
await m.getByRole('button', { name: 'Save' }).click();
await expect(m, 'Edit 팝업 닫힘').toBeHidden({ timeout: 30_000 });
await page.waitForTimeout(800);
const t = await toastTexts(page);
test.info().annotations.push({ type: 'toast', description: t.join(' | ') || '(없음)' });
expect(t.join(' | ')).toMatch(/Project updated/i);
});
await test.step('5. 상세·목록에 변경된 설명 표시 확인(다시 열어 저장 확인)', async () => {
await expect(page.locator('#detail-description')).toHaveText(newDesc, { timeout: 20_000 });
await page.goto(PROJECTS_URL);
await expect(projectRow(page, TEMP_PROJECT).first()).toBeVisible({ timeout: 60_000 });
await expect(projectRow(page, TEMP_PROJECT).first().locator('[tabulator-field="description"]')).toHaveText(newDesc);
await selectProject(page, TEMP_PROJECT);
await expect(page.locator('#detail-description')).toHaveText(newDesc);
await protectedStillThere(page);
});
} finally {
if (created) {
await test.step(`6. (정리) 이 테스트가 만든 ${TEMP_PROJECT}: ws01 Unassign → Delete`, async () => {
await page.goto(PROJECTS_URL);
await expect(projectRow(page, TEMP_PROJECT).first()).toBeVisible({ timeout: 60_000 });
await selectProject(page, TEMP_PROJECT);
await expect(page.locator('#detail-name')).toHaveText(TEMP_PROJECT);
const assigned = page.locator('#assign-list > div').filter({ hasText: 'ws01' });
if (await assigned.count()) {
await assigned.getByRole('button', { name: 'Unassign' }).click();
await expect(page.locator('#assign-list > div').filter({ hasText: 'ws01' })).toHaveCount(0, { timeout: 30_000 });
}
await expect(page.locator('#detail-name')).toHaveText(TEMP_PROJECT);
await page.locator('#project-detail-panel').getByRole('button', { name: 'Delete', exact: true }).click();
const modal = page.locator('#commonDefaultModal');
await expect(modal).toBeVisible();
const msg = (await modal.innerText()).replace(/\s+/g, ' ').trim();
test.info().annotations.push({ type: 'confirm-modal', description: msg });
expect(msg).toContain(`"${TEMP_PROJECT}"`);
await modal.locator('#commonDefaultModal-confirm-btn').click();
await expect(projectRow(page, TEMP_PROJECT)).toHaveCount(0, { timeout: 30_000 });
test.info().annotations.push({ type: 'cleanup', description: `${TEMP_PROJECT} 삭제 완료 / 토스트: ${(await toastTexts(page)).join(' | ')}` });
await page.goto(PROJECTS_URL);
await page.getByRole('gridcell', { name: 'default', exact: true }).first().waitFor({ timeout: 60_000 });
await expect(projectRow(page, TEMP_PROJECT), `${TEMP_PROJECT} 정리됨`).toHaveCount(0, { timeout: 30_000 });
await protectedStillThere(page);
});
}
}
});