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

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);
});
โš ๏ธ **GitHub.com Fallback** โš ๏ธ