Skip to content

Test Specs IV10

yh-noh edited this page Oct 14, 2026 · 1 revision

Test Specs — IV10 CSP Credential

생성: 2026-10-14 · 기준: v07 ← Test Scenarios · IV 허브 · 기능 TC · TC-INFRA

IV 프로젝트(이미 설치된 Playwright)의 tests/iv10/ 에 spec 과 _register.ts, lib/ 에 iv10.ts 를 추가해 실행한다. 공용 헬퍼는 Test-Specs-Common.

실행 방법

BASE_URL=<웹 콘솔 주소> npx playwright test tests/iv10/IV10_S01.spec.ts tests/iv10/IV10_S02.spec.ts   # 조회
BASE_URL=<웹 콘솔 주소> CRED_VALUES_FILE=<값 JSON> npx playwright test tests/iv10/IV10_S03.spec.ts   # 등록(API)
BASE_URL=<웹 콘솔 주소> CRED_VALUES_FILE=<값 JSON> IV10_CSPS=gcp,azure npx playwright test tests/iv10/IV10_S05.spec.ts
환경변수 설명
BASE_URL 웹 콘솔 주소
CRED_HOLDER 등록할 holder (기본 test)
CRED_VALUES_FILE 자격증명 값 JSON {csp: {key: value}} — 화면 Key(cb-spider 형식)와 CSP 형식 Key 를 모두 담는다. 권한 600, 실행 후 삭제
IV10_CSPS S05 대상 CSP (기본 gcp,azure,alibaba,ibm,ncp,nhn,kt,tencent)
파일 줄 해시
lib/iv10.ts 79 70b1e63c
tests/iv10/_register.ts 34 d807c126
tests/iv10/IV10_S01.spec.ts 26 53b94608
tests/iv10/IV10_S02.spec.ts 32 eb633cae
tests/iv10/IV10_S03.spec.ts 36 c458f9f3
tests/iv10/IV10_S04.spec.ts 3 708d1dff
tests/iv10/IV10_S05.spec.ts 5 af3e7a15
tests/iv10/IV10_S06.spec.ts 24 139b3ee6

iv10.ts

lib/iv10.ts · 79줄 · 해시 70b1e63c

import { expect, type Page, type Locator } from '@playwright/test';

// IV10 Credential(Settings > Cloud SPs > Credentials) 화면 헬퍼 — 2026-10-14 소스(credentials.html/js) 기준
//  - 목록: Tabulator #credential-table (Credential Holder, Providers, Connections, Verified, Default)
//  - 행 클릭 → #credential-detail-panel (#detail-holder-id/provider/connections/verified/default)
//  - Register: #credential-create-modal → #create-holder-name, #create-holder-provider(선택 시 KV 행 자동 생성), #kv-list-container(.kv-key/.kv-value), Register 버튼
export const CREDENTIALS_URL = '/webconsole/settings/environment/cloudsps/credentials';
export const TEST_HOLDER = process.env.CRED_HOLDER ?? 'test';

export async function gotoCredentials(page: Page) {
  await page.goto(CREDENTIALS_URL);
  await expect(page.locator('#credential-table')).toBeVisible({ timeout: 30_000 });
  await expect(page.locator('#credential-table .tabulator-row').first()).toBeVisible({ timeout: 30_000 });
}

export const holderRow = (page: Page, holder: string): Locator =>
  page.locator('#credential-table .tabulator-row').filter({ has: page.locator('[tabulator-field="credentialHolder"]', { hasText: new RegExp(`^\\s*${holder}\\s*$`) }) });

export async function rowCells(row: Locator): Promise<Record<string, string>> {
  const cells = row.locator('.tabulator-cell');
  const out: Record<string, string> = {};
  for (let i = 0; i < await cells.count(); i++) {
    const c = cells.nth(i);
    out[(await c.getAttribute('tabulator-field')) ?? `col${i}`] = (await c.innerText()).replace(/\s+/g, ' ').trim();
  }
  return out;
}

// 웹 콘솔 API(공용 proxy)로 mc-infra-manager 호출 — 화면 값과 비교할 때만 사용
export async function consoleApi(page: Page, operationId: string, data: unknown = {}) {
  return page.evaluate(async ([op, body]) => {
    const r = await (window as any).webconsolejs['common/api/http'].commonAPIPost(`/api/mc-infra-manager/${op}`, body);
    return { status: r?.status, data: r?.data };
  }, [operationId, data] as const);
}

// ── 등록 ────────────────────────────────────────────────────────────────
// 자격증명 값 파일(CRED_VALUES_FILE, JSON {csp: {key: value}})을 읽는다. 값은 리포트·녹화에 남기지 않는다.
import fs from 'node:fs';
export function credValues(csp: string): Record<string, string> {
  const f = process.env.CRED_VALUES_FILE;
  if (!f || !fs.existsSync(f)) throw new Error('CRED_VALUES_FILE(자격증명 값 JSON) 미지정');
  return JSON.parse(fs.readFileSync(f, 'utf8'))[csp] ?? {};
}
export const scrub = (s: string, values: Record<string, string>) => Object.values(values).reduce((t, v) => (v && v.length > 3 ? t.split(v).join('***') : t), s);

export type HolderInfo = { credentialHolder?: string; providers?: string[]; connectionCount?: number; verifiedConnectionCount?: number };
export async function getHolder(page: Page, holder: string): Promise<{ status: number; info: HolderInfo }> {
  const r = await consoleApi(page, 'GetCredentialHolder', { pathParams: { holderId: holder } });
  return { status: r.status, info: r.data?.responseData ?? {} };
}

// Register Credential 모달로 등록. 화면이 자동으로 채운 Key 를 그대로 쓰고 Value 만 입력한다.
// 녹화에 비밀값이 찍히지 않도록 Value 입력란은 글자를 가린다(-webkit-text-security: disc, 화면 동작에는 영향 없음).
export async function uiRegister(page: Page, holder: string, provider: string, values: Record<string, string>) {
  await page.getByRole('button', { name: /Register Credential/ }).first().click();
  const modal = page.locator('#credential-create-modal');
  await expect(modal).toBeVisible();
  await page.addStyleTag({ content: '#credential-create-modal .kv-value{-webkit-text-security:disc;}' });
  await page.locator('#create-holder-name').fill(holder);
  await page.locator('#create-holder-provider').selectOption(provider);
  const keyInputs = page.locator('#kv-list-container .kv-key');
  await expect(keyInputs.first()).toBeVisible();
  const keys = (await keyInputs.evaluateAll((els) => els.map((e) => (e as HTMLInputElement).value))).filter(Boolean);
  const missing: string[] = [];
  for (let i = 0; i < keys.length; i++) {
    const v = values[keys[i]];
    if (v === undefined) { missing.push(keys[i]); continue; }
    await page.locator('#kv-list-container .kv-value').nth(i).fill(v);
  }
  const respP = page.waitForResponse((r) => r.url().includes('/RegisterCredential'), { timeout: 180_000 });
  await modal.getByRole('button', { name: /^Register$/ }).click();
  const resp = await respP;
  let body = '';
  try { body = await resp.text(); } catch { /* ignore */ }
  const toast = await page.locator('.toast, .toastify, [class*="toast"]').last().innerText({ timeout: 10_000 }).catch(() => '');
  if (await modal.isVisible()) await modal.getByRole('button', { name: 'Cancel' }).click().catch(() => {});
  return { keys, missing, status: resp.status(), body: scrub(body, values).slice(0, 600), toast: scrub(toast, values) };
}

_register.ts

tests/iv10/_register.ts · 34줄 · 해시 d807c126

import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
import { gotoCredentials, credValues, getHolder, uiRegister, TEST_HOLDER } from '../../lib/iv10';

// IV10 UI 등록 공용 테스트 본문 (S04 AWS, S05 CSP별)
export function uiRegisterTest(id: string, tc: string, csp: string) {
  test(`${id} ${tc} Credential 등록 — ${csp} UI`, async ({ page }) => {
    const v = credValues(csp);
    expect(Object.keys(v).length, `${csp} 자격증명 값 없음`).toBeGreaterThan(0);
    let r: Awaited<ReturnType<typeof uiRegister>>;

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

    await test.step(`2. Register Credential → holder ${TEST_HOLDER}, Provider ${csp} 선택 → 자동 생성된 Key 에 Value 입력 → Register`, async () => {
      r = await uiRegister(page, TEST_HOLDER, csp, v);
      test.info().annotations.push({ type: 'keys', description: `화면 자동 Key: ${r.keys.join(', ')}${r.missing.length ? ` / 값 없는 Key: ${r.missing.join(', ')}` : ''}` });
      test.info().annotations.push({ type: 'response', description: `RegisterCredential HTTP ${r.status}: ${r.body}` });
      test.info().annotations.push({ type: 'toast', description: r.toast || '(토스트 없음)' });
    });

    await test.step('3. 등록 성공: 서버 거부 없음 · 성공 토스트', async () => {
      expect(r.missing, '자격증명 값이 없는 Key').toEqual([]);
      expect(r.body, '서버가 등록을 거부함').not.toMatch(/invalid credential key|"code":\s*[45]\d\d|Internal Server Error|Bad Request/i);
      expect(r.status).toBe(200);
      expect(r.toast).toMatch(/registered successfully/i);
    });

    await test.step(`4. GetCredentialHolder(${TEST_HOLDER}) providers 에 ${csp} 포함`, async () => {
      const h = await getHolder(page, TEST_HOLDER);
      test.info().annotations.push({ type: 'holder', description: JSON.stringify(h.info) });
      expect(h.info.providers ?? []).toContain(csp);
    });
  });
}

IV10_S01

tests/iv10/IV10_S01.spec.ts · 26줄 · 해시 53b94608

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

// IV10_S01 : TC-INFRA-001 Credential 목록 조회 (Settings > Cloud SPs > Credentials)
test('IV10_S01 TC-INFRA-001 Credential 목록 조회', async ({ page }) => {
  await test.step('1. admin 로그인', () => loginAsAdmin(page));

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

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

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

IV10_S02

tests/iv10/IV10_S02.spec.ts · 32줄 · 해시 eb633cae

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

// IV10_S02 : TC-INFRA-002 Credential 단건 조회 — 목록에서 holder 클릭 → 상세 패널, GetCredentialHolder 응답과 비교
const HOLDER = 'admin';
test('IV10_S02 TC-INFRA-002 Credential 단건 조회', async ({ page }) => {
  await test.step('1. admin 로그인 후 Credentials 이동', async () => { await loginAsAdmin(page); await gotoCredentials(page); });

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

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

IV10_S03

tests/iv10/IV10_S03.spec.ts · 36줄 · 해시 c458f9f3

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

// IV10_S03 : TC-INFRA-003 Credential 등록 — API (웹 콘솔 API POST /api/mc-infra-manager/RegisterCredential, 평문 → API 서버가 암호화)
// mc-infra-manager 가 허용하는 CSP 형식 키(metainfo CredentialCSP: aws_access_key_id / aws_secret_access_key)로 holder 'test' 에 AWS 를 등록한다.
// 주의: mc-infra-manager 에 credential 삭제 API 가 없어 등록 결과는 남는다(2026-10-14 사용자 승인).
const CSP = 'aws';
test('IV10_S03 TC-INFRA-003 Credential 등록 (API)', async ({ page }) => {
  const v = credValues(CSP);
  const kv = ['aws_access_key_id', 'aws_secret_access_key'].map((key) => ({ key, value: v[key] }));
  expect(kv.every((x) => x.value), 'aws 자격증명 값 없음').toBe(true);

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

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

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

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

IV10_S04

tests/iv10/IV10_S04.spec.ts · 3줄 · 해시 708d1dff

import { uiRegisterTest } from './_register';
// IV10_S04 : TC-INFRA-004 Credential 등록 — AWS UI (화면 자동 Key: ClientId, ClientSecret)
uiRegisterTest('IV10_S04', 'TC-INFRA-004', 'aws');

IV10_S05

tests/iv10/IV10_S05.spec.ts · 5줄 · 해시 af3e7a15

import { uiRegisterTest } from './_register';
// IV10_S05 : TC-INFRA-005 Credential 등록 — CSP별 UI (Provider 마다 자동 생성 Key 가 다름). 테스트 1건 = CSP 1개
// openstack 은 stage 에 연결 정보가 없어 제외. 대상 CSP 는 IV10_CSPS 로 바꿀 수 있다.
const CSPS = (process.env.IV10_CSPS ?? 'gcp,azure,alibaba,ibm,ncp,nhn,kt,tencent').split(',');
for (const c of CSPS) uiRegisterTest(`IV10_S05_${c.toUpperCase()}`, 'TC-INFRA-005', c);

IV10_S06

tests/iv10/IV10_S06.spec.ts · 24줄 · 해시 139b3ee6

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

// IV10_S06 : TC-INFRA-006 Credential 삭제
// 2026-10-14 확인: mc-infra-manager(edge) Swagger 에 credential·credentialHolder·connConfig 삭제 API 가 없고, Credentials 화면에도 삭제 기능이 없다.
// 화면에서 삭제 수단을 찾고, 없으면 기능 미제공으로 SKIP 한다(삭제 기능이 생기면 이 spec 을 삭제 절차로 바꾼다).
test('IV10_S06 TC-INFRA-006 Credential 삭제', async ({ page }) => {
  await test.step('1. admin 로그인 후 Credentials 이동', async () => { await loginAsAdmin(page); await gotoCredentials(page); });

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

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

Clone this wiki locally