Phase 19.6 測試文檔收尾:
- E2E 測試擴充至 18 項 (Terminal/GenUI 驗證)
- 新增 PHASE19-VERIFICATION-CHECKLIST.md (完整驗證清單)
P1 驗證:
- ArgoCD Metrics NodePort 監控 (30883/30884)
- TLS 證書監控 (Blackbox Exporter 9115)
P2 改進:
- waitForTimeout → waitForLoadState('networkidle')
- 跨平台快捷鍵 (Meta+J / Control+J)
- SKIP_MULTISIG_TESTS 環境變數控制
- Prometheus GitOps 部署腳本
P3 改進:
- HPA maxReplicas 4 → 6 (API/Web)
首席架構師審查: 47/50 OUTSTANDING (94%)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
import { test, expect } from '@playwright/test'
|
||
|
||
/**
|
||
* Multi-Sig Security E2E Test
|
||
* ===========================
|
||
* CISO-101: 資安驗收測試
|
||
*
|
||
* 驗證項目:
|
||
* 1. CRITICAL 授權需要 2 人簽核
|
||
* 2. 同一人不能重複簽核 (Identity Check)
|
||
* 3. 第二人簽核後 → APPROVED
|
||
*
|
||
* ⚠️ 條件式執行說明:
|
||
* - 此測試需要後端 API 連線 (localhost:8000 或 192.168.0.125:32334)
|
||
* - CI/CD 環境無法連接生產 API,故標記為條件式執行
|
||
* - 本地開發環境可正常執行
|
||
* - 設定 SKIP_MULTISIG_TESTS=true 可強制跳過
|
||
*
|
||
* P2 改進 (2026-03-29 首席架構師審查):
|
||
* - 新增 SKIP_MULTISIG_TESTS 環境變數控制
|
||
* - 改善跳過訊息的可讀性
|
||
*
|
||
* @version 1.1.0
|
||
* @date 2026-03-29 (台北時間)
|
||
*/
|
||
|
||
const API_BASE_URL = process.env.TEST_API_URL || 'http://localhost:8000'
|
||
const FORCE_SKIP = process.env.SKIP_MULTISIG_TESTS === 'true'
|
||
|
||
// 檢查 API 是否可用
|
||
async function isApiAvailable(): Promise<boolean> {
|
||
if (FORCE_SKIP) {
|
||
console.log('⚠️ Multi-Sig tests skipped: SKIP_MULTISIG_TESTS=true')
|
||
return false
|
||
}
|
||
try {
|
||
const response = await fetch(`${API_BASE_URL}/api/v1/health`, {
|
||
method: 'GET',
|
||
signal: AbortSignal.timeout(5000),
|
||
})
|
||
return response.ok
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
test.describe('Multi-Sig Security Verification', () => {
|
||
// 條件式跳過: 當 API 不可用時
|
||
test.beforeAll(async () => {
|
||
const apiAvailable = await isApiAvailable()
|
||
if (!apiAvailable) {
|
||
console.log('┌────────────────────────────────────────────────┐')
|
||
console.log('│ ⚠️ Multi-Sig tests SKIPPED │')
|
||
console.log('├────────────────────────────────────────────────┤')
|
||
console.log(`│ API URL: ${API_BASE_URL.padEnd(35)}│`)
|
||
console.log('│ Reason: Backend API not available │')
|
||
console.log('│ To run: Start API server first │')
|
||
console.log('└────────────────────────────────────────────────┘')
|
||
test.skip()
|
||
}
|
||
})
|
||
test.setTimeout(120000)
|
||
|
||
// 輔助函數: 建立 CRITICAL 授權
|
||
async function createCriticalApproval(): Promise<string> {
|
||
const response = await fetch(`${API_BASE_URL}/api/v1/approvals`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
action: 'DROP TABLE user_sessions',
|
||
description: 'E2E Security Test - Multi-Sig verification',
|
||
risk_level: 'critical',
|
||
blast_radius: {
|
||
affected_pods: 0,
|
||
estimated_downtime: '0',
|
||
related_services: ['auth-service', 'api-gateway'],
|
||
data_impact: 'destructive',
|
||
},
|
||
dry_run_checks: [
|
||
{ name: 'RBAC Check', passed: true, message: 'test-admin' },
|
||
{ name: 'Syntax Check', passed: true },
|
||
],
|
||
requested_by: 'E2E-Test',
|
||
}),
|
||
})
|
||
|
||
expect(response.ok).toBeTruthy()
|
||
const data = await response.json()
|
||
expect(data.status).toBe('pending')
|
||
expect(data.required_signatures).toBe(2)
|
||
return data.id
|
||
}
|
||
|
||
// 輔助函數: 簽核
|
||
async function signApproval(
|
||
approvalId: string,
|
||
signerId: string,
|
||
signerName: string
|
||
): Promise<{ success: boolean; status: number; data: any }> {
|
||
const response = await fetch(
|
||
`${API_BASE_URL}/api/v1/approvals/${approvalId}/sign`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
signer_id: signerId,
|
||
signer_name: signerName,
|
||
comment: `E2E Test sign by ${signerName}`,
|
||
}),
|
||
}
|
||
)
|
||
|
||
const data = await response.json()
|
||
return {
|
||
success: response.ok,
|
||
status: response.status,
|
||
data,
|
||
}
|
||
}
|
||
|
||
test('Step 1-4: Full Multi-Sig Security Flow', async ({ page }) => {
|
||
// ========================================================================
|
||
// Step 1: 建立 CRITICAL 授權
|
||
// ========================================================================
|
||
console.log('Step 1: Creating CRITICAL approval...')
|
||
|
||
const approvalId = await createCriticalApproval()
|
||
console.log(`Created approval: ${approvalId}`)
|
||
|
||
// 截圖: 初始狀態
|
||
await page.goto('/zh-TW/demo', { waitUntil: 'domcontentloaded' })
|
||
await page.waitForTimeout(3000)
|
||
await page.screenshot({
|
||
path: 'test-results/screenshots/multisig-01-initial.png',
|
||
fullPage: true,
|
||
})
|
||
|
||
// ========================================================================
|
||
// Step 2: User A (admin-1) 第一次簽核
|
||
// ========================================================================
|
||
console.log('Step 2: User A (admin-1) signing...')
|
||
|
||
const sign1Result = await signApproval(approvalId, 'admin-1', 'Admin A (CTO)')
|
||
|
||
expect(sign1Result.success).toBeTruthy()
|
||
expect(sign1Result.data.approval.status).toBe('pending')
|
||
expect(sign1Result.data.approval.current_signatures).toBe(1)
|
||
expect(sign1Result.data.approval.required_signatures).toBe(2)
|
||
expect(sign1Result.data.execution_triggered).toBe(false)
|
||
|
||
console.log('✅ First signature accepted: 1/2')
|
||
|
||
// 截圖: 1/2 簽核狀態
|
||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||
await page.waitForTimeout(2000)
|
||
await page.screenshot({
|
||
path: 'test-results/screenshots/multisig-02-first-sign.png',
|
||
fullPage: true,
|
||
})
|
||
|
||
// ========================================================================
|
||
// Step 3: User A (admin-1) 嘗試重複簽核 → 應被拒絕
|
||
// ========================================================================
|
||
console.log('Step 3: User A attempting duplicate signature...')
|
||
|
||
const duplicateResult = await signApproval(approvalId, 'admin-1', 'Admin A (CTO)')
|
||
|
||
// 關鍵斷言: 重複簽核必須被拒絕
|
||
expect(duplicateResult.success).toBeFalsy()
|
||
expect(duplicateResult.status).toBe(400)
|
||
expect(duplicateResult.data.detail).toContain('already signed')
|
||
|
||
console.log('✅ Duplicate signature REJECTED: 400 Bad Request')
|
||
console.log(` Error: ${duplicateResult.data.detail}`)
|
||
|
||
// 截圖: 重複簽核被拒
|
||
await page.screenshot({
|
||
path: 'test-results/screenshots/multisig-03-duplicate-rejected.png',
|
||
fullPage: true,
|
||
})
|
||
|
||
// ========================================================================
|
||
// Step 4: User B (admin-2) 簽核 → APPROVED
|
||
// ========================================================================
|
||
console.log('Step 4: User B (admin-2) signing...')
|
||
|
||
const sign2Result = await signApproval(approvalId, 'admin-2', 'Admin B (CISO)')
|
||
|
||
expect(sign2Result.success).toBeTruthy()
|
||
expect(sign2Result.data.approval.status).toBe('approved')
|
||
expect(sign2Result.data.approval.current_signatures).toBe(2)
|
||
expect(sign2Result.data.execution_triggered).toBe(true)
|
||
|
||
console.log('✅ Second signature accepted: 2/2 → APPROVED')
|
||
console.log('✅ Execution triggered!')
|
||
|
||
// 截圖: 最終狀態
|
||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||
await page.waitForTimeout(2000)
|
||
await page.screenshot({
|
||
path: 'test-results/screenshots/multisig-04-approved.png',
|
||
fullPage: true,
|
||
})
|
||
|
||
// ========================================================================
|
||
// 驗證: 確認已從 pending 清單移除
|
||
// ========================================================================
|
||
const pendingResponse = await fetch(`${API_BASE_URL}/api/v1/approvals/pending`)
|
||
const pendingData = await pendingResponse.json()
|
||
|
||
const stillPending = pendingData.approvals.find(
|
||
(a: any) => a.id === approvalId
|
||
)
|
||
expect(stillPending).toBeUndefined()
|
||
|
||
console.log('✅ Approval removed from pending list')
|
||
|
||
// ========================================================================
|
||
// 最終報告
|
||
// ========================================================================
|
||
console.log('')
|
||
console.log('═══════════════════════════════════════════════════════')
|
||
console.log(' Multi-Sig Security Test: ALL PASSED')
|
||
console.log('═══════════════════════════════════════════════════════')
|
||
console.log('')
|
||
console.log(' ✅ Step 1: CRITICAL approval created (0/2)')
|
||
console.log(' ✅ Step 2: First signature accepted (1/2)')
|
||
console.log(' ✅ Step 3: Duplicate signature REJECTED (400)')
|
||
console.log(' ✅ Step 4: Second signature → APPROVED (2/2)')
|
||
console.log('')
|
||
console.log(' Identity Check: ENFORCED')
|
||
console.log(' Multi-Sig Logic: VERIFIED')
|
||
console.log('═══════════════════════════════════════════════════════')
|
||
})
|
||
|
||
test('Duplicate signature returns 400 with correct error message', async () => {
|
||
// 建立授權
|
||
const approvalId = await createCriticalApproval()
|
||
|
||
// 第一次簽核
|
||
const first = await signApproval(approvalId, 'user-test-001', 'Test User')
|
||
expect(first.success).toBeTruthy()
|
||
|
||
// 重複簽核
|
||
const duplicate = await signApproval(approvalId, 'user-test-001', 'Test User')
|
||
|
||
// 斷言
|
||
expect(duplicate.success).toBeFalsy()
|
||
expect(duplicate.status).toBe(400)
|
||
expect(duplicate.data.detail).toMatch(/already signed/i)
|
||
})
|
||
|
||
test('Cannot sign after approval is completed', async () => {
|
||
// 建立授權
|
||
const approvalId = await createCriticalApproval()
|
||
|
||
// 兩人簽核完成
|
||
await signApproval(approvalId, 'signer-A', 'Signer A')
|
||
const complete = await signApproval(approvalId, 'signer-B', 'Signer B')
|
||
expect(complete.data.approval.status).toBe('approved')
|
||
|
||
// 第三人嘗試簽核已完成的授權
|
||
const lateSign = await signApproval(approvalId, 'signer-C', 'Signer C')
|
||
|
||
expect(lateSign.success).toBeFalsy()
|
||
expect(lateSign.status).toBe(400)
|
||
expect(lateSign.data.detail).toMatch(/cannot sign/i)
|
||
})
|
||
})
|