Skip to content

Test Specs IAM

dogfootman edited this page Oct 22, 2026 · 1 revision

Test Specs — IAM (mc-iam-manager)

생성: 2026-10-22 · 기준: v07 설치 후 통합 검증(IV) · 원본: 여정별 계획서(Excel) ← Test Scenarios · IV 허브 · 기능 TC

mc-iam-manager 기능 TC 의 Playwright(TypeScript) spec 소스입니다. 절마다 앵커는 #<TC-ID 소문자>(예: #tc-iam-001)이고, 제목 끝 (구 …) 는 이전 시나리오 번호입니다. 공용 헬퍼는 Test-Specs-Common. 비밀번호 등은 마스킹했습니다.

TC 구 번호 파일 줄 해시
TC-IAM-001 IV9_S01 tests/iam/TC-IAM-001.spec.ts 47 cc6fc9b2
TC-IAM-002 IV9_S02 tests/iam/TC-IAM-002.spec.ts 29 5bf2f140
TC-IAM-003 IV9_S03 tests/iam/TC-IAM-003.spec.ts 42 36e9a879
TC-IAM-004 IV9_S04 tests/iam/TC-IAM-004.spec.ts 33 2516bd76
TC-IAM-005 IV1_S03 tests/iam/TC-IAM-005.spec.ts 27 8892ff9e
TC-IAM-006 IV1_S04 tests/iam/TC-IAM-006.spec.ts 25 3fd6a340
TC-IAM-007 IV1_S31 tests/iam/TC-IAM-007.spec.ts 18 00ea3019
TC-IAM-008 IV1_S10 tests/iam/TC-IAM-008.spec.ts 15 5a9fcd45
TC-IAM-009 IV1_S11 tests/iam/TC-IAM-009.spec.ts 23 cb0c25d7
TC-IAM-010 IV9_S05 tests/iam/TC-IAM-010.spec.ts 44 85ab8c2e
TC-IAM-011 IV9_S06 tests/iam/TC-IAM-011.spec.ts 29 d33b1f03
TC-IAM-012 IV9_S07 tests/iam/TC-IAM-012.spec.ts 30 7d42d398
TC-IAM-013 IV1_S02 tests/iam/TC-IAM-013.spec.ts 26 977676b3
TC-IAM-014 IV9_S08 tests/iam/TC-IAM-014.spec.ts 27 4a0315a4
TC-IAM-015 IV9_S09 tests/iam/TC-IAM-015.spec.ts 29 0fa0474d
TC-IAM-016 IV1_S24 tests/iam/TC-IAM-016.spec.ts 44 3aef86c2
TC-IAM-017 IV1_S22 tests/iam/TC-IAM-017.spec.ts 32 bc74c0bd
TC-IAM-018 IV9_S10 tests/iam/TC-IAM-018.spec.ts 36 bb5fe8a6
TC-IAM-019 IV9_S11 tests/iam/TC-IAM-019.spec.ts 31 410e51de
TC-IAM-020 IV9_S12 tests/iam/TC-IAM-020.spec.ts 45 b79a6080
TC-IAM-021 IV1_S01 tests/iam/TC-IAM-021.spec.ts 24 dd67fafc
TC-IAM-022 IV1_S25 tests/iam/TC-IAM-022.spec.ts 23 67222c0d
TC-IAM-023 IV1_S12 tests/iam/TC-IAM-023.spec.ts 20 34af23c8
TC-IAM-024 IV1_S29 tests/iam/TC-IAM-024.spec.ts 21 91d034c6
TC-IAM-025 IV1_S26 tests/iam/TC-IAM-025.spec.ts 28 b8c501b8
TC-IAM-026 IV1_S20 tests/iam/TC-IAM-026.spec.ts 27 ef59ceb0
TC-IAM-027 IV1_S21 tests/iam/TC-IAM-027.spec.ts 32 9a24a37a
TC-IAM-028 IV1_S30 tests/iam/TC-IAM-028.spec.ts 31 e5c53c88
TC-IAM-029 IV1_S17 tests/iam/TC-IAM-029.spec.ts 36 7f948740
TC-IAM-030 IV1_S18 tests/iam/TC-IAM-030.spec.ts 38 70ca17bc
TC-IAM-031 IV1_S19 tests/iam/TC-IAM-031.spec.ts 25 4118a6a7
TC-IAM-032 IV1_S27 tests/iam/TC-IAM-032.spec.ts 35 174e26db
TC-IAM-033 IV9_S13 tests/iam/TC-IAM-033.spec.ts 52 9ab2ad0d
TC-IAM-034 IV9_S14 tests/iam/TC-IAM-034.spec.ts 31 70ad9dc6
TC-IAM-035 IV9_S15 tests/iam/TC-IAM-035.spec.ts 47 273bc77c
TC-IAM-036 IV1_S28 tests/iam/TC-IAM-036.spec.ts 36 a39bd510
TC-IAM-037 IV1_S23 tests/iam/TC-IAM-037.spec.ts 38 22033f9e
TC-IAM-038 IV9_S16 tests/iam/TC-IAM-038.spec.ts 76 73de227b
TC-IAM-039 IV1_S05 tests/iam/TC-IAM-039.spec.ts 5 d39c7170
TC-IAM-040 IV1_S06 tests/iam/TC-IAM-040.spec.ts 5 2cb111d9
TC-IAM-041 IV1_S07 tests/iam/TC-IAM-041.spec.ts 5 28d2a530
TC-IAM-042 IV1_S08 tests/iam/TC-IAM-042.spec.ts 5 a4553df1
TC-IAM-043 IV1_S09 tests/iam/TC-IAM-043.spec.ts 5 43c6bdba
TC-IAM-044 IV1_S13 tests/iam/TC-IAM-044.spec.ts 5 aa016b95
TC-IAM-045 IV1_S14 tests/iam/TC-IAM-045.spec.ts 5 dd9b9b71
TC-IAM-046 IV1_S15 tests/iam/TC-IAM-046.spec.ts 5 b92164a5
TC-IAM-047 IV1_S16 tests/iam/TC-IAM-047.spec.ts 5 f19d17fd
TC-IAM-048 IV9_S17 tests/iam/TC-IAM-048.spec.ts 30 383d14fd
TC-IAM-049 IV9_S18 tests/iam/TC-IAM-049.spec.ts 30 f48fb0f9
TC-IAM-050 IV9_S19 tests/iam/TC-IAM-050.spec.ts 37 a8d8a7ff
TC-IAM-051 IV9_S20 tests/iam/TC-IAM-051.spec.ts 73 34613fc5
TC-IAM-052 IV3_S12 tests/iam/TC-IAM-052.spec.ts 54 b422c0c9
TC-IAM-053 IV9_S21 tests/iam/TC-IAM-053.spec.ts 77 275eaaef
TC-IAM-054 IV3_S27 tests/iam/TC-IAM-054.spec.ts 51 35451b76
TC-IAM-055 IV3_S26 tests/iam/TC-IAM-055.spec.ts 28 97e8ccc4
TC-IAM-056 IV3_S28 tests/iam/TC-IAM-056.spec.ts 29 f4516ac3

TC-IAM-001 (구 IV9_S01)

tests/iam/TC-IAM-001.spec.ts · 47줄 · 해시 cc6fc9b2 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { URL_CLOUD_OVERVIEW } from '../../lib/iv9';

// TC-IAM-001 서비스 상태 확인 (Cloud Overview > Readyz 영역 > 프레임워크별 Readyz 버튼)
// TC 대상 endpoint 는 GET /readyz. 화면의 Readyz 표에는 Readyz 버튼이 있는 프레임워크만 표시되며, 각 결과(OK/ERROR)를 기록한다.
// 화면 표시: 정상='✅ Ready'(=OK), 실패='❌ ERROR'(+ 사유 문구).
// 판정: 모든 Readyz 버튼이 결과(OK 또는 ERROR)를 표시하면 통과(각 프레임워크의 상태 자체는 기록 대상).
test('TC-IAM-001 서비스 상태 확인 (Cloud Overview Readyz) (구 IV9_S01)', 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);
  });
});

TC-IAM-002 (구 IV9_S02)

tests/iam/TC-IAM-002.spec.ts · 29줄 · 해시 5bf2f140 · 시나리오 보기

import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';

// TC-IAM-002 : 초기 관리자 계정 설정 (POST /api/initial-admin) — TC-IAM-002
// TC 문구의 POST /api/init 은 현재 경로 POST /api/initial-admin (인증 없이 호출, Keycloak realm·client·관리자 생성).
// 설치 후 mc-iam-manager-post-initial 이 기동할 때마다 같은 호출을 한다(멱등) → 설치된 서버에서는 재호출이 성공하고 기존 관리자가 유지되는지 확인한다.
test('TC-IAM-002 초기 관리자 계정 설정 (initial-admin 호출 성공 → 플랫폼 관리자 로그인·역할 유지) (구 IV9_S02)', 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 역할 조회에 반영되지 않음' });
  });
});

TC-IAM-003 (구 IV9_S03)

tests/iam/TC-IAM-003.spec.ts · 42줄 · 해시 36e9a879 · 시나리오 보기

import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';

// TC-IAM-003 : 역할·메뉴·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('TC-IAM-003 역할·메뉴·API 권한 일괄 초기화 (initial-menus · role-menu 권한 · mcmp-api 동기화 → 데이터 존재) (구 IV9_S03)', 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);
  });
});

TC-IAM-004 (구 IV9_S04)

tests/iam/TC-IAM-004.spec.ts · 33줄 · 해시 2516bd76 · 시나리오 보기

import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';

// TC-IAM-004 : 프로젝트 동기화 — 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('TC-IAM-004 프로젝트 동기화 (sync-diff → sync-projects → 차이 해소) (구 IV9_S04)', 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([]);
  });
});

TC-IAM-005 (구 IV1_S03)

tests/iam/TC-IAM-005.spec.ts · 27줄 · 해시 8892ff9e · 시나리오 보기

import { test } from '../../lib/fixtures';
import { loginAsAdmin, logout } from '../../lib/ui';
import { USERS } from '../../lib/data';
import { signUp, pendingApproval } from '../../lib/forms';
import { REC_SIGNUP_USER, userExistsAll } from '../../lib/groups';

// TC-IAM-005 : 사용자 직접 가입 (로그인 화면 -> Sign Up). 관리자가 만드는 흐름(TC-IAM-013)과 별개.
// 실제 대상(workflow01 …)은 이미 가입돼 있어 가입 장면이 녹화되지 않으므로(2026-10-04 사용자 지적),
//   녹화용 사용자 rec{MMDD}b 로 Sign Up → 'Registration request submitted' 안내까지 보여 준다(승인은 TC-IAM-006).
// 재실행 가능: 이미 가입/승인된 사용자는 건너뛴다(어노테이션 'preexisting').
// 존재 확인은 Users 목록 전체 페이지 기준(userExistsAll) — 사용자가 늘어 2페이지로 밀리면 '없음'으로 오판하던 문제(2026-10-05) 보완.
test('TC-IAM-005 신규 가입 (workflow01, app01, monitor01, cost01, cost02) (구 IV1_S03)', async ({ page }) => {
  const targets = [REC_SIGNUP_USER, ...USERS.filter((x) => x.via === 'signup')];
  const todo: typeof targets = [];
  await loginAsAdmin(page, 'ws01', null);
  for (const u of targets) {
    if ((await userExistsAll(page, u)) || (await pendingApproval(page, u))) {
      test.info().annotations.push({ type: 'preexisting', description: `${u.id}: 이미 가입/승인됨 → 가입 건너뜀` });
    } else todo.push(u);
  }
  await logout(page);
  for (const u of todo) {
    await signUp(page, u); // 안내 'Registration request submitted…' 확인 포함
    test.info().annotations.push({ type: 'signup', description: `${u.id} (${u.email}) 가입 요청 완료` });
  }
});

TC-IAM-006 (구 IV1_S04)

tests/iam/TC-IAM-006.spec.ts · 25줄 · 해시 3fd6a340 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { USERS } from '../../lib/data';
import { pendingApproval } from '../../lib/forms';
import { REC_SIGNUP_USER, pendingApprovalWait } from '../../lib/groups';

// TC-IAM-006 : admin 가입 승인 (Organizations > Approvals > Approve) — 한 테스트 = 한 동작(2026-10-05 사용자 지시)
//   조직 할당은 TC-IAM-032, 역할 부여는 TC-IAM-036 로 분리.
// 선행: TC-IAM-005(가입). 녹화용 rec{MMDD}b 는 승인 대기여야 하므로 목록이 뜰 때까지 기다린다. 실제 대상은 대기 중일 때만 승인(멱등).
test('TC-IAM-006 admin: 가입 승인 (Approvals) (구 IV1_S04)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  for (const u of [REC_SIGNUP_USER, ...USERS.filter((x) => x.via === 'signup')]) {
    const pending = u === REC_SIGNUP_USER ? await pendingApprovalWait(page, u) : await pendingApproval(page, u);
    if (!pending) {
      test.info().annotations.push({ type: 'preexisting', description: `${u.id}: 승인 대기 없음(이미 승인됨 또는 미가입)` });
      continue;
    }
    const before = dialogs.length;
    const row = page.getByRole('row', { name: new RegExp(u.email) });
    await row.getByRole('button', { name: 'Approve' }).click(); // confirm("Approve this user?") 는 fixture 가 수락
    await expect(row, `${u.id} 승인 후 대기 목록에서 사라짐`).toHaveCount(0, { timeout: 20_000 });
    test.info().annotations.push({ type: 'approved', description: `${u.id} / ${dialogs.slice(before).join(' | ') || '(알럿 없음)'}` });
  }
});

TC-IAM-007 (구 IV1_S31)

tests/iam/TC-IAM-007.spec.ts · 18줄 · 해시 00ea3019 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAndSettle, menuSnapshot } from '../../lib/ui';
import { pwOf } from '../../lib/data';
import { REC_SIGNUP_USER } from '../../lib/groups';

// TC-IAM-007 : 승인된 사용자 로그인 — 가입(TC-IAM-005) → 관리자 승인(TC-IAM-006) → 로그인 흐름의 마지막 단계(2026-10-05 사용자 요청)
// 녹화용 rec{MMDD}b 가 가입 때 입력한 비밀번호({id}_password)로 로그인한다. 이 시점에는 조직·역할이 아직 없다(TC-IAM-032·S28 에서 할당).
// 기대: 로그인 성공(콘솔 화면 진입). 역할이 없을 때 보이는 메뉴·화면은 어노테이션으로 기록.
test('TC-IAM-007 승인된 사용자 로그인 (구 IV1_S31)', async ({ page, dialogs }) => {
  const u = REC_SIGNUP_USER;
  await loginAndSettle(page, u.id, pwOf(u.id));
  await expect(page, `${u.id} 로그인 성공(로그인 화면을 벗어남)`).not.toHaveURL(/\/auth\//);
  await page.waitForTimeout(2000);
  const menus = await menuSnapshot(page);
  test.info().annotations.push({ type: 'login', description: `${u.id} → ${new URL(page.url()).pathname}, 메뉴 ${menus.length}개${menus.length ? `: ${menus.map((m) => m.split(' -> ')[0]).join(', ')}` : ''}` });
  test.info().annotations.push({ type: 'messages', description: dialogs.join(' | ') || '(알럿 없음)' });
});

TC-IAM-008 (구 IV1_S10)

tests/iam/TC-IAM-008.spec.ts · 15줄 · 해시 5a9fcd45 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { USERS } from '../../lib/data';
import { loginExpectRejected } from '../../lib/ui';

// TC-IAM-008 : 잘못된 비밀번호로 로그인 (예상 실패)
// 2026-10-19: 거부 시 alert(401) → 확인 → toast 표시까지 확인·녹화(loginExpectRejected)
test('TC-IAM-008 잘못된 비밀번호로 로그인 (예상실패) (구 IV1_S10)', async ({ page }) => {
  const u = USERS[0];
  const r = await loginExpectRejected(page, u.id, 'wrong_password');
  // 기대: 로그인 거부 + 에러 표시(alert·toast). 로그인 화면에 머문다.
  await expect(page).toHaveURL(/\/auth\/login/);
  expect(r.alerts.length, '로그인 거부 alert').toBeGreaterThan(0);
  expect(r.toast, '로그인 거부 toast').not.toBe('');
});

TC-IAM-009 (구 IV1_S11)

tests/iam/TC-IAM-009.spec.ts · 23줄 · 해시 cb0c25d7 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { PENDING_USER, pwOf } from '../../lib/data';
import { signUp } from '../../lib/forms';
import { loginExpectRejected } from '../../lib/ui';

// TC-IAM-009 : 미승인 사용자 가입 후 로그인 (예상 실패)
// 재실행 가능: 이미 가입된 이메일이면 가입은 건너뛰고(어노테이션에 화면 문구 기록) 로그인 시도만 한다.
// 2026-10-19: 거부 시 alert(401) → 확인 → toast 표시까지 확인·녹화(loginExpectRejected)
test('TC-IAM-009 미승인 유저 가입 후 로그인 (예상실패) (구 IV1_S11)', async ({ page }) => {
  try {
    await signUp(page, PENDING_USER); // 가입 후 /auth/signup 에 안내 문구가 표시됨(로그인 화면 아님)
  } catch {
    const text = (await page.locator('body').innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 200);
    test.info().annotations.push({ type: 'preexisting', description: `${PENDING_USER.id}: 이미 가입됨. 재가입 시 화면: "${text}"` });
  }
  const r = await loginExpectRejected(page, PENDING_USER.id, pwOf(PENDING_USER.id));
  const after = (await page.locator('body').innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 200);
  test.info().annotations.push({ type: 'unapproved-login-result', description: `url=${page.url()} text="${after}"` });
  // 기대: 로그인 거부 또는 승인 대기 안내 → 콘솔 화면으로 들어가지 못한다.
  await expect(page).not.toHaveURL(/\/webconsole\//);
  expect(r.alerts.length + (r.toast ? 1 : 0), '로그인 거부 표시(alert 또는 toast)').toBeGreaterThan(0);
});

TC-IAM-010 (구 IV9_S05)

tests/iam/TC-IAM-010.spec.ts · 44줄 · 해시 85ab8c2e · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { URL_ROLES } from '../../lib/forms';

// TC-IAM-010 로그아웃 (상단 프로필 버튼 > Logout)
// 2026-10-13 확인: Logout 클릭 → /auth/logout('Logout' + [Back to Login]) 화면. 로그아웃 후 콘솔 주소를 열면 /auth/unauthorized(#cookieError, [Back to Login]).
// 판정: 로그아웃 후 콘솔 화면에 다시 들어갈 수 없어야 하고(인증 화면으로 이동), 로그인 화면으로 돌아갈 수 있어야 한다.
test('TC-IAM-010 로그아웃 (구 IV9_S05)', 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();
  });
});

TC-IAM-011 (구 IV9_S06)

tests/iam/TC-IAM-011.spec.ts · 29줄 · 해시 d33b1f03 · 시나리오 보기

import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin, jwtExp } from '../../lib/iam-api';

// TC-IAM-011 : 토큰 갱신 (POST /api/auth/refresh) — TC-IAM-011  (TC 문구의 /api/refresh 는 현재 /api/auth/refresh)
test('TC-IAM-011 토큰 갱신 (refresh_token → 새 access_token 발급, 잘못된 토큰은 401) (구 IV9_S06)', 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);
  });
});

TC-IAM-012 (구 IV9_S07)

tests/iam/TC-IAM-012.spec.ts · 30줄 · 해시 7d42d398 · 시나리오 보기

import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';

// TC-IAM-012 : 토큰 검증 및 만료 임박 자동 갱신 (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('TC-IAM-012 토큰 검증 (유효 토큰 valid:true · 무효 토큰은 refresh 로 재발급 · 헤더 없으면 401) (구 IV9_S07)', 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);
  });
});

TC-IAM-013 (구 IV1_S02)

tests/iam/TC-IAM-013.spec.ts · 26줄 · 해시 977676b3 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { USERS } from '../../lib/data';
import { adminCreateUser } from '../../lib/forms';
import { REC_ADMIN_USER, gotoUsersAll, userExistsAll } from '../../lib/groups';

// TC-IAM-013 : admin 사용자 추가 (Users > Add User) — 한 테스트 = 한 동작(2026-10-05 사용자 지시)
//   조직·역할 할당은 TC-IAM-037, 비밀번호 설정은 TC-IAM-016 로 분리.
// 녹화용 사용자 rec{MMDD}a 로 생성 장면을 보여 준다(TC-IAM-017 에서 삭제되므로 재녹화 시 다시 생성됨).
// 실제 사용자 iam01·infra01 은 없을 때만 생성(멱등).
test('TC-IAM-013 admin: 사용자 추가 (Add User) (구 IV1_S02)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  for (const u of [REC_ADMIN_USER, ...USERS.filter((x) => x.via === 'admin')]) {
    if (await userExistsAll(page, u)) {
      test.info().annotations.push({ type: 'preexisting', description: `${u.id}: 이미 존재 → 생성 건너뜀` });
      continue;
    }
    const before = dialogs.length;
    await adminCreateUser(page, u);
    await expect.poll(() => dialogs.slice(before).join('|')).toContain('User created successfully');
    await gotoUsersAll(page);
    await expect(page.getByRole('row').filter({ hasText: u.email }).first(), `목록에 ${u.id}`).toBeVisible({ timeout: 20_000 });
    test.info().annotations.push({ type: 'created', description: `${u.id} (${u.email})` });
  }
});

TC-IAM-014 (구 IV9_S08)

tests/iam/TC-IAM-014.spec.ts · 27줄 · 해시 4a0315a4 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { URL_USERS } from '../../lib/forms';
import { gotoUsersAll } from '../../lib/groups';

// TC-IAM-014 사용자 목록 조회 (Organizations > Users)
test('TC-IAM-014 사용자 목록 조회 (구 IV9_S08)', 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();
  });
});

TC-IAM-015 (구 IV9_S09)

tests/iam/TC-IAM-015.spec.ts · 29줄 · 해시 0fa0474d · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { gotoUsersAll } from '../../lib/groups';
import { IV9_USER } from '../../lib/iv9';

// TC-IAM-015 사용자 상세 조회 (Users > 사용자 행 클릭 > User Info)
// 대상: 테스트 계정 pending01 (조회만, 변경 없음)
test('TC-IAM-015 사용자 상세 조회 (구 IV9_S09)', 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);
  });
});

TC-IAM-016 (구 IV1_S24)

tests/iam/TC-IAM-016.spec.ts · 44줄 · 해시 3aef86c2 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, loginAndSettle, logout, dialogsWithMemo } from '../../lib/ui';
import { USERS, pwOf } from '../../lib/data';
import { selectUserRow } from '../../lib/forms';
import { REC_ADMIN_USER, gotoUsersAll, userExistsAll } from '../../lib/groups';

// TC-IAM-016 : admin 사용자 비밀번호 설정 (Users > 사용자 선택 > Reset Password) — TC-IAM-013 에서 분리(2026-10-05)
// 시나리오: 관리자가 사용자의 비밀번호를 설정한다. Add User 폼에는 비밀번호 입력란이 없어 Reset Password 로 {id}_password 를 설정한다.
// 설정 후 그 사용자로 로그인해 새 비밀번호가 적용됐는지 확인.
// 2026-10-19 사용자 지적(재확인 요청): 영상에서 비밀번호 설정 과정·성공 alert 가 보이지 않음 → 녹화 대상 사용자 1명(REC_ADMIN_USER)을
//   천천히 진행(모달 입력값이 보이게 대기, 성공 alert 는 [녹화 메모]로 표시)하고, 나머지 admin 생성 사용자는 뒤에서 같은 방식으로 설정.
// 선행: TC-IAM-013(사용자), TC-IAM-037(역할 — 역할이 없으면 로그인 후 화면이 비어 있을 수 있음).
test('TC-IAM-016 admin: 사용자 비밀번호 설정 (Reset Password) (구 IV1_S24)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  const r = REC_ADMIN_USER;
  test.skip(!(await userExistsAll(page, r)), `선행 TC-IAM-013 의 ${r.id} 없음`);
  dialogsWithMemo(page, dialogs); // 성공 alert 를 녹화에 남김(공통 dialogs 기록에도 쌓임)
  for (const u of [r, ...USERS.filter((x) => x.via === 'admin' && x.id !== r.id)]) {
    await test.step(`${u.id}: Users → 사용자 선택 → Reset Password → 새 비밀번호 입력 → Reset Password`, async () => {
      await gotoUsersAll(page);
      await selectUserRow(page, u);
      await page.waitForTimeout(1000);
      await page.getByRole('button', { name: 'Reset Password' }).click();
      const dlg = page.getByRole('dialog');
      await expect(dlg).toBeVisible();
      await dlg.getByPlaceholder('Enter new password').fill(pwOf(u.id));
      await dlg.getByPlaceholder('Confirm new password').fill(pwOf(u.id));
      await page.waitForTimeout(1500); // 입력한 모달이 영상에 보이도록
      const before = dialogs.length;
      await dlg.getByRole('button', { name: 'Reset Password' }).click();
      await expect.poll(() => dialogs.slice(before).join('|'), { timeout: 20_000 }).toContain('Password reset successfully');
      await page.waitForTimeout(2500); // 성공 메모가 보이도록
      test.info().annotations.push({ type: `reset ${u.id}`, description: dialogs.slice(before).join(' | ') });
    });
  }
  await test.step(`${r.id}: 로그아웃 → 설정한 비밀번호로 로그인`, async () => {
    await logout(page);
    await loginAndSettle(page, r.id, pwOf(r.id));
    await expect(page, `${r.id} 로그인 성공`).not.toHaveURL(/\/auth\//);
    await page.waitForTimeout(3000);
    test.info().annotations.push({ type: 'login', description: `${r.id} 로그인 성공 → ${new URL(page.url()).pathname}` });
  });
});

TC-IAM-017 (구 IV1_S22)

tests/iam/TC-IAM-017.spec.ts · 32줄 · 해시 bc74c0bd · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, dialogsWithMemo } from '../../lib/ui';
import { REC_USER_ID, deleteUserByRow, gotoUsersAll, recUserFromId } from '../../lib/groups';

// TC-IAM-017 : admin 사용자 삭제 (Users > 사용자 행 클릭 > ⌄ Delete) [정상]
// 대상: TC-IAM-013~S04 녹화용 사용자 rec{MMDD}{a|b…}(삭제 가능, 2026-10-05 사용자 확인). 실제 사용자(iam01 등)는 대상 아님.
// 사용자마다 행을 클릭해 선택한 뒤 Delete(체크박스 선택은 사용하지 않음, 2026-10-05 사용자 지시) → 목록에서 사라짐 확인
test('TC-IAM-017 admin: 사용자 삭제 (녹화용 사용자) (구 IV1_S22)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  await gotoUsersAll(page);
  const emails = await page.getByRole('row').filter({ hasText: /rec\d{4}[a-z]{1,2}@mcmp\.test/ }).allInnerTexts();
  const ids = [...new Set(emails.map((t) => t.match(/(rec\d{4}[a-z]{1,2})@mcmp\.test/)?.[1]).filter((x): x is string => !!x && REC_USER_ID.test(x)))].sort();
  test.info().annotations.push({ type: 'targets', description: ids.join(', ') || '(없음)' });
  test.skip(!ids.length, '삭제할 녹화용 사용자 없음');
  const users = ids.map(recUserFromId);

  const gone = async (list: typeof users) => {
    await gotoUsersAll(page);
    for (const u of list) await expect(page.getByRole('row').filter({ hasText: u.email }), `${u.id} 목록에서 삭제됨`).toHaveCount(0, { timeout: 20_000 });
  };

  // 사용자별: 행 클릭 → ⌄ Delete → Confirm → 목록에서 사라짐
  dialogsWithMemo(page, dialogs); // 삭제 후 브라우저 alert 가 있으면 녹화에 [녹화 메모]로 남김(2026-10-19)
  for (const u of users) {
    const b = dialogs.length;
    const m = await deleteUserByRow(page, u);
    test.info().annotations.push({ type: `delete:${u.id}`, description: `모달 '${m}' → ${dialogs.slice(b).join(' | ') || '(알럿 없음)'}` });
    await gone([u]);
    await page.waitForTimeout(2000); // 삭제 후 목록이 영상에 보이도록
  }
});

TC-IAM-018 (구 IV9_S10)

tests/iam/TC-IAM-018.spec.ts · 36줄 · 해시 bb5fe8a6 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { gotoUsersAll } from '../../lib/groups';

// TC-IAM-018 사용자 동기화
// mc-iam-manager 에 사용자 동기화 API 경로가 없다. Users 화면에 동기화 기능(버튼/메뉴)이 있는지 확인하고, 없으면 근거(스크린샷)를 남기고 SKIP.
test('TC-IAM-018 사용자 동기화 (구 IV9_S10)', 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 가 생기면 이후 단계를 추가해야 함
});

TC-IAM-019 (구 IV9_S11)

tests/iam/TC-IAM-019.spec.ts · 31줄 · 해시 410e51de · 시나리오 보기

import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';

// TC-IAM-019 : 회사(법인) 등록 (POST /api/company) — TC-IAM-019
// 회사는 시스템에 1개(realm_name 유일). 설치(post-initial) 때 'Default Company'(realm mciam)가 등록된다.
// 설치된 서버: 등록 결과 조회 + 같은 realm 으로 다시 등록하면 409(중복 거부). 회사가 없는 서버에서만 실제 등록을 수행한다.
test('TC-IAM-019 회사(법인) 등록 (필수값 검증 400 · 등록 정보 조회 · 중복 등록 409) (구 IV9_S11)', 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);
  });
});

TC-IAM-020 (구 IV9_S12)

tests/iam/TC-IAM-020.spec.ts · 45줄 · 해시 b79a6080 · 시나리오 보기

import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';

// TC-IAM-020 : 회사 정보 수정·비활성화·활성화 (PUT / DELETE / POST /activate /api/company) — TC-IAM-020
// 설명만 바꿨다가 원래 값으로 되돌리고, 비활성화 직후 다시 활성화한다(회사 상태는 로그인 등에 쓰이지 않음 — 2026-10-13 소스 확인).
test('TC-IAM-020 회사 정보 수정·비활성화·활성화 (수정 → inactive → active, 원래 값 복원) (구 IV9_S12)', 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');
    });
  }
});

TC-IAM-021 (구 IV1_S01)

tests/iam/TC-IAM-021.spec.ts · 24줄 · 해시 dd67fafc · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, dialogsWithMemo } from '../../lib/ui';
import { REC_ORG, createGroup, gotoGroups, openGroup, treeItem } from '../../lib/groups';

// TC-IAM-021 : admin 최상위 조직 생성 (Groups > Create Org, Parent=None)
// 한 테스트 = 한 동작(2026-10-05 사용자 지시): 하위 조직 생성은 TC-IAM-022, 실제 조직(mcmp + 6개 팀) 확인은 TC-IAM-025.
// 실제 조직은 사용자가 배정돼 있어 다시 만들 수 없으므로(2026-10-04 사용자 결정) 녹화용 조직 recorg 로 생성 장면을 보여 준다.
// recorg 는 TC-IAM-027(삭제)에서 정리된다. 이미 있으면 생성은 건너뛰고(preexisting) 상세만 확인.
test('TC-IAM-021 admin: 최상위 조직 생성 (recorg) (구 IV1_S01)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  dialogsWithMemo(page, dialogs); // 브라우저 alert 를 [녹화 메모]로 영상에 남김(2026-10-19)
  await gotoGroups(page);
  if (await treeItem(page, REC_ORG).count()) {
    test.info().annotations.push({ type: 'preexisting', description: `${REC_ORG} 이미 존재(이전 실행 잔존) → 생성 건너뜀. TC-IAM-027 로 정리 필요` });
  } else {
    await createGroup(page, REC_ORG, 'IV1 녹화용 최상위 조직');
    await expect.poll(() => dialogs.join('|')).toContain('Group created successfully');
  }
  const detail = await openGroup(page, REC_ORG, `/${REC_ORG}`);
  expect(detail, '최상위 조직 Level 1').toMatch(/Level 1/);
  expect(detail, '설명 표시').toContain('IV1 녹화용 최상위 조직');
  await page.waitForTimeout(3000); // END_WAIT: 처리 결과가 영상에 보이도록 마지막 동작 후 3초(2026-10-19 사용자 지시)
});

TC-IAM-022 (구 IV1_S25)

tests/iam/TC-IAM-022.spec.ts · 23줄 · 해시 67222c0d · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, dialogsWithMemo } from '../../lib/ui';
import { REC_ORG, REC_TEAM, createGroup, gotoGroups, openGroup, treeItem } from '../../lib/groups';

// TC-IAM-022 : admin 하위 조직 생성 (Groups > Create Org, Parent=recorg) — TC-IAM-021 에서 분리(2026-10-05)
// 선행: TC-IAM-021(recorg). recteam 은 TC-IAM-026(수정)·TC-IAM-027(삭제)에서 이어서 사용. 이미 있으면 생성은 건너뛰고 상세만 확인.
test('TC-IAM-022 admin: 하위 조직 생성 (recteam) (구 IV1_S25)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  dialogsWithMemo(page, dialogs); // 브라우저 alert 를 [녹화 메모]로 영상에 남김(2026-10-19)
  await gotoGroups(page);
  test.skip(!(await treeItem(page, REC_ORG).count()), `선행 TC-IAM-021 의 ${REC_ORG} 없음`);
  if (await treeItem(page, REC_TEAM).count()) {
    test.info().annotations.push({ type: 'preexisting', description: `${REC_TEAM} 이미 존재 → 생성 건너뜀` });
  } else {
    await createGroup(page, REC_TEAM, 'IV1 녹화용 하위 조직', REC_ORG);
    await expect.poll(() => dialogs.join('|')).toContain('Group created successfully');
  }
  const detail = await openGroup(page, REC_TEAM, `/${REC_ORG}/${REC_TEAM}`);
  expect(detail, '하위 조직 Level 2').toMatch(/Level 2/);
  expect(detail, '설명 표시').toContain('IV1 녹화용 하위 조직');
  await page.waitForTimeout(3000); // END_WAIT: 처리 결과가 영상에 보이도록 마지막 동작 후 3초(2026-10-19 사용자 지시)
});

TC-IAM-023 (구 IV1_S12)

tests/iam/TC-IAM-023.spec.ts · 20줄 · 해시 34af23c8 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, toasts, dialogsWithMemo } from '../../lib/ui';
import { ROOT_ORG } from '../../lib/data';
import { createOrg } from '../../lib/forms';
import { gotoGroups, treeItem } from '../../lib/groups';

// TC-IAM-023 : 조직 이름 중복 생성 (예상 실패) — 필수값 누락은 TC-IAM-024 로 분리(2026-10-05)
// 기대: 409 오류 알럿, 트리에 mcmp 가 하나뿐(중복 생성 안 됨)
test('TC-IAM-023 조직 이름 중복 생성 (예상실패) (구 IV1_S12)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  dialogsWithMemo(page, dialogs); // 브라우저 alert 를 [녹화 메모]로 영상에 남김(2026-10-19)
  await gotoGroups(page);
  await createOrg(page, ROOT_ORG);
  await expect.poll(() => dialogs.join(' | ')).toMatch(/Failed to create group|409/);
  await page.waitForTimeout(1500);
  await expect(treeItem(page, ROOT_ORG), `${ROOT_ORG} 는 하나뿐`).toHaveCount(1);
  test.info().annotations.push({ type: 'duplicate-result', description: [...dialogs, ...(await toasts(page))].join(' | ') || '(메시지 없음)' });
  await page.waitForTimeout(3000); // END_WAIT: 처리 결과가 영상에 보이도록 마지막 동작 후 3초(2026-10-19 사용자 지시)
});

TC-IAM-024 (구 IV1_S29)

tests/iam/TC-IAM-024.spec.ts · 21줄 · 해시 91d034c6 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, dialogsWithMemo } from '../../lib/ui';
import { gotoGroups } from '../../lib/groups';

// TC-IAM-024 : 조직 생성 필수값(Name) 누락 (예상 실패) — TC-IAM-023 에서 분리(2026-10-05)
// 기대: Name 을 비우고 Save → 알럿 'Name is required.', 팝업이 닫히지 않고 생성되지 않음
test('TC-IAM-024 조직 생성 필수값 누락 (예상실패) (구 IV1_S29)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  dialogsWithMemo(page, dialogs); // 브라우저 alert 를 [녹화 메모]로 영상에 남김(2026-10-19)
  await gotoGroups(page);
  await page.getByRole('button', { name: 'Create Org' }).click();
  const dlg = page.getByRole('dialog');
  await expect(dlg.locator('#group-name')).toHaveValue('');
  await dlg.getByRole('button', { name: 'Save' }).click();
  await expect.poll(() => dialogs.join(' | ')).toContain('Name is required');
  await expect(dlg, '팝업이 닫히지 않음').toBeVisible();
  test.info().annotations.push({ type: 'required-result', description: dialogs.join(' | ') });
  await dlg.getByRole('button', { name: 'Cancel' }).click();
  await page.waitForTimeout(3000); // END_WAIT: 처리 결과가 영상에 보이도록 마지막 동작 후 3초(2026-10-19 사용자 지시)
});

TC-IAM-025 (구 IV1_S26)

tests/iam/TC-IAM-025.spec.ts · 28줄 · 해시 b8c501b8 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, dialogsWithMemo } from '../../lib/ui';
import { ROOT_ORG, TEAMS } from '../../lib/data';
import { createOrg } from '../../lib/forms';
import { gotoGroups, openGroup, treeItem } from '../../lib/groups';

// TC-IAM-025 : admin 실제 조직 구성 확인 (mcmp + 하위 6개 팀) — TC-IAM-021 에서 분리(2026-10-05)
// 다른 시나리오(사용자 조직 할당 등)의 전제. 없을 때만 생성하고(멱등) 각 조직 상세 Path /mcmp/{team} 을 확인한다.
test('TC-IAM-025 admin: 실제 조직 구성 확인 (mcmp + 6개 팀) (구 IV1_S26)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  dialogsWithMemo(page, dialogs); // 브라우저 alert 를 [녹화 메모]로 영상에 남김(2026-10-19)
  await gotoGroups(page);
  if (!(await treeItem(page, ROOT_ORG).count())) {
    await createOrg(page, ROOT_ORG);
    await expect.poll(() => dialogs.join('|')).toContain('Group created successfully');
    test.info().annotations.push({ type: 'created', description: ROOT_ORG });
  }
  for (const team of TEAMS) {
    if (await treeItem(page, team).count()) continue;
    await createOrg(page, team, ROOT_ORG);
    await expect(treeItem(page, team).first()).toBeVisible({ timeout: 20_000 });
    test.info().annotations.push({ type: 'created', description: team });
  }
  await openGroup(page, ROOT_ORG, `/${ROOT_ORG}`);
  for (const team of TEAMS) await openGroup(page, team, `/${ROOT_ORG}/${team}`);
  await page.waitForTimeout(3000); // END_WAIT: 처리 결과가 영상에 보이도록 마지막 동작 후 3초(2026-10-19 사용자 지시)
});

TC-IAM-026 (구 IV1_S20)

tests/iam/TC-IAM-026.spec.ts · 27줄 · 해시 ef59ceb0 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, dialogsWithMemo } from '../../lib/ui';
import { REC_ORG, REC_TEAM, REC_TEAM_EDITED, editGroup, gotoGroups, groupToasts, openGroup, treeItem } from '../../lib/groups';

// TC-IAM-026 : admin 조직 수정 (Groups > 조직 선택 > Edit) [정상]
// 대상: TC-IAM-021 에서 만든 녹화용 하위 조직 recteam → 이름 recteam2, 설명 변경 → 트리·상세(Path/Description) 반영 확인
// 멱등: 이미 recteam2 로 바뀌어 있으면 수정은 건너뛰고 상태만 확인.
test('TC-IAM-026 admin: 조직 수정 (Edit) (구 IV1_S20)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  dialogsWithMemo(page, dialogs); // 브라우저 alert 를 [녹화 메모]로 영상에 남김(2026-10-19)
  await gotoGroups(page);
  const desc = 'TC-IAM-026 수정됨';
  if (await treeItem(page, REC_TEAM).count()) {
    await openGroup(page, REC_TEAM, `/${REC_ORG}/${REC_TEAM}`);
    await editGroup(page, REC_TEAM_EDITED, desc);
    await expect(treeItem(page, REC_TEAM_EDITED).first(), `트리에 ${REC_TEAM_EDITED} 표시`).toBeVisible({ timeout: 20_000 });
    await expect(treeItem(page, REC_TEAM), `이전 이름 ${REC_TEAM} 없음`).toHaveCount(0);
  } else {
    test.skip(!(await treeItem(page, REC_TEAM_EDITED).count()), `선행 TC-IAM-021 의 ${REC_TEAM} 없음`);
    test.info().annotations.push({ type: 'preexisting', description: `${REC_TEAM_EDITED} 이미 수정됨 → 확인만` });
  }
  const detail = await openGroup(page, REC_TEAM_EDITED, `/${REC_ORG}/${REC_TEAM_EDITED}`);
  expect(detail, '수정한 설명 표시').toContain(desc);
  test.info().annotations.push({ type: 'messages', description: [...dialogs, ...(await groupToasts(page))].join(' | ') || '(없음)' });
  await page.waitForTimeout(3000); // END_WAIT: 처리 결과가 영상에 보이도록 마지막 동작 후 3초(2026-10-19 사용자 지시)
});

TC-IAM-027 (구 IV1_S21)

tests/iam/TC-IAM-027.spec.ts · 32줄 · 해시 9a24a37a · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, dialogsWithMemo } from '../../lib/ui';
import { REC_ORG, REC_TEAM, REC_TEAM_EDITED, deleteGroup, gotoGroups, groupToasts, openGroup, treeItem } from '../../lib/groups';

// TC-IAM-027 : admin 조직 삭제 (Groups > 조직 선택 > Delete) — 하위 조직 → 상위 조직 순서로 삭제
// 하위 조직이 있는 조직의 삭제 거부는 TC-IAM-028 으로 분리(2026-10-05).
// 대상: 녹화용 recteam2(TC-IAM-026 수정 후, 없으면 recteam) → recorg. 실제 조직은 대상 아님.
test('TC-IAM-027 admin: 조직 삭제 (Delete) (구 IV1_S21)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  dialogsWithMemo(page, dialogs); // 브라우저 alert 를 [녹화 메모]로 영상에 남김(2026-10-19)
  await gotoGroups(page);
  test.skip(!(await treeItem(page, REC_ORG).count()), `선행 TC-IAM-021 의 ${REC_ORG} 없음`);

  for (const child of [REC_TEAM_EDITED, REC_TEAM]) {
    if (!(await treeItem(page, child).count())) continue;
    await openGroup(page, child, `/${REC_ORG}/${child}`);
    const b = dialogs.length;
    await deleteGroup(page);
    test.info().annotations.push({ type: `delete:${child}`, description: [...dialogs.slice(b), ...(await groupToasts(page))].join(' | ') || '(메시지 없음)' });
    await gotoGroups(page);
    await expect(treeItem(page, child), `${child} 삭제됨`).toHaveCount(0);
  }

  await openGroup(page, REC_ORG, `/${REC_ORG}`);
  const b = dialogs.length;
  await deleteGroup(page);
  test.info().annotations.push({ type: `delete:${REC_ORG}`, description: [...dialogs.slice(b), ...(await groupToasts(page))].join(' | ') || '(메시지 없음)' });
  await gotoGroups(page);
  await expect(treeItem(page, REC_ORG), `${REC_ORG} 삭제됨`).toHaveCount(0);
  await page.waitForTimeout(3000); // END_WAIT: 처리 결과가 영상에 보이도록 마지막 동작 후 3초(2026-10-19 사용자 지시)
});

TC-IAM-028 (구 IV1_S30)

tests/iam/TC-IAM-028.spec.ts · 31줄 · 해시 e5c53c88 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, dialogsWithMemo } from '../../lib/ui';
import { REC_ORG, REC_TEAM, REC_TEAM_EDITED, deleteGroup, gotoGroups, groupToasts, openGroup, treeItem } from '../../lib/groups';

// TC-IAM-028 : 하위 조직이 있는 조직 삭제 (예상 실패) — TC-IAM-027 에서 분리(2026-10-05), TC-IAM-027(삭제) 전에 실행
// 기대: 확인 팝업 안내("Groups with child organizations or assigned users cannot be deleted.") 후 Delete 해도 삭제되지 않고 트리에 남음
// 선행: TC-IAM-021(recorg) + TC-IAM-022(recteam, TC-IAM-026 이후 recteam2)
test('TC-IAM-028 하위 조직이 있는 조직 삭제 (예상실패) (구 IV1_S30)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  dialogsWithMemo(page, dialogs); // 브라우저 alert 를 [녹화 메모]로 영상에 남김(2026-10-19)
  await gotoGroups(page);
  const child = (await treeItem(page, REC_TEAM_EDITED).count()) ? REC_TEAM_EDITED : REC_TEAM;
  test.skip(!(await treeItem(page, REC_ORG).count()) || !(await treeItem(page, child).count()), `선행 ${REC_ORG}/${child} 없음(TC-IAM-021, TC-IAM-022)`);

  await openGroup(page, REC_ORG, `/${REC_ORG}`);
  const before = dialogs.length;
  const confirmText = await deleteGroup(page);
  // 삭제 거부 toast 가 영상에 3초 정도 보이도록: toast 표시를 기다린 뒤 3초 유지하고 나서 이동(2026-10-19 사용자 지적: toast 가 너무 짧게 보임)
  const toast = page.locator('[class*="toast"]:visible, [role="alert"]:visible').filter({ hasText: /\S/ }).first();
  await toast.waitFor({ timeout: 10_000 }).catch(() => {});
  const toastText = (await toast.innerText().catch(() => '')).replace(/\s+/g, ' ').trim();
  test.info().annotations.push({ type: 'toast', description: toastText || '(toast 없음)' });
  await page.waitForTimeout(3000);
  const result = [...dialogs.slice(before), ...(await groupToasts(page)), toastText].filter(Boolean).join(' | ') || '(메시지 없음)';
  test.info().annotations.push({ type: 'delete-parent', description: `팝업: ${confirmText} / 결과: ${result}` });
  await gotoGroups(page);
  await expect(treeItem(page, REC_ORG).first(), '하위 조직이 있으면 상위 조직은 삭제되지 않음').toBeVisible();
  await expect(treeItem(page, child).first()).toBeVisible();
  await page.waitForTimeout(3000); // END_WAIT: 처리 결과가 영상에 보이도록 마지막 동작 후 3초(2026-10-19 사용자 지시)
});

TC-IAM-029 (구 IV1_S17)

tests/iam/TC-IAM-029.spec.ts · 36줄 · 해시 7f948740 · 시나리오 보기

import fs from 'node:fs';
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { ROOT_ORG, TEAMS } from '../../lib/data';
import { URL_GROUPS, exportGroupsJson, showDownloadOverlay } from '../../lib/forms';

// TC-IAM-029 : admin Groups 조직도 Export JSON (선행: TC-IAM-021). 데이터를 바꾸지 않으므로 반복 실행 가능.
// 2026-10-08: Export JSON 클릭 → 다운로드 파일명 표시 → 파일 내용(JSON) 표시까지 녹화(사용자 지시). 다운로드 표시줄은 헤드리스에서 영상에 안 보여 화면 오버레이로 대체.
test('TC-IAM-029 admin: Groups 조직도 Export JSON (구 IV1_S17)', async ({ page }) => {
  await loginAsAdmin(page, 'ws01', null);
  await page.goto(URL_GROUPS);
  await expect(page.getByRole('tree').getByText(ROOT_ORG, { exact: true })).toBeVisible({ timeout: 60_000 });

  const file = test.info().outputPath('groups-export.json');
  const name = await exportGroupsJson(page, file);
  expect(name).toMatch(/^.+-OrganizationTree-\d{8}\.json$/);

  // 내려받은 파일명과 내용을 화면에 표시해 녹화에 남긴다(헤드리스 브라우저는 다운로드 표시줄이 영상에 없음)
  await showDownloadOverlay(page, name, fs.readFileSync(file, 'utf-8'));

  const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
  expect(data.version).toBe('1.0');
  const root = (data.groups ?? []).find((g: any) => g.name === ROOT_ORG);
  expect(root, `${ROOT_ORG} 가 export 파일에 없음`).toBeTruthy();
  const children: any[] = root.children ?? [];
  for (const t of TEAMS) expect(children.map((c) => c.name)).toContain(t);
  for (const c of children) expect(c.organization_code, `${c.name} 코드 없음`).toBeTruthy();

  test.info().annotations.push({
    type: 'export-file',
    description: `${name} (최상위 ${data.groups.length}개) ${ROOT_ORG}(${root.organization_code ?? '-'}) > ${children.map((c) => `${c.name}(${c.organization_code})`).join(', ')}`,
  });
  await test.info().attach('groups-export.json', { path: file, contentType: 'application/json' });
  await page.waitForTimeout(3000); // END_WAIT: 처리 결과가 영상에 보이도록 마지막 동작 후 3초(2026-10-19 사용자 지시)
});

TC-IAM-030 (구 IV1_S18)

tests/iam/TC-IAM-030.spec.ts · 38줄 · 해시 70ca17bc · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, dialogsWithMemo } from '../../lib/ui';
import { ROOT_ORG, TEAMS } from '../../lib/data';
import { URL_GROUPS, exportGroupsJson, importGroupsJson } from '../../lib/forms';

// TC-IAM-030 : admin Groups 조직도 Import JSON (선행: TC-IAM-021).
// 방금 Export 한 파일을 그대로 Import 한다 → 모든 그룹이 organization_code·이름이 같으므로 생성/수정 0건(변경 없음)이어야 한다.
// stage 는 데이터를 되돌리기 어려워 기존 조직을 바꾸지 않는 왕복(round-trip) 방식으로 검증한다. 반복 실행 가능.
test('TC-IAM-030 admin: Groups 조직도 Import JSON (Export 파일 재적용) (구 IV1_S18)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  dialogsWithMemo(page, dialogs); // 브라우저 alert 를 [녹화 메모]로 영상에 남김(2026-10-19)
  await page.goto(URL_GROUPS);
  const tree = page.getByRole('tree');
  await expect(tree.getByText(ROOT_ORG, { exact: true })).toBeVisible({ timeout: 60_000 });

  const file = test.info().outputPath('groups-export.json');
  await exportGroupsJson(page, file);
  await importGroupsJson(page, file);

  const result = page.locator('#import-result-body');
  await expect(result).toContainText('Import complete', { timeout: 60_000 });
  const text = (await result.innerText()).replace(/\s+/g, ' ').trim();
  test.info().annotations.push({ type: 'import-result', description: text });
  expect(text, '같은 파일을 다시 가져오면 생성/수정/실패가 없어야 함').not.toMatch(/created|updated|fail/i);

  // 트리가 그대로인지(중복 생성 없음) 화면의 Refresh 로 다시 불러와 확인.
  // (주소로 다시 이동하면 트리가 60초 넘게 비어 있는 경우가 있었음, 2026-10-02)
  // 결과 창(#import-result-modal)은 Escape 로 닫히지 않아 Refresh 클릭을 가린다(2026-10-05) → 창의 닫기 버튼 사용
  const modal = page.locator('#import-result-modal');
  await modal.locator('[data-bs-dismiss="modal"]').or(modal.getByRole('button', { name: /^(Close|OK|Confirm)$/i })).first().click();
  await expect(modal).toBeHidden({ timeout: 10_000 });
  await page.getByRole('button', { name: 'Refresh' }).click();
  await expect(tree.getByText(ROOT_ORG, { exact: true })).toHaveCount(1, { timeout: 60_000 });
  for (const t of TEAMS) await expect(tree.getByText(t, { exact: true })).toHaveCount(1);
  expect(dialogs.join(' | ')).not.toMatch(/Invalid JSON/);
  await page.waitForTimeout(3000); // END_WAIT: 처리 결과가 영상에 보이도록 마지막 동작 후 3초(2026-10-19 사용자 지시)
});

TC-IAM-031 (구 IV1_S19)

tests/iam/TC-IAM-031.spec.ts · 25줄 · 해시 4118a6a7 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, dialogsWithMemo, recMemo } from '../../lib/ui';
import { ROOT_ORG } from '../../lib/data';
import { URL_GROUPS, importGroupsJson } from '../../lib/forms';

// TC-IAM-031 : admin Groups Import JSON 에 잘못된(파싱 불가) JSON 파일 → 알럿 "Invalid JSON file.", 조직 변경 없음 (예상실패)
test('TC-IAM-031 admin: Groups Import JSON 잘못된 파일 (예상실패) (구 IV1_S19)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  dialogsWithMemo(page, dialogs); // 브라우저 alert 를 [녹화 메모]로 영상에 남김(2026-10-19)
  await page.goto(URL_GROUPS);
  const tree = page.getByRole('tree');
  await expect(tree.getByText(ROOT_ORG, { exact: true })).toBeVisible({ timeout: 60_000 });

  // 파일 선택창은 녹화에 안 찍히므로 고르는 파일을 메모로 먼저 보여 준다(2026-10-19 사용자 지적: 잘못된 파일 선택·실패 메시지를 영상에서 확인 불가)
  const bad = '{ "version": "1.0", "groups": [ { "name": ';
  await recMemo(page, `[녹화 메모] Import JSON → 잘못된 파일 선택: invalid-groups.json (내용이 중간에 끊긴 JSON: ${bad})`);
  await page.waitForTimeout(3000);
  await importGroupsJson(page, { name: 'invalid-groups.json', mimeType: 'application/json', buffer: Buffer.from(bad) });
  await expect.poll(() => dialogs.join(' | ')).toContain('Invalid JSON file');
  await page.waitForTimeout(1500);
  await expect(page.locator('#import-result-body')).not.toContainText('Import complete');
  await expect(tree.getByText(ROOT_ORG, { exact: true })).toHaveCount(1);
  await page.waitForTimeout(3000); // END_WAIT: 처리 결과가 영상에 보이도록 마지막 동작 후 3초(2026-10-19 사용자 지시)
});

TC-IAM-032 (구 IV1_S27)

tests/iam/TC-IAM-032.spec.ts · 35줄 · 해시 174e26db · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, dialogsWithMemo } from '../../lib/ui';
import { USERS } from '../../lib/data';
import { selectUserRow, addGroupFromUsers } from '../../lib/forms';
import { REC_SIGNUP_USER, gotoUsersAll, inviteToGroup, roleAssignedAll, userDetail, userExistsAll } from '../../lib/groups';

// TC-IAM-032 : admin 가입 사용자 조직 할당 — TC-IAM-006 에서 분리(2026-10-05)
// 할당 방법 2종: 녹화용 rec{MMDD}b·workflow01·app01 = Groups > 조직 선택 > Invite Member, 나머지(monitor01·cost01·cost02) = Users > Add Group.
// 선행: TC-IAM-006(승인). Invite Member 는 이미 멤버면 체크박스가 비활성 → 건너뜀. 실제 사용자는 역할까지 끝난(Platform Roles=Y) 경우 건너뜀.
test('TC-IAM-032 admin: 가입 사용자 조직 할당 (Invite Member / Add Group) (구 IV1_S27)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  dialogsWithMemo(page, dialogs); // 브라우저 alert 를 [녹화 메모]로 영상에 남김(2026-10-19)
  const r = REC_SIGNUP_USER;
  test.skip(!(await userExistsAll(page, r)), `선행 TC-IAM-005·S04 의 ${r.id} 없음(미승인 사용자는 Users 목록에 없음)`);
  for (const u of [r, ...USERS.filter((x) => x.via === 'signup')]) {
    if (u !== r && (await roleAssignedAll(page, u))) {
      test.info().annotations.push({ type: 'preexisting', description: `${u.id}: 조직·역할 이미 할당됨 → 건너뜀` });
      continue;
    }
    if (u === r || u.team === 'workflow' || u.team === 'app') {
      await inviteToGroup(page, u);
    } else {
      await gotoUsersAll(page);
      await selectUserRow(page, u);
      const before = dialogs.length;
      await addGroupFromUsers(page, u);
      await expect.poll(() => dialogs.slice(before).join('|')).toContain('Group assigned successfully');
    }
    test.info().annotations.push({ type: 'assigned', description: `${u.id} → ${u.team}` });
  }
  await gotoUsersAll(page);
  expect(await userDetail(page, r), `${r.id} 상세에 조직 ${r.team}`).toContain(r.team);
  await page.waitForTimeout(3000); // END_WAIT: 처리 결과가 영상에 보이도록 마지막 동작 후 3초(2026-10-19 사용자 지시)
});

TC-IAM-033 (구 IV9_S13)

tests/iam/TC-IAM-033.spec.ts · 52줄 · 해시 9ab2ad0d · 시나리오 보기

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

// TC-IAM-033 역할 생성 (Roles > Add Role > Create New Role > Role Name/Description > Save Role)
// 테스트 전용 역할 iv9role 을 만들고, 마지막 정리 단계에서 삭제한다(실패해도 finally 로 삭제 시도).
test('TC-IAM-033 역할 생성 (구 IV9_S13)', 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);
    });
  }
});

TC-IAM-034 (구 IV9_S14)

tests/iam/TC-IAM-034.spec.ts · 31줄 · 해시 70ad9dc6 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { URL_ROLES } from '../../lib/forms';
import { gotoRoles, roleRow } from '../../lib/iv9';

// TC-IAM-034 역할 목록 조회 (Workspaces > Roles, Refresh)
// 기본 역할 5종(admin, operator, viewer, billadmin, billviewer) 표시 확인. 2026-10-13: 목록은 트리(계층)가 아닌 표 형태로 표시됨 → 기록.
const BASE_ROLES = ['admin', 'operator', 'viewer', 'billadmin', 'billviewer'];
test('TC-IAM-034 역할 목록 조회 (구 IV9_S14)', 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)' });
  });
});

TC-IAM-035 (구 IV9_S15)

tests/iam/TC-IAM-035.spec.ts · 47줄 · 해시 273bc77c · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, clickMenu } from '../../lib/ui';
import { URL_ROLES } from '../../lib/forms';
import { gotoRoles, openRole } from '../../lib/iv9';

// TC-IAM-035 역할 상세 조회 (Roles > 역할 클릭 > Role Detail). 조회만, 변경 없음.
// 2026-10-19 사용자 지시: 상세 폼이 나타나면 접혀 있는 Platform access / Workspace access / CSP Role 을 펼쳐 확인하고, 모두 끝난 뒤 3초 후 종료
test('TC-IAM-035 역할 상세 조회 (구 IV9_S15)', 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');
  });
  const card = page.locator('#role-detail-card');
  await test.step('3. 하단 Role Detail 정보(Name, Description, Platform/Workspace access, CSP Role) 표시 확인', async () => {
    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);
  });
  // 접힌 섹션: 헤더의 토글 버튼(#<name>-toggle-btn) → 본문(.collapse) 이 펼쳐짐(2026-10-19 탐색)
  const sections: [string, string, string][] = [
    ['Platform access', '#platform-toggle-btn', '#platform-menu-body'],
    ['Workspace access', '#workspace-toggle-btn', '#workspace-menu-body'],
    ['CSP Role', '#csp-toggle-btn', '#csp-role-mapping-body'],
  ];
  for (const [name, btn, body] of sections) {
    await test.step(`4. ${name} 펼쳐서 확인`, async () => {
      const b = page.locator(body);
      if (!(await b.isVisible())) await page.locator(btn).click();
      await expect(b, `${name} 펼쳐짐`).toBeVisible({ timeout: 10_000 });
      await b.scrollIntoViewIfNeeded();
      await page.waitForTimeout(2500); // 펼친 내용이 영상에 보이도록
      test.info().annotations.push({ type: `expanded ${name}`, description: (await b.innerText()).replace(/\s+/g, ' ').slice(0, 300) });
    });
  }
  await page.waitForTimeout(3000); // 모두 확인한 뒤 3초 후 종료
});

TC-IAM-036 (구 IV1_S28)

tests/iam/TC-IAM-036.spec.ts · 36줄 · 해시 a39bd510 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { USERS } from '../../lib/data';
import { selectUserRow, addBothRoles } from '../../lib/forms';
import { REC_SIGNUP_USER, gotoUsersAll, roleAssignedAll, userDetail, userExistsAll } from '../../lib/groups';

// TC-IAM-036 : admin 가입 사용자 역할 부여 (Users > 사용자 선택 > Add platform role / Add Workspace role) — TC-IAM-006 에서 분리(2026-10-05)
// 선행: TC-IAM-032(조직 할당). 녹화용 rec{MMDD}b = viewer, 실제 workflow01·app01=operator, monitor01=viewer, cost01=billviewer, cost02=billadmin.
// Platform Roles=Y 인 사용자는 건너뛴다(멱등).
test('TC-IAM-036 admin: 가입 사용자 역할 부여 (구 IV1_S28)', async ({ page }) => {
  await loginAsAdmin(page, 'ws01', null);
  const r = REC_SIGNUP_USER;
  test.skip(!(await userExistsAll(page, r)), `선행 TC-IAM-005·S04 의 ${r.id} 없음`);
  for (const u of [r, ...USERS.filter((x) => x.via === 'signup')]) {
    if (await roleAssignedAll(page, u)) {
      test.info().annotations.push({ type: 'preexisting', description: `${u.id}: 역할 이미 할당됨 → 건너뜀` });
      continue;
    }
    await gotoUsersAll(page);
    await selectUserRow(page, u);
    await addBothRoles(page, u.role);
    test.info().annotations.push({ type: 'assigned', description: `${u.id} → ${u.role} (platform·workspace)` });
  }
  expect(await roleAssignedAll(page, r), `${r.id} Platform Roles=Y`).toBe(true);
  // 2026-10-19 사용자 지시: 역할 할당 후 상세 화면에서 해당 역할이 제대로 할당됐는지 확인하고 영상 종료
  await gotoUsersAll(page);
  const detail = await userDetail(page, r);
  test.info().annotations.push({ type: 'detail', description: detail.slice(0, 300) });
  expect(detail, `${r.id} 상세 Platform Roles 에 ${r.role}`).toMatch(new RegExp(`Platform Roles[^]*?Name\\s+${r.role}\\b`));
  // 상세의 Workspace 칸에는 역할명이 아니라 할당된 Workspace(ws01)가 표시됨(2026-10-19 확인)
  expect(detail, `${r.id} 상세 Workspace 에 ws01`).toMatch(/Workspace\s+Name\s+ws01\b/);
  const roles = page.getByText('Platform Roles', { exact: true }).first();
  await roles.scrollIntoViewIfNeeded().catch(() => {});
  await page.waitForTimeout(3000); // 상세 화면의 할당된 역할이 영상에 보이도록
});

TC-IAM-037 (구 IV1_S23)

tests/iam/TC-IAM-037.spec.ts · 38줄 · 해시 22033f9e · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin, dialogsWithMemo } from '../../lib/ui';
import { USERS } from '../../lib/data';
import { selectUserRow, addGroupFromUsers, addBothRoles } from '../../lib/forms';
import { REC_ADMIN_USER, gotoUsersAll, roleAssignedAll, userDetail, userExistsAll } from '../../lib/groups';

// TC-IAM-037 : admin 사용자 조직·역할 할당 (Users > 사용자 선택 > Add Group, Add platform role / Add Workspace role) — TC-IAM-013 에서 분리(2026-10-05)
// 선행: TC-IAM-013. 녹화용 rec{MMDD}a = 조직 monitor / 역할 viewer, 실제 iam01(iam/admin)·infra01(infra/operator).
// 이미 역할이 할당된 사용자(Platform Roles=Y)는 건너뛴다(멱등).
test('TC-IAM-037 admin: 사용자 조직·역할 할당 (구 IV1_S23)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  dialogsWithMemo(page, dialogs); // 'Group assigned successfully' alert 를 [녹화 메모]로 영상에 남김(2026-10-19)
  const r = REC_ADMIN_USER;
  test.skip(!(await userExistsAll(page, r)), `선행 TC-IAM-013 의 ${r.id} 없음`);
  for (const u of [r, ...USERS.filter((x) => x.via === 'admin')]) {
    if (await roleAssignedAll(page, u)) {
      test.info().annotations.push({ type: 'preexisting', description: `${u.id}: 역할 이미 할당됨 → 건너뜀` });
      continue;
    }
    await gotoUsersAll(page);
    await selectUserRow(page, u);
    const before = dialogs.length;
    await addGroupFromUsers(page, u);
    await expect.poll(() => dialogs.slice(before).join('|')).toContain('Group assigned successfully');
    await addBothRoles(page, u.role);
    test.info().annotations.push({ type: 'assigned', description: `${u.id}: 조직 ${u.team}, 역할 ${u.role}` });
  }
  expect(await roleAssignedAll(page, r), `${r.id} Platform Roles=Y`).toBe(true);
  // 2026-10-19 사용자 지시: 할당 후 상세 화면에서 조직·역할이 제대로 할당됐는지 확인(3초 보이게)하고 종료
  await gotoUsersAll(page);
  const d = await userDetail(page, r);
  expect(d, `${r.id} 상세에 조직 ${r.team}`).toContain(r.team);
  expect(d, `${r.id} 상세 Platform Roles 에 ${r.role}`).toMatch(new RegExp(`Platform Roles[^]*?Name\\s+${r.role}\\b`));
  expect(d, `${r.id} 상세 Workspace 에 ws01`).toMatch(/Workspace\s+Name\s+ws01\b/);
  await page.getByText('Platform Roles', { exact: true }).first().scrollIntoViewIfNeeded().catch(() => {});
  await page.waitForTimeout(3000);
});

TC-IAM-038 (구 IV9_S16)

tests/iam/TC-IAM-038.spec.ts · 76줄 · 해시 73de227b · 시나리오 보기

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

// 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('TC-IAM-038 사용자에게 워크스페이스 역할 할당 (구 IV9_S16)', 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);
      });
    }
  }
});

TC-IAM-039 (구 IV1_S05)

tests/iam/TC-IAM-039.spec.ts · 5줄 · 해시 d39c7170 · 시나리오 보기

import { menuTest } from '../../lib/roles';

// TC-IAM-039 : 역할별 로그인 -> 노출 메뉴 확인 (대표 사용자는 lib/roles.ts 의 MENU_USERS)
menuTest('TC-IAM-039');

TC-IAM-040 (구 IV1_S06)

tests/iam/TC-IAM-040.spec.ts · 5줄 · 해시 2cb111d9 · 시나리오 보기

import { menuTest } from '../../lib/roles';

// TC-IAM-040 : 역할별 로그인 -> 노출 메뉴 확인 (대표 사용자는 lib/roles.ts 의 MENU_USERS)
menuTest('TC-IAM-040');

TC-IAM-041 (구 IV1_S07)

tests/iam/TC-IAM-041.spec.ts · 5줄 · 해시 28d2a530 · 시나리오 보기

import { menuTest } from '../../lib/roles';

// TC-IAM-041 : 역할별 로그인 -> 노출 메뉴 확인 (대표 사용자는 lib/roles.ts 의 MENU_USERS)
menuTest('TC-IAM-041');

TC-IAM-042 (구 IV1_S08)

tests/iam/TC-IAM-042.spec.ts · 5줄 · 해시 a4553df1 · 시나리오 보기

import { menuTest } from '../../lib/roles';

// TC-IAM-042 : 역할별 로그인 -> 노출 메뉴 확인 (대표 사용자는 lib/roles.ts 의 MENU_USERS)
menuTest('TC-IAM-042');

TC-IAM-043 (구 IV1_S09)

tests/iam/TC-IAM-043.spec.ts · 5줄 · 해시 43c6bdba · 시나리오 보기

import { menuTest } from '../../lib/roles';

// TC-IAM-043 : 역할별 로그인 -> 노출 메뉴 확인 (대표 사용자는 lib/roles.ts 의 MENU_USERS)
menuTest('TC-IAM-043');

TC-IAM-044 (구 IV1_S13)

tests/iam/TC-IAM-044.spec.ts · 5줄 · 해시 aa016b95 · 시나리오 보기

import { accessTest } from '../../lib/roles';

// TC-IAM-044 : 역할별 타 메뉴 URL 직접 접근 (예상실패, 대표 사용자는 lib/roles.ts 의 ACCESS_USERS)
accessTest('TC-IAM-044');

TC-IAM-045 (구 IV1_S14)

tests/iam/TC-IAM-045.spec.ts · 5줄 · 해시 dd9b9b71 · 시나리오 보기

import { accessTest } from '../../lib/roles';

// TC-IAM-045 : 역할별 타 메뉴 URL 직접 접근 (예상실패, 대표 사용자는 lib/roles.ts 의 ACCESS_USERS)
accessTest('TC-IAM-045');

TC-IAM-046 (구 IV1_S15)

tests/iam/TC-IAM-046.spec.ts · 5줄 · 해시 b92164a5 · 시나리오 보기

import { accessTest } from '../../lib/roles';

// TC-IAM-046 : 역할별 타 메뉴 URL 직접 접근 (예상실패, 대표 사용자는 lib/roles.ts 의 ACCESS_USERS)
accessTest('TC-IAM-046');

TC-IAM-047 (구 IV1_S16)

tests/iam/TC-IAM-047.spec.ts · 5줄 · 해시 f19d17fd · 시나리오 보기

import { accessTest } from '../../lib/roles';

// TC-IAM-047 : 역할별 타 메뉴 URL 직접 접근 (예상실패, 대표 사용자는 lib/roles.ts 의 ACCESS_USERS)
accessTest('TC-IAM-047');

TC-IAM-048 (구 IV9_S17)

tests/iam/TC-IAM-048.spec.ts · 30줄 · 해시 383d14fd · 시나리오 보기

import { test, expect } from '@playwright/test';
import { iamContext, call, iamLogin } from '../../lib/iam-api';

// TC-IAM-048 : MCMP API 동기화 (POST /api/setup/sync-mcmp-apis) — TC-IAM-048
// api.yaml(프레임워크별 서비스·액션 정의)을 IAM DB 로 동기화한다. 설치 스크립트가 기동마다 호출하는 멱등 동작.
test('TC-IAM-048 MCMP API 동기화 (sync-mcmp-apis → 서비스·액션 목록 존재·유지) (구 IV9_S17)', 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);
  });
});

TC-IAM-049 (구 IV9_S18)

tests/iam/TC-IAM-049.spec.ts · 30줄 · 해시 f48fb0f9 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { URL_WORKSPACES, gotoWorkspaces, wsRow } from '../../lib/iv9';

// TC-IAM-049 워크스페이스 목록 조회
// 2026-10-13: 좌측 'Workspaces' 그룹에는 Projects/Roles/CSP Roles 만 있고 Workspaces 목록 화면 링크가 없다
//   → 그룹 클릭이 화면을 열지 않으면 그룹 주소(/webconsole/operations/manage/workspaces)로 이동(기록).
test('TC-IAM-049 워크스페이스 목록 조회 (구 IV9_S18)', 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) });
  });
  await page.waitForTimeout(3000); // END_WAIT: 워크스페이스 목록 화면에서 3초 머문 뒤 종료(2026-10-19 사용자 지시)
});

TC-IAM-050 (구 IV9_S19)

tests/iam/TC-IAM-050.spec.ts · 37줄 · 해시 a8d8a7ff · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { IV9_WS, createWorkspace, deleteWorkspace, gotoWorkspaces, openWorkspace, toastTexts, wsRow } from '../../lib/iv9';

// TC-IAM-050 워크스페이스 생성 (Workspaces > Add Workspace > 이름·설명 > Confirm)
// 테스트 전용 iv9ws 를 만들고 마지막 정리 단계에서 삭제한다(실패해도 finally 로 삭제 시도).
test('TC-IAM-050 워크스페이스 생성 (구 IV9_S19)', 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();
    });
  }
});

TC-IAM-051 (구 IV9_S20)

tests/iam/TC-IAM-051.spec.ts · 73줄 · 해시 34613fc5 · 시나리오 보기

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

// 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('TC-IAM-051 워크스페이스에 사용자 추가 (구 IV9_S20)', 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);
    });
  }
});

TC-IAM-052 (구 IV3_S12)

tests/iam/TC-IAM-052.spec.ts · 54줄 · 해시 b422c0c9 · 시나리오 보기

// TC-IAM-052: Project 생성 → Workspace(ws01) 할당 [정상, 공통 — CSP 무관 기능]
// 화면: Workspaces > Projects(/webconsole/operations/manage/workspaces/projects) → 목록(ID, Name, Description, NsId, Workspaces, Created), Add Project 팝업(#modal-project-add:
//   #add-project-name, #add-project-description, #add-project-initial-workspace(ws01)), 행 클릭 → 상세(Assigned Workspaces 'ws01 (id: 1) Unassign', 'Assign to Workspace...' + Assign)
// 절차: 1.Projects 진입 2.p1 이 없으면 Add Project(Name=p1, Initial Workspace=ws01) → Save 3.목록에 p1(Workspaces 1) 4.행 클릭 → Assigned Workspaces 에 ws01 5.상단 Project 선택 목록에 p1
// 2026-10-12 사용자 결정: 프로젝트는 CSP 와 무관한 공통 기능 → CSP 별 9행을 공통 1행으로 합침(녹화번호 TC-IAM-052).
// p1: 사전점검(2026-10-01)으로 이미 존재 → 사용자 결정(2026-10-05) '존재 확인으로 대체'(생성 생략, 특화명 IV3_S12_Projects_ViewProject-CheckWorkspace)

import { test, expect } from '../../lib/fixtures';
import { WORKSPACE } from '../../lib/data';
import { loginAsAdmin, selectWorkspaceProject } from '../../lib/ui';

const URL_PROJECTS = '/webconsole/operations/manage/workspaces/projects';
const PRJ = 'p1';

test('TC-IAM-052 Project 생성 → Workspace(ws01) 할당 (구 IV3_S12)', async ({ page, dialogs }) => {
  await loginAsAdmin(page);
  await page.goto(URL_PROJECTS);
  await page.getByRole('gridcell', { name: 'default', exact: true }).first().waitFor({ timeout: 60_000 });
  const cell = page.getByRole('gridcell', { name: PRJ, exact: true });
  if (await cell.count()) {
    test.info().annotations.push({ type: 'preexisting', description: `${PRJ} 이미 존재 → 생성 생략(존재 확인으로 대체)` });
  } else {
    await page.getByRole('button', { name: /Add Project/ }).or(page.getByRole('link', { name: /Add Project/ })).first().click();
    const m = page.locator('#modal-project-add');
    await expect(m).toBeVisible();
    await m.locator('#add-project-name').fill(PRJ);
    await m.locator('#add-project-description').fill('TC-IAM-052 공통');
    await m.getByRole('button', { name: 'Save' }).click();
    await expect(m).toBeHidden({ timeout: 30_000 });
    const t = (await page.locator('[class*="toast"], [role="alert"]').allInnerTexts()).map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
    test.info().annotations.push({ type: 'create', description: [...t, ...dialogs].join(' | ').slice(0, 300) || '(메시지 없음)' });
    await page.goto(URL_PROJECTS);
    await expect(cell.first(), `목록에 ${PRJ}`).toBeVisible({ timeout: 60_000 });
  }
  // 목록 행: Workspaces 수
  const row = page.getByRole('row').filter({ has: cell.first() }).first();
  const ws = (await row.locator('[tabulator-field="workspaceCount"]').innerText()).trim();
  test.info().annotations.push({ type: 'row', description: (await row.innerText()).replace(/\s+/g, ' ') });
  expect(Number(ws), 'Workspaces 1 이상').toBeGreaterThanOrEqual(1);
  // 상단 Project 선택 목록에 표시(새 프로젝트는 즉시 반영 안 될 수 있어 Workspace 재선택 후 확인)
  await selectWorkspaceProject(page, WORKSPACE, null, true);
  await expect(page.locator('#select-current-project option', { hasText: new RegExp(`^${PRJ}$`) }), `상단 Project 목록에 ${PRJ}`).toHaveCount(1, { timeout: 30_000 });
  await page.locator('#select-current-project').selectOption({ label: PRJ });
  test.info().annotations.push({ type: 'header', description: `Project 선택: ${PRJ}` });
  // 2026-10-19 사용자 지시: 프로젝트를 선택해 상세 화면을 확인한 뒤 3초 후 종료 → 상세 확인을 마지막으로 옮김
  await page.goto(URL_PROJECTS);
  await cell.first().waitFor({ timeout: 60_000 });
  await cell.first().click();
  const assigned = page.getByText(new RegExp(`${WORKSPACE} \\(id: \\d+\\)`)).first();
  await expect(assigned, `Assigned Workspaces 에 ${WORKSPACE}`).toBeVisible({ timeout: 30_000 });
  await assigned.scrollIntoViewIfNeeded().catch(() => {});
  await page.waitForTimeout(3000);
});

TC-IAM-053 (구 IV9_S21)

tests/iam/TC-IAM-053.spec.ts · 77줄 · 해시 275eaaef · 시나리오 보기

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

// TC-IAM-053 프로젝트 수정 (Workspaces > Projects > 프로젝트 선택 > Edit > 설명 변경 > Save)
// 대상: 임시 Project ptmp(lib/projects.ts ensureTempProject). default / p1 / system 은 수정하지 않는다.
// 이 테스트가 ptmp 를 새로 만들었으면 마지막에 정리한다(ws01 Unassign → Delete). 원래 있던 ptmp 는 그대로 둔다.
test('TC-IAM-053 프로젝트 수정 (구 IV9_S21)', 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);
      });
    }
  }
});

TC-IAM-054 (구 IV3_S27)

tests/iam/TC-IAM-054.spec.ts · 51줄 · 해시 35451b76 · 시나리오 보기

// TC-IAM-054: Project 할당 해제: Workspace 에서 Project 를 Unassign [공통]  (사용자 지시 2026-10-08)
// 화면: Workspaces > Projects → 목록 행 선택 → 상세 Assigned Workspaces 의 Unassign
// 절차: 1.Projects 진입 2.임시 ptmp 행 선택 3.상세 Assigned Workspaces 에서 ws01 Unassign(확인창이 있으면 확인) 4.상세에서 ws01 이 사라지고 목록 Workspaces 수가 0 인지 확인
//   5.상단 Workspace=ws01 의 Project 선택 목록에서 ptmp 가 사라졌는지 확인
// 기대결과: ptmp 가 ws01 에서 해제되어 Assigned Workspaces 가 비고 ws01 의 Project 선택 목록에서 사라진다(Project 자체는 목록에 남음)
import { test, expect } from '../../lib/fixtures';
import { WORKSPACE } from '../../lib/data';
import { selectWorkspaceProject, recMemo } from '../../lib/ui';
import { openProjects, ensureTempProject, selectProject, projectRow, rowWorkspaceCount, protectedStillThere, PROJECTS_URL, TEMP_PROJECT, projectsAllRows } from '../../lib/projects';

test('TC-IAM-054 Project 할당 해제: Workspace 에서 Project 를 Unassign (구 IV3_S27)', async ({ page }) => {
  await openProjects(page);
  await ensureTempProject(page);
  await selectProject(page, TEMP_PROJECT);
  const detail0 = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  test.info().annotations.push({ type: 'before', description: `Workspaces ${await rowWorkspaceCount(page, TEMP_PROJECT)} / ${detail0.slice(detail0.indexOf('Assigned Workspaces'), detail0.indexOf('Assigned Workspaces') + 100)}` });
  const unassign = page.getByRole('button', { name: 'Unassign', exact: true });
  expect(await unassign.count(), 'Unassign 버튼(할당된 Workspace)').toBeGreaterThan(0);
  for (let i = 0; i < 5 && (await unassign.count()); i++) {
    await unassign.first().click();
    const um = page.locator('.modal.show').last();
    if (await um.isVisible({ timeout: 3000 }).catch(() => false)) {
      test.info().annotations.push({ type: 'unassign-confirm', description: (await um.innerText()).replace(/\s+/g, ' ').slice(0, 200) });
      await um.getByRole('button', { name: /Confirm|Unassign|OK|Yes/i }).last().click();
    }
    await page.waitForTimeout(2500);
  }
  // 목록/상세 갱신 확인
  await page.goto(PROJECTS_URL);
  await page.getByRole('gridcell', { name: 'default', exact: true }).first().waitFor({ timeout: 60_000 });
  await projectsAllRows(page);
  await expect(projectRow(page, TEMP_PROJECT), 'Project 자체는 목록에 남음').toHaveCount(1, { timeout: 30_000 });
  expect(await rowWorkspaceCount(page, TEMP_PROJECT), '목록 Workspaces 수').toBe(0);
  await selectProject(page, TEMP_PROJECT);
  const detail = (await page.locator('body').innerText()).replace(/\s+/g, ' ');
  test.info().annotations.push({ type: 'after', description: detail.slice(detail.indexOf('Assigned Workspaces'), detail.indexOf('Assigned Workspaces') + 100) });
  expect(await page.getByText(new RegExp(`${WORKSPACE} \\(id: \\d+\\)`)).count(), `Assigned Workspaces 에서 ${WORKSPACE} 사라짐`).toBe(0);
  await protectedStillThere(page);
  // 2026-10-19 사용자 지시: Unassign 후 화면 상단 refresh 버튼(#refresh-user-ws-prj) 클릭 → Workspace 선택 → Project 목록에서 해당 Project 가 안 보이는지 확인, 3초 뒤 종료
  await page.locator('#refresh-user-ws-prj').click();
  await page.waitForTimeout(2000);
  await selectWorkspaceProject(page, WORKSPACE, null, true);
  await expect(page.locator('#select-current-project option', { hasText: new RegExp(`^${TEMP_PROJECT}$`) }), `상단 Project 목록에서 ${TEMP_PROJECT} 사라짐`).toHaveCount(0, { timeout: 30_000 });
  const opts = (await page.locator('#select-current-project option').allInnerTexts()).map((s) => s.trim()).filter(Boolean);
  test.info().annotations.push({ type: 'header-projects', description: opts.join(', ') });
  // 선택 상자의 펼친 목록(브라우저 기본 드롭다운)은 녹화에 안 찍혀 목록 내용을 메모로 표시
  await recMemo(page, `[녹화 메모] refresh 후 Workspace=${WORKSPACE} 의 Project 목록: ${opts.join(', ')} — ${TEMP_PROJECT} 없음`);
  await page.locator('#select-current-project').focus().catch(() => {});
  await page.waitForTimeout(3000);
});

TC-IAM-055 (구 IV3_S26)

tests/iam/TC-IAM-055.spec.ts · 28줄 · 해시 97e8ccc4 · 시나리오 보기

// TC-IAM-055: Project 삭제 거부: Workspace 에 할당된 Project 는 삭제할 수 없다 (예상실패) [공통]  (사용자 지시 2026-10-08)
// 화면: Workspaces > Projects → 목록 행 선택 → 상세 Delete → 안내 팝업
// 절차: 1.Projects 진입 2.임시 ptmp(ws01 할당, 없으면 생성) 행 선택 3.상세 Delete 4.안내 팝업 확인 5.Close 후 ptmp 가 그대로 남아 있는지 확인
// 기대결과: 삭제가 거부되고 안내 팝업(Cannot Delete Project / assigned to: ws01 / Unassign it from all workspaces first)이 표시되며 ptmp 는 남는다
import { test, expect } from '../../lib/fixtures';
import { openProjects, ensureTempProject, selectProject, projectRow, rowWorkspaceCount, protectedStillThere, TEMP_PROJECT } from '../../lib/projects';

test('TC-IAM-055 Project 삭제 거부: Workspace 에 할당된 Project 는 삭제할 수 없다 (예상실패) (구 IV3_S26)', async ({ page }) => {
  await openProjects(page);
  await ensureTempProject(page);
  const ws = await rowWorkspaceCount(page, TEMP_PROJECT);
  test.info().annotations.push({ type: 'before', description: `${TEMP_PROJECT}: Workspaces ${ws}` });
  expect(ws, '시작 상태: ptmp 가 Workspace 에 할당됨').toBeGreaterThanOrEqual(1);
  await selectProject(page, TEMP_PROJECT);
  await page.getByRole('button', { name: 'Delete', exact: true }).click();
  const m = page.locator('.modal.show').last();
  await expect(m).toBeVisible();
  const msg = (await m.innerText()).replace(/\s+/g, ' ').trim();
  test.info().annotations.push({ type: 'delete-popup', description: msg });
  expect(msg, '삭제 거부 안내').toMatch(/Cannot Delete Project/i);
  expect(msg, '할당된 Workspace 표시').toMatch(/ws01/);
  expect(msg, '해제 안내').toMatch(/Unassign/i);
  await page.waitForTimeout(2500); // 녹화에 팝업이 보이도록
  await m.locator('.modal-footer').getByRole('button', { name: 'Close', exact: true }).click(); // 헤더 X(Close)와 구분해 푸터의 Close 버튼
  await expect(projectRow(page, TEMP_PROJECT), '삭제되지 않고 남음').toHaveCount(1);
  await protectedStillThere(page);
});

TC-IAM-056 (구 IV3_S28)

tests/iam/TC-IAM-056.spec.ts · 29줄 · 해시 f4516ac3 · 시나리오 보기

// TC-IAM-056: Project 삭제: 할당 해제된 Project 삭제 [공통]  (사용자 지시 2026-10-08)
// 화면: Workspaces > Projects → 목록 행 선택 → 상세 Delete → 확인 팝업
// 전제: 임시 ptmp 가 어떤 Workspace 에도 할당되지 않은 상태(TC-IAM-054 완료)
// 절차: 1.Projects 진입 2.ptmp 행 선택 3.상세 Delete → 확인 팝업(대상 ptmp) Confirm 4.목록에서 ptmp 가 사라지고 default / p1 / system 은 그대로인지 확인
// 기대결과: 삭제가 성공하고 ptmp 만 목록에서 제거된다
import { test, expect } from '../../lib/fixtures';
import { openProjects, selectProject, projectRow, rowWorkspaceCount, protectedStillThere, PROJECTS_URL, TEMP_PROJECT } from '../../lib/projects';

test('TC-IAM-056 Project 삭제: 할당 해제된 Project 삭제 (구 IV3_S28)', async ({ page }) => {
  await openProjects(page);
  test.skip((await projectRow(page, TEMP_PROJECT).count()) === 0, `선행 TC-IAM-055/S27: 임시 ${TEMP_PROJECT} 없음`);
  const ws = await rowWorkspaceCount(page, TEMP_PROJECT);
  test.skip(ws > 0, `BLOCKED: ${TEMP_PROJECT} 가 아직 Workspace ${ws}개에 할당됨(TC-IAM-054 먼저)`);
  await selectProject(page, TEMP_PROJECT);
  await page.getByRole('button', { name: 'Delete', exact: true }).click();
  const m = page.locator('.modal.show').last();
  await expect(m).toBeVisible();
  const msg = (await m.innerText()).replace(/\s+/g, ' ').trim();
  test.info().annotations.push({ type: 'confirm', description: msg });
  expect(msg, '삭제 거부 팝업이 아님').not.toMatch(/Cannot Delete Project/i);
  expect(msg, `확인 대상이 ${TEMP_PROJECT}`).toContain(TEMP_PROJECT);
  await m.getByRole('button', { name: /Confirm|Delete|OK|Yes/i }).last().click();
  await page.waitForTimeout(4000);
  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);
});

Clone this wiki locally