Skip to content

Test Specs IV0

dogfootman edited this page Oct 14, 2026 · 6 revisions

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 주소(예: ubuntu@1.2.3.4). 비우면 로컬에서 실행
//   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);
  });
});

Clone this wiki locally