Several desktop paths quietly substituted defaults or partial results: a config write could fall back to a throwaway in-memory store, speech provider errors were absorbed into empty transcriptions, and meeting exports built file names from raw titles. Writes now fail explicitly when the store is unavailable, provider and model failures reach the UI as errors, and export names pass through one sanitizer. Settings, license, ad, and support surfaces use the shared theme tokens, unused hotkey helpers are gone, and the package gains strict node/renderer typecheck configs plus red-team e2e scenarios for these flows.
265 lines
9 KiB
TypeScript
265 lines
9 KiB
TypeScript
import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
|
|
const SCREENSHOT_DIR = path.resolve('C:/Users/encep/.gemini/antigravity/brain/bbff18a3-721d-4c43-989f-1d6964e15be7/screenshots');
|
|
|
|
test.describe.serial('Extreme Red Team - Cycle 5: Rapid Route Thrashing, Onboarding Wizard & Input Fuzzing', () => {
|
|
let app: ElectronApplication;
|
|
let window: Page;
|
|
let testUserDataDir: string;
|
|
let uncaughtExceptions: string[] = [];
|
|
|
|
test.beforeAll(async () => {
|
|
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
|
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
}
|
|
|
|
testUserDataDir = path.join(
|
|
'C:/Users/encep/AppData/Local/Temp',
|
|
'playwright-redteam-cycle5-' + Date.now()
|
|
);
|
|
|
|
app = await electron.launch({
|
|
args: [
|
|
'out/main/index.js',
|
|
'--disable-gpu',
|
|
'--no-sandbox',
|
|
`--user-data-dir=${testUserDataDir}`,
|
|
],
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: 'test',
|
|
},
|
|
});
|
|
|
|
// Find main window
|
|
for (let i = 0; i < 50; i++) {
|
|
for (const w of app.windows()) {
|
|
try {
|
|
const url = w.url();
|
|
if (url && url.includes('index.html') && !url.includes('popups/')) {
|
|
window = w;
|
|
break;
|
|
}
|
|
} catch {
|
|
// window navigating
|
|
}
|
|
}
|
|
if (window) break;
|
|
await new Promise((r) => setTimeout(r, 400));
|
|
}
|
|
|
|
if (!window) {
|
|
window = app.windows()[0] || (await app.firstWindow());
|
|
}
|
|
|
|
window.on('pageerror', (err) => {
|
|
console.error('[PAGEERROR in Electron Window]:', err.message);
|
|
uncaughtExceptions.push(err.message);
|
|
});
|
|
|
|
await window.waitForLoadState('domcontentloaded');
|
|
|
|
// Bypass Onboarding modal initially
|
|
await window.evaluate(async () => {
|
|
if (window.electronAPI?.config) {
|
|
await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true });
|
|
await window.electronAPI.config.set({ key: 'appUsageMode', value: 'online' });
|
|
await window.electronAPI.config.set({ key: 'llmBackend', value: 'online' });
|
|
}
|
|
});
|
|
|
|
await window.waitForTimeout(500);
|
|
await window.reload();
|
|
await window.waitForLoadState('domcontentloaded');
|
|
await window.waitForTimeout(1000);
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
if (app) {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
test.beforeEach(() => {
|
|
uncaughtExceptions = [];
|
|
});
|
|
|
|
test('RT-16: Rapid Route Thrashing & Concurrency Stress Test', async () => {
|
|
// 7 navigation routes in AppLayout
|
|
const routes = [
|
|
'대시보드',
|
|
'기록',
|
|
'단어장',
|
|
'명령어',
|
|
'음성 대화',
|
|
'지식 베이스',
|
|
'회의록'
|
|
];
|
|
|
|
// Rapid thrashing loop - 10 full passes at high speed
|
|
for (let loop = 0; loop < 5; loop++) {
|
|
for (const routeName of routes) {
|
|
const navButton = window.getByText(routeName, { exact: true }).first();
|
|
if (await navButton.isVisible()) {
|
|
await navButton.click();
|
|
// Hyper rapid 40ms interval
|
|
await window.waitForTimeout(40);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Settle back to dashboard
|
|
const dashboardNav = window.getByText('대시보드', { exact: true }).first();
|
|
await dashboardNav.click();
|
|
await window.waitForTimeout(500);
|
|
|
|
// Verify system survived rapid thrashing and dashboard is completely healthy
|
|
await expect(window.getByText(/총 발화|오늘 사용량|음성 인식|대시보드/i).first()).toBeVisible({ timeout: 5000 });
|
|
|
|
await window.screenshot({
|
|
path: path.join(SCREENSHOT_DIR, 'rt16_route_thrashing_settled.png'),
|
|
});
|
|
|
|
expect(uncaughtExceptions).toEqual([]);
|
|
});
|
|
|
|
test('RT-17: Onboarding Modal & First-Run Wizard Headful Execution', async () => {
|
|
// 1. Reset onboarding to false and reload to trigger OnboardingModal
|
|
await window.evaluate(async () => {
|
|
if (window.electronAPI?.config) {
|
|
await window.electronAPI.config.set({ key: 'onboardingCompleted', value: false });
|
|
}
|
|
});
|
|
|
|
await window.reload();
|
|
await window.waitForLoadState('domcontentloaded');
|
|
await window.waitForTimeout(1000);
|
|
|
|
// 2. Verify OnboardingModal is visible
|
|
await expect(window.getByText(/D3RO Voice 시작 가이드/i)).toBeVisible({ timeout: 10000 });
|
|
await expect(window.getByText(/로컬 AI 모드/i).first()).toBeVisible();
|
|
await expect(window.getByText(/온라인 클라우드 모드/i).first()).toBeVisible();
|
|
|
|
await window.screenshot({
|
|
path: path.join(SCREENSHOT_DIR, 'rt17_01_onboarding_select_mode.png'),
|
|
});
|
|
|
|
// 3. Test Local Mode Selection
|
|
await window.getByRole('button', { name: /로컬 AI 모드로 시작/i }).click();
|
|
await window.waitForTimeout(600);
|
|
|
|
// Verify transitions to local_ollama_setup phase
|
|
await expect(window.getByText(/Ollama 로컬 엔진/i).first()).toBeVisible({ timeout: 5000 });
|
|
|
|
await window.screenshot({
|
|
path: path.join(SCREENSHOT_DIR, 'rt17_02_onboarding_local_setup.png'),
|
|
});
|
|
|
|
// Test back button
|
|
const backBtn = window.getByText('뒤로 가기', { exact: true });
|
|
await expect(backBtn).toBeVisible();
|
|
await backBtn.click();
|
|
await window.waitForTimeout(500);
|
|
|
|
// Verify back to select_mode
|
|
await expect(window.getByText(/로컬 AI 모드/i).first()).toBeVisible();
|
|
|
|
// 4. Test Online Cloud Mode Selection
|
|
await window.getByRole('button', { name: /온라인 계정 로그인/i }).click();
|
|
await window.waitForTimeout(600);
|
|
|
|
// Verify transitions to online_auth phase
|
|
await expect(window.getByText(/온라인 서비스를 이용하기 위해/i).first()).toBeVisible({ timeout: 5000 });
|
|
|
|
await window.screenshot({
|
|
path: path.join(SCREENSHOT_DIR, 'rt17_03_onboarding_online_auth.png'),
|
|
});
|
|
|
|
// Go back again
|
|
await window.getByText('뒤로 가기', { exact: true }).click();
|
|
await window.waitForTimeout(500);
|
|
|
|
// 5. Restore onboardingCompleted
|
|
await window.evaluate(async () => {
|
|
await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true });
|
|
});
|
|
|
|
await window.reload();
|
|
await window.waitForLoadState('domcontentloaded');
|
|
await window.waitForTimeout(1000);
|
|
expect(uncaughtExceptions).toEqual([]);
|
|
});
|
|
|
|
test('RT-18: Adversarial Input Fuzzing, Boundary Conditions & SQL/XSS Injection Defense', async () => {
|
|
// 1. Dictionary Fuzzing
|
|
const dictNav = window.locator('text=/^(Dictionary|사전|단어장)$/').first();
|
|
await dictNav.click();
|
|
await window.waitForTimeout(600);
|
|
|
|
const sqlXssPayload = '\' OR \'1\'=\'1\'; <script>alert("XSS")</script>';
|
|
const safeReplacement = 'FuzzReplacement';
|
|
|
|
// Click 추가 button
|
|
const addWordBtn = window.locator('button:has-text("추가"), button:has-text("Add")').first();
|
|
await addWordBtn.click();
|
|
await window.waitForTimeout(400);
|
|
|
|
// Fill inputs in dialog
|
|
const wordInput = window.locator('div[role="dialog"] input').first();
|
|
const pronInput = window.locator('div[role="dialog"] input').nth(1);
|
|
await wordInput.fill(sqlXssPayload);
|
|
await pronInput.fill(safeReplacement);
|
|
|
|
// Save
|
|
const saveBtn = window.locator('div[role="dialog"] button:has-text("저장"), div[role="dialog"] button:has-text("Save")').first();
|
|
await saveBtn.click();
|
|
await window.waitForTimeout(600);
|
|
|
|
// Verify word was stored safely without executing XSS or corrupting SQLite
|
|
await expect(window.getByText(sqlXssPayload).first()).toBeVisible({ timeout: 5000 });
|
|
|
|
await window.screenshot({
|
|
path: path.join(SCREENSHOT_DIR, 'rt18_01_dictionary_fuzzing_saved.png'),
|
|
});
|
|
|
|
// Clean up: delete the fuzzed word
|
|
const deleteBtn = window.locator('button[aria-label*="삭제"], button:has(svg.lucide-trash-2)').first();
|
|
if (await deleteBtn.isVisible()) {
|
|
await deleteBtn.click();
|
|
await window.waitForTimeout(400);
|
|
// Confirm dialog if any
|
|
const confirmBtn = window.locator('div[role="dialog"] button:has-text("삭제"), div[role="dialog"] button:has-text("Delete")').first();
|
|
if (await confirmBtn.isVisible()) {
|
|
await confirmBtn.click();
|
|
await window.waitForTimeout(400);
|
|
}
|
|
}
|
|
|
|
// 2. Knowledge Base Query Fuzzing (Regex Break Characters)
|
|
const kbNav = window.locator('text=/^(Knowledge|지식 베이스)$/').first();
|
|
await kbNav.click();
|
|
await window.waitForTimeout(600);
|
|
|
|
const regexBreakQuery = '[([{\\\\^$|?*+';
|
|
const kbSearchInput = window.locator('input[placeholder*="질문"], input[placeholder*="검색"]').first();
|
|
if (await kbSearchInput.isVisible()) {
|
|
await kbSearchInput.fill(regexBreakQuery);
|
|
await window.keyboard.press('Enter');
|
|
await window.waitForTimeout(800);
|
|
}
|
|
|
|
await window.screenshot({
|
|
path: path.join(SCREENSHOT_DIR, 'rt18_02_knowledge_fuzzing_query.png'),
|
|
});
|
|
|
|
// Settle back to dashboard
|
|
const dashboardNav = window.locator('text=/^(Dashboard|대시보드)$/').first();
|
|
await dashboardNav.click();
|
|
await window.waitForTimeout(500);
|
|
|
|
expect(uncaughtExceptions).toEqual([]);
|
|
});
|
|
});
|
|
|