Test Specs IV9 - MZC-CSC/mc-web-console GitHub Wiki

Test Specs β€” IV9 IAM 보완 (mc-iam-manager)

생성: 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

iv9.ts

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: '[email protected]' };

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;
}

iam-api.ts

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;

IV9_S01

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);
  });
});

IV9_S02

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 μ—­ν•  μ‘°νšŒμ— λ°˜μ˜λ˜μ§€ μ•ŠμŒ' });
  });
});

IV9_S03

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);
  });
});

IV9_S04

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([]);
  });
});

IV9_S05

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();
  });
});

IV9_S06

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);
  });
});

IV9_S07

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);
  });
});

IV9_S08

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' / 이메일 [email protected] 둜 ν‘œμ‹œ
    await expect(rows.filter({ hasText: /mcmp/ }).first(), 'admin mcmp ν–‰').toBeVisible();
  });
});

IV9_S09

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);
  });
});

IV9_S10

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 κ°€ 생기면 이후 단계λ₯Ό μΆ”κ°€ν•΄μ•Ό 함
});

IV9_S11

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);
  });
});

IV9_S12

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');
    });
  }
});

IV9_S13

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);
    });
  }
});

IV9_S14

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)' });
  });
});

IV9_S15

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);
  });
});

IV9_S16

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);
      });
    }
  }
});

IV9_S17

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);
  });
});

IV9_S18

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) });
  });
});

IV9_S19

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();
    });
  }
});

IV9_S20

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);
    });
  }
});

IV9_S21

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);
      });
    }
  }
});
⚠️ **GitHub.com Fallback** ⚠️