Skip to content

Test Specs IV1

dogfootman edited this page Oct 15, 2026 · 6 revisions

Test Specs — IV1 조직·사용자·역할 (IAM)

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

Playwright(TypeScript) spec 소스입니다. 시나리오마다 ## <시나리오번호> 섹션이 있고 앵커는 #<시나리오번호 소문자> 입니다. 공용 헬퍼는 Test-Specs-Common. 비밀번호 등은 마스킹했습니다.

시나리오 파일 줄 해시
IV1_S01 tests/iv1/IV1_S01.spec.ts 22 4d0616ba
IV1_S02 tests/iv1/IV1_S02.spec.ts 26 66a5d4b2
IV1_S03 tests/iv1/IV1_S03.spec.ts 27 5260c257
IV1_S04 tests/iv1/IV1_S04.spec.ts 25 75954919
IV1_S05 tests/iv1/IV1_S05.spec.ts 5 685c364b
IV1_S06 tests/iv1/IV1_S06.spec.ts 5 cbc31f44
IV1_S07 tests/iv1/IV1_S07.spec.ts 5 c5124eb1
IV1_S08 tests/iv1/IV1_S08.spec.ts 5 0ba3173e
IV1_S09 tests/iv1/IV1_S09.spec.ts 5 d7f7e62b
IV1_S10 tests/iv1/IV1_S10.spec.ts 14 15c788bf
IV1_S11 tests/iv1/IV1_S11.spec.ts 24 25497423
IV1_S12 tests/iv1/IV1_S12.spec.ts 18 5f92dbd9
IV1_S13 tests/iv1/IV1_S13.spec.ts 5 9e6522a3
IV1_S14 tests/iv1/IV1_S14.spec.ts 5 a43ff7be
IV1_S15 tests/iv1/IV1_S15.spec.ts 5 44a66632
IV1_S16 tests/iv1/IV1_S16.spec.ts 5 6f3a9ef6
IV1_S17 tests/iv1/IV1_S17.spec.ts 35 f2d56ae7
IV1_S18 tests/iv1/IV1_S18.spec.ts 36 fbab4993
IV1_S19 tests/iv1/IV1_S19.spec.ts 19 3727775a
IV1_S20 tests/iv1/IV1_S20.spec.ts 25 04bf21ce
IV1_S21 tests/iv1/IV1_S21.spec.ts 30 f2287741
IV1_S22 tests/iv1/IV1_S22.spec.ts 30 1d8286f1
IV1_S23 tests/iv1/IV1_S23.spec.ts 32 e20e3862
IV1_S24 tests/iv1/IV1_S24.spec.ts 27 782d7f99
IV1_S25 tests/iv1/IV1_S25.spec.ts 21 411ddd2d
IV1_S26 tests/iv1/IV1_S26.spec.ts 26 7766748c
IV1_S27 tests/iv1/IV1_S27.spec.ts 33 f1f360e2
IV1_S28 tests/iv1/IV1_S28.spec.ts 28 b09ce7c5
IV1_S29 tests/iv1/IV1_S29.spec.ts 19 1f0b6d13
IV1_S30 tests/iv1/IV1_S30.spec.ts 23 4eb9c563
IV1_S31 tests/iv1/IV1_S31.spec.ts 18 f3191ed5
IV1_S32 tests/iv1/IV1_S32.spec.ts 28 fa00655a
IV1_S33 tests/iv1/IV1_S33.spec.ts 34 ae180d2e
IV1_S34 tests/iv1/IV1_S34.spec.ts 38 36df68db
IV1_S35 tests/iv1/IV1_S35.spec.ts 6 c20df840
IV1_S36 tests/iv1/IV1_S36.spec.ts 7 fbed94ad
IV1_S37 tests/iv1/IV1_S37.spec.ts 26 f486d866

IV1_S01

tests/iv1/IV1_S01.spec.ts · 22줄 · 해시 4d0616ba · 시나리오 보기

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

// IV1_S01 : admin 최상위 조직 생성 (Groups > Create Org, Parent=None)
// 한 테스트 = 한 동작(2026-10-05 사용자 지시): 하위 조직 생성은 IV1_S25, 실제 조직(mcmp + 6개 팀) 확인은 IV1_S26.
// 실제 조직은 사용자가 배정돼 있어 다시 만들 수 없으므로(2026-10-04 사용자 결정) 녹화용 조직 recorg 로 생성 장면을 보여 준다.
// recorg 는 IV1_S21(삭제)에서 정리된다. 이미 있으면 생성은 건너뛰고(preexisting) 상세만 확인.
test('IV1_S01 admin: 최상위 조직 생성 (recorg)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  await gotoGroups(page);
  if (await treeItem(page, REC_ORG).count()) {
    test.info().annotations.push({ type: 'preexisting', description: `${REC_ORG} 이미 존재(이전 실행 잔존) → 생성 건너뜀. IV1_S21 로 정리 필요` });
  } 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 녹화용 최상위 조직');
});

IV1_S02

tests/iv1/IV1_S02.spec.ts · 26줄 · 해시 66a5d4b2 · 시나리오 보기

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

// IV1_S02 : admin 사용자 추가 (Users > Add User) — 한 테스트 = 한 동작(2026-10-05 사용자 지시)
//   조직·역할 할당은 IV1_S23, 비밀번호 설정은 IV1_S24 로 분리.
// 녹화용 사용자 rec{MMDD}a 로 생성 장면을 보여 준다(IV1_S22 에서 삭제되므로 재녹화 시 다시 생성됨).
// 실제 사용자 iam01·infra01 은 없을 때만 생성(멱등).
test('IV1_S02 admin: 사용자 추가 (Add User)', 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})` });
  }
});

IV1_S03

tests/iv1/IV1_S03.spec.ts · 27줄 · 해시 5260c257 · 시나리오 보기

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

// IV1_S03 : 사용자 직접 가입 (로그인 화면 -> Sign Up). 관리자가 만드는 흐름(IV1_S02)과 별개.
// 실제 대상(workflow01 …)은 이미 가입돼 있어 가입 장면이 녹화되지 않으므로(2026-10-04 사용자 지적),
//   녹화용 사용자 rec{MMDD}b 로 Sign Up → 'Registration request submitted' 안내까지 보여 준다(승인은 IV1_S04).
// 재실행 가능: 이미 가입/승인된 사용자는 건너뛴다(어노테이션 'preexisting').
// 존재 확인은 Users 목록 전체 페이지 기준(userExistsAll) — 사용자가 늘어 2페이지로 밀리면 '없음'으로 오판하던 문제(2026-10-05) 보완.
test('IV1_S03 신규 가입 (workflow01, app01, monitor01, cost01, cost02)', 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}) 가입 요청 완료` });
  }
});

IV1_S04

tests/iv1/IV1_S04.spec.ts · 25줄 · 해시 75954919 · 시나리오 보기

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

// IV1_S04 : admin 가입 승인 (Organizations > Approvals > Approve) — 한 테스트 = 한 동작(2026-10-05 사용자 지시)
//   조직 할당은 IV1_S27, 역할 부여는 IV1_S28 로 분리.
// 선행: IV1_S03(가입). 녹화용 rec{MMDD}b 는 승인 대기여야 하므로 목록이 뜰 때까지 기다린다. 실제 대상은 대기 중일 때만 승인(멱등).
test('IV1_S04 admin: 가입 승인 (Approvals)', 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(' | ') || '(알럿 없음)'}` });
  }
});

IV1_S05

tests/iv1/IV1_S05.spec.ts · 5줄 · 해시 685c364b · 시나리오 보기

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

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

IV1_S06

tests/iv1/IV1_S06.spec.ts · 5줄 · 해시 cbc31f44 · 시나리오 보기

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

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

IV1_S07

tests/iv1/IV1_S07.spec.ts · 5줄 · 해시 c5124eb1 · 시나리오 보기

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

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

IV1_S08

tests/iv1/IV1_S08.spec.ts · 5줄 · 해시 0ba3173e · 시나리오 보기

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

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

IV1_S09

tests/iv1/IV1_S09.spec.ts · 5줄 · 해시 d7f7e62b · 시나리오 보기

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

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

IV1_S10

tests/iv1/IV1_S10.spec.ts · 14줄 · 해시 15c788bf · 시나리오 보기

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

// IV1_S10 : 잘못된 비밀번호로 로그인 (예상 실패)
test('IV1_S10 잘못된 비밀번호로 로그인 (예상실패)', async ({ page }) => {
  const u = USERS[0];
  await page.goto('/auth/login');
  await page.getByPlaceholder('Your ID').fill(u.id);
  await page.getByPlaceholder('Your password').fill('wrong_password');
  await page.getByRole('button', { name: 'Login' }).click();
  // 기대: 로그인 거부 + 에러 메시지(4xx). 로그인 화면에 머문다. (에러 문구는 첫 실행에서 확인 후 보완)
  await expect(page).toHaveURL(/\/auth\/login/);
});

IV1_S11

tests/iv1/IV1_S11.spec.ts · 24줄 · 해시 25497423 · 시나리오 보기

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

// IV1_S11 : 미승인 사용자 가입 후 로그인 (예상 실패)
// 재실행 가능: 이미 가입된 이메일이면 가입은 건너뛰고(어노테이션에 화면 문구 기록) 로그인 시도만 한다.
test('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}"` });
  }
  await page.goto('/auth/login');
  await page.getByPlaceholder('Your ID').fill(PENDING_USER.id);
  await page.getByPlaceholder('Your password').fill(pwOf(PENDING_USER.id));
  await page.getByRole('button', { name: 'Login' }).click();
  await page.waitForTimeout(3000);
  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\//);
});

IV1_S12

tests/iv1/IV1_S12.spec.ts · 18줄 · 해시 5f92dbd9 · 시나리오 보기

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

// IV1_S12 : 조직 이름 중복 생성 (예상 실패) — 필수값 누락은 IV1_S29 로 분리(2026-10-05)
// 기대: 409 오류 알럿, 트리에 mcmp 가 하나뿐(중복 생성 안 됨)
test('IV1_S12 조직 이름 중복 생성 (예상실패)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  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(' | ') || '(메시지 없음)' });
});

IV1_S13

tests/iv1/IV1_S13.spec.ts · 5줄 · 해시 9e6522a3 · 시나리오 보기

공용 로직: lib/menu-access.ts (2026-10-15 WEB-BUG-126 기준으로 교체 — 이전 lib/roles.ts 의 accessTest 는 Groups·Users·Roles 3개 화면을 목록 API 응답 코드로 판정했음)

import { menuAccessTest } from '../../lib/menu-access';

// IV1_S13 : TC-IAM-044 operator(workflow01) 메뉴 URL 직접 접근 — 사용자 메뉴에 있는 화면은 열리고 없는 화면은 /auth/unauthorized (lib/menu-access.ts)
menuAccessTest('IV1_S13');

IV1_S14

tests/iv1/IV1_S14.spec.ts · 5줄 · 해시 a43ff7be · 시나리오 보기

공용 로직: lib/menu-access.ts (2026-10-15 WEB-BUG-126 기준으로 교체 — 이전 lib/roles.ts 의 accessTest 는 Groups·Users·Roles 3개 화면을 목록 API 응답 코드로 판정했음)

import { menuAccessTest } from '../../lib/menu-access';

// IV1_S14 : TC-IAM-045 viewer(monitor01) 메뉴 URL 직접 접근 — 사용자 메뉴에 있는 화면은 열리고 없는 화면은 /auth/unauthorized (lib/menu-access.ts)
menuAccessTest('IV1_S14');

IV1_S15

tests/iv1/IV1_S15.spec.ts · 5줄 · 해시 44a66632 · 시나리오 보기

공용 로직: lib/menu-access.ts (2026-10-15 WEB-BUG-126 기준으로 교체 — 이전 lib/roles.ts 의 accessTest 는 Groups·Users·Roles 3개 화면을 목록 API 응답 코드로 판정했음)

import { menuAccessTest } from '../../lib/menu-access';

// IV1_S15 : TC-IAM-046 billviewer(cost01) 메뉴 URL 직접 접근 — 사용자 메뉴에 있는 화면은 열리고 없는 화면은 /auth/unauthorized (lib/menu-access.ts)
menuAccessTest('IV1_S15');

IV1_S16

tests/iv1/IV1_S16.spec.ts · 5줄 · 해시 6f3a9ef6 · 시나리오 보기

공용 로직: lib/menu-access.ts (2026-10-15 WEB-BUG-126 기준으로 교체 — 이전 lib/roles.ts 의 accessTest 는 Groups·Users·Roles 3개 화면을 목록 API 응답 코드로 판정했음)

import { menuAccessTest } from '../../lib/menu-access';

// IV1_S16 : TC-IAM-047 billadmin(cost02) 메뉴 URL 직접 접근 — 사용자 메뉴에 있는 화면은 열리고 없는 화면은 /auth/unauthorized (lib/menu-access.ts)
menuAccessTest('IV1_S16');

IV1_S17

tests/iv1/IV1_S17.spec.ts · 35줄 · 해시 f2d56ae7 · 시나리오 보기

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

// IV1_S17 : admin Groups 조직도 Export JSON (선행: IV1_S01). 데이터를 바꾸지 않으므로 반복 실행 가능.
// 2026-10-08: Export JSON 클릭 → 다운로드 파일명 표시 → 파일 내용(JSON) 표시까지 녹화(사용자 지시). 다운로드 표시줄은 헤드리스에서 영상에 안 보여 화면 오버레이로 대체.
test('IV1_S17 admin: Groups 조직도 Export JSON', 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' });
});

IV1_S18

tests/iv1/IV1_S18.spec.ts · 36줄 · 해시 fbab4993 · 시나리오 보기

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

// IV1_S18 : admin Groups 조직도 Import JSON (선행: IV1_S01).
// 방금 Export 한 파일을 그대로 Import 한다 → 모든 그룹이 organization_code·이름이 같으므로 생성/수정 0건(변경 없음)이어야 한다.
// stage 는 데이터를 되돌리기 어려워 기존 조직을 바꾸지 않는 왕복(round-trip) 방식으로 검증한다. 반복 실행 가능.
test('IV1_S18 admin: Groups 조직도 Import JSON (Export 파일 재적용)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  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/);
});

IV1_S19

tests/iv1/IV1_S19.spec.ts · 19줄 · 해시 3727775a · 시나리오 보기

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

// IV1_S19 : admin Groups Import JSON 에 잘못된(파싱 불가) JSON 파일 → 알럿 "Invalid JSON file.", 조직 변경 없음 (예상실패)
test('IV1_S19 admin: Groups Import JSON 잘못된 파일 (예상실패)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  await page.goto(URL_GROUPS);
  const tree = page.getByRole('tree');
  await expect(tree.getByText(ROOT_ORG, { exact: true })).toBeVisible({ timeout: 60_000 });

  await importGroupsJson(page, { name: 'invalid-groups.json', mimeType: 'application/json', buffer: Buffer.from('{ "version": "1.0", "groups": [ { "name": ') });
  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);
});

IV1_S20

tests/iv1/IV1_S20.spec.ts · 25줄 · 해시 04bf21ce · 시나리오 보기

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

// IV1_S20 : admin 조직 수정 (Groups > 조직 선택 > Edit) [정상]
// 대상: IV1_S01 에서 만든 녹화용 하위 조직 recteam → 이름 recteam2, 설명 변경 → 트리·상세(Path/Description) 반영 확인
// 멱등: 이미 recteam2 로 바뀌어 있으면 수정은 건너뛰고 상태만 확인.
test('IV1_S20 admin: 조직 수정 (Edit)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  await gotoGroups(page);
  const desc = 'IV1_S20 수정됨';
  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()), `선행 IV1_S01 의 ${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(' | ') || '(없음)' });
});

IV1_S21

tests/iv1/IV1_S21.spec.ts · 30줄 · 해시 f2287741 · 시나리오 보기

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

// IV1_S21 : admin 조직 삭제 (Groups > 조직 선택 > Delete) — 하위 조직 → 상위 조직 순서로 삭제
// 하위 조직이 있는 조직의 삭제 거부는 IV1_S30 으로 분리(2026-10-05).
// 대상: 녹화용 recteam2(IV1_S20 수정 후, 없으면 recteam) → recorg. 실제 조직은 대상 아님.
test('IV1_S21 admin: 조직 삭제 (Delete)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  await gotoGroups(page);
  test.skip(!(await treeItem(page, REC_ORG).count()), `선행 IV1_S01 의 ${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);
});

IV1_S22

tests/iv1/IV1_S22.spec.ts · 30줄 · 해시 1d8286f1 · 시나리오 보기

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

// IV1_S22 : admin 사용자 삭제 (Users > 사용자 행 클릭 > ⌄ Delete) [정상]
// 대상: IV1_S02~S04 녹화용 사용자 rec{MMDD}{a|b…}(삭제 가능, 2026-10-05 사용자 확인). 실제 사용자(iam01 등)는 대상 아님.
// 사용자마다 행을 클릭해 선택한 뒤 Delete(체크박스 선택은 사용하지 않음, 2026-10-05 사용자 지시) → 목록에서 사라짐 확인
test('IV1_S22 admin: 사용자 삭제 (녹화용 사용자)', 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 → 목록에서 사라짐
  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]);
  }
});

IV1_S23

tests/iv1/IV1_S23.spec.ts · 32줄 · 해시 e20e3862 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } 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';

// IV1_S23 : admin 사용자 조직·역할 할당 (Users > 사용자 선택 > Add Group, Add platform role / Add Workspace role) — IV1_S02 에서 분리(2026-10-05)
// 선행: IV1_S02. 녹화용 rec{MMDD}a = 조직 monitor / 역할 viewer, 실제 iam01(iam/admin)·infra01(infra/operator).
// 이미 역할이 할당된 사용자(Platform Roles=Y)는 건너뛴다(멱등).
test('IV1_S23 admin: 사용자 조직·역할 할당', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  const r = REC_ADMIN_USER;
  test.skip(!(await userExistsAll(page, r)), `선행 IV1_S02 의 ${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}` });
  }
  await gotoUsersAll(page);
  const d = await userDetail(page, r);
  expect(d, `${r.id} 상세에 조직 ${r.team}`).toContain(r.team);
  expect(await roleAssignedAll(page, r), `${r.id} Platform Roles=Y`).toBe(true);
});

IV1_S24

tests/iv1/IV1_S24.spec.ts · 27줄 · 해시 782d7f99 · 시나리오 보기

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

// IV1_S24 : admin 사용자 비밀번호 설정 (Users > 사용자 선택 > Reset Password) — IV1_S02 에서 분리(2026-10-05)
// Add User 폼에는 비밀번호 입력란이 없어 Reset Password 로 {id}_password 를 설정한다. 설정 후 녹화용 사용자로 로그인해 확인.
// 선행: IV1_S02(사용자), IV1_S23(역할 — 역할이 없으면 로그인 후 화면이 비어 있을 수 있음).
test('IV1_S24 admin: 사용자 비밀번호 설정 (Reset Password)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  const r = REC_ADMIN_USER;
  test.skip(!(await userExistsAll(page, r)), `선행 IV1_S02 의 ${r.id} 없음`);
  for (const u of [r, ...USERS.filter((x) => x.via === 'admin')]) {
    await gotoUsersAll(page);
    await selectUserRow(page, u);
    const before = dialogs.length;
    await resetPassword(page, u);
    await expect.poll(() => dialogs.slice(before).join('|')).toContain('Password reset successfully');
  }
  // 설정한 비밀번호로 로그인되는지 확인
  await logout(page);
  await loginAndSettle(page, r.id, pwOf(r.id));
  await expect(page, `${r.id} 로그인 성공`).not.toHaveURL(/\/auth\//);
  test.info().annotations.push({ type: 'login', description: `${r.id} 로그인 성공 → ${new URL(page.url()).pathname}` });
});

IV1_S25

tests/iv1/IV1_S25.spec.ts · 21줄 · 해시 411ddd2d · 시나리오 보기

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

// IV1_S25 : admin 하위 조직 생성 (Groups > Create Org, Parent=recorg) — IV1_S01 에서 분리(2026-10-05)
// 선행: IV1_S01(recorg). recteam 은 IV1_S20(수정)·IV1_S21(삭제)에서 이어서 사용. 이미 있으면 생성은 건너뛰고 상세만 확인.
test('IV1_S25 admin: 하위 조직 생성 (recteam)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  await gotoGroups(page);
  test.skip(!(await treeItem(page, REC_ORG).count()), `선행 IV1_S01 의 ${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 녹화용 하위 조직');
});

IV1_S26

tests/iv1/IV1_S26.spec.ts · 26줄 · 해시 7766748c · 시나리오 보기

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

// IV1_S26 : admin 실제 조직 구성 확인 (mcmp + 하위 6개 팀) — IV1_S01 에서 분리(2026-10-05)
// 다른 시나리오(사용자 조직 할당 등)의 전제. 없을 때만 생성하고(멱등) 각 조직 상세 Path /mcmp/{team} 을 확인한다.
test('IV1_S26 admin: 실제 조직 구성 확인 (mcmp + 6개 팀)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  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}`);
});

IV1_S27

tests/iv1/IV1_S27.spec.ts · 33줄 · 해시 f1f360e2 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } 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';

// IV1_S27 : admin 가입 사용자 조직 할당 — IV1_S04 에서 분리(2026-10-05)
// 할당 방법 2종: 녹화용 rec{MMDD}b·workflow01·app01 = Groups > 조직 선택 > Invite Member, 나머지(monitor01·cost01·cost02) = Users > Add Group.
// 선행: IV1_S04(승인). Invite Member 는 이미 멤버면 체크박스가 비활성 → 건너뜀. 실제 사용자는 역할까지 끝난(Platform Roles=Y) 경우 건너뜀.
test('IV1_S27 admin: 가입 사용자 조직 할당 (Invite Member / Add Group)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  const r = REC_SIGNUP_USER;
  test.skip(!(await userExistsAll(page, r)), `선행 IV1_S03·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);
});

IV1_S28

tests/iv1/IV1_S28.spec.ts · 28줄 · 해시 b09ce7c5 · 시나리오 보기

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

// IV1_S28 : admin 가입 사용자 역할 부여 (Users > 사용자 선택 > Add platform role / Add Workspace role) — IV1_S04 에서 분리(2026-10-05)
// 선행: IV1_S27(조직 할당). 녹화용 rec{MMDD}b = viewer, 실제 workflow01·app01=operator, monitor01=viewer, cost01=billviewer, cost02=billadmin.
// Platform Roles=Y 인 사용자는 건너뛴다(멱등).
test('IV1_S28 admin: 가입 사용자 역할 부여', async ({ page }) => {
  await loginAsAdmin(page, 'ws01', null);
  const r = REC_SIGNUP_USER;
  test.skip(!(await userExistsAll(page, r)), `선행 IV1_S03·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);
  await gotoUsersAll(page);
  test.info().annotations.push({ type: 'detail', description: (await userDetail(page, r)).slice(0, 300) });
});

IV1_S29

tests/iv1/IV1_S29.spec.ts · 19줄 · 해시 1f0b6d13 · 시나리오 보기

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

// IV1_S29 : 조직 생성 필수값(Name) 누락 (예상 실패) — IV1_S12 에서 분리(2026-10-05)
// 기대: Name 을 비우고 Save → 알럿 'Name is required.', 팝업이 닫히지 않고 생성되지 않음
test('IV1_S29 조직 생성 필수값 누락 (예상실패)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  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();
});

IV1_S30

tests/iv1/IV1_S30.spec.ts · 23줄 · 해시 4eb9c563 · 시나리오 보기

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

// IV1_S30 : 하위 조직이 있는 조직 삭제 (예상 실패) — IV1_S21 에서 분리(2026-10-05), IV1_S21(삭제) 전에 실행
// 기대: 확인 팝업 안내("Groups with child organizations or assigned users cannot be deleted.") 후 Delete 해도 삭제되지 않고 트리에 남음
// 선행: IV1_S01(recorg) + IV1_S25(recteam, IV1_S20 이후 recteam2)
test('IV1_S30 하위 조직이 있는 조직 삭제 (예상실패)', async ({ page, dialogs }) => {
  await loginAsAdmin(page, 'ws01', null);
  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} 없음(IV1_S01, IV1_S25)`);

  await openGroup(page, REC_ORG, `/${REC_ORG}`);
  const before = dialogs.length;
  const confirmText = await deleteGroup(page);
  const result = [...dialogs.slice(before), ...(await groupToasts(page))].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();
});

IV1_S31

tests/iv1/IV1_S31.spec.ts · 18줄 · 해시 f3191ed5 · 시나리오 보기

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

// IV1_S31 : 승인된 사용자 로그인 — 가입(IV1_S03) → 관리자 승인(IV1_S04) → 로그인 흐름의 마지막 단계(2026-10-05 사용자 요청)
// 녹화용 rec{MMDD}b 가 가입 때 입력한 비밀번호({id}_password)로 로그인한다. 이 시점에는 조직·역할이 아직 없다(IV1_S27·S28 에서 할당).
// 기대: 로그인 성공(콘솔 화면 진입). 역할이 없을 때 보이는 메뉴·화면은 어노테이션으로 기록.
test('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(' | ') || '(알럿 없음)' });
});

IV1_S32

tests/iv1/IV1_S32.spec.ts · 28줄 · 해시 fa00655a · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoCredentials, holderRow, rowCells, consoleApi } from '../../lib/credentials';

// IV1_S32 : TC-INFRA-001 Credential 목록 조회 (Settings > Cloud SPs > Credentials)
// 출처: 위키 Test-Specs-IV10 의 IV10_S01(다른 세션 작성, 2026-10-14). 같은 날 IV1(플랫폼 온보딩)로 편입하며 번호만 바꿈
test('IV1_S32 TC-INFRA-001 Credential 목록 조회', async ({ page }) => {
  await test.step('1. admin 로그인', () => loginAsAdmin(page));

  await test.step('2. Settings > Cloud SPs > Credentials 이동 → 목록 표시', () => gotoCredentials(page));

  await test.step('3. 기본 holder(admin) 행의 Providers·Connections·Verified·Default 표시 확인', async () => {
    const row = holderRow(page, 'admin');
    await expect(row).toHaveCount(1);
    const cells = await rowCells(row);
    test.info().annotations.push({ type: 'admin-row', description: JSON.stringify(cells) });
    expect(Object.values(cells).join(' ')).toMatch(/\d+/);
  });

  await test.step('4. 화면 목록 = GetCredentialHolderList 응답 (holder 이름·수)', async () => {
    const api = await consoleApi(page, 'GetCredentialHolderList');
    const list: any[] = api.data?.responseData?.credentialHolderList ?? [];
    const shown = await page.locator('#credential-table [tabulator-field="credentialHolder"]').filter({ hasNotText: 'Credential Holder' }).allInnerTexts();
    test.info().annotations.push({ type: 'holders', description: `API ${list.map((h) => `${h.credentialHolder}(${h.connectionCount}/${h.verifiedConnectionCount})`).join(', ')} / 화면 ${shown.map((s) => s.trim()).join(', ')}` });
    expect(shown.map((s) => s.trim()).sort()).toEqual(list.map((h) => h.credentialHolder).sort());
  });
});

IV1_S33

tests/iv1/IV1_S33.spec.ts · 34줄 · 해시 ae180d2e · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoCredentials, holderRow, consoleApi } from '../../lib/credentials';

// IV1_S33 : TC-INFRA-002 Credential 단건 조회 — 목록에서 holder 클릭 → 상세 패널, GetCredentialHolder 응답과 비교
// 출처: 위키 Test-Specs-IV10 의 IV10_S02(다른 세션 작성, 2026-10-14). 같은 날 IV1(플랫폼 온보딩)로 편입하며 번호만 바꿈
const HOLDER = 'admin';
test('IV1_S33 TC-INFRA-002 Credential 단건 조회', async ({ page }) => {
  await test.step('1. admin 로그인 후 Credentials 이동', async () => { await loginAsAdmin(page); await gotoCredentials(page); });

  await test.step(`2. 목록에서 ${HOLDER} 행 클릭 → 상세 패널 표시`, async () => {
    await holderRow(page, HOLDER).click();
    await expect(page.locator('#credential-detail-panel')).toBeVisible();
    await expect(page.locator('#detail-holder-id')).toHaveText(HOLDER);
  });

  await test.step('3. 상세(Providers·Connections·Verified·Default) = GetCredentialHolder 응답', async () => {
    const ui = {
      providers: (await page.locator('#detail-holder-provider').innerText()).replace(/\s+/g, ' ').trim(),
      connections: (await page.locator('#detail-holder-connections').innerText()).trim(),
      verified: (await page.locator('#detail-holder-verified').innerText()).trim(),
      isDefault: (await page.locator('#detail-holder-default').innerText()).trim(),
    };
    const api = await consoleApi(page, 'GetCredentialHolder', { pathParams: { holderId: HOLDER } });
    const h = api.data?.responseData ?? {};
    test.info().annotations.push({ type: 'detail', description: `화면 ${JSON.stringify(ui)} / API(${api.status}) ${JSON.stringify(h).slice(0, 300)}` });
    expect(api.status, 'GetCredentialHolder 호출 실패').toBe(200);
    expect(h.credentialHolder).toBe(HOLDER);
    expect(Number(ui.connections)).toBe(h.connectionCount);
    expect(Number(ui.verified)).toBe(h.verifiedConnectionCount);
    for (const p of h.providers ?? []) expect(ui.providers.toLowerCase(), `Providers 에 ${p}`).toContain(String(p).toLowerCase());
  });
});

IV1_S34

tests/iv1/IV1_S34.spec.ts · 38줄 · 해시 36df68db · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoCredentials, consoleApi, credValues, getHolder, scrub, TEST_HOLDER } from '../../lib/credentials';

// IV1_S34 : TC-INFRA-003 Credential 등록 — API (웹 콘솔 API POST /api/mc-infra-manager/RegisterCredential, 평문 → API 서버가 암호화)
// 출처: 위키 Test-Specs-IV10 의 IV10_S03(다른 세션 작성, 2026-10-14). 같은 날 IV1(플랫폼 온보딩)로 편입하며 번호만 바꿈
// mc-infra-manager 가 허용하는 CSP 형식 키(metainfo CredentialCSP: aws_access_key_id / aws_secret_access_key)로 holder 'test' 에 AWS 를 등록한다.
// 주의: mc-infra-manager 에 credential 삭제 API 가 없어 등록 결과는 남는다(2026-10-14 사용자 승인).
const CSP = 'aws';
test('IV1_S34 TC-INFRA-003 Credential 등록 (API)', async ({ page }) => {
  const v = credValues(CSP);
  const kv = ['aws_access_key_id', 'aws_secret_access_key'].map((key) => ({ key, value: v[key] }));
  expect(kv.every((x) => x.value), 'aws 자격증명 값 없음').toBe(true);

  await test.step('1. admin 로그인 후 Credentials 이동', async () => { await loginAsAdmin(page); await gotoCredentials(page); });

  await test.step(`2. RegisterCredential API 호출 — holder ${TEST_HOLDER}, provider ${CSP}, CSP 형식 키`, async () => {
    const r = await consoleApi(page, 'RegisterCredential', { request: { credentialHolder: TEST_HOLDER, providerName: CSP, credentialKeyValueList: kv } });
    const text = scrub(JSON.stringify(r.data ?? {}), v);
    test.info().annotations.push({ type: 'register', description: `HTTP ${r.status}: ${text.slice(0, 300)}` });
    expect(r.status, text.slice(0, 200)).toBe(200);
  });

  await test.step(`3. GetCredentialHolder(${TEST_HOLDER}) → ${CSP} 포함, 연결·verified 생성`, async () => {
    const h = await getHolder(page, TEST_HOLDER);
    test.info().annotations.push({ type: 'holder', description: JSON.stringify(h.info) });
    expect(h.status).toBe(200);
    expect(h.info.providers ?? []).toContain(CSP);
    expect(h.info.connectionCount ?? 0).toBeGreaterThan(0);
    expect(h.info.verifiedConnectionCount ?? 0).toBeGreaterThan(0);
  });

  await test.step(`4. 목록 새로고침 → ${TEST_HOLDER} 행 표시`, async () => {
    await gotoCredentials(page);
    await expect(page.locator('#credential-table [tabulator-field="credentialHolder"]', { hasText: TEST_HOLDER })).toBeVisible();
  });
});

IV1_S35

tests/iv1/IV1_S35.spec.ts · 6줄 · 해시 c20df840 · 시나리오 보기

import { activeCsps, tid } from '../../lib/data';
import { uiRegisterTest, PROVIDER } from '../../lib/credentials';
// IV1_S35 : TC-INFRA-004 Credential 등록 — AWS UI (화면 자동 Key — WEB-TECH-108 수정 후 aws_access_key_id, aws_secret_access_key)
// 출처: 위키 Test-Specs-IV10 의 IV10_S04(다른 세션 작성, 2026-10-14). 같은 날 IV1(플랫폼 온보딩)로 편입하며 번호만 바꿈
for (const c of activeCsps(['AWS'])) uiRegisterTest(tid('IV1_S35', c), 'TC-INFRA-004', PROVIDER[c.code]);

IV1_S36

tests/iv1/IV1_S36.spec.ts · 7줄 · 해시 fbed94ad · 시나리오 보기

import { activeCsps, tid } from '../../lib/data';
import { uiRegisterTest, PROVIDER } from '../../lib/credentials';
// IV1_S36 : TC-INFRA-005 Credential 등록 — CSP별 UI (Provider 마다 자동 생성 Key 가 다름). 테스트 1건 = CSP 1개
// 출처: 위키 Test-Specs-IV10 의 IV10_S05(다른 세션 작성, 2026-10-14). 같은 날 IV1(플랫폼 온보딩)로 편입하며 번호만 바꿈
// AWS 는 IV1_S35. openstack 은 stage 에 연결 정보가 없어 제외. 대상 CSP 는 TC_VARIANT 로 고른다.
for (const c of activeCsps(['Azure', 'Alibaba', 'GCP', 'Tencent', 'NHN Cloud', 'NCP', 'IBM', 'KT'])) uiRegisterTest(tid('IV1_S36', c), 'TC-INFRA-005', PROVIDER[c.code]);

IV1_S37

tests/iv1/IV1_S37.spec.ts · 26줄 · 해시 f486d866 · 시나리오 보기

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoCredentials, holderRow, TEST_HOLDER } from '../../lib/credentials';

// IV1_S37 : TC-INFRA-006 Credential 삭제
// 출처: 위키 Test-Specs-IV10 의 IV10_S06(다른 세션 작성, 2026-10-14). 같은 날 IV1(플랫폼 온보딩)로 편입하며 번호만 바꿈
// 2026-10-14 확인: mc-infra-manager(edge) Swagger 에 credential·credentialHolder·connConfig 삭제 API 가 없고, Credentials 화면에도 삭제 기능이 없다.
// 화면에서 삭제 수단을 찾고, 없으면 기능 미제공으로 SKIP 한다(삭제 기능이 생기면 이 spec 을 삭제 절차로 바꾼다).
test('IV1_S37 TC-INFRA-006 Credential 삭제', async ({ page }) => {
  await test.step('1. admin 로그인 후 Credentials 이동', async () => { await loginAsAdmin(page); await gotoCredentials(page); });

  const found = await test.step(`2. ${TEST_HOLDER} 행 선택 → 상세·목록·Actions 에서 Delete 기능 찾기`, async () => {
    const row = holderRow(page, TEST_HOLDER);
    if (await row.count()) await row.click();
    const del = page.locator('#credential-detail-panel, .page-header, #page-header-btn-list, .card').getByRole('button', { name: /delete|remove/i });
    const n = await del.count();
    await page.screenshot({ path: test.info().outputPath('credentials-no-delete.png'), fullPage: true });
    await test.info().attach('credentials-no-delete.png', { path: test.info().outputPath('credentials-no-delete.png'), contentType: 'image/png' });
    test.info().annotations.push({ type: 'delete-ui', description: `Delete 버튼 ${n}개` });
    return n > 0;
  });

  test.skip(!found, 'Credential 삭제 기능 미제공 — 화면에 삭제 버튼 없음, mc-infra-manager 에 삭제 API 없음');
  expect(found).toBe(true);
});

Clone this wiki locally