feat: add all application source code
- apps/api: FastAPI backend with Dockerfile - apps/web: Next.js frontend with Dockerfile - apps/sensor: Signal collection agent - packages: shared packages Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
160
apps/web/tests/e2e/action-log.spec.ts
Normal file
160
apps/web/tests/e2e/action-log.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Action Log 頁面測試
|
||||
* ====================
|
||||
* Phase 4: 行動日誌 E2E 測試
|
||||
*
|
||||
* 測試項目:
|
||||
* 1. 頁面正確載入
|
||||
* 2. i18n 標題顯示正確
|
||||
* 3. 統計卡片顯示
|
||||
* 4. 表格結構正確
|
||||
* 5. 分頁控制項存在
|
||||
*/
|
||||
|
||||
test.describe('Action Log 頁面測試', () => {
|
||||
test.setTimeout(60000)
|
||||
|
||||
test('繁體中文頁面 - 基本元素驗證', async ({ page }) => {
|
||||
// 1. 導覽至 Action Log 頁面
|
||||
await page.goto('/zh-TW/action-logs', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// 等待主要內容渲染 (使用 main 作為等待目標,更可靠)
|
||||
await page.waitForSelector('main', { timeout: 15000 })
|
||||
await page.waitForTimeout(2000) // 等待 hydration
|
||||
|
||||
// 截圖: 初始頁面
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/action-log-01-zh-TW-initial.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// 2. 驗證頁面標題為繁體中文「行動日誌」(h2 或 h3)
|
||||
const pageTitle = page.locator('h2, h3').filter({ hasText: '行動日誌' }).first()
|
||||
await expect(pageTitle).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// 3. 驗證副標題
|
||||
const subtitle = page.locator('text=K8s 操作執行稽核軌跡')
|
||||
await expect(subtitle).toBeVisible()
|
||||
|
||||
// 4. 驗證頁面結構 - 有 main 內容區域
|
||||
const mainContent = page.locator('main')
|
||||
await expect(mainContent).toBeVisible()
|
||||
|
||||
// 截圖: 頁面結構
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/action-log-02-page-structure.png',
|
||||
})
|
||||
})
|
||||
|
||||
test('英文頁面 - 基本元素驗證', async ({ page }) => {
|
||||
// 導覽至英文 Action Log 頁面
|
||||
await page.goto('/en/action-logs', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('h2', { timeout: 15000 })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// 截圖: 英文頁面
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/action-log-03-en-initial.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// 驗證英文標題
|
||||
const pageTitle = page.locator('h2').filter({ hasText: 'Action Log' })
|
||||
await expect(pageTitle).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// 驗證英文副標題
|
||||
const subtitle = page.locator('text=K8s Operation Execution Audit Trail')
|
||||
await expect(subtitle).toBeVisible()
|
||||
})
|
||||
|
||||
test('資料狀態顯示 - 顯示空狀態、表格或錯誤訊息', async ({ page }) => {
|
||||
// 導覽至 Action Log 頁面
|
||||
await page.goto('/zh-TW/action-logs', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('main', { timeout: 15000 })
|
||||
await page.waitForTimeout(3000) // 等待 API 回應
|
||||
|
||||
// 截圖: 當前狀態
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/action-log-04-data-state.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// 檢查各種可能的狀態
|
||||
const emptyState = page.locator('text=目前沒有執行紀錄')
|
||||
const table = page.locator('table')
|
||||
const errorState = page.locator('text=無法取得稽核日誌')
|
||||
const loadingState = page.locator('text=載入中')
|
||||
|
||||
// 至少一個狀態元素可見 (資料、空、錯誤或載入中)
|
||||
const hasEmptyState = await emptyState.isVisible()
|
||||
const hasTable = await table.isVisible()
|
||||
const hasError = await errorState.isVisible()
|
||||
const hasLoading = await loadingState.isVisible()
|
||||
|
||||
// 驗證頁面正確處理了各種狀態
|
||||
expect(hasEmptyState || hasTable || hasError || hasLoading).toBeTruthy()
|
||||
})
|
||||
|
||||
test('側邊欄導航 - 行動日誌連結可點擊', async ({ page }) => {
|
||||
// 先導覽至首頁
|
||||
await page.goto('/zh-TW/demo', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('h1', { timeout: 15000 })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// 截圖: 首頁
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/action-log-05-before-nav.png',
|
||||
})
|
||||
|
||||
// 找到側邊欄中的「行動日誌」連結
|
||||
const actionLogLink = page.locator('a').filter({ hasText: '行動日誌' })
|
||||
await expect(actionLogLink).toBeVisible()
|
||||
|
||||
// 點擊導航到 Action Log 頁面
|
||||
await actionLogLink.click()
|
||||
|
||||
// 等待導航完成
|
||||
await page.waitForURL('**/action-logs', { timeout: 15000 })
|
||||
await page.waitForSelector('h2', { timeout: 15000 })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// 截圖: 導航後
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/action-log-06-after-nav.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// 驗證標題
|
||||
const pageTitle = page.locator('h2').filter({ hasText: '行動日誌' })
|
||||
await expect(pageTitle).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('重新整理按鈕存在且可點擊', async ({ page }) => {
|
||||
await page.goto('/zh-TW/action-logs', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('h2', { timeout: 15000 })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// 找到重新整理按鈕
|
||||
const refreshButton = page.locator('button').filter({ hasText: '重新整理' })
|
||||
await expect(refreshButton).toBeVisible()
|
||||
|
||||
// 截圖: 點擊前
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/action-log-07-before-refresh.png',
|
||||
})
|
||||
|
||||
// 點擊重新整理
|
||||
await refreshButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// 截圖: 點擊後
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/action-log-08-after-refresh.png',
|
||||
})
|
||||
|
||||
// 按鈕仍然存在
|
||||
await expect(refreshButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
59
apps/web/tests/e2e/approval-card-verify.spec.ts
Normal file
59
apps/web/tests/e2e/approval-card-verify.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* AWOOOI 自動化 QA - ApprovalCard 渲染驗證
|
||||
* =========================================
|
||||
* 驗證待簽核卡片是否正確顯示在戰情室
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('ApprovalCard 即時驗證', () => {
|
||||
test('待簽核卡片應顯示在右側 AI 面板', async ({ page }) => {
|
||||
// Navigate to dashboard (skip networkidle due to SSE)
|
||||
await page.goto('/zh-TW')
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
|
||||
// Wait for ClawBot state machine to fetch data (max 10s)
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
// Take screenshot for evidence
|
||||
await page.screenshot({
|
||||
path: 'test-results/approval-card-check.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// Check machine state
|
||||
const stateLabel = page.locator('text=STATE:')
|
||||
await expect(stateLabel).toBeVisible()
|
||||
|
||||
// Look for approval-related elements
|
||||
const aiPanel = page.locator('[class*="DataPincerPanel"]').last()
|
||||
|
||||
// Check if approval card is visible (look for key indicators)
|
||||
const hasApprovalCard =
|
||||
(await page.locator('text=/PostgreSQL|重新啟動|CRITICAL/i').count()) > 0 ||
|
||||
(await page.locator('text=/awaiting_approval/i').count()) > 0
|
||||
|
||||
// Log findings
|
||||
const stateText = await page.locator('text=/idle|thinking|awaiting_approval/').first().textContent()
|
||||
console.log(`[QA] Machine State: ${stateText}`)
|
||||
console.log(`[QA] ApprovalCard visible: ${hasApprovalCard}`)
|
||||
|
||||
// If no card, try manual refresh
|
||||
if (!hasApprovalCard) {
|
||||
console.log('[QA] Clicking REFRESH button...')
|
||||
const refreshBtn = page.locator('button:has-text("REFRESH")')
|
||||
if (await refreshBtn.isVisible()) {
|
||||
await refreshBtn.click()
|
||||
await page.waitForTimeout(2000)
|
||||
await page.screenshot({
|
||||
path: 'test-results/approval-card-after-refresh.png',
|
||||
fullPage: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Final state check
|
||||
const finalState = await page.locator('[class*="uppercase"]').filter({ hasText: /idle|thinking|awaiting_approval/ }).first().textContent()
|
||||
console.log(`[QA] Final Machine State: ${finalState?.trim()}`)
|
||||
})
|
||||
})
|
||||
47
apps/web/tests/e2e/cpo102-visual.spec.ts
Normal file
47
apps/web/tests/e2e/cpo102-visual.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* CPO-102 Visual Verification
|
||||
* ============================
|
||||
* 截圖驗收:Glass Sidebar + Header + HITL Approval Cards
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('CPO-102 Layout Visual Verification', () => {
|
||||
test('Screenshot: Full Command Center Layout', async ({ page }) => {
|
||||
// Navigate to demo page (zh-TW)
|
||||
await page.goto('/zh-TW/demo')
|
||||
|
||||
// Wait for DOM ready (skip networkidle due to SSE)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.waitForTimeout(3000) // Allow SSE data + animations
|
||||
|
||||
// Take full page screenshot
|
||||
await page.screenshot({
|
||||
path: 'test-results/cpo102-fullpage-zh-TW.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// Scroll to HITL section and capture
|
||||
await page.evaluate(() => window.scrollTo(0, 800))
|
||||
await page.waitForTimeout(500)
|
||||
await page.screenshot({
|
||||
path: 'test-results/cpo102-hitl-section-zh-TW.png',
|
||||
fullPage: false,
|
||||
})
|
||||
|
||||
// Scroll to Status Orb section
|
||||
await page.evaluate(() => window.scrollTo(0, 2000))
|
||||
await page.waitForTimeout(500)
|
||||
await page.screenshot({
|
||||
path: 'test-results/cpo102-status-section-zh-TW.png',
|
||||
fullPage: false,
|
||||
})
|
||||
|
||||
// Verify key Chinese text
|
||||
const pageContent = await page.content()
|
||||
expect(pageContent).toContain('即時戰情室')
|
||||
expect(pageContent).toContain('授權卡片')
|
||||
|
||||
console.log('✅ Screenshots saved to test-results/')
|
||||
})
|
||||
})
|
||||
183
apps/web/tests/e2e/dashboard-acceptance.spec.ts
Normal file
183
apps/web/tests/e2e/dashboard-acceptance.spec.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Dashboard 視覺驗收測試
|
||||
* ======================
|
||||
* Phase VI E2E 自動化測試
|
||||
*
|
||||
* 注意: 使用 domcontentloaded 而非 networkidle
|
||||
* 因 SSE 連線會持續保持網路活動
|
||||
*/
|
||||
|
||||
test.describe('Dashboard 視覺驗收', () => {
|
||||
// 增加超時時間
|
||||
test.setTimeout(60000)
|
||||
|
||||
test('繁體中文頁面驗證 - 無 MOCK MODE', async ({ page }) => {
|
||||
// 1. 導覽至 /zh-TW/demo
|
||||
await page.goto('/zh-TW/demo', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// 等待主要內容渲染
|
||||
await page.waitForSelector('h1', { timeout: 15000 })
|
||||
await page.waitForTimeout(2000) // 等待 hydration
|
||||
|
||||
// 截圖: 初始頁面
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/01-zh-TW-initial.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// 2. 驗證畫面不存在 MOCK MODE 字樣
|
||||
const pageContent = await page.content()
|
||||
expect(pageContent).not.toContain('MOCK MODE')
|
||||
|
||||
// 3. 驗證標題正確渲染為繁體中文「全局戰情室」
|
||||
const dashboardTitle = page.locator('h2').filter({ hasText: '全局戰情室' })
|
||||
await expect(dashboardTitle).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// 截圖: 全局戰情室標題
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/02-zh-TW-dashboard-title.png',
|
||||
})
|
||||
|
||||
// 驗證 Demo 頁面標題包含 AWOOOI
|
||||
const demoTitle = page.locator('h1')
|
||||
await expect(demoTitle).toContainText('AWOOOI')
|
||||
|
||||
// 驗證視覺驗收測試副標題 (繁體中文)
|
||||
const subtitle = page.locator('text=視覺驗收測試')
|
||||
await expect(subtitle).toBeVisible()
|
||||
|
||||
// 驗證 LIVE 指示器 (非 MOCK MODE)
|
||||
const liveIndicator = page.locator('text=LIVE')
|
||||
await expect(liveIndicator).toBeVisible()
|
||||
})
|
||||
|
||||
test('語系切換器功能 - 繁中轉英文', async ({ page }) => {
|
||||
// 導覽至繁體中文頁面
|
||||
await page.goto('/zh-TW/demo', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('h2', { timeout: 15000 })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// 驗證初始為繁體中文
|
||||
const zhTitle = page.locator('h2').filter({ hasText: '全局戰情室' })
|
||||
await expect(zhTitle).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// 截圖: 切換前
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/03-before-locale-switch.png',
|
||||
})
|
||||
|
||||
// 4. 點擊語系切換器切換至 EN
|
||||
const enButton = page.locator('button').filter({ hasText: 'English' })
|
||||
await expect(enButton).toBeVisible()
|
||||
await enButton.click()
|
||||
|
||||
// 等待頁面導航
|
||||
await page.waitForURL('**/en/demo', { timeout: 15000 })
|
||||
await page.waitForSelector('h2', { timeout: 15000 })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// 驗證標題變更為 "Command Center"
|
||||
const enTitle = page.locator('h2').filter({ hasText: 'Command Center' })
|
||||
await expect(enTitle).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// 驗證副標題變更為英文
|
||||
const enSubtitle = page.locator('text=Visual Acceptance Test')
|
||||
await expect(enSubtitle).toBeVisible()
|
||||
|
||||
// 截圖: 切換後 (英文)
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/04-after-locale-switch-en.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// 驗證 LIVE 指示器存在 (非 MOCK MODE)
|
||||
const liveIndicator = page.locator('span:has-text("LIVE")')
|
||||
await expect(liveIndicator).toBeVisible()
|
||||
})
|
||||
|
||||
test('主機卡片顯示真實狀態', async ({ page }) => {
|
||||
await page.goto('/zh-TW/demo', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('h2', { timeout: 15000 })
|
||||
|
||||
// 等待 SSE 數據載入
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
// 截圖: 主機卡片區域
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/05-host-cards.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// 驗證至少有一個 IP 地址顯示 (真實主機卡片)
|
||||
const ipPattern = page.locator('text=/192\\.168\\.0\\.\\d+/')
|
||||
const ipCount = await ipPattern.count()
|
||||
expect(ipCount).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// 驗證 LIVE 狀態指示器存在 (非 MOCK MODE)
|
||||
const liveIndicator = page.locator('text=LIVE')
|
||||
await expect(liveIndicator).toBeVisible()
|
||||
})
|
||||
|
||||
test('HITL 授權卡片功能驗證', async ({ page }) => {
|
||||
await page.goto('/zh-TW/demo', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('h2', { timeout: 15000 })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// 滾動到授權卡片區域
|
||||
await page.evaluate(() => window.scrollBy(0, 800))
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// 截圖: 授權卡片區域
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/06-approval-cards.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// 驗證授權卡片標題存在
|
||||
const approvalTitle = page.locator('h2').filter({ hasText: 'HITL' })
|
||||
await expect(approvalTitle).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// 驗證「長按」相關按鈕存在 (繁體中文)
|
||||
const holdButtons = page.locator('button').filter({ hasText: '長按' })
|
||||
const holdButtonCount = await holdButtons.count()
|
||||
expect(holdButtonCount).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// 驗證「拒絕」按鈕存在
|
||||
const rejectButtons = page.locator('button').filter({ hasText: '拒絕' })
|
||||
const rejectButtonCount = await rejectButtons.count()
|
||||
expect(rejectButtonCount).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('完整頁面截圖 - 雙語對照', async ({ page }) => {
|
||||
// 繁體中文完整截圖
|
||||
await page.goto('/zh-TW/demo', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('h2', { timeout: 15000 })
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/07-final-zh-TW-fullpage.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// 切換到英文
|
||||
const enButton = page.locator('button').filter({ hasText: 'English' })
|
||||
await enButton.click()
|
||||
await page.waitForURL('**/en/demo', { timeout: 15000 })
|
||||
await page.waitForSelector('h2', { timeout: 15000 })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// 英文完整截圖
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/08-final-en-fullpage.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// 最終驗證: LIVE 指示器存在, Command Center 標題
|
||||
const liveIndicator = page.locator('span:has-text("LIVE")')
|
||||
await expect(liveIndicator).toBeVisible()
|
||||
const commandCenter = page.locator('h2:has-text("Command Center")')
|
||||
await expect(commandCenter).toBeVisible()
|
||||
})
|
||||
})
|
||||
31
apps/web/tests/e2e/debug-error.spec.ts
Normal file
31
apps/web/tests/e2e/debug-error.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Debug Error Test
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('capture console errors', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(msg.text())
|
||||
}
|
||||
})
|
||||
|
||||
page.on('pageerror', error => {
|
||||
errors.push(`Page Error: ${error.message}`)
|
||||
})
|
||||
|
||||
await page.goto('/zh-TW')
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
await page.screenshot({
|
||||
path: 'test-results/debug-error.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
console.log('=== Console Errors ===')
|
||||
errors.forEach((e, i) => console.log(`${i + 1}. ${e}`))
|
||||
console.log('======================')
|
||||
})
|
||||
224
apps/web/tests/e2e/multisig-security.spec.ts
Normal file
224
apps/web/tests/e2e/multisig-security.spec.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Multi-Sig Security E2E Test
|
||||
* ===========================
|
||||
* CISO-101: 資安驗收測試
|
||||
*
|
||||
* 驗證項目:
|
||||
* 1. CRITICAL 授權需要 2 人簽核
|
||||
* 2. 同一人不能重複簽核 (Identity Check)
|
||||
* 3. 第二人簽核後 → APPROVED
|
||||
*/
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000'
|
||||
|
||||
test.describe('Multi-Sig Security Verification', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
95
apps/web/tests/e2e/phase4-final-demo.spec.ts
Normal file
95
apps/web/tests/e2e/phase4-final-demo.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Phase 4: Alpha 版最終火力展示
|
||||
* =============================
|
||||
* 模擬完整流程:
|
||||
* 1. [SYSTEM] 接收告警
|
||||
* 2. [AGENT] ClawBot 分析完成
|
||||
* 3. [SECURITY] DevOps Access Denied
|
||||
* 4. [HUMAN] CTO 成功簽核
|
||||
*/
|
||||
|
||||
test('Phase 4 Alpha Demo - Full Flow', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1920, height: 1200 });
|
||||
|
||||
// Navigate
|
||||
await page.goto('http://localhost:3000/zh-TW/demo');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Screenshot 1: Initial state with approval cards
|
||||
await page.screenshot({ path: 'test-results/phase4-alpha-01-initial.png', fullPage: true });
|
||||
|
||||
// Step 1: Trigger CRITICAL alert (generates SYSTEM + AGENT events)
|
||||
console.log('[Demo] Step 1: Triggering CRITICAL alert...');
|
||||
const triggerBtn = page.locator('button:has-text("嚴重")').first();
|
||||
if (await triggerBtn.isVisible({ timeout: 3000 })) {
|
||||
await triggerBtn.click();
|
||||
await page.waitForTimeout(5000); // Wait for AI thinking animation
|
||||
}
|
||||
|
||||
// Screenshot 2: After AI analysis
|
||||
await page.screenshot({ path: 'test-results/phase4-alpha-02-ai-analysis.png', fullPage: true });
|
||||
|
||||
// Step 2: DevOps tries to sign CRITICAL (generates SECURITY event)
|
||||
console.log('[Demo] Step 2: DevOps attempting CRITICAL sign...');
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const approveBtn = page.locator('button:has-text("長按")').first();
|
||||
if (await approveBtn.isVisible({ timeout: 3000 })) {
|
||||
const box = await approveBtn.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(2500);
|
||||
await page.mouse.up();
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Screenshot 3: Access Denied
|
||||
await page.screenshot({ path: 'test-results/phase4-alpha-03-access-denied.png' });
|
||||
|
||||
// Close modal
|
||||
const closeBtn = page.locator('button:has-text("了解")');
|
||||
if (await closeBtn.isVisible({ timeout: 2000 })) {
|
||||
await closeBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Step 3: Switch to CTO
|
||||
console.log('[Demo] Step 3: Switching to CTO...');
|
||||
const ctoBtn = page.locator('button:has-text("CTO")').first();
|
||||
await ctoBtn.scrollIntoViewIfNeeded();
|
||||
await ctoBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Screenshot 4: CTO role active
|
||||
await page.screenshot({ path: 'test-results/phase4-alpha-04-cto-role.png', fullPage: true });
|
||||
|
||||
// Step 4: CTO signs (generates HUMAN event)
|
||||
console.log('[Demo] Step 4: CTO signing...');
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
const approveBtnCTO = page.locator('button:has-text("長按")').first();
|
||||
if (await approveBtnCTO.isVisible({ timeout: 3000 })) {
|
||||
const box = await approveBtnCTO.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(2500);
|
||||
await page.mouse.up();
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Screenshot 5: After CTO signature
|
||||
await page.screenshot({ path: 'test-results/phase4-alpha-05-cto-signed.png', fullPage: true });
|
||||
|
||||
// Final: Full page with timeline
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: 'test-results/phase4-alpha-final.png', fullPage: true });
|
||||
|
||||
console.log('[Demo] Phase 4 Alpha Demo completed!');
|
||||
});
|
||||
107
apps/web/tests/e2e/phase4-timeline.spec.ts
Normal file
107
apps/web/tests/e2e/phase4-timeline.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Phase 4: Action Timeline 完整流程驗證
|
||||
* =====================================
|
||||
* 模擬: AI 提案 ➡️ DevOps 被拒絕 ➡️ CTO 成功批准
|
||||
*/
|
||||
|
||||
test('Phase 4: Full Action Timeline Demo Flow', async ({ page }) => {
|
||||
// Set larger viewport
|
||||
await page.setViewportSize({ width: 1920, height: 1200 });
|
||||
|
||||
// Navigate to demo page
|
||||
await page.goto('http://localhost:3333/zh-TW/demo');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Screenshot 1: Initial state
|
||||
await page.screenshot({ path: 'test-results/phase4-01-initial.png', fullPage: true });
|
||||
|
||||
// Step 1: Trigger a CRITICAL alert (AI Decision) - use Chinese text
|
||||
console.log('Step 1: Triggering CRITICAL alert...');
|
||||
// The button says "+ 嚴重" in Chinese
|
||||
const criticalButton = page.locator('button:has-text("嚴重")').first();
|
||||
await criticalButton.click({ timeout: 5000 });
|
||||
await page.waitForTimeout(4000); // Wait for AI thinking animation
|
||||
|
||||
// Screenshot 2: After AI decision
|
||||
await page.evaluate(() => window.scrollTo(0, 400));
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: 'test-results/phase4-02-ai-decision.png' });
|
||||
|
||||
// Scroll to see approval cards
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Screenshot 3: Approval cards visible
|
||||
await page.screenshot({ path: 'test-results/phase4-03-approval-cards.png' });
|
||||
|
||||
// Step 2: Try to approve as DevOps (should be denied)
|
||||
console.log('Step 2: DevOps attempting to sign CRITICAL...');
|
||||
|
||||
// Look for the hold-to-approve button
|
||||
const approveButton = page.locator('button:has-text("長按批准")').first();
|
||||
|
||||
// Try to click and hold
|
||||
if (await approveButton.isVisible()) {
|
||||
const box = await approveButton.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(2500); // Hold for 2.5 seconds
|
||||
await page.mouse.up();
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Screenshot 4: Access Denied modal (should be visible)
|
||||
await page.screenshot({ path: 'test-results/phase4-04-access-denied.png' });
|
||||
|
||||
// Close the modal if visible
|
||||
const closeButton = page.locator('button:has-text("了解,返回")');
|
||||
if (await closeButton.isVisible()) {
|
||||
await closeButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Step 3: Switch to CTO role
|
||||
console.log('Step 3: Switching to CTO role...');
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const ctoButton = page.locator('button:has-text("CTO")');
|
||||
await ctoButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Screenshot 5: Role switched to CTO
|
||||
await page.screenshot({ path: 'test-results/phase4-05-cto-role.png' });
|
||||
|
||||
// Scroll back to approval cards
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 4: CTO approves (should succeed)
|
||||
console.log('Step 4: CTO signing CRITICAL...');
|
||||
|
||||
const approveButtonCTO = page.locator('button:has-text("長按批准")').first();
|
||||
if (await approveButtonCTO.isVisible()) {
|
||||
const box = await approveButtonCTO.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(2500);
|
||||
await page.mouse.up();
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Screenshot 6: After CTO signature
|
||||
await page.screenshot({ path: 'test-results/phase4-06-cto-signed.png' });
|
||||
|
||||
// Final screenshot with full page showing timeline
|
||||
await page.screenshot({ path: 'test-results/phase4-07-final.png', fullPage: true });
|
||||
|
||||
console.log('Phase 4 demo flow completed!');
|
||||
});
|
||||
27
apps/web/tests/e2e/rbac-screenshot.spec.ts
Normal file
27
apps/web/tests/e2e/rbac-screenshot.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
test('Screenshot RBAC permission UI', async ({ page }) => {
|
||||
// Set larger viewport for better visibility
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
|
||||
await page.goto('http://localhost:3333/zh-TW/demo');
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
// Scroll to show HITL section with approval cards
|
||||
await page.evaluate(() => window.scrollTo(0, 550));
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await page.screenshot({ path: 'test-results/phase3-hitl-section-final.png' });
|
||||
|
||||
// Scroll more to see full approval cards with permission badge
|
||||
await page.evaluate(() => window.scrollTo(0, 750));
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.screenshot({ path: 'test-results/phase3-rbac-permission-badge.png' });
|
||||
|
||||
// Scroll to bottom to see user role display
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.screenshot({ path: 'test-results/phase3-user-role-display.png' });
|
||||
});
|
||||
67
apps/web/tests/e2e/visual-armor-upgrade.spec.ts
Normal file
67
apps/web/tests/e2e/visual-armor-upgrade.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Visual Armor Upgrade - Phase 2.5 視覺裝甲升級驗收
|
||||
* ================================================
|
||||
* 截圖驗證 Magic UI 特效與 shadcn/ui 風格升級
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Visual Armor Upgrade', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to demo page
|
||||
await page.goto('/zh-TW/demo')
|
||||
// Wait for SSE connection
|
||||
await page.waitForTimeout(3000)
|
||||
})
|
||||
|
||||
test('capture full dashboard with Magic UI effects', async ({ page }) => {
|
||||
// Full page screenshot
|
||||
await page.screenshot({
|
||||
path: 'test-results/visual-armor-dashboard.png',
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
// Verify LiveDashboard is rendered
|
||||
const dashboard = page.locator('h2:has-text("即時戰情室")')
|
||||
await expect(dashboard).toBeVisible()
|
||||
})
|
||||
|
||||
test('capture HITL approval cards with Border Beam', async ({ page }) => {
|
||||
// Create a CRITICAL approval for Border Beam effect
|
||||
const criticalBtn = page.locator('button:has-text("CRITICAL")')
|
||||
if (await criticalBtn.isVisible()) {
|
||||
await criticalBtn.click()
|
||||
await page.waitForTimeout(2000)
|
||||
}
|
||||
|
||||
// Wait for cards to render
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Screenshot the approval section
|
||||
const approvalSection = page.locator('section:has(h2:has-text("HITL"))')
|
||||
if (await approvalSection.isVisible()) {
|
||||
await approvalSection.screenshot({
|
||||
path: 'test-results/visual-armor-approvals.png',
|
||||
})
|
||||
}
|
||||
|
||||
// Full page with effects
|
||||
await page.screenshot({
|
||||
path: 'test-results/visual-armor-full.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('verify ClawBot panel with Brain icon', async ({ page }) => {
|
||||
const clawbotPanel = page.locator('h3:has-text("ClawBot")')
|
||||
await expect(clawbotPanel).toBeVisible()
|
||||
|
||||
// Screenshot ClawBot panel
|
||||
const panel = page.locator('.glass-copilot').first()
|
||||
if (await panel.isVisible()) {
|
||||
await panel.screenshot({
|
||||
path: 'test-results/visual-armor-clawbot.png',
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user