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

Test Specs โ€” IV0 ํ”Œ๋žซํผ ์„ค์น˜ยท์šด์˜ CLI (mc-admin-cli)

์ƒ์„ฑ: 2026-10-13 ยท ๊ธฐ์ค€: v07 โ† Test Scenarios ยท IV ํ—ˆ๋ธŒ ยท ๊ธฐ๋Šฅ TC ยท TC-ADMIN

Playwright(TypeScript) spec ์†Œ์Šค์ž…๋‹ˆ๋‹ค. ์‹œ๋‚˜๋ฆฌ์˜ค๋งˆ๋‹ค ## <์‹œ๋‚˜๋ฆฌ์˜ค๋ฒˆํ˜ธ> ์„น์…˜์ด ์žˆ๊ณ  ์•ต์ปค๋Š” #<์‹œ๋‚˜๋ฆฌ์˜ค๋ฒˆํ˜ธ ์†Œ๋ฌธ์ž> ์ž…๋‹ˆ๋‹ค. IV0 ๋Š” ๋ธŒ๋ผ์šฐ์ € ์—†์ด ์„ค์น˜ ๋Œ€์ƒ ์„œ๋ฒ„์˜ ํ„ฐ๋ฏธ๋„ ๋ช…๋ น์œผ๋กœ ํ™•์ธํ•˜๋Š” CLI ํ…Œ์ŠคํŠธ์ž…๋‹ˆ๋‹ค. ์˜์กด์€ @playwright/test ๋ฟ์ด๋ผ, ์ด๋ฏธ ์„ค์น˜๋œ Playwright ํ”„๋กœ์ ํŠธ์˜ tests/iv0/ ์— ๊ณต์šฉ ํ—ฌํผ _cli.ts ์™€ spec ํŒŒ์ผ์„ ๋ณต์‚ฌํ•˜๋ฉด ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค.

์‹คํ–‰ ๋ฐฉ๋ฒ•

# ์›๊ฒฉ ์„œ๋ฒ„(ssh)์—์„œ ์‹คํ–‰ โ€” ์ƒํƒœ๋ฅผ ๋ฐ”๊พธ์ง€ ์•Š๋Š” ์‹œ๋‚˜๋ฆฌ์˜ค๋งŒ ์‹คํ–‰๋˜๊ณ  ๋‚˜๋จธ์ง€๋Š” ๊ฑด๋„ˆ๋œ€
ADMIN_CLI_SSH=ubuntu@<host> ADMIN_CLI_SSH_KEY=<key.pem> ADMIN_CLI_BIN=~/mc-admin-cli/bin npx playwright test tests/iv0

# ์‹œ๋‚˜๋ฆฌ์˜ค 1๊ฑด
ADMIN_CLI_SSH=ubuntu@<host> ADMIN_CLI_SSH_KEY=<key.pem> npx playwright test tests/iv0/IV0_S14.spec.ts

# ์„ค์น˜ยท์‚ญ์ œ ๋“ฑ ์„œ๋ฒ„ ์ƒํƒœ๋ฅผ ๋ฐ”๊พธ๋Š” ์‹œ๋‚˜๋ฆฌ์˜ค(์ „์šฉ ํ…Œ์ŠคํŠธ ์„œ๋ฒ„์—์„œ๋งŒ)
ADMIN_CLI_ALLOW_DESTRUCTIVE=1 ADMIN_CLI_SSH=ubuntu@<host> ADMIN_CLI_SSH_KEY=<key.pem> npx playwright test tests/iv0/IV0_S01.spec.ts
ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ ์‹œ๋‚˜๋ฆฌ์˜ค ์„ค๋ช…
ADMIN_CLI_SSH (์—†์Œ = ๋กœ์ปฌ) ์ „์ฒด ์„ค์น˜ ๋Œ€์ƒ ssh ์ฃผ์†Œ
ADMIN_CLI_SSH_KEY (์—†์Œ) ์ „์ฒด ssh ๊ฐœ์ธํ‚ค ๊ฒฝ๋กœ
ADMIN_CLI_BIN ~/mc-admin-cli/bin ์ „์ฒด mc-admin-cli bin ๊ฒฝ๋กœ
ADMIN_CLI_ALLOW_DESTRUCTIVE (์—†์Œ) S01 S02 S05~S12 S24 S26 1 ์ด๋ฉด ์„œ๋ฒ„ ์ƒํƒœ๋ฅผ ๋ฐ”๊พธ๋Š” ์‹œ๋‚˜๋ฆฌ์˜ค ์‹คํ–‰
ADMIN_CLI_ALLOW_REINSTALL (์—†์Œ) S01 S02 S10 1 ์ด๋ฉด ๊ธฐ์กด mc- ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์žˆ์–ด๋„ ์„ค์น˜ ์ง„ํ–‰
ADMIN_CLI_DOMAIN (์—†์Œ = Enter, localhost) S01 ๋„๋ฉ”์ธ ์ž…๋ ฅ๊ฐ’. IP ๋ฉด ์ž์ฒด ์„œ๋ช… ์ธ์ฆ์„œ ๊ฒฝ๋กœ
ADMIN_CLI_FQDN (์—†์Œ) S02 S10 ์‹ค์ œ ๋„๋ฉ”์ธ(Let's Encrypt)
ADMIN_CLI_PUBLIC_HOST (์—†์Œ) S03 ์™ธ๋ถ€์—์„œ ์ ‘์†ํ•  ์„œ๋ฒ„ ์ฃผ์†Œ
ADMIN_CLI_PORTS 80,443,3001,33002,7780,9090,9000,3000 S03 ์ ๊ฒ€ํ•  ํฌํŠธ
ADMIN_CLI_DEFAULT_WORKSPACE / _PROJECT ws01 / default S04 ์ดˆ๊ธฐํ™”๋กœ ์ƒ์„ฑ๋˜๋Š” ๊ธฐ๋ณธ workspace/project
ADMIN_CLI_TARGET_SERVICE mc-data-manager S07 S08 ๋Œ€์ƒ ์„œ๋น„์Šค(all=์ „์ฒด, S08)
ADMIN_CLI_K8S_PROVIDER / _VALUES minikube / ../helm-chart/values.yaml S11~S13 K8s ๋ฐฐํฌ ๋Œ€์ƒ
ADMIN_CLI_ECHO_PORT 18999 S14~S18 ์ž„์‹œ echo ์„œ๋ฒ„ ํฌํŠธ(๋Œ€์ƒ ์„œ๋ฒ„ 127.0.0.1)
ADMIN_CLI_TB_AUTH default:default S15 S21~S25 mc-infra-manager Basic ์ธ์ฆ
ADMIN_CLI_API_CONFIG (์—†์Œ = api.yaml ํ˜ธ์ŠคํŠธ๋ช…โ†’localhost ์‚ฌ๋ณธ) S20~S22 mcc api ์„ค์ • ํŒŒ์ผ
ADMIN_CLI_CSP / ADMIN_CLI_CRED_FILE aws / (์—†์Œ) S24 ๋“ฑ๋กํ•  CSP, credential key=value ํŒŒ์ผ(์ด PC)
ADMIN_CLI_INIT_INPUT y\n1\ny\n S26 infra-init ๋Œ€ํ™”ํ˜• ์ž…๋ ฅ

๊ฒฐ๊ณผ: ๋ช…๋ น ์ถœ๋ ฅ์ด Playwright ๋ฆฌํฌํŠธ ์ฒจ๋ถ€๋กœ ๋‚จ์Šต๋‹ˆ๋‹ค(๋น„๋ฐ€๋ฒˆํ˜ธยทcredential ๊ฐ’์€ ์ฒจ๋ถ€ ์ „์— ๊ฐ€๋ฆผ). ํ™”๋ฉด์ด ์—†์–ด ์˜์ƒ์€ ์ƒ์„ฑ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

์‹œ๋‚˜๋ฆฌ์˜ค ํŒŒ์ผ ์ค„ ํ•ด์‹œ
๊ณต์šฉ ํ—ฌํผ tests/iv0/_cli.ts 179 1754fd79
IV0_S01 tests/iv0/IV0_S01.spec.ts 37 36583587
IV0_S02 tests/iv0/IV0_S02.spec.ts 48 1fda97b0
IV0_S03 tests/iv0/IV0_S03.spec.ts 36 8210157a
IV0_S04 tests/iv0/IV0_S04.spec.ts 39 7159259d
IV0_S05 tests/iv0/IV0_S05.spec.ts 33 cb875115
IV0_S06 tests/iv0/IV0_S06.spec.ts 27 a84ddad3
IV0_S07 tests/iv0/IV0_S07.spec.ts 33 79f3ea35
IV0_S08 tests/iv0/IV0_S08.spec.ts 38 fcc2a71a
IV0_S09 tests/iv0/IV0_S09.spec.ts 35 b47701dd
IV0_S10 tests/iv0/IV0_S10.spec.ts 44 279c16b7
IV0_S11 tests/iv0/IV0_S11.spec.ts 39 6b23b35d
IV0_S12 tests/iv0/IV0_S12.spec.ts 23 fd80da3a
IV0_S13 tests/iv0/IV0_S13.spec.ts 23 c9c82ca1
IV0_S14 tests/iv0/IV0_S14.spec.ts 26 b6c58611
IV0_S15 tests/iv0/IV0_S15.spec.ts 34 94094503
IV0_S16 tests/iv0/IV0_S16.spec.ts 30 1caf97f6
IV0_S17 tests/iv0/IV0_S17.spec.ts 28 e71547ba
IV0_S18 tests/iv0/IV0_S18.spec.ts 25 087c2c71
IV0_S19 tests/iv0/IV0_S19.spec.ts 37 9fe1e75f
IV0_S20 tests/iv0/IV0_S20.spec.ts 32 653f91eb
IV0_S21 tests/iv0/IV0_S21.spec.ts 35 aed0c1ea
IV0_S22 tests/iv0/IV0_S22.spec.ts 24 9740de4b
IV0_S23 tests/iv0/IV0_S23.spec.ts 41 9f25ea19
IV0_S24 tests/iv0/IV0_S24.spec.ts 59 a55caa67
IV0_S25 tests/iv0/IV0_S25.spec.ts 62 56b20ddb
IV0_S26 tests/iv0/IV0_S26.spec.ts 46 140a8e5b

๊ณต์šฉ ํ—ฌํผ _cli.ts

tests/iv0/_cli.ts ยท 179์ค„ ยท ํ•ด์‹œ 1754fd79

import { expect, test, type TestInfo } from '@playwright/test';
import { spawn } from 'node:child_process';

// IV0 ๊ณต์šฉ ํ—ฌํผ โ€” mc-admin-cli(CLI) ํ…Œ์ŠคํŠธ๋ฅผ ์„ค์น˜ ๋Œ€์ƒ ์„œ๋ฒ„์˜ ํ„ฐ๋ฏธ๋„ ๋ช…๋ น์œผ๋กœ ์‹คํ–‰ํ•œ๋‹ค.
// ์™ธ๋ถ€ ์˜์กด์€ @playwright/test ๋ฟ์ด๋‹ค. ์ด๋ฏธ ์„ค์น˜๋œ Playwright ํ”„๋กœ์ ํŠธ์˜ tests/iv0/ ์— ์ด ํŒŒ์ผ๊ณผ spec ์„ ํ•จ๊ป˜ ๋ณต์‚ฌํ•œ๋‹ค.
//
// ๊ณตํ†ต ํ™˜๊ฒฝ๋ณ€์ˆ˜
//   ADMIN_CLI_SSH          ์„ค์น˜ ๋Œ€์ƒ ssh ์ฃผ์†Œ(์˜ˆ: [email protected]). ๋น„์šฐ๋ฉด ๋กœ์ปฌ์—์„œ ์‹คํ–‰
//   ADMIN_CLI_SSH_KEY      ssh ๊ฐœ์ธํ‚ค ๊ฒฝ๋กœ(์„ ํƒ)
//   ADMIN_CLI_BIN          mc-admin-cli ์˜ bin ๊ฒฝ๋กœ (๊ธฐ๋ณธ ~/mc-admin-cli/bin)
//   ADMIN_CLI_TB_AUTH      mc-infra-manager Basic ์ธ์ฆ user:password (๊ธฐ๋ณธ default:default)
//   ADMIN_CLI_ALLOW_DESTRUCTIVE=1  ์„ค์น˜ยท์‚ญ์ œ์ฒ˜๋Ÿผ ์„œ๋ฒ„ ์ƒํƒœ๋ฅผ ๋ฐ”๊พธ๋Š” TC ์‹คํ–‰ ํ—ˆ์šฉ(๊ธฐ๋ณธ: ๊ฑด๋„ˆ๋œ€)

export const SSH = process.env.ADMIN_CLI_SSH ?? '';
export const KEY = process.env.ADMIN_CLI_SSH_KEY ?? '';
export const BIN = process.env.ADMIN_CLI_BIN ?? '~/mc-admin-cli/bin';
export const TB_AUTH = process.env.ADMIN_CLI_TB_AUTH ?? 'default:default';
export const TB = 'http://localhost:1323/tumblebug';

export type Run = { code: number; out: string };

const q = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;

// ๋Œ€์ƒ ์„œ๋ฒ„์—์„œ bash ๋ช…๋ น ์‹คํ–‰. stdin ์œผ๋กœ ๋Œ€ํ™”ํ˜• ์ž…๋ ฅ์„ ๋„˜๊ธด๋‹ค. stdout+stderr ๋ฅผ ํ•ฉ์ณ ๋Œ๋ ค์ค€๋‹ค.
export function sh(cmd: string, stdin = '', timeout = 120_000): Promise<Run> {
  const args = SSH
    ? ['-o', 'StrictHostKeyChecking=accept-new', '-o', 'ServerAliveInterval=30', ...(KEY ? ['-i', KEY] : []), SSH, 'bash', '-lc', q(cmd)]
    : ['-lc', cmd];
  return new Promise((resolve) => {
    const p = spawn(SSH ? 'ssh' : 'bash', args, { env: { ...process.env, TERM: 'dumb' } });
    let out = '';
    p.stdout.on('data', (d) => (out += d));
    p.stderr.on('data', (d) => (out += d));
    const t = setTimeout(() => p.kill('SIGTERM'), timeout);
    p.on('close', (code) => { clearTimeout(t); resolve({ code: code ?? -1, out }); });
    p.stdin.end(stdin);
  });
}

// bin ํด๋”์—์„œ ์‹คํ–‰ (mcc, *.sh ๋Š” bin ๊ธฐ์ค€ ์ƒ๋Œ€๊ฒฝ๋กœ๋ฅผ ์“ด๋‹ค)
export const inBin = (cmd: string, stdin = '', timeout?: number) => sh(`cd ${BIN} && ${cmd}`, stdin, timeout);

// ๋ช…๋ น ์ถœ๋ ฅ์„ ๋ฆฌํฌํŠธ์— ์ฒจ๋ถ€
export async function attach(testInfo: TestInfo, name: string, r: Run) {
  await testInfo.attach(name, { body: `exit=${r.code}\n${r.out}`, contentType: 'text/plain' });
}

// ์„œ๋ฒ„ ์ƒํƒœ๋ฅผ ๋ฐ”๊พธ๋Š” TC ๋Š” ๋ช…์‹œ์ ์œผ๋กœ ํ—ˆ์šฉํ–ˆ์„ ๋•Œ๋งŒ ์‹คํ–‰
export function requireDestructive(what: string) {
  test.skip(process.env.ADMIN_CLI_ALLOW_DESTRUCTIVE !== '1', `${what} โ€” ์„œ๋ฒ„ ์ƒํƒœ๋ฅผ ๋ฐ”๊พธ๋ฏ€๋กœ ADMIN_CLI_ALLOW_DESTRUCTIVE=1 ์ผ ๋•Œ๋งŒ ์‹คํ–‰`);
}

// installAll.sh ์˜ EXPECTED_CONTAINERS / NO_HEALTH_CHECK_CONTAINERS ๋ฐฐ์—ด (๋ฒ„์ „๋งˆ๋‹ค ๋‹ค๋ฅด๋ฏ€๋กœ ์Šคํฌ๋ฆฝํŠธ์—์„œ ์ฝ์Œ)
export async function readArray(name: string): Promise<string[]> {
  const r = await inBin(`sed -n '/^${name}=(/,/^)/p' installAll.sh`);
  return r.out.split('\n').map((l) => l.trim()).filter((l) => /^"[^"]+"$/.test(l)).map((l) => l.slice(1, -1));
}

// mcc infra info ์ถœ๋ ฅ์—์„œ ์ปจํ…Œ์ด๋„ˆ๋ณ„ ์ƒํƒœ ํ–‰์„ ์ฐพ๋Š”๋‹ค(์•ž์ชฝ compose ps ํ‘œ๋งŒ ์‚ฌ์šฉ)
export function psLine(info: string, name: string): string | undefined {
  return info.split('[v]Status of M-CMP runtime images')[0].split('\n').find((l) => new RegExp(`^${name}\\s`).test(l));
}

// ๊ธฐ๋Œ€ ์ปจํ…Œ์ด๋„ˆ ์ „์ฒด๊ฐ€ Up(+health check ๊ฐ€ ์žˆ์œผ๋ฉด healthy)์ธ์ง€ ํ™•์ธ. ๋ฌธ์ œ ๋ชฉ๋ก์„ ๋Œ๋ ค์ค€๋‹ค.
// docker compose ๋ฒ„์ „์— ๋”ฐ๋ผ ์ƒํƒœ๊ฐ€ 'Up 5 minutes (healthy)' ๋˜๋Š” 'running (healthy)' ๋กœ ๋‚˜์˜จ๋‹ค.
export async function unhealthy(names?: string[]): Promise<{ bad: string[]; info: Run }> {
  const info = await inBin('./mcc infra info');
  const expected = names ?? (await readArray('EXPECTED_CONTAINERS'));
  const noHealth = new Set(await readArray('NO_HEALTH_CHECK_CONTAINERS'));
  const bad: string[] = [];
  for (const name of expected) {
    const line = psLine(info.out, name);
    if (!line) bad.push(`${name}: ์—†์Œ`);
    else if (!/\bUp\b|\brunning\b/.test(line) || /unhealthy|starting|restarting/i.test(line)) bad.push(`${name}: ${line.replace(/\s{2,}/g, ' ')}`);
    else if (!noHealth.has(name) && !/\(healthy\)/.test(line)) bad.push(`${name}: healthy ํ‘œ์‹œ ์—†์Œ`);
  }
  return { bad, info };
}

// healthy ๊ฐ€ ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ
export async function waitHealthy(names?: string[], timeoutMs = 20 * 60_000) {
  let last: string[] = [];
  await expect.poll(async () => (last = (await unhealthy(names)).bad).length, { timeout: timeoutMs, intervals: [15_000] })
    .toBe(0)
    .catch(() => { throw new Error(`healthy ์•„๋‹Œ ์ปจํ…Œ์ด๋„ˆ:\n${last.join('\n')}`); });
}

// M-CMP(mcc compose ํ”„๋กœ์ ํŠธ) ์ปจํ…Œ์ด๋„ˆ ์ด๋ฆ„ ๋ชฉ๋ก โ€” ์ด๋ฆ„์ด mc- ๋กœ ์‹œ์ž‘ํ•˜๋Š” ๋‹ค๋ฅธ ์ œํ’ˆ ์ปจํ…Œ์ด๋„ˆ(mc-terrarium ๋“ฑ)๋Š” ์ œ์™ธ
export async function mcContainers(all = true): Promise<string[]> {
  const r = await sh(`docker ps ${all ? '-a' : ''} --filter label=com.docker.compose.project=mcc --format '{{.Names}}'`);
  return r.out.split('\n').map((s) => s.trim()).filter(Boolean);
}

// ํด๋ฆฐ ์„ค์น˜ TC ์˜ ์„ ํ–‰ ์ƒํƒœ: mc- ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์—†์–ด์•ผ ํ•œ๋‹ค
export async function requireClean() {
  const existing = await mcContainers();
  if (existing.length && process.env.ADMIN_CLI_ALLOW_REINSTALL !== '1') {
    throw new Error(`M-CMP(mcc) ์ปจํ…Œ์ด๋„ˆ ${existing.length}๊ฐœ๊ฐ€ ์ด๋ฏธ ์žˆ์Šต๋‹ˆ๋‹ค. ํด๋ฆฐ ์„ค์น˜๊ฐ€ ์•„๋‹ˆ๋ฉด TC-ADMIN-005(cleanAll.sh) ํ›„ ์‹คํ–‰ํ•˜๊ฑฐ๋‚˜ ADMIN_CLI_ALLOW_REINSTALL=1 ๋กœ ์ง„ํ–‰ํ•˜์„ธ์š”.`);
  }
}

// mc-infra-manager(tumblebug) REST ํ˜ธ์ถœ โ€” ๋Œ€์ƒ ์„œ๋ฒ„์˜ localhost ๊ธฐ์ค€
export async function tb(path: string, method = 'GET', body?: unknown): Promise<{ status: number; json: any; raw: string }> {
  const data = body === undefined ? '' : ` -H 'Content-Type: application/json' -d ${q(JSON.stringify(body))}`;
  const r = await sh(`curl -s -u ${q(TB_AUTH)} -X ${method} -w '\\n%{http_code}' ${q(TB + path)}${data}`);
  const lines = r.out.trimEnd().split('\n');
  const status = Number(lines.pop());
  const raw = lines.join('\n');
  let json: any = null;
  try { json = JSON.parse(raw); } catch { /* ๋ณธ๋ฌธ์ด JSON ์ด ์•„๋‹˜ */ }
  return { status, json, raw };
}

// mc-iam-manager ํ”Œ๋žซํผ ๊ด€๋ฆฌ์ž ํ† ํฐ โ€” ๊ณ„์ •์€ ๋Œ€์ƒ ์„œ๋ฒ„ .env ์—์„œ ์ฝ๊ณ  ํ™”๋ฉดยท๋ฆฌํฌํŠธ์— ๋‚จ๊ธฐ์ง€ ์•Š๋Š”๋‹ค
export async function iamToken(): Promise<string> {
  const env = `${BIN}/../conf/docker/conf/mc-iam-manager/.env`;
  const r = await sh(
    `set -a; . ${env}; set +a; ` +
      `curl -s -X POST http://localhost:5000/api/auth/login -H 'Content-Type: application/json' ` +
      `-d "{\\"id\\":\\"$MC_IAM_MANAGER_PLATFORMADMIN_ID\\",\\"password\\":\\"$MC_IAM_MANAGER_PLATFORMADMIN_PASSWORD\\"}"`,
  );
  const token = (() => { try { return JSON.parse(r.out).access_token as string; } catch { return ''; } })();
  expect(token, `ํ”Œ๋žซํผ ๊ด€๋ฆฌ์ž ๋กœ๊ทธ์ธ ์‹คํŒจ: ${r.out.slice(0, 200)}`).toBeTruthy();
  return token;
}

// mc-iam-manager POST */list ํ˜ธ์ถœ
export async function iamList(token: string, path: string): Promise<any> {
  const r = await sh(`curl -s -X POST ${q('http://localhost:5000' + path)} -H 'Content-Type: application/json' -H ${q('Authorization: Bearer ' + token)} -d '{}'`);
  try { return JSON.parse(r.out); } catch { return r.out; }
}

// โ”€โ”€ mcc rest ๊ฒ€์ฆ์šฉ echo ์„œ๋ฒ„ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// ๋Œ€์ƒ ์„œ๋ฒ„์— ๋ฐ›์€ ์š”์ฒญ(method, path, headers, body)์„ JSON ์œผ๋กœ ๋Œ๋ ค์ฃผ๋Š” ์ž„์‹œ ์„œ๋ฒ„๋ฅผ ๋„์šด๋‹ค.
// ?status=NNN ์ด๋ฉด ๊ทธ ์ƒํƒœ ์ฝ”๋“œ๋กœ ์‘๋‹ตํ•œ๋‹ค. ํ…Œ์ŠคํŠธ ๋์— stopEcho() ๋กœ ์ข…๋ฃŒ.
export const ECHO_PORT = Number(process.env.ADMIN_CLI_ECHO_PORT ?? 18999);
export const ECHO = `http://127.0.0.1:${ECHO_PORT}/echo`;
const ECHO_PY = `
import json, sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
class H(BaseHTTPRequestHandler):
    def _h(self):
        n = int(self.headers.get('Content-Length') or 0)
        body = self.rfile.read(n).decode() if n else ''
        st = int(parse_qs(urlparse(self.path).query).get('status', ['200'])[0])
        out = json.dumps({'method': self.command, 'path': self.path, 'headers': dict(self.headers), 'body': body}).encode()
        self.send_response(st); self.send_header('Content-Type', 'application/json'); self.send_header('X-Echo', 'iv0')
        self.send_header('Content-Length', str(len(out))); self.end_headers()
        if self.command != 'HEAD': self.wfile.write(out)
    do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = _h
    def log_message(self, *a): pass
HTTPServer(('127.0.0.1', int(sys.argv[1])), H).serve_forever()
`;
export async function startEcho() {
  await sh(`cat > /tmp/iv0-echo.py <<'PYEOF'\n${ECHO_PY}\nPYEOF\nnohup python3 /tmp/iv0-echo.py ${ECHO_PORT} >/dev/null 2>&1 & echo $! > /tmp/iv0-echo.pid; sleep 1`);
  expect((await sh(`curl -s -o /dev/null -w '%{http_code}' ${ECHO}`)).out, 'echo ์„œ๋ฒ„ ๊ธฐ๋™ ์‹คํŒจ').toBe('200');
}
export async function stopEcho() {
  await sh('kill $(cat /tmp/iv0-echo.pid) 2>/dev/null; rm -f /tmp/iv0-echo.pid /tmp/iv0-echo.py');
}
// mcc ์ถœ๋ ฅ์—์„œ echo ์‘๋‹ต JSON ์„ ์ฐพ๋Š”๋‹ค
export function echoed(out: string): any {
  const m = out.match(/\{"method".*\}/);
  return m ? JSON.parse(m[0]) : null;
}

// โ”€โ”€ mcc api ์„ค์ • โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// conf/api.yaml ์˜ baseurl ์€ docker ๋‚ด๋ถ€ ํ˜ธ์ŠคํŠธ๋ช…(http://mc-infra-manager:1323 โ€ฆ)์ด๋ผ ์„œ๋ฒ„ ์…ธ์—์„œ๋Š” ํ’€๋ฆฌ์ง€ ์•Š๋Š”๋‹ค.
// ๋ฌธ์„œ ์•ˆ๋‚ด๋Œ€๋กœ '์‹ค์ œ ์„œ๋ฒ„ ์ฃผ์†Œ์— ๋งž๊ฒŒ' ํ˜ธ์ŠคํŠธ๋ช…๋งŒ localhost ๋กœ ๋ฐ”๊พผ ์‚ฌ๋ณธ(/tmp/iv0-api.yaml)์„ ๋งŒ๋“ ๋‹ค.
// ADMIN_CLI_API_CONFIG ๋ฅผ ์ฃผ๋ฉด ๊ทธ ํŒŒ์ผ์„ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค.
export async function apiConfig(): Promise<string> {
  if (process.env.ADMIN_CLI_API_CONFIG) return process.env.ADMIN_CLI_API_CONFIG;
  await inBin(`sed -E 's#(baseurl: *"?https?://)[a-z0-9-]+:#\\1localhost:#' ../conf/api.yaml > /tmp/iv0-api.yaml`);
  return '/tmp/iv0-api.yaml';
}
const [TBU, TBP] = TB_AUTH.split(':');
// mc-infra-manager ํ˜ธ์ถœ ์‹œ ์ธ์ฆ์„ ๋ช…๋ น ์ธ์ž๋กœ ๋ฎ์–ด์“ด๋‹ค(--authUser/--authPassword)
export const TB_AUTH_ARGS = `--authUser ${q(TBU)} --authPassword ${q(TBP)}`;

IV0_S01

tests/iv0/IV0_S01.spec.ts ยท 37์ค„ ยท ํ•ด์‹œ 36583587 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { inBin, attach, requireDestructive, requireClean, unhealthy } from './_cli';

// IV0_S01 : Mode A ๊ฐœ๋ฐœ ์„ค์น˜ (installAll.sh ๋Œ€ํ™”ํ˜• ์ž…๋ ฅ) โ€” TC-ADMIN-001
// ์ž…๋ ฅ: ๋ชจ๋“œ 1(Developer) / ๋„๋ฉ”์ธ Enter(๊ธฐ๋ณธ localhost) / ์‹คํ–‰ ๋ฐฉ์‹ 2(Background)
// ADMIN_CLI_DOMAIN ์— ์„œ๋ฒ„ IP ๋ฅผ ๋„ฃ์œผ๋ฉด ์ž์ฒด ์„œ๋ช… ์ธ์ฆ์„œ(HTTPS) ๊ฒฝ๋กœ๋กœ ์„ค์น˜ํ•œ๋‹ค(๋น„์šฐ๋ฉด localhost, plain HTTP).
const DOMAIN = process.env.ADMIN_CLI_DOMAIN ?? '';

test('IV0_S01 Mode A ๊ฐœ๋ฐœ ์„ค์น˜ (installAll.sh โ†’ mcc infra info ์ „์ฒด healthy)', async ({}, testInfo) => {
  requireDestructive('ํด๋ฆฐ ์„ค์น˜');
  test.setTimeout(60 * 60_000);

  await test.step('1. ์„ ํ–‰ ์ƒํƒœ ํ™•์ธ (docker compose, installAll.sh, ๊ธฐ์กด mc- ์ปจํ…Œ์ด๋„ˆ ์—†์Œ)', async () => {
    expect((await inBin('docker compose version && ls installAll.sh mcc')).code).toBe(0);
    await requireClean();
  });

  const install = await test.step(`2. installAll.sh ์‹คํ–‰ โ€” 1 / ${DOMAIN || 'Enter(localhost)'} / 2`, async () => {
    const r = await inBin('./installAll.sh', `1\n${DOMAIN}\n2\n`, 45 * 60_000);
    await attach(testInfo, 'installAll.log', r);
    return r;
  });

  await test.step('3. ์„ค์น˜ ์™„๋ฃŒ ๋ฉ”์‹œ์ง€ ํ™•์ธ', async () => {
    expect(install.out).toContain(`Using domain: ${DOMAIN || 'localhost'}`);
    expect(install.out).toMatch(DOMAIN ? /Developer mode configuration completed/ : /Local HTTP mode configuration completed/);
    expect(install.out, '์ปจํ…Œ์ด๋„ˆ ๋ชจ๋‹ˆํ„ฐ๋ง์ด healthy ๋กœ ๋๋‚˜์ง€ ์•Š์Œ').toContain('All environments have been set up!');
    expect(install.out, 'mc-iam-manager-post-initial ๋ฏธ์™„๋ฃŒ').not.toContain('mc-iam-manager-post-initial did not complete');
    expect(install.code, 'installAll.sh ์ข…๋ฃŒ ์ฝ”๋“œ').toBe(0);
  });

  await test.step('4. ./mcc infra info โ†’ ์ „์ฒด framework healthy', async () => {
    const { bad, info } = await unhealthy();
    await attach(testInfo, 'mcc-infra-info.txt', info);
    expect(bad, `healthy ์•„๋‹Œ ์ปจํ…Œ์ด๋„ˆ:\n${bad.join('\n')}`).toEqual([]);
  });
});

IV0_S02

tests/iv0/IV0_S02.spec.ts ยท 48์ค„ ยท ํ•ด์‹œ 1fda97b0 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { inBin, sh, attach, requireDestructive, requireClean, unhealthy } from './_cli';

// IV0_S02 : Mode B ์šด์˜ ์„ค์น˜ (Let's Encrypt) โ€” TC-ADMIN-002
// ์ž…๋ ฅ: ๋ชจ๋“œ 2(Production) / ๋„๋ฉ”์ธ = ์‹ค์ œ FQDN(์ด ์„œ๋ฒ„๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋Š” DNS A ๋ ˆ์ฝ”๋“œ ํ•„์š”) / ์‹คํ–‰ ๋ฐฉ์‹ 2(Background)
// ์ „์ œ: 80ยท443 ์ด ์™ธ๋ถ€์—์„œ ์—ด๋ ค ์žˆ์–ด์•ผ certbot(HTTP-01) ๋ฐœ๊ธ‰์ด ๋œ๋‹ค. ์ฃผ: Let's Encrypt ๋ฐœ๊ธ‰ ํšŸ์ˆ˜ ์ œํ•œ(๊ฐ™์€ ๋„๋ฉ”์ธ ์ฃผ 5ํšŒ)
const FQDN = process.env.ADMIN_CLI_FQDN ?? '';

test("IV0_S02 Mode B ์šด์˜ ์„ค์น˜ (Let's Encrypt ์ธ์ฆ์„œ ๋ฐœ๊ธ‰ โ†’ HTTPS ์ •์ƒ ยท ์ „์ฒด healthy)", async ({ request }, testInfo) => {
  requireDestructive('์šด์˜ ๋ชจ๋“œ ํด๋ฆฐ ์„ค์น˜');
  test.skip(!FQDN, 'ADMIN_CLI_FQDN(์‹ค์ œ ๋„๋ฉ”์ธ) ๋ฏธ์ง€์ •');
  test.setTimeout(70 * 60_000);

  await test.step('1. ์„ ํ–‰ ์ƒํƒœ ํ™•์ธ (ํด๋ฆฐ ์ƒํƒœ, ๋„๋ฉ”์ธ DNS ๊ฐ€ ์ด ์„œ๋ฒ„๋ฅผ ๊ฐ€๋ฆฌํ‚ด, 80 ๋ฏธ์‚ฌ์šฉ)', async () => {
    await requireClean();
    const dns = await sh(`getent hosts ${FQDN} | awk '{print $1}'; curl -s https://checkip.amazonaws.com`);
    const [resolved, mine] = dns.out.trim().split('\n');
    testInfo.annotations.push({ type: 'dns', description: `${FQDN} โ†’ ${resolved} / ์„œ๋ฒ„ ๊ณต์ธ IP ${mine}` });
    expect(resolved, 'DNS A ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ด ์„œ๋ฒ„์˜ ๊ณต์ธ IP ์™€ ๋‹ค๋ฆ„').toBe(mine);
    expect((await sh(`ss -ltn '( sport = :80 )' | tail -n +2`)).out.trim(), '80 ํฌํŠธ๊ฐ€ ์ด๋ฏธ ์‚ฌ์šฉ ์ค‘').toBe('');
  });

  const install = await test.step(`2. installAll.sh ์‹คํ–‰ โ€” 2 / ${FQDN} / 2`, async () => {
    const r = await inBin('./installAll.sh', `2\n${FQDN}\n2\n`, 55 * 60_000);
    await attach(testInfo, 'installAll.log', r);
    return r;
  });

  await test.step('3. ์ธ์ฆ์„œ ๋ฐœ๊ธ‰ยท์šด์˜ ๋ชจ๋“œ ์„ค์ •ยท์„ค์น˜ ์™„๋ฃŒ ๋ฉ”์‹œ์ง€ ํ™•์ธ', async () => {
    expect(install.out).toContain('Certificate generation completed');
    expect(install.out).toContain('Production mode configuration completed');
    expect(install.out).toContain('All environments have been set up!');
    expect(install.code).toBe(0);
  });

  await test.step('4. ์™ธ๋ถ€์—์„œ HTTPS ์ ‘์† โ€” ๊ณต์ธ ์ธ์ฆ์„œ ๊ฒ€์ฆ ํ†ต๊ณผ (443, 3001)', async () => {
    for (const url of [`https://${FQDN}/`, `https://${FQDN}:3001/`]) {
      const res = await request.get(url, { ignoreHTTPSErrors: false, maxRedirects: 0 });
      expect(res.status(), url).toBeLessThan(500);
    }
  });

  await test.step('5. ./mcc infra info โ†’ ์ „์ฒด framework healthy', async () => {
    const { bad, info } = await unhealthy();
    await attach(testInfo, 'mcc-infra-info.txt', info);
    expect(bad, bad.join('\n')).toEqual([]);
  });
});

IV0_S03

tests/iv0/IV0_S03.spec.ts ยท 36์ค„ ยท ํ•ด์‹œ 8210157a ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import net from 'node:net';
import { sh, attach } from './_cli';

// IV0_S03 : ๋ฐฉํ™”๋ฒฝ ํฌํŠธ ์ ๊ฒ€ โ€” TC-ADMIN-003
// ํ•„์ˆ˜ ์™ธ๋ถ€ ํฌํŠธ(README "Required Firewall Services" ๊ธฐ์ค€, Cost FE ํ”„๋ก์‹œ๋Š” ํ˜„์žฌ .env.setup ๊ฐ’ 7780 โ€” README ์˜ 7781 ์€ ์˜› ๊ฐ’)๊ฐ€ (1) ์„œ๋ฒ„์—์„œ LISTEN ์ค‘์ด๊ณ  (2) ์™ธ๋ถ€(์ด ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ํ•˜๋Š” PC)์—์„œ TCP ์ ‘์†๋˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค.
// ADMIN_CLI_PUBLIC_HOST: ์™ธ๋ถ€์—์„œ ์ ‘์†ํ•  ์„œ๋ฒ„ ์ฃผ์†Œ(๋„๋ฉ”์ธ ๋˜๋Š” ๊ณต์ธ IP). ADMIN_CLI_PORTS ๋กœ ๋ชฉ๋ก์„ ๋ฐ”๊ฟ€ ์ˆ˜ ์žˆ๋‹ค.
const HOST = process.env.ADMIN_CLI_PUBLIC_HOST ?? '';
const PORTS = (process.env.ADMIN_CLI_PORTS ?? '80,443,3001,33002,7780,9090,9000,3000').split(',').map(Number);

function connect(host: string, port: number, timeout = 5_000): Promise<boolean> {
  return new Promise((resolve) => {
    const s = net.connect({ host, port, timeout });
    s.once('connect', () => { s.destroy(); resolve(true); });
    s.once('timeout', () => { s.destroy(); resolve(false); });
    s.once('error', () => resolve(false));
  });
}

test('IV0_S03 ๋ฐฉํ™”๋ฒฝ ํฌํŠธ ์ ๊ฒ€ (ํ•„์ˆ˜ ํฌํŠธ LISTEN + ์™ธ๋ถ€ TCP ์ ‘์†)', async ({}, testInfo) => {
  test.skip(!HOST, 'ADMIN_CLI_PUBLIC_HOST ๋ฏธ์ง€์ •');

  await test.step(`1. ์„œ๋ฒ„์—์„œ ํ•„์ˆ˜ ํฌํŠธ LISTEN ํ™•์ธ (${PORTS.join(', ')})`, async () => {
    const r = await sh(`ss -ltnH | awk '{print $4}' | sed 's/.*://' | sort -un`);
    await attach(testInfo, 'listen-ports.txt', r);
    const listening = new Set(r.out.split('\n').map(Number));
    expect(PORTS.filter((p) => !listening.has(p)), 'LISTEN ํ•˜์ง€ ์•Š๋Š” ํฌํŠธ').toEqual([]);
  });

  await test.step(`2. ์™ธ๋ถ€(${HOST})์—์„œ TCP ์ ‘์† ํ™•์ธ`, async () => {
    const closed: number[] = [];
    for (const p of PORTS) if (!(await connect(HOST, p))) closed.push(p);
    testInfo.annotations.push({ type: 'ports', description: `์—ด๋ฆผ ${PORTS.length - closed.length}/${PORTS.length}${closed.length ? `, ๋ง‰ํž˜: ${closed.join(', ')}` : ''}` });
    expect(closed, '์™ธ๋ถ€์—์„œ ์ ‘์†๋˜์ง€ ์•Š๋Š” ํฌํŠธ(๋ณด์•ˆ๊ทธ๋ฃนยท๋ฐฉํ™”๋ฒฝ ํ™•์ธ)').toEqual([]);
  });
});

IV0_S04

tests/iv0/IV0_S04.spec.ts ยท 39์ค„ ยท ํ•ด์‹œ 7159259d ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { sh, attach, tb, iamToken, iamList } from './_cli';

// IV0_S04 : IAM ์ดˆ๊ธฐํ™” (iam_manager_init.sh / mc-iam-manager-post-initial) โ€” TC-ADMIN-004
// ์„ ํ–‰: TC-ADMIN-001(์„ค์น˜) ์™„๋ฃŒ, mc-infra-manager healthy
// ํ™•์ธ: post-initial ๋กœ๊ทธ์˜ ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ โ†’ IAM(๊ด€๋ฆฌ์žยท์—ญํ• ยท๋ฉ”๋‰ดยท๊ธฐ๋ณธ workspace/project) โ†’ INFRA(namespace) ์ƒ์„ฑ
const WS = process.env.ADMIN_CLI_DEFAULT_WORKSPACE ?? 'ws01';
const PJ = process.env.ADMIN_CLI_DEFAULT_PROJECT ?? 'default';

test('IV0_S04 IAM ์ดˆ๊ธฐํ™” ๊ฒฐ๊ณผ ํ™•์ธ (post-initial ์„ฑ๊ณต ๋กœ๊ทธ ยท IAM ๊ธฐ๋ณธ ๋ฐ์ดํ„ฐ ยท INFRA namespace)', async ({}, testInfo) => {
  await test.step('1. docker logs mc-iam-manager-post-initial โ†’ ์„ฑ๊ณต ๋ฉ”์‹œ์ง€, ์ข…๋ฃŒ ์ฝ”๋“œ 0', async () => {
    const log = await sh('docker logs mc-iam-manager-post-initial 2>&1 | tail -n 60; echo "exit=$(docker inspect -f {{.State.ExitCode}} mc-iam-manager-post-initial)"');
    await attach(testInfo, 'post-initial.log', log);
    expect(log.out).toContain('[Success] MC-IAM-Manager initialization completed successfully!');
    expect(log.out).toContain('Container will exit normally.');
    expect(log.out).toContain('exit=0');
  });

  const token = await test.step('2. MC-IAM ํ”Œ๋žซํผ ๊ด€๋ฆฌ์ž ๋กœ๊ทธ์ธ', iamToken);

  await test.step('3. MC-IAM ์—ญํ• ยท๋ฉ”๋‰ดยท๊ธฐ๋ณธ workspace/project ์ƒ์„ฑ ํ™•์ธ', async () => {
    const roles = JSON.stringify(await iamList(token, '/api/roles/list'));
    const menus = JSON.stringify(await iamList(token, '/api/menus/list'));
    const wss = JSON.stringify(await iamList(token, '/api/workspaces/list'));
    const pjs = JSON.stringify(await iamList(token, '/api/projects/list'));
    testInfo.annotations.push({ type: 'iam', description: `roles ${roles.length}B, menus ${menus.length}B, workspaces ${wss.length}B, projects ${pjs.length}B` });
    expect(roles, 'ํ”Œ๋žซํผ ๊ด€๋ฆฌ์ž ์—ญํ•  admin').toContain('"name":"admin"');
    expect(menus.length, '๋ฉ”๋‰ด ๋ชฉ๋ก').toBeGreaterThan(100);
    expect(wss, `๊ธฐ๋ณธ workspace ${WS}`).toContain(`"${WS}"`);
    expect(pjs, `๊ธฐ๋ณธ project ${PJ}`).toContain(`"${PJ}"`);
  });

  await test.step('4. MC-INFRA namespace ์ƒ์„ฑ ํ™•์ธ (GET /ns)', async () => {
    const ns = await tb('/ns');
    await testInfo.attach('ns.json', { body: ns.raw, contentType: 'application/json' });
    expect(ns.status).toBe(200);
    expect((ns.json?.ns ?? []).map((n: any) => n.id), `project ${PJ} ์˜ namespace`).toContain(PJ);
  });
});

IV0_S05

tests/iv0/IV0_S05.spec.ts ยท 33์ค„ ยท ํ•ด์‹œ cb875115 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { sh, inBin, attach, requireDestructive } from './_cli';

// IV0_S05 : ํ™˜๊ฒฝ ์ „์ฒด ์ •๋ฆฌ (cleanAll.sh) โ€” TC-ADMIN-005
// ์ฃผ์˜: cleanAll.sh ๋Š” M-CMP ๋ฟ ์•„๋‹ˆ๋ผ ์ด ์„œ๋ฒ„์˜ ๋ชจ๋“  docker ์ด๋ฏธ์ง€ยท์ปจํ…Œ์ด๋„ˆยท๋ณผ๋ฅจยท๋„คํŠธ์›Œํฌ์™€ container-volume ํด๋”๋ฅผ ์ง€์šด๋‹ค.
//       ์ „์šฉ ํ…Œ์ŠคํŠธ ์„œ๋ฒ„์—์„œ๋งŒ ์‹คํ–‰ํ•œ๋‹ค. sudo ๋น„๋ฐ€๋ฒˆํ˜ธ ์—†์ด ์‹คํ–‰ ๊ฐ€๋Šฅํ•ด์•ผ ํ•œ๋‹ค(container-volume ์‚ญ์ œ).
test('IV0_S05 ํ™˜๊ฒฝ ์ „์ฒด ์ •๋ฆฌ (cleanAll.sh โ†’ ์ด๋ฏธ์ง€ยท์ปจํ…Œ์ด๋„ˆยท๋ณผ๋ฅจ ์‚ญ์ œ)', async ({}, testInfo) => {
  requireDestructive('์ „์ฒด docker ์ž์› ์‚ญ์ œ');
  test.setTimeout(30 * 60_000);

  await test.step('1. ์„ ํ–‰ ์ƒํƒœ ๊ธฐ๋ก (์ด๋ฏธ์ง€ยท์ปจํ…Œ์ด๋„ˆยท๋ณผ๋ฅจ ์ˆ˜)', async () => {
    const r = await sh('echo images=$(docker images -q | wc -l) containers=$(docker ps -aq | wc -l) volumes=$(docker volume ls -q | wc -l)');
    testInfo.annotations.push({ type: 'before', description: r.out.trim() });
    expect(r.out).not.toContain('images=0 ');
  });

  const clean = await test.step('2. bin ํด๋”์—์„œ ./cleanAll.sh ์‹คํ–‰ โ†’ ํ™•์ธ ์งˆ๋ฌธ์— y', async () => {
    const r = await inBin('./cleanAll.sh', 'y\n', 25 * 60_000);
    await attach(testInfo, 'cleanAll.log', r);
    return r;
  });

  await test.step('3. docker ์ด๋ฏธ์ง€ยท์ปจํ…Œ์ด๋„ˆยท๋ณผ๋ฅจ์ด ๋ชจ๋‘ ์‚ญ์ œ๋จ', async () => {
    expect(clean.out).not.toContain('Aborted by user');
    const r = await sh('echo images=$(docker images -q | wc -l) containers=$(docker ps -aq | wc -l) volumes=$(docker volume ls -q | wc -l)');
    testInfo.annotations.push({ type: 'after', description: r.out.trim() });
    expect(r.out).toContain('images=0 containers=0 volumes=0');
  });

  await test.step('4. container-volume ํด๋” ์‚ญ์ œ๋จ', async () => {
    expect((await inBin('test -d ../conf/docker/container-volume && echo exists || echo removed')).out.trim()).toBe('removed');
  });
});

IV0_S06

tests/iv0/IV0_S06.spec.ts ยท 27์ค„ ยท ํ•ด์‹œ a84ddad3 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { inBin, attach, requireDestructive, waitHealthy, unhealthy } from './_cli';

// IV0_S06 : ํ’€์Šคํƒ ์ด๋ฏธ์ง€ ํ’€ ๋ฐ ๊ธฐ๋™ (mcc infra pull โ†’ run -d) โ€” TC-ADMIN-006
// ์„ ํ–‰: installAll.sh ์„ค์ • ๋‹จ๊ณ„ ์™„๋ฃŒ(.env, nginx.conf ์ƒ์„ฑ) โ€” TC-ADMIN-001 ์˜ '์‹คํ–‰ ๋ฐฉ์‹ 3(Skip)' ๋˜๋Š” ๊ธฐ์กด ์„ค์น˜
test('IV0_S06 ํ’€์Šคํƒ ์ด๋ฏธ์ง€ ํ’€ ๋ฐ ๊ธฐ๋™ (pull โ†’ run -d โ†’ ์ „์ฒด healthy)', async ({}, testInfo) => {
  requireDestructive('์ „์ฒด ์Šคํƒ ์ด๋ฏธ์ง€ pullยท๊ธฐ๋™');
  test.setTimeout(60 * 60_000);

  await test.step('1. ./mcc infra pull โ†’ ์ด๋ฏธ์ง€ ๋‚ด๋ ค๋ฐ›๊ธฐ ์„ฑ๊ณต', async () => {
    const r = await inBin('./mcc infra pull 2>&1', '', 40 * 60_000);
    await attach(testInfo, 'pull.log', r);
    expect(r.code).toBe(0);
    expect(r.out).not.toMatch(/error|denied|not found/i);
  });

  await test.step('2. ./mcc infra run -d โ†’ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๊ธฐ๋™ (์ข…๋ฃŒ ์ฝ”๋“œ 0)', async () => {
    const r = await inBin('./mcc infra run -d 2>&1', '', 20 * 60_000);
    await attach(testInfo, 'run.log', r);
    expect(r.code).toBe(0);
  });

  await test.step('3. ./mcc infra info โ†’ ์ „์ฒด framework healthy (์ตœ๋Œ€ 20๋ถ„ ๋Œ€๊ธฐ)', async () => {
    await waitHealthy();
    await attach(testInfo, 'mcc-infra-info.txt', (await unhealthy()).info);
  });
});

IV0_S07

tests/iv0/IV0_S07.spec.ts ยท 33์ค„ ยท ํ•ด์‹œ 79f3ea35 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { sh, inBin, attach, requireDestructive, waitHealthy } from './_cli';

// IV0_S07 : ๋‹จ์ผ ์„œ๋น„์Šค ํƒ€๊ฒŸ ๊ธฐ๋™ (-s ํ”Œ๋ž˜๊ทธ) โ€” TC-ADMIN-007
// ๋Œ€์ƒ ์„œ๋น„์Šค 1๊ฐœ๋งŒ ์ค‘์ง€ํ–ˆ๋‹ค๊ฐ€ -s ๋กœ ๊ทธ ์„œ๋น„์Šค๋งŒ ๋‹ค์‹œ ๊ธฐ๋™ํ•˜๊ณ , ๋‹ค๋ฅธ ์„œ๋น„์Šค๋Š” ์žฌ์‹œ์ž‘๋˜์ง€ ์•Š์•˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค.
// ADMIN_CLI_TARGET_SERVICE(๊ธฐ๋ณธ mc-data-manager), ๋น„๊ต์šฉ ADMIN_CLI_OTHER_SERVICE(๊ธฐ๋ณธ mc-web-console-api)
const TARGET = process.env.ADMIN_CLI_TARGET_SERVICE ?? 'mc-data-manager';
const OTHER = process.env.ADMIN_CLI_OTHER_SERVICE ?? 'mc-web-console-api';
const startedAt = async (c: string) => (await sh(`docker inspect -f '{{.State.StartedAt}}' ${c}`)).out.trim();
const status = async (c: string) => (await sh(`docker inspect -f '{{.State.Status}}' ${c}`)).out.trim();

test(`IV0_S07 ๋‹จ์ผ ์„œ๋น„์Šค ํƒ€๊ฒŸ ๊ธฐ๋™ (-s ${TARGET})`, async ({}, testInfo) => {
  requireDestructive(`${TARGET} ์ค‘์ง€ยท์žฌ๊ธฐ๋™`);
  test.setTimeout(20 * 60_000);
  const otherBefore = await startedAt(OTHER);

  await test.step(`1. ./mcc infra stop -s ${TARGET} โ†’ ๋Œ€์ƒ๋งŒ ์ค‘์ง€`, async () => {
    await attach(testInfo, 'stop.log', await inBin(`./mcc infra stop -s ${TARGET} 2>&1`));
    expect(await status(TARGET)).toBe('exited');
    expect(await status(OTHER)).toBe('running');
  });

  await test.step(`2. ./mcc infra run -d -s ${TARGET} โ†’ ๋Œ€์ƒ๋งŒ ๊ธฐ๋™`, async () => {
    const r = await inBin(`./mcc infra run -d -s ${TARGET} 2>&1`, '', 10 * 60_000);
    await attach(testInfo, 'run.log', r);
    expect(r.code).toBe(0);
  });

  await test.step('3. ๋Œ€์ƒ healthy, ๋‹ค๋ฅธ ์„œ๋น„์Šค๋Š” ์žฌ์‹œ์ž‘๋˜์ง€ ์•Š์Œ', async () => {
    await waitHealthy([TARGET], 10 * 60_000);
    expect(await startedAt(OTHER), `${OTHER} ๊ฐ€ ํ•จ๊ป˜ ์žฌ์‹œ์ž‘๋จ`).toBe(otherBefore);
  });
});

IV0_S08

tests/iv0/IV0_S08.spec.ts ยท 38์ค„ ยท ํ•ด์‹œ fcc2a71a ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { sh, inBin, attach, requireDestructive, waitHealthy } from './_cli';

// IV0_S08 : ์ค‘์ง€ โ†’ ์žฌ๊ธฐ๋™ โ†’ ์ด๋ฏธ์ง€ ์—…๋ฐ์ดํŠธ โ€” TC-ADMIN-008
// ADMIN_CLI_TARGET_SERVICE(๊ธฐ๋ณธ mc-data-manager)๋กœ ๋ฒ”์œ„๋ฅผ ํ•œ์ •ํ•œ๋‹ค. 'all' ์ด๋ฉด ์ „์ฒด ์Šคํƒ(-s ์—†์ด) ๋Œ€์ƒ.
// ์ฐธ๊ณ : v07 bin/mcc ์˜ update ๋Š” pull ํ›„ 'docker compose up'(-d ์—†์Œ)์œผ๋กœ ํฌ๊ทธ๋ผ์šด๋“œ์— ๋ถ™์–ด ๋๋‚˜์ง€ ์•Š๋Š”๋‹ค
//       (MZC-CSC/mc-admin-cli#100 ์—์„œ ํ•ญ์ƒ up -d ๋กœ ์ˆ˜์ •). 15๋ถ„ timeout(124)ยท๊ฐ•์ œ ์ข…๋ฃŒ(255 ๋“ฑ) ๋ชจ๋‘ FAIL โ€”
//       ๋ช…๋ น์ด ์Šค์Šค๋กœ exit 0 ์œผ๋กœ ๋๋‚˜์•ผ PASS.
const TARGET = process.env.ADMIN_CLI_TARGET_SERVICE ?? 'mc-data-manager';
const S = TARGET === 'all' ? '' : ` -s ${TARGET}`;
// ์ค‘์ง€ ํ™•์ธ: ๋‹จ์ผ ์„œ๋น„์Šค๋Š” exited, ์ „์ฒด๋Š” ์‹คํ–‰ ์ค‘์ธ mc- ์ปจํ…Œ์ด๋„ˆ 0
const stopped = async () => TARGET === 'all'
  ? (await sh(`docker ps -q --filter label=com.docker.compose.project=mcc | wc -l`)).out.trim() === '0'
  : (await sh(`docker inspect -f '{{.State.Status}}' ${TARGET}`)).out.trim() === 'exited';

test(`IV0_S08 ์ค‘์ง€ โ†’ ์žฌ๊ธฐ๋™ โ†’ ์ด๋ฏธ์ง€ ์—…๋ฐ์ดํŠธ (${TARGET})`, async ({}, testInfo) => {
  requireDestructive(`${TARGET} ์ค‘์ง€ยท์žฌ๊ธฐ๋™ยท์—…๋ฐ์ดํŠธ`);
  test.setTimeout(60 * 60_000);
  const targets = TARGET === 'all' ? undefined : [TARGET];

  await test.step(`1. ./mcc infra stop${S} โ†’ ์ค‘์ง€๋จ`, async () => {
    await attach(testInfo, 'stop.log', await inBin(`./mcc infra stop${S} 2>&1`, '', 15 * 60_000));
    expect(await stopped(), '์ค‘์ง€๋˜์ง€ ์•Š์Œ').toBe(true);
  });

  await test.step(`2. ./mcc infra run -d${S} โ†’ ๋‹ค์‹œ healthy`, async () => {
    expect((await inBin(`./mcc infra run -d${S} 2>&1`, '', 15 * 60_000)).code).toBe(0);
    await waitHealthy(targets);
  });

  await test.step(`3. ./mcc infra update${S} โ†’ ์ตœ์‹  ์ด๋ฏธ์ง€ pull ํ›„ ์žฌ๊ธฐ๋™, ๋ช…๋ น์ด ์ข…๋ฃŒ๋จ`, async () => {
    const r = await inBin(`timeout 900 ./mcc infra update${S} 2>&1`, '', 16 * 60_000);
    await attach(testInfo, 'update.log', r);
    expect(r.out).toContain('Install the latest Docker images');
    expect(r.code, r.code === 124 ? 'update ๋ช…๋ น์ด 15๋ถ„ ์•ˆ์— ๋๋‚˜์ง€ ์•Š์Œ(ํฌ๊ทธ๋ผ์šด๋“œ up)' : 'update ๋ช…๋ น์ด ์ •์ƒ ์ข…๋ฃŒ(exit 0)ํ•˜์ง€ ์•Š์Œ').toBe(0);
    await waitHealthy(targets);
  });
});

IV0_S09

tests/iv0/IV0_S09.spec.ts ยท 35์ค„ ยท ํ•ด์‹œ b47701dd ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { sh, inBin, attach, requireDestructive } from './_cli';

// IV0_S09 : ์Šคํƒ ์ •๋ฆฌ (mcc infra remove -v -i) โ€” TC-ADMIN-009
// ์ปจํ…Œ์ด๋„ˆยทnamed volumeยทcompose ์ด๋ฏธ์ง€๋ฅผ ๋ชจ๋‘ ์ง€์šด๋‹ค. ์ „์šฉ ํ…Œ์ŠคํŠธ ์„œ๋ฒ„์—์„œ๋งŒ ์‹คํ–‰.
const COMPOSE = '../conf/docker/docker-compose.yaml';

test('IV0_S09 ์Šคํƒ ์ •๋ฆฌ (remove -v -i โ†’ ์ปจํ…Œ์ด๋„ˆยท๋ณผ๋ฅจยท์ด๋ฏธ์ง€ ์‚ญ์ œ)', async ({}, testInfo) => {
  requireDestructive('์Šคํƒ ์ „์ฒด ์‚ญ์ œ');
  test.setTimeout(30 * 60_000);

  const images = await test.step('1. ์„ ํ–‰ ์ƒํƒœ ๊ธฐ๋ก (mcc ํ”„๋กœ์ ํŠธ ์ปจํ…Œ์ด๋„ˆยท๋ณผ๋ฅจ, compose ์ด๋ฏธ์ง€)', async () => {
    const r = await inBin(`docker compose -f ${COMPOSE} config --images | sort -u`);
    const list = r.out.split('\n').map((s) => s.trim()).filter(Boolean);
    const c = (await sh(`docker ps -aq --filter label=com.docker.compose.project=mcc | wc -l`)).out.trim();
    testInfo.annotations.push({ type: 'before', description: `์ปจํ…Œ์ด๋„ˆ ${c}, compose ์ด๋ฏธ์ง€ ${list.length}` });
    expect(Number(c), '์ •๋ฆฌํ•  ์Šคํƒ์ด ์—†์Œ').toBeGreaterThan(0);
    return list;
  });

  await test.step('2. ./mcc infra remove -v -i ์‹คํ–‰', async () => {
    await attach(testInfo, 'remove.log', await inBin('./mcc infra remove -v -i 2>&1', '', 25 * 60_000));
  });

  await test.step('3. mcc ํ”„๋กœ์ ํŠธ ์ปจํ…Œ์ด๋„ˆยท๋ณผ๋ฅจ ์—†์Œ', async () => {
    const r = await sh(`echo c=$(docker ps -aq --filter label=com.docker.compose.project=mcc | wc -l) v=$(docker volume ls -q --filter label=com.docker.compose.project=mcc | wc -l)`);
    expect(r.out.trim()).toBe('c=0 v=0');
  });

  await test.step('4. compose ์ด๋ฏธ์ง€๊ฐ€ ๋กœ์ปฌ์— ๋‚จ์•„ ์žˆ์ง€ ์•Š์Œ', async () => {
    const left: string[] = [];
    for (const img of images) if ((await sh(`docker image inspect ${img} >/dev/null 2>&1 && echo y || echo n`)).out.trim() === 'y') left.push(img);
    expect(left, '์‚ญ์ œ๋˜์ง€ ์•Š์€ ์ด๋ฏธ์ง€').toEqual([]);
  });
});

IV0_S10

tests/iv0/IV0_S10.spec.ts ยท 44์ค„ ยท ํ•ด์‹œ 279c16b7 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { sh, inBin, attach, requireDestructive, requireClean, waitHealthy } from './_cli';

// IV0_S10 : Compose Variant ์ „ํ™˜ (cert / mini) โ€” TC-ADMIN-010
// mini : docker-compose.mini.yaml ๋กœ ์ตœ์†Œ ๊ตฌ์„ฑ(infra-connector/manager, iam, web-console)๋งŒ ๊ธฐ๋™ โ†’ healthy โ†’ remove
// cert : docker-compose.cert.yaml ์˜ certbot ์œผ๋กœ ์ธ์ฆ์„œ ๋ฐœ๊ธ‰(ADMIN_CLI_FQDN ์ง€์ • ์‹œ์—๋งŒ, 80 ํฌํŠธ ๋น„์–ด ์žˆ์–ด์•ผ ํ•จ)
const MINI = '../conf/docker/docker-compose.mini.yaml';
const CERT = '../conf/docker/docker-compose.cert.yaml';
const FQDN = process.env.ADMIN_CLI_FQDN ?? '';

test('IV0_S10 Compose Variant ์ „ํ™˜ (mini ๊ธฐ๋™ / cert ๋ฐœ๊ธ‰)', async ({}, testInfo) => {
  requireDestructive('variant ์Šคํƒ ๊ธฐ๋™ยท์‚ญ์ œ');
  test.setTimeout(60 * 60_000);

  await test.step('1. ์„ ํ–‰ ์ƒํƒœ: ๊ธฐ๋ณธ ์Šคํƒ์ด ์—†์Œ (์ปจํ…Œ์ด๋„ˆ ์ด๋ฆ„์ด ๊ฐ™์•„ ํ•จ๊ป˜ ๋„์šธ ์ˆ˜ ์—†์Œ)', requireClean);

  const services = await test.step('2. ./mcc infra run -d -f mini โ†’ ๊ธฐ๋™', async () => {
    const svc = (await inBin(`docker compose -f ${MINI} config --services`)).out.split('\n').map((s) => s.trim()).filter((s) => s && s !== 'mc-iam-manager-post-initial');
    const r = await inBin(`./mcc infra run -d -f ${MINI} 2>&1`, '', 30 * 60_000);
    await attach(testInfo, 'mini-run.log', r);
    expect(r.code).toBe(0);
    return svc;
  });

  await test.step(`3. mini ์„œ๋น„์Šค ${services.length}๊ฐœ healthy, ๊ทธ ๋ฐ–์˜ mc- ์ปจํ…Œ์ด๋„ˆ ์—†์Œ`, async () => {
    await waitHealthy(services);
    const extra = (await sh(`docker ps --filter label=com.docker.compose.project=mcc --format '{{.Names}}'`)).out.split('\n').filter((n) => n && !services.includes(n) && n !== 'mc-iam-manager-post-initial');
    expect(extra, 'mini ์ •์˜์— ์—†๋Š” ์ปจํ…Œ์ด๋„ˆ').toEqual([]);
  });

  await test.step('4. ./mcc infra remove -v -f mini โ†’ ์ •๋ฆฌ', async () => {
    await attach(testInfo, 'mini-remove.log', await inBin(`./mcc infra remove -v -f ${MINI} 2>&1`, '', 10 * 60_000));
    expect((await sh(`docker ps -aq --filter label=com.docker.compose.project=mcc | wc -l`)).out.trim()).toBe('0');
  });

  await test.step('5. ./mcc infra run -f cert โ†’ certbot ์ธ์ฆ์„œ ๋ฐœ๊ธ‰ (ADMIN_CLI_FQDN ์ง€์ • ์‹œ)', async () => {
    if (!FQDN) { testInfo.annotations.push({ type: 'cert', description: 'ADMIN_CLI_FQDN ๋ฏธ์ง€์ • โ€” cert ๋‹จ๊ณ„ ๊ฑด๋„ˆ๋œ€' }); return; }
    const r = await inBin(`./mcc infra run -f ${CERT} 2>&1`, '', 10 * 60_000);
    await attach(testInfo, 'cert-run.log', r);
    expect((await sh(`docker inspect -f '{{.State.ExitCode}}' mcmp-certbot`)).out.trim()).toBe('0');
    const pem = await inBin(`sudo test -f ../conf/docker/container-volume/mc-iam-manager/certs/live/${FQDN}/fullchain.pem && echo ok`);
    expect(pem.out.trim(), '์ธ์ฆ์„œ ํŒŒ์ผ ์—†์Œ').toBe('ok');
  });
});

IV0_S11

tests/iv0/IV0_S11.spec.ts ยท 39์ค„ ยท ํ•ด์‹œ 6b23b35d ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { inBin, sh, attach, requireDestructive } from './_cli';

// IV0_S11 : K8s ๋ฐฐํฌ (mcc k8s run, GKE/EKS/AKS/minikube) โ€” TC-ADMIN-011
// ํ˜„ ๋ฒ„์ „ README: "K8S is not currently supported" โ€” k8s ์„œ๋ธŒ์ปค๋งจ๋“œ๊ฐ€ ๋“ฑ๋ก๋˜์ง€ ์•Š์œผ๋ฉด(src/cmd/k8s/root.go AddCommand ์ฃผ์„) SKIP ์œผ๋กœ ๋‚จ๊ธด๋‹ค.
// ์ง€์›๋  ๋•Œ์˜ ์ ˆ์ฐจ: kubectl ์ปจํ…์ŠคํŠธ๊ฐ€ ๋Œ€์ƒ ํด๋Ÿฌ์Šคํ„ฐ / helm ์„ค์น˜ / bin/../helm-chart ์กด์žฌ
// ADMIN_CLI_K8S_PROVIDER = gke|eks|aks|minikube, ADMIN_CLI_K8S_VALUES = values ํŒŒ์ผ(๊ธฐ๋ณธ ../helm-chart/values.yaml)
const PROVIDER = process.env.ADMIN_CLI_K8S_PROVIDER ?? 'minikube';
const VALUES = process.env.ADMIN_CLI_K8S_VALUES ?? '../helm-chart/values.yaml';

test(`IV0_S11 K8s ๋ฐฐํฌ (mcc k8s run --k8sprovider ${PROVIDER})`, async ({}, testInfo) => {
  const help = await inBin('./mcc k8s --help 2>&1');
  test.skip(/unknown command "k8s"/.test(help.out), 'mcc k8s ๋ฏธ์ง€์›(ํ˜„ ๋ฒ„์ „ ๋น„ํ™œ์„ฑ, README: K8S is not currently supported)');
  requireDestructive('K8s ํด๋Ÿฌ์Šคํ„ฐ์— M-CMP ๋ฐฐํฌ');
  test.setTimeout(60 * 60_000);

  await test.step('1. ์„ ํ–‰ ์ƒํƒœ ํ™•์ธ (kubectl ์ ‘์†, helm, helm-chart)', async () => {
    expect((await sh('kubectl cluster-info && helm version')).code).toBe(0);
    expect((await inBin(`test -f ${VALUES} && echo ok`)).out.trim(), 'values ํŒŒ์ผ ์—†์Œ').toBe('ok');
  });

  await test.step(`2. ./mcc k8s run -f ${VALUES} --k8sprovider ${PROVIDER}`, async () => {
    const r = await inBin(`./mcc k8s run -f ${VALUES} --k8sprovider ${PROVIDER} 2>&1`, '', 30 * 60_000);
    await attach(testInfo, 'k8s-run.log', r);
    expect(r.out).not.toMatch(/Error:|failed/i);
  });

  await test.step('3. ./mcc k8s info โ†’ ๋ชจ๋“  Pod Running/Completed', async () => {
    await expect.poll(async () => {
      const r = await sh(`kubectl get pods -n mcmp --no-headers | awk '$3!="Running" && $3!="Completed"' | wc -l`);
      return r.out.trim();
    }, { timeout: 25 * 60_000, intervals: [20_000] }).toBe('0');
    await attach(testInfo, 'k8s-info.log', await inBin('./mcc k8s info 2>&1'));
  });

  await test.step('4. ./mcc k8s stop โ†’ ์ •๋ฆฌ', async () => {
    await attach(testInfo, 'k8s-stop.log', await inBin(`./mcc k8s stop -f ${VALUES} 2>&1`, '', 10 * 60_000));
  });
});

IV0_S12

tests/iv0/IV0_S12.spec.ts ยท 23์ค„ ยท ํ•ด์‹œ fd80da3a ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { inBin, sh, attach, requireDestructive } from './_cli';

// IV0_S12 : Weave Scope ์„ค์น˜/์ œ๊ฑฐ (mcc k8s weave-scope install/uninstall) โ€” TC-ADMIN-012
// k8s ์„œ๋ธŒ์ปค๋งจ๋“œ๊ฐ€ ๋น„ํ™œ์„ฑ์ด๋ฉด SKIP. ์ฐธ๊ณ : ์„ค์น˜ ๋งค๋‹ˆํŽ˜์ŠคํŠธ URL(cloud.weave.works)์€ Weaveworks ์„œ๋น„์Šค ์ข…๋ฃŒ๋กœ ์ ‘์†๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ๋‹ค.
const PROVIDER = process.env.ADMIN_CLI_K8S_PROVIDER ?? 'minikube';

test(`IV0_S12 Weave Scope ์„ค์น˜/์ œ๊ฑฐ (--k8sprovider ${PROVIDER})`, async ({}, testInfo) => {
  const help = await inBin('./mcc k8s --help 2>&1');
  test.skip(/unknown command "k8s"/.test(help.out), 'mcc k8s ๋ฏธ์ง€์›(ํ˜„ ๋ฒ„์ „ ๋น„ํ™œ์„ฑ)');
  requireDestructive('ํด๋Ÿฌ์Šคํ„ฐ์— weave ๋„ค์ž„์ŠคํŽ˜์ด์Šค ์ƒ์„ฑยท์‚ญ์ œ');
  test.setTimeout(20 * 60_000);

  await test.step('1. install-weave-scope โ†’ weave ๋„ค์ž„์ŠคํŽ˜์ด์Šค Pod Running', async () => {
    await attach(testInfo, 'install.log', await inBin(`./mcc k8s weave-scope install-weave-scope --k8sprovider ${PROVIDER} 2>&1`, '', 10 * 60_000));
    await expect.poll(async () => (await sh(`kubectl get pods -n weave --no-headers 2>/dev/null | grep -c Running || true`)).out.trim(), { timeout: 8 * 60_000 }).not.toBe('0');
  });

  await test.step('2. weave-scope uninstall โ†’ weave ์ž์› ์‚ญ์ œ', async () => {
    await attach(testInfo, 'uninstall.log', await inBin(`./mcc k8s weave-scope uninstall --k8sprovider ${PROVIDER} 2>&1`, '', 10 * 60_000));
    await expect.poll(async () => (await sh(`kubectl get ns weave --no-headers 2>/dev/null | wc -l`)).out.trim(), { timeout: 5 * 60_000 }).toBe('0');
  });
});

IV0_S13

tests/iv0/IV0_S13.spec.ts ยท 23์ค„ ยท ํ•ด์‹œ c9c82ca1 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { inBin, mcContainers, attach } from './_cli';

// IV0_S13 : Docker-K8s ์ƒํ˜ธ๋ฐฐํƒ€ ๊ฒ€์ฆ (์˜ˆ์™ธ) โ€” TC-ADMIN-013
// Docker Compose ์Šคํƒ์ด ์‹คํ–‰ ์ค‘์ผ ๋•Œ mcc k8s run ์€ ๊ฑฐ๋ถ€๋˜์–ด์•ผ ํ•œ๋‹ค(๊ฐ™์€ ํ”Œ๋žซํผ์˜ ์ด์ค‘ ๋ฐฐํฌ ๋ฐฉ์ง€).
// k8s ์„œ๋ธŒ์ปค๋งจ๋“œ๊ฐ€ ๋น„ํ™œ์„ฑ์ด๋ฉด SKIP.
const PROVIDER = process.env.ADMIN_CLI_K8S_PROVIDER ?? 'minikube';

test('IV0_S13 Docker-K8s ์ƒํ˜ธ๋ฐฐํƒ€ (Compose ์‹คํ–‰ ์ค‘ k8s run ๊ฑฐ๋ถ€)', async ({}, testInfo) => {
  const help = await inBin('./mcc k8s --help 2>&1');
  test.skip(/unknown command "k8s"/.test(help.out), 'mcc k8s ๋ฏธ์ง€์›(ํ˜„ ๋ฒ„์ „ ๋น„ํ™œ์„ฑ)');

  await test.step('1. ์„ ํ–‰ ์ƒํƒœ: Docker Compose ์Šคํƒ ์‹คํ–‰ ์ค‘', async () => {
    expect((await mcContainers(false)).length, 'Compose ์Šคํƒ์ด ์‹คํ–‰ ์ค‘์ด ์•„๋‹˜').toBeGreaterThan(0);
  });

  await test.step('2. ./mcc k8s run ์‹คํ–‰ โ†’ ๊ฑฐ๋ถ€ ๋ฉ”์‹œ์ง€, helm install ๋ฏธ์‹คํ–‰', async () => {
    const r = await inBin(`./mcc k8s run --k8sprovider ${PROVIDER} 2>&1`, '', 5 * 60_000);
    await attach(testInfo, 'k8s-run.log', r);
    expect(r.out, 'Docker ์‹คํ–‰ ์ค‘์ธ๋ฐ helm install ์ด ์‹คํ–‰๋จ').not.toContain('helm install');
    expect(r.out).toMatch(/docker|compose|already running|mutually exclusive/i);
  });
});

IV0_S14

tests/iv0/IV0_S14.spec.ts ยท 26์ค„ ยท ํ•ด์‹œ b6c58611 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { inBin, attach, startEcho, stopEcho, echoed, ECHO } from './_cli';

// IV0_S14 : mcc rest HTTP ๋ฉ”์„œ๋“œ๋ณ„ ํ˜ธ์ถœ (GET/POST/PUT/PATCH/DELETE) โ€” TC-ADMIN-014
// ๋Œ€์ƒ ์„œ๋ฒ„์— ์ž„์‹œ echo ์„œ๋ฒ„(127.0.0.1:18999)๋ฅผ ๋„์›Œ, ์„œ๋ฒ„๊ฐ€ ๋ฐ›์€ ๋ฉ”์„œ๋“œยท๋ณธ๋ฌธ์„ ๊ทธ๋Œ€๋กœ ๋Œ๋ ค๋ฐ›์•„ ๋น„๊ตํ•œ๋‹ค.
const METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const;

test.describe('IV0_S14', () => {
  test.beforeAll(startEcho);
  test.afterAll(stopEcho);

  test('IV0_S14 HTTP ๋ฉ”์„œ๋“œ๋ณ„ ํ˜ธ์ถœ (5์ข… ๋ฉ”์„œ๋“œ๊ฐ€ ์„œ๋ฒ„์— ๊ทธ๋Œ€๋กœ ์ „๋‹ฌ๋จ)', async ({}, testInfo) => {
    for (const m of METHODS) {
      await test.step(`./mcc rest ${m} ${ECHO}${m === 'get' || m === 'delete' ? '' : " -d '{\"k\":\"v\"}'"}`, async () => {
        const data = m === 'get' || m === 'delete' ? '' : ` -d '{"k":"v"}'`;
        const r = await inBin(`./mcc rest ${m} ${ECHO}${data}`);
        await attach(testInfo, `${m}.txt`, r);
        const e = echoed(r.out);
        // 5์ข…์„ ๋ชจ๋‘ ํ™•์ธํ•˜๋„๋ก soft ๋‹จ์–ธ(ํ•œ ๋ฉ”์„œ๋“œ ์‹คํŒจ๋กœ ๋‚˜๋จธ์ง€๋ฅผ ๊ฑด๋„ˆ๋›ฐ์ง€ ์•Š์Œ)
        expect.soft(e?.method, `${m} ์š”์ฒญ์ด ๋‹ค๋ฅธ ๋ฉ”์„œ๋“œ๋กœ ์ „์†ก๋จ`).toBe(m.toUpperCase());
        if (data) expect.soft(e?.body, `${m} ๋ณธ๋ฌธ ๋ถˆ์ผ์น˜`).toBe('{"k":"v"}');
        expect.soft(r.code).toBe(0);
      });
    }
  });
});

IV0_S15

tests/iv0/IV0_S15.spec.ts ยท 34์ค„ ยท ํ•ด์‹œ 94094503 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { inBin, attach, startEcho, stopEcho, echoed, ECHO, TB, TB_AUTH } from './_cli';

// IV0_S15 : mcc rest ์ธ์ฆ ๋ชจ๋“œ (Basic / Bearer) โ€” TC-ADMIN-015
// echo ์„œ๋ฒ„๋กœ Authorization ํ—ค๋” ๊ฐ’์„ ํ™•์ธํ•˜๊ณ , ์‹ค์ œ ์„œ๋น„์Šค(mc-infra-manager readyz)์— Basic ์ธ์ฆ์œผ๋กœ ํ˜ธ์ถœํ•œ๋‹ค.
test.describe('IV0_S15', () => {
  test.beforeAll(startEcho);
  test.afterAll(stopEcho);

  test('IV0_S15 ์ธ์ฆ ๋ชจ๋“œ (Basic -u/-p, Bearer --authToken, ์‚ฌ์šฉ์ž scheme --authScheme)', async ({}, testInfo) => {
    await test.step('1. Basic: -u iv0user -p iv0pass โ†’ Authorization: Basic base64(iv0user:iv0pass)', async () => {
      const r = await inBin(`./mcc rest get -u iv0user -p iv0pass ${ECHO}`);
      expect(echoed(r.out)?.headers?.Authorization).toBe(`Basic ${Buffer.from('iv0user:iv0pass').toString('base64')}`);
    });

    await test.step('2. Bearer: --authToken iv0token โ†’ Authorization: Bearer iv0token', async () => {
      const r = await inBin(`./mcc rest get --authToken iv0token ${ECHO}`);
      expect(echoed(r.out)?.headers?.Authorization).toBe('Bearer iv0token');
    });

    await test.step('3. ์‚ฌ์šฉ์ž scheme: --authScheme OAuth --authToken iv0token โ†’ Authorization: OAuth iv0token', async () => {
      const r = await inBin(`./mcc rest get --authScheme OAuth --authToken iv0token ${ECHO}`);
      expect(echoed(r.out)?.headers?.Authorization).toBe('OAuth iv0token');
    });

    await test.step('4. ์‹ค์ œ ์„œ๋น„์Šค: mc-infra-manager /readyz ๋ฅผ Basic ์ธ์ฆ์œผ๋กœ ํ˜ธ์ถœ โ†’ ready', async () => {
      const [u, p] = TB_AUTH.split(':');
      const r = await inBin(`./mcc rest get -u '${u}' -p '${p}' ${TB}/readyz`);
      await attach(testInfo, 'tb-readyz.txt', { ...r, out: r.out.replace(p, '***') });
      expect(r.out).toContain('"ready":true');
      expect(r.code).toBe(0);
    });
  });
});

IV0_S16

tests/iv0/IV0_S16.spec.ts ยท 30์ค„ ยท ํ•ด์‹œ 1caf97f6 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { sh, inBin, attach, startEcho, stopEcho, echoed, ECHO } from './_cli';

// IV0_S16 : mcc rest ํ—ค๋”ยท๋ฐ”๋”” ์„ค์ • (-H, -d, -f) โ€” TC-ADMIN-016
// ๋ณธ๋ฌธ ์ „๋‹ฌ์€ PUT ์œผ๋กœ ํ™•์ธํ•œ๋‹ค(POST ์ž์ฒด์˜ ๋ฉ”์„œ๋“œ ๊ฒฐํ•จ์€ IV0_S14 ์—์„œ ํŒ์ •).
test.describe('IV0_S16', () => {
  test.beforeAll(startEcho);
  test.afterAll(stopEcho);

  test('IV0_S16 ํ—ค๋”ยท๋ฐ”๋”” ์„ค์ • (-H ์—ฌ๋Ÿฌ ๊ฐœ, -d ๋ฌธ์ž์—ด, -f ํŒŒ์ผ)', async ({}, testInfo) => {
    await test.step('1. -H "Content-Type:application/json" -H "X-Iv0:hello" โ†’ ์„œ๋ฒ„๊ฐ€ ๋‘ ํ—ค๋”๋ฅผ ๋ฐ›์Œ', async () => {
      const r = await inBin(`./mcc rest get -H "Content-Type:application/json" -H "X-Iv0:hello" ${ECHO}`);
      const h = echoed(r.out)?.headers ?? {};
      expect(h['Content-Type']).toBe('application/json');
      expect(h['X-Iv0']).toBe('hello');
    });

    await test.step('2. -d ๋กœ JSON ๋ณธ๋ฌธ ์ „๋‹ฌ(put) โ†’ ์„œ๋ฒ„๊ฐ€ ๊ฐ™์€ ๋ณธ๋ฌธ์„ ๋ฐ›์Œ', async () => {
      const r = await inBin(`./mcc rest put -H "Content-Type:application/json" ${ECHO} -d '{"RegionName":"iv0"}'`);
      expect(echoed(r.out)?.body).toBe('{"RegionName":"iv0"}');
    });

    await test.step('3. -f ํŒŒ์ผ๋กœ JSON ๋ณธ๋ฌธ ์ „๋‹ฌ(put) โ†’ ์„œ๋ฒ„๊ฐ€ ํŒŒ์ผ ๋‚ด์šฉ์„ ๋ฐ›์Œ', async () => {
      await sh(`echo -n '{"from":"file"}' > /tmp/iv0-body.json`);
      const r = await inBin(`./mcc rest put -H "Content-Type:application/json" ${ECHO} -f /tmp/iv0-body.json`);
      await attach(testInfo, 'post-file.txt', r);
      expect(echoed(r.out)?.body, '-f ํŒŒ์ผ ๋‚ด์šฉ์ด ์ „์†ก๋˜์ง€ ์•Š์Œ').toBe('{"from":"file"}');
    });
  });
});

IV0_S17

tests/iv0/IV0_S17.spec.ts ยท 28์ค„ ยท ํ•ด์‹œ e71547ba ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { inBin, attach, startEcho, stopEcho, ECHO } from './_cli';

// IV0_S17 : mcc rest HEAD(-I ์‘๋‹ต ํ—ค๋”) / verbose(-v ์ถ”์  ์ •๋ณด) โ€” TC-ADMIN-017
test.describe('IV0_S17', () => {
  test.beforeAll(startEcho);
  test.afterAll(stopEcho);

  test('IV0_S17 -I ์‘๋‹ต ํ—ค๋” ํ‘œ์‹œ / -v ์š”์ฒญ ์ถ”์  ์ •๋ณด ํ‘œ์‹œ', async ({}, testInfo) => {
    await test.step('1. ./mcc rest get -I โ†’ ์‘๋‹ต ํ—ค๋”(X-Echo, Content-Type) ์ถœ๋ ฅ', async () => {
      const r = await inBin(`./mcc rest get -I ${ECHO}`);
      await attach(testInfo, 'head.txt', r);
      expect(r.out).toContain('Headers:');
      expect(r.out).toMatch(/X-Echo: iv0/);
      expect(r.out).toMatch(/Content-Type: application\/json/);
      // ํ”Œ๋ž˜๊ทธ ์„ค๋ช…์€ "Show response headers only" โ€” ๋ณธ๋ฌธ๋„ ํ•จ๊ป˜ ์ถœ๋ ฅ๋˜๋ฉด ๋น„๊ณ ๋กœ ๋‚จ๊ธด๋‹ค
      if (r.out.includes('"method"')) testInfo.annotations.push({ type: '๊ฐœ์„  ๋ฉ”๋ชจ', description: '-I ๊ฐ€ ํ—ค๋”์™€ ํ•จ๊ป˜ ๋ณธ๋ฌธ๋„ ์ถœ๋ ฅ(์„ค๋ช…: headers only)' });
    });

    await test.step('2. ./mcc rest get -v โ†’ Request Trace Info ยท Status Code 200 ์ถœ๋ ฅ', async () => {
      const r = await inBin(`./mcc rest get -v ${ECHO}`);
      await attach(testInfo, 'verbose.txt', r);
      expect(r.out).toContain('Request Trace Info:');
      expect(r.out).toContain('TotalTime');
      expect(r.out).toMatch(/Status Code: 200/);
    });
  });
});

IV0_S18

tests/iv0/IV0_S18.spec.ts ยท 25์ค„ ยท ํ•ด์‹œ 087c2c71 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { sh, inBin, attach, startEcho, stopEcho, ECHO } from './_cli';

// IV0_S18 : mcc rest ์ข…๋ฃŒ ์ฝ”๋“œ = HTTP ์ƒํƒœ ์ฒซ ์ž๋ฆฌ (Docker Compose healthcheck ์—ฐ๋™) โ€” TC-ADMIN-018
// 200 โ†’ 0, 4xx โ†’ 4, 5xx โ†’ 5. compose healthcheck ๊ฐ€ ์ด ์ข…๋ฃŒ ์ฝ”๋“œ๋กœ healthy ๋ฅผ ํŒ์ •ํ•œ๋‹ค.
test.describe('IV0_S18', () => {
  test.beforeAll(startEcho);
  test.afterAll(stopEcho);

  test('IV0_S18 healthcheck ์ข…๋ฃŒ ์ฝ”๋“œ (200โ†’0, 404โ†’4, 503โ†’5 ยท compose healthcheck ์—ฐ๋™)', async ({}, testInfo) => {
    for (const [st, code] of [[200, 0], [404, 4], [503, 5]] as const) {
      await test.step(`./mcc rest get ${ECHO}?status=${st} โ†’ ์ข…๋ฃŒ ์ฝ”๋“œ ${code}`, async () => {
        const r = await inBin(`./mcc rest get '${ECHO}?status=${st}'`);
        expect(r.code).toBe(code);
      });
    }

    await test.step('compose healthcheck ๊ฐ€ mcc rest get ์„ ์‚ฌ์šฉํ•˜๊ณ  healthy ๋กœ ํŒ์ •๋จ (mc-infra-manager)', async () => {
      const r = await sh(`docker inspect -f '{{json .Config.Healthcheck.Test}} {{.State.Health.Status}}' mc-infra-manager`);
      await attach(testInfo, 'healthcheck.txt', r);
      expect(r.out).toContain('"/app/tool/mcc","rest","get"');
      expect(r.out.trim()).toMatch(/healthy$/);
    });
  });
});

IV0_S19

tests/iv0/IV0_S19.spec.ts ยท 37์ค„ ยท ํ•ด์‹œ 9fe1e75f ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { inBin, attach } from './_cli';

// IV0_S19 : mcc api ์„œ๋น„์Šคยท์•ก์…˜ ์นดํƒˆ๋กœ๊ทธ ์กฐํšŒ (--list) โ€” TC-ADMIN-019
// ๊ธฐ์ค€: bin/../conf/api.yaml ์˜ services / serviceActions ๋ชฉ๋ก๊ณผ ์ถœ๋ ฅ์ด ์ผ์น˜ํ•ด์•ผ ํ•œ๋‹ค.
const yamlKeys = (section: string, svc = '') => inBin(
  `python3 -c "import yaml,sys;d=yaml.safe_load(open('../conf/api.yaml'));s=d['${section}']${svc ? `['${svc}']` : ''};print('\\n'.join(sorted(s or {})))"`,
);

test('IV0_S19 ์„œ๋น„์Šคยท์•ก์…˜ ์นดํƒˆ๋กœ๊ทธ ์กฐํšŒ (api --list, -s <service> --list)', async ({}, testInfo) => {
  await test.step('1. ./mcc api --list โ†’ api.yaml ์˜ ์„œ๋น„์Šค ์ „์ฒด ์ถœ๋ ฅ', async () => {
    const r = await inBin('./mcc api --list');
    await attach(testInfo, 'service-list.txt', r);
    const expected = (await yamlKeys('services')).out.split('\n').filter(Boolean);
    expect(expected.length).toBeGreaterThan(0);
    const shown = r.out.split('\n').map((s) => s.trim());
    expect(expected.filter((s) => !shown.includes(s)), '๋ชฉ๋ก์— ์—†๋Š” ์„œ๋น„์Šค').toEqual([]);
  });

  await test.step('2. ./mcc api -s mc-infra-manager --list โ†’ ์•ก์…˜ ์ „์ฒด ์ถœ๋ ฅ (GetReadyz ํฌํ•จ)', async () => {
    const r = await inBin('./mcc api -s mc-infra-manager --list');
    await attach(testInfo, 'action-list.txt', r);
    const expected = (await yamlKeys('serviceActions', 'mc-infra-manager')).out.split('\n').filter(Boolean);
    const shown = r.out.split('\n').map((s) => s.trim());
    expect(r.out).toContain('[mc-infra-manager] Service Actions list');
    // viper ๊ฐ€ ํ‚ค๋ฅผ ์†Œ๋ฌธ์ž๋กœ ๋ฐ”๊ฟ” ์ถœ๋ ฅํ•œ๋‹ค(getreadyz) โ€” ๋Œ€์†Œ๋ฌธ์ž ๋ฌด์‹œ ๋น„๊ต, ํ˜ธ์ถœ์€ ์›๋ž˜ ์ด๋ฆ„(-a GetReadyz)์œผ๋กœ๋„ ๋œ๋‹ค
    const lower = new Set(shown.map((s) => s.toLowerCase()));
    expect(lower.has('getreadyz')).toBe(true);
    if (!shown.includes('GetReadyz')) testInfo.annotations.push({ type: '๊ฐœ์„  ๋ฉ”๋ชจ', description: '์•ก์…˜ ๋ชฉ๋ก์ด ์†Œ๋ฌธ์ž๋กœ ์ถœ๋ ฅ๋จ(api.yaml ์ด๋ฆ„๊ณผ ๋‹ค๋ฆ„)' });
    expect(expected.filter((a) => !lower.has(a.toLowerCase())), '๋ชฉ๋ก์— ์—†๋Š” ์•ก์…˜').toEqual([]);
  });

  await test.step('3. ์—†๋Š” ์„œ๋น„์Šค ์ง€์ • โ†’ ์ง€์› ์„œ๋น„์Šค๊ฐ€ ์•„๋‹ˆ๋ผ๋Š” ์•ˆ๋‚ด', async () => {
    const r = await inBin('./mcc api -s no-such-service -a X');
    expect(r.out).toContain('is not on the list of supported services');
  });
});

IV0_S20

tests/iv0/IV0_S20.spec.ts ยท 32์ค„ ยท ํ•ด์‹œ 653f91eb ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { inBin, attach, apiConfig } from './_cli';

// IV0_S20 : mcc api ์„œ๋น„์Šค๋ณ„ ๊ธฐ๋ณธ ํ˜ธ์ถœ (9์ข…) โ€” TC-ADMIN-020
// api.yaml ์˜ 9๊ฐœ ์„œ๋น„์Šค์— ๋Œ€ํ•ด ์ธ์ฆยท๊ฒฝ๋กœ ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ํ•„์š” ์—†๋Š” ์ƒํƒœ ํ™•์ธ ์•ก์…˜์„ ํ˜ธ์ถœํ•œ๋‹ค(์„ค์ • ํŒŒ์ผ์˜ ์ธ์ฆ๊ฐ’ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ).
// baseurl ์˜ docker ๋‚ด๋ถ€ ํ˜ธ์ŠคํŠธ๋ช…์€ localhost ๋กœ ๋ฐ”๊พผ ์‚ฌ๋ณธ์„ ์“ด๋‹ค(_cli.apiConfig). ํ˜ธ์ŠคํŠธ์— ํฌํŠธ๊ฐ€ ๊ณต๊ฐœ๋˜์ง€ ์•Š์€ ์„œ๋น„์Šค๋Š” ์‹คํŒจ๋กœ ๋‚จ๋Š”๋‹ค.
const CALLS: [string, string, RegExp][] = [
  ['mc-infra-connector', 'Health-Check-Readyz', /ready/i],
  ['mc-infra-manager', 'GetReadyz', /"ready":\s*true/],
  ['mc-iam-manager', 'Readyz', /healthy|ready/i],
  ['mc-web-console', 'getSetupYamlCheck', /\{/],
  ['mc-observability', 'GetObsReadyz', /ready|ok|success/i],
  ['mc-application-manager', 'GetAppMgrReadyz', /ready|ok|success/i],
  ['mc-workflow-manager', 'GetWorkflowReadyz', /ready|ok|success/i],
  ['mc-cost-optimizer', 'GetCostReadyz', /ready|ok|success/i],
  ['mc-data-manager', 'GetDataMgrReadyz', /ready|ok|success/i],
];

test('IV0_S20 ์„œ๋น„์Šค๋ณ„ ๊ธฐ๋ณธ ํ˜ธ์ถœ (9์ข… ์ƒํƒœ ํ™•์ธ ์•ก์…˜ ์‘๋‹ต)', async ({}, testInfo) => {
  const cfg = await apiConfig();
  const failed: string[] = [];
  for (const [svc, action, ok] of CALLS) {
    await test.step(`./mcc api -c ${cfg} -s ${svc} -a ${action}`, async () => {
      const r = await inBin(`./mcc api -c ${cfg} -s ${svc} -a ${action}`, '', 60_000);
      await attach(testInfo, `${svc}.txt`, r);
      const body = r.out.split('service calling...').pop() ?? '';
      if (!ok.test(body) || /connection refused|no such host|Unauthorized|dial tcp/i.test(body)) failed.push(`${svc}/${action}: ${body.trim().slice(0, 120)}`);
    });
  }
  testInfo.annotations.push({ type: 'services', description: `์„ฑ๊ณต ${CALLS.length - failed.length}/${CALLS.length}` });
  expect(failed, `์‘๋‹ต์ด ๊ธฐ๋Œ€์™€ ๋‹ค๋ฅธ ์„œ๋น„์Šค:\n${failed.join('\n')}`).toEqual([]);
});

IV0_S21

tests/iv0/IV0_S21.spec.ts ยท 35์ค„ ยท ํ•ด์‹œ aed0c1ea ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { inBin, attach, apiConfig, TB_AUTH_ARGS } from './_cli';

// IV0_S21 : mcc api ๊ฒฝ๋กœ ํŒŒ๋ผ๋ฏธํ„ฐยท์ฟผ๋ฆฌ ๋ฌธ์ž์—ด ์น˜ํ™˜ (-p, -q) โ€” TC-ADMIN-021
// mc-infra-manager ์˜ GetNs(/ns/{nsId}), GetAllNs(/ns) ์‚ฌ์šฉ. ์ธ์ฆ์€ --authUser/--authPassword ๋กœ ์ง€์ •(ADMIN_CLI_TB_AUTH).
const NS = process.env.ADMIN_CLI_TB_NS ?? 'default';

test('IV0_S21 ๊ฒฝ๋กœ ํŒŒ๋ผ๋ฏธํ„ฐยท์ฟผ๋ฆฌ ๋ฌธ์ž์—ด ์น˜ํ™˜ (-p nsId:<ns>, -q option=id)', async ({}, testInfo) => {
  const cfg = await apiConfig();
  const api = (args: string) => inBin(`./mcc api -c ${cfg} -s mc-infra-manager ${TB_AUTH_ARGS} ${args}`);

  await test.step(`1. -a GetNs -p nsId:${NS} โ†’ /ns/${NS} ํ˜ธ์ถœ, ํ•ด๋‹น namespace ๋ฐ˜ํ™˜`, async () => {
    const r = await api(`-a GetNs -p nsId:${NS}`);
    await attach(testInfo, 'getns.txt', r);
    expect(r.out).toContain(`"id":"${NS}"`);
  });

  await test.step('2. -p ์—†์ด ๊ฐ€๋ณ€ ๊ฒฝ๋กœ ์•ก์…˜ ํ˜ธ์ถœ โ†’ ๊ฒฝ๋กœ ํŒŒ๋ผ๋ฏธํ„ฐ ํ•„์š” ์•ˆ๋‚ด', async () => {
    const r = await api('-a GetNs');
    expect(r.out).toContain("couldn't find uri path parameter");
    expect(r.out).toContain('/ns/{nsId}');
  });

  await test.step('3. -a GetAllNs -q option=id โ†’ /ns?option=id ํ˜ธ์ถœ, id ๋ชฉ๋ก(output) ๋ฐ˜ํ™˜', async () => {
    const r = await api('-a GetAllNs -q option=id');
    await attach(testInfo, 'getallns-id.txt', r);
    expect(r.out).toMatch(/"output":\s*\[/);
    expect(r.out).toContain(`"${NS}"`);
  });

  await test.step('4. -q "?option=id" (๋ฌผ์Œํ‘œ ํฌํ•จ) โ†’ ๊ฐ™์€ ๊ฒฐ๊ณผ', async () => {
    const r = await api(`-a GetAllNs -q '?option=id'`);
    expect(r.out).toMatch(/"output":\s*\[/);
  });
});

IV0_S22

tests/iv0/IV0_S22.spec.ts ยท 24์ค„ ยท ํ•ด์‹œ 9740de4b ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { sh, inBin, attach, apiConfig, TB_AUTH_ARGS } from './_cli';

// IV0_S22 : mcc api ์ถœ๋ ฅ ํŒŒ์ผ ์ €์žฅ (-o) โ€” TC-ADMIN-022
const OUT = '/tmp/iv0-getallns.json';

test('IV0_S22 ์ถœ๋ ฅ ํŒŒ์ผ ์ €์žฅ (-o ํŒŒ์ผ์— ์‘๋‹ต JSON ์ €์žฅ)', async ({}, testInfo) => {
  const cfg = await apiConfig();
  await sh(`rm -f ${OUT}`);

  await test.step(`1. ./mcc api -s mc-infra-manager -a GetAllNs -o ${OUT}`, async () => {
    const r = await inBin(`./mcc api -c ${cfg} -s mc-infra-manager ${TB_AUTH_ARGS} -a GetAllNs -o ${OUT}`);
    await attach(testInfo, 'stdout.txt', r);
  });

  await test.step('2. ํŒŒ์ผ์ด ์ƒ์„ฑ๋˜๊ณ  ์‘๋‹ต JSON(ns ๋ชฉ๋ก)์ด ๋“ค์–ด ์žˆ์Œ', async () => {
    const r = await sh(`cat ${OUT}`);
    await attach(testInfo, 'output-file.json', r);
    expect(r.code, '์ถœ๋ ฅ ํŒŒ์ผ ์—†์Œ').toBe(0);
    const json = JSON.parse(r.out);
    expect(Array.isArray(json.ns), 'ns ๋ชฉ๋ก ์•„๋‹˜').toBe(true);
  });
  await sh(`rm -f ${OUT}`);
});

IV0_S23

tests/iv0/IV0_S23.spec.ts ยท 41์ค„ ยท ํ•ด์‹œ 9f25ea19 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { sh, inBin, attach, TB, TB_AUTH } from './_cli';

// IV0_S23 : mcc api tool โ€” Swagger JSON โ†’ api.yaml ์•ก์…˜ ์ •์˜ ์ƒ์„ฑ โ€” TC-ADMIN-023
// ์‹คํ–‰ ์ค‘์ธ mc-infra-manager ์˜ Swagger(/tumblebug/api/doc.json)๋ฅผ ๋ฐ›์•„ tool ๋กœ ๋ณ€ํ™˜ํ•˜๊ณ ,
// (1) operationId ๋งˆ๋‹ค ์•ก์…˜์ด ์ƒ์„ฑ๋˜๋Š”์ง€ (2) ํ˜„์žฌ api.yaml ์˜ mc-infra-manager ์•ก์…˜๊ณผ ์–ผ๋งˆ๋‚˜ ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค.
const SWAGGER = '/tmp/iv0-tb-swagger.json';

test('IV0_S23 api tool Swagger โ†’ api.yaml ์•ก์…˜ ์žฌ์ƒ์„ฑ (operationId 1:1)', async ({}, testInfo) => {
  await test.step('1. mc-infra-manager Swagger JSON ๋‚ด๋ ค๋ฐ›๊ธฐ', async () => {
    const r = await sh(`curl -s -u '${TB_AUTH}' ${TB}/api/doc.json -o ${SWAGGER} && python3 -c "import json;d=json.load(open('${SWAGGER}'));print(d.get('basePath'), sum(1 for p in d['paths'].values() for m in p.values() if isinstance(m,dict) and m.get('operationId')))"`);
    testInfo.annotations.push({ type: 'swagger', description: `basePath, operationId ์ˆ˜: ${r.out.trim()}` });
    expect(Number(r.out.trim().split(' ').pop()), 'operationId ์—†์Œ').toBeGreaterThan(0);
  });

  const generated = await test.step(`2. ./mcc api tool -f ${SWAGGER} โ†’ ์•ก์…˜ ์ •์˜ ์ถœ๋ ฅ`, async () => {
    const r = await inBin(`./mcc api tool -f ${SWAGGER}`);
    await attach(testInfo, 'tool-output.yaml', r);
    expect(r.out).toContain('Base Path:');
    return r.out;
  });

  await test.step('3. operationId ์ˆ˜ = ์ƒ์„ฑ๋œ ์•ก์…˜ ์ˆ˜, ๊ฐ ์•ก์…˜์— methodยทresourcePath ์กด์žฌ', async () => {
    const ops = Number((await sh(`python3 -c "import json;d=json.load(open('${SWAGGER}'));print(sum(1 for p in d['paths'].values() for m in p.values() if isinstance(m,dict) and m.get('operationId')))"`)).out.trim());
    const actions = generated.match(/^ {4}\S+:$/gm) ?? [];
    expect(actions.length).toBe(ops);
    expect((generated.match(/^ {6}method: /gm) ?? []).length).toBe(ops);
    expect((generated.match(/^ {6}resourcePath: /gm) ?? []).length).toBe(ops);
  });

  await test.step('4. ํ˜„์žฌ api.yaml(mc-infra-manager)๊ณผ ๋น„๊ต โ€” ๋ˆ„๋ฝยท์ถ”๊ฐ€ ์•ก์…˜ ์ˆ˜ ๊ธฐ๋ก', async () => {
    const r = await inBin(`python3 -c "import yaml;print('\\n'.join(yaml.safe_load(open('../conf/api.yaml'))['serviceActions']['mc-infra-manager']))"`);
    const current = new Set(r.out.split('\n').filter(Boolean));
    const gen = new Set((generated.match(/^ {4}(\S+):$/gm) ?? []).map((s) => s.trim().slice(0, -1)));
    const missing = [...gen].filter((a) => !current.has(a));
    const stale = [...current].filter((a) => !gen.has(a));
    testInfo.annotations.push({ type: 'diff', description: `api.yaml ์— ์—†๋Š” ์ƒˆ ์•ก์…˜ ${missing.length}, Swagger ์— ์—†๋Š” ์˜› ์•ก์…˜ ${stale.length}` });
    await testInfo.attach('diff.txt', { body: `[์ƒˆ ์•ก์…˜]\n${missing.join('\n')}\n\n[์˜› ์•ก์…˜]\n${stale.join('\n')}`, contentType: 'text/plain' });
  });
  await sh(`rm -f ${SWAGGER}`);
});

IV0_S24

tests/iv0/IV0_S24.spec.ts ยท 59์ค„ ยท ํ•ด์‹œ a55caa67 ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import fs from 'node:fs';
import { inBin, attach, tb, requireDestructive, TB_AUTH } from './_cli';

// IV0_S24 : CSP Credential ๋“ฑ๋ก (mcc setup credential, RSA-OAEP + AES-CBC ์•”ํ˜ธํ™”) โ€” TC-ADMIN-024
// ์ ˆ์ฐจ: CSP ์„ ํƒ(--csp) โ†’ ํ•„์š”ํ•œ credential ํ•ญ๋ชฉ ์ž…๋ ฅ(๋น„๋ฐ€๋ฒˆํ˜ธ ๋ฐฉ์‹, TTY ํ•„์š”) โ†’ ๊ณต๊ฐœํ‚ค๋กœ ์•”ํ˜ธํ™” โ†’ POST /credential
// ์ž…๋ ฅ๊ฐ’: ADMIN_CLI_CSP(๊ธฐ๋ณธ aws), ADMIN_CLI_CRED_FILE = ์ด PC ์˜ key=value ํŒŒ์ผ
//   ํ•ญ๋ชฉ ์ด๋ฆ„์€ metainfo ์˜ CredentialCSP(์˜ˆ: aws_access_key_id / aws_secret_access_key) โ€” mc-infra-manager ๊ฐ€ ํ—ˆ์šฉํ•˜๋Š” ํ‚ค
// ์ฃผ์˜: ๊ฐ™์€ CSP ์˜ ๊ธฐ์กด credential(credentialHolder=admin)์„ ๋ฎ์–ด์“ด๋‹ค. ์ž…๋ ฅ๊ฐ’์€ ๋ฆฌํฌํŠธ์— ๋‚จ๊ธฐ์ง€ ์•Š๋Š”๋‹ค.
// ์ธ์ฆ: api.yaml ์˜ mc-infra-manager ์ธ์ฆ๊ฐ’(spider)์€ ์„ค์น˜ ๊ธฐ๋ณธ๊ฐ’(default)๊ณผ ๋‹ฌ๋ผ 401 ์ด ๋‚˜๋ฏ€๋กœ -u/-p ๋กœ ์ง€์ •ํ•œ๋‹ค(ADMIN_CLI_TB_AUTH).
const CSP = (process.env.ADMIN_CLI_CSP ?? 'aws').toLowerCase();
const CRED_FILE = process.env.ADMIN_CLI_CRED_FILE ?? '';

test(`IV0_S24 CSP Credential ๋“ฑ๋ก (${CSP}, ์•”ํ˜ธํ™” ํ›„ mc-infra-manager ๋“ฑ๋ก โ†’ connConfig verified)`, async ({}, testInfo) => {
  requireDestructive(`${CSP} credential ๋“ฑ๋ก(๊ธฐ์กด ๊ฐ’ ๋ฎ์–ด์”€)`);
  test.skip(!CRED_FILE, 'ADMIN_CLI_CRED_FILE(credential key=value ํŒŒ์ผ) ๋ฏธ์ง€์ •');
  test.setTimeout(15 * 60_000);

  const keys = await test.step(`1. ํ•„์š”ํ•œ credential ํ•ญ๋ชฉ ์กฐํšŒ (POST /forward/cloudos/metainfo/${CSP} โ†’ CredentialCSP)`, async () => {
    const meta = await tb(`/forward/cloudos/metainfo/${CSP}`, 'POST', {});
    const list: string[] = meta.json?.CredentialCSP ?? meta.json?.Credential ?? [];
    testInfo.annotations.push({ type: 'keys', description: list.join(', ') });
    expect(list.length, `metainfo ์กฐํšŒ ์‹คํŒจ: ${meta.raw.slice(0, 200)}`).toBeGreaterThan(0);
    return list;
  });

  const values = Object.fromEntries(fs.readFileSync(CRED_FILE, 'utf8').split('\n').filter((l) => l.includes('=')).map((l) => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]));
  const before = (await tb(`/connConfig?filterCredentialHolder=admin`)).json?.connectionconfig?.filter((c: any) => c.providerName?.toLowerCase() === CSP).length ?? 0;

  const r = await test.step(`2. ./mcc setup credential --csp ${CSP} โ†’ ํ•ญ๋ชฉ ์ž…๋ ฅ โ†’ ๊ฒ€ํ†  no โ†’ ํ™•์ธ yes`, async () => {
    const missing = keys.filter((k) => !(k in values));
    expect(missing, 'credential ํŒŒ์ผ์— ์—†๋Š” ํ•ญ๋ชฉ').toEqual([]);
    const stdin = keys.map((k) => values[k]).join('\n') + '\nno\nyes\n'; // ๊ฒ€ํ†  no โ†’ ํ™•์ธ yes
    // ๊ฐ€์ƒ ํ„ฐ๋ฏธ๋„(script)์ด ์ž…๋ ฅ์„ ํ™”๋ฉด์— ๋˜๋Œ๋ ค ์ฐ์ง€ ์•Š๋„๋ก stty -echo. ์ถœ๋ ฅ์€ ํ•ญ์ƒ ๊ฐ’์„ ๊ฐ€๋ฆฐ ๋’ค์—๋งŒ ์“ด๋‹ค(๋‹จ์–ธ ๋ฉ”์‹œ์ง€ ํฌํ•จ).
    const res = await inBin(`script -qec 'stty -echo; ./mcc setup credential --csp ${CSP} -u ${TB_AUTH.split(':')[0]} -p ${TB_AUTH.split(':')[1]}' /dev/null`, stdin, 10 * 60_000);
    let masked = res.out;
    for (const v of Object.values(values)) if (v) masked = masked.split(v).join('***');
    await attach(testInfo, 'credential.log', { ...res, out: masked });
    return { code: res.code, out: masked };
  });

  await test.step('3. ๊ณต๊ฐœํ‚ค ์กฐํšŒยท์•”ํ˜ธํ™”ยท๋“ฑ๋ก ๊ฒฐ๊ณผ ํ™•์ธ', async () => {
    expect(r.out).toContain(`Processing authentication information for selected [${CSP}] CSP`);
    expect(r.out, '๋“ฑ๋ก ์™„๋ฃŒ ๋ฉ”์‹œ์ง€ ์—†์Œ').toContain('CSP Credential registration completed successfully');
    expect(r.out).toContain('Result:');
    expect(r.out).not.toMatch(/^Error:/m);
    // ์„œ๋ฒ„ ๊ฑฐ๋ถ€ ์—ฌ๋ถ€์™€ ์ด๋ฒˆ ๋“ฑ๋ก ๊ฒฐ๊ณผ๋ฅผ ์ง์ ‘ ํ™•์ธํ•œ๋‹ค(๊ธฐ์กด ์—ฐ๊ฒฐ์ด ๋‚จ์•„ ์žˆ์œผ๋ฉด 4๋‹จ๊ณ„๋งŒ์œผ๋กœ๋Š” ๋“ฑ๋ก ์‹คํŒจ๋ฅผ ๋†“์นœ๋‹ค)
    expect(r.out, '์„œ๋ฒ„๊ฐ€ credential ๋“ฑ๋ก์„ ๊ฑฐ๋ถ€ํ•จ').not.toMatch(/invalid credential|"message":\s*"[^"]*(invalid|error|fail)/i);
    expect(r.out, '๋“ฑ๋ก ๊ฒฐ๊ณผ๊ฐ€ ๋น„์–ด ์žˆ์Œ(Result: map[])').not.toMatch(/Result: map\[\]\s*$/m);
  });

  await test.step('4. mc-infra-manager connConfig ์— ํ•ด๋‹น CSP ์—ฐ๊ฒฐ์ด ์ƒ๊ธฐ๊ณ  verified', async () => {
    const list = (await tb(`/connConfig?filterCredentialHolder=admin`)).json?.connectionconfig ?? [];
    const mine = list.filter((c: any) => c.providerName?.toLowerCase() === CSP);
    testInfo.annotations.push({ type: 'connConfig', description: `${CSP} ์—ฐ๊ฒฐ ${before} โ†’ ${mine.length}, verified ${mine.filter((c: any) => c.verified).length}` });
    expect(mine.length).toBeGreaterThan(0);
    expect(mine.some((c: any) => c.verified), 'verified ์—ฐ๊ฒฐ ์—†์Œ').toBe(true);
  });
});

IV0_S25

tests/iv0/IV0_S25.spec.ts ยท 62์ค„ ยท ํ•ด์‹œ 56b20ddb ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { sh, inBin, attach, tb } from './_cli';

// IV0_S25 : ์•”ํ˜ธํ™” cross-check (Python PyCryptodome) โ€” TC-ADMIN-025
// mcc setup credential ๊ณผ ๊ฐ™์€ ๋ฐฉ์‹(RSA-OAEP(SHA-256)๋กœ AES-256 ํ‚ค ์•”ํ˜ธํ™” + ๊ฐ’๋งˆ๋‹ค AES-CBC(IV ์•ž๋ถ™์ž„, PKCS7))์„
// ์ €์žฅ์†Œ์˜ test_encryption.py ๋กœ ์žฌํ˜„ํ•˜๊ณ , (1) ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ (2) ์ž„์‹œ ํ‚ค์Œ์œผ๋กœ ๋ณตํ˜ธํ™” ์™•๋ณต (3) mc-infra-manager ๊ณต๊ฐœํ‚ค๋กœ ์•”ํ˜ธํ™” ํ˜•์‹ ํ™•์ธ.
// ์„ ํ–‰: python3 + pycryptodome. ์—†์œผ๋ฉด uv ๋กœ ์ž„์‹œ ํ™˜๊ฒฝ์„ ๋งŒ๋“ค์–ด ์‹คํ–‰ํ•œ๋‹ค(uv run --with pycryptodome, ์‹œ์Šคํ…œ ํŒจํ‚ค์ง€ ์„ค์น˜ ์—†์Œ).
const PY = String.raw`
import base64, json, sys, runpy, io, contextlib
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP, AES
from Crypto.Hash import SHA256
with contextlib.redirect_stdout(io.StringIO()):
    mod = runpy.run_path('../test_encryption.py')
enc = mod['encrypt_credential_value_with_publickey']
creds = {'ClientId': 'iv0-id', 'ClientSecret': 'iv0-secret-โœ“', 'Region': 'ap-northeast-2'}
mode = sys.argv[1]
if mode == 'roundtrip':
    key = RSA.generate(4096)
    ec, ek = enc(key.publickey().export_key().decode(), creds)
    aes = PKCS1_OAEP.new(key, hashAlgo=SHA256).decrypt(base64.b64decode(ek))
    out = {}
    for k, v in ec.items():
        raw = base64.b64decode(v); c = AES.new(aes, AES.MODE_CBC, raw[:16]).decrypt(raw[16:])
        out[k] = c[:-c[-1]].decode()
    print(json.dumps({'aesKeyLen': len(aes), 'match': out == creds}))
else:
    pub = sys.stdin.read()
    ec, ek = enc(pub, creds)
    print(json.dumps({'encKeyBytes': len(base64.b64decode(ek)), 'valueBlocksOk': all((len(base64.b64decode(v)) - 16) % 16 == 0 for v in ec.values())}))
`;

test('IV0_S25 ์•”ํ˜ธํ™” cross-check (test_encryption.py ยท ๋ณตํ˜ธํ™” ์™•๋ณต ยท ์‹ค์ œ ๊ณต๊ฐœํ‚ค ํ˜•์‹)', async ({}, testInfo) => {
  const py = await test.step('1. ์„ ํ–‰: pycryptodome ์‚ฌ์šฉ ๊ฐ€๋Šฅ (python3 ๋˜๋Š” uv)', async () => {
    if ((await sh(`python3 -c "import Crypto"`)).code === 0) return 'python3';
    const uv = 'PATH=$HOME/.local/bin:$PATH uv run -q --no-project --with pycryptodome python3';
    expect((await sh(`${uv} -c "import Crypto"`, '', 5 * 60_000)).code, 'pycryptodome ์—†์Œ, uv ๋„ ์—†์Œ').toBe(0);
    testInfo.annotations.push({ type: 'python', description: 'uv ์ž„์‹œ ํ™˜๊ฒฝ(pycryptodome) ์‚ฌ์šฉ' });
    return uv;
  });
  await sh(`cat > /tmp/iv0-crypto.py <<'PYEOF'\n${PY}\nPYEOF`);

  await test.step('2. python3 test_encryption.py ์‹คํ–‰ โ†’ 4๊ฐœ ๊ฐ’ ์•”ํ˜ธํ™” ์™„๋ฃŒ', async () => {
    const r = await inBin(`${py} ../test_encryption.py`);
    await attach(testInfo, 'test_encryption.log', r);
    expect(r.code).toBe(0);
    expect(r.out).toContain('์ด 4 ๊ฐœ์˜ credentials๊ฐ€ ์•”ํ˜ธํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค.');
  });

  await test.step('3. ์ž„์‹œ RSA-4096 ํ‚ค์Œ์œผ๋กœ ์•”ํ˜ธํ™” โ†’ ๋ณตํ˜ธํ™” ์™•๋ณต ๊ฒฐ๊ณผ๊ฐ€ ์›๋ฌธ๊ณผ ๊ฐ™์Œ (AES-256)', async () => {
    const r = await inBin(`${py} /tmp/iv0-crypto.py roundtrip`, '', 5 * 60_000);
    expect(JSON.parse(r.out.trim().split('\n').pop()!)).toEqual({ aesKeyLen: 32, match: true });
  });

  await test.step('4. mc-infra-manager ๊ณต๊ฐœํ‚ค(GET /credential/publicKey)๋กœ ์•”ํ˜ธํ™” โ†’ RSA 4096 ํ‚ค ๊ธธ์ดยทAES ๋ธ”๋ก ํ˜•์‹', async () => {
    const pk = await tb('/credential/publicKey');
    expect(pk.json?.publicKey, `๊ณต๊ฐœํ‚ค ์กฐํšŒ ์‹คํŒจ: ${pk.raw.slice(0, 200)}`).toBeTruthy();
    const r = await inBin(`${py} /tmp/iv0-crypto.py live`, pk.json.publicKey);
    expect(JSON.parse(r.out.trim().split('\n').pop()!)).toEqual({ encKeyBytes: 512, valueBlocksOk: true });
  });
  await sh('rm -f /tmp/iv0-crypto.py');
});

IV0_S26

tests/iv0/IV0_S26.spec.ts ยท 46์ค„ ยท ํ•ด์‹œ 140a8e5b ยท ์‹œ๋‚˜๋ฆฌ์˜ค ๋ณด๊ธฐ

import { test, expect } from '@playwright/test';
import { sh, inBin, attach, tb, requireDestructive } from './_cli';

// IV0_S26 : CB-Tumblebug infra-init (mcc setup infra-init: ๋ฒ„์ „ ํ™•์ธ โ†’ clone โ†’ init.sh) โ€” TC-ADMIN-026
// ์„ ํ–‰: mc-infra-manager healthy, ์•”ํ˜ธํ™”๋œ credential ํŒŒ์ผ(~/.cloud-barista/credentials.yaml.enc) ์ค€๋น„, uv ์„ค์น˜, docker ๊ทธ๋ฃน ์‚ฌ์šฉ์ž
// ๋Œ€ํ™”ํ˜• ์ž…๋ ฅ์€ ADMIN_CLI_INIT_INPUT ๋กœ ๋„˜๊ธด๋‹ค(๊ธฐ๋ณธ: ์ค€๋น„ ํ™•์ธ y โ†’ ํด๋” ๋ฉ”๋‰ด 1(์ƒˆ๋กœ ๋ฐ›๊ธฐ) โ†’ init.sh ์ง„ํ–‰ y).
// init.sh ๋Š” CSP ์—ฐ๊ฒฐ ๋“ฑ๋กยท์ž์‚ฐ(spec/image) ์ ์žฌ๋กœ ์ˆ˜์‹ญ ๋ถ„ ๊ฑธ๋ฆด ์ˆ˜ ์žˆ๋‹ค.
const INPUT = (process.env.ADMIN_CLI_INIT_INPUT ?? 'y\\n1\\ny\\n').replace(/\\n/g, '\n');
const TB_DIR = '~/go/src/github.com/cloud-barista/cb-tumblebug';

test('IV0_S26 CB-Tumblebug infra-init (์‹คํ–‰ ๋ฒ„์ „๊ณผ ๊ฐ™์€ ํƒœ๊ทธ clone โ†’ init.sh โ†’ initialized)', async ({}, testInfo) => {
  requireDestructive('mc-infra-manager ์ดˆ๊ธฐํ™”(์—ฐ๊ฒฐยท์ž์‚ฐ ๋“ฑ๋ก)');
  test.setTimeout(90 * 60_000);

  await test.step('1. ์„ ํ–‰: credential ์•”ํ˜ธํ™” ํŒŒ์ผ ์กด์žฌ', async () => {
    expect((await sh('test -f ~/.cloud-barista/credentials.yaml.enc && echo ok')).out.trim(), 'credentials.yaml.enc ์—†์Œ').toBe('ok');
  });

  const r = await test.step('2. ./mcc setup infra-init ์‹คํ–‰', async () => {
    const res = await inBin('script -qec "./mcc setup infra-init" /dev/null', INPUT, 80 * 60_000);
    await attach(testInfo, 'infra-init.log', res);
    return res;
  });

  const version = await test.step('3. ์‹คํ–‰ ์ƒํƒœยท๋ฒ„์ „ยทhealth ํ™•์ธ ๋ฉ”์‹œ์ง€', async () => {
    expect(r.out).toContain('MC-Infra-Manager is running.');
    expect(r.out).toContain('MC-Infra-Manager is healthy.');
    const v = r.out.match(/Version confirmed: (\d+\.\d+\.\d+)/)?.[1];
    expect(v, '์‹คํ–‰ ๋ฒ„์ „ ํ™•์ธ ์‹คํŒจ(์ด๋ฏธ์ง€ ํƒœ๊ทธ๊ฐ€ x.y.z ๊ฐ€ ์•„๋‹ˆ๋ฉด ๊ฐ์ง€ ๋ชปํ•จ)').toBeTruthy();
    expect(r.out).toContain('Executing init.sh');
    return v!;
  });

  await test.step(`4. cb-tumblebug ํด๋”๊ฐ€ ์‹คํ–‰ ๋ฒ„์ „ ํƒœ๊ทธ(v${version})๋กœ checkout`, async () => {
    const tag = (await sh(`cd ${TB_DIR} && git describe --tags --exact-match HEAD`)).out.trim();
    expect(tag.replace(/^v/, '')).toBe(version);
  });

  await test.step('5. mc-infra-manager initialized=true, ์—ฐ๊ฒฐ ์ •๋ณด(connConfig) ๋“ฑ๋ก๋จ', async () => {
    const ready = await tb('/readyz');
    expect(ready.json?.initialized).toBe(true);
    const conns = (await tb('/connConfig')).json?.connectionconfig ?? [];
    testInfo.annotations.push({ type: 'connConfig', description: `์—ฐ๊ฒฐ ${conns.length}๊ฐœ, verified ${conns.filter((c: any) => c.verified).length}๊ฐœ` });
    expect(conns.length).toBeGreaterThan(0);
  });
});
โš ๏ธ **GitHub.com Fallback** โš ๏ธ