1112 lines
48 KiB
TypeScript
1112 lines
48 KiB
TypeScript
/**
|
|
* Waggle OS — Complete E2E Test Suite
|
|
*
|
|
* Covers every major system implemented across M2, M3, and M4 sprints:
|
|
* - API health + core endpoints
|
|
* - Tier enforcement (requireTier middleware)
|
|
* - Stripe integration (checkout session creation)
|
|
* - Marketplace (search, install, sources, sync)
|
|
* - Connectors (logos, categories, setup guides)
|
|
* - Personas (17 personas, suggested skills/connectors/MCP)
|
|
* - Plugin system (CRUD, tool files, hot-reload API)
|
|
* - Hooks API (deny rules CRUD)
|
|
* - Cost routes (tier gate)
|
|
* - Admin overview
|
|
* - Cloud sync toggle
|
|
* - Embedding quota
|
|
* - Skills (list, create, search)
|
|
* - UI journeys (app loads, sidebar, navigation, settings tabs)
|
|
*
|
|
* Run: npx playwright test tests/e2e/waggle-complete.spec.ts --reporter=list
|
|
* Prerequisites: server on :3333, frontend built (npm run build)
|
|
*/
|
|
|
|
import { test, expect, type Page, type APIRequestContext } from '@playwright/test';
|
|
|
|
const API = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
|
|
|
|
// ── Minimal response shapes (API JSON is untyped at the boundary) ──────────
|
|
interface ConnectorShape { id?: string; name?: string }
|
|
interface PersonaShape { id: string }
|
|
interface HookRuleShape { pattern: string }
|
|
interface SkillShape { name?: string }
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
async function waitForApp(page: Page) {
|
|
await page.waitForSelector(
|
|
'.waggle-app-shell, .waggle-sidebar, [role="navigation"], [class*="onboarding"]',
|
|
{ timeout: 15_000 },
|
|
).catch(() => {});
|
|
await page.waitForTimeout(800);
|
|
}
|
|
|
|
async function skipOnboarding(page: Page) {
|
|
await page.evaluate(() => {
|
|
localStorage.setItem('waggle-booted', 'true');
|
|
localStorage.setItem('waggle:onboarding', JSON.stringify({
|
|
completed: true,
|
|
step: 7,
|
|
tier: 'power',
|
|
tooltipsDismissed: true,
|
|
}));
|
|
});
|
|
}
|
|
|
|
async function dismissLoginBriefing(page: Page) {
|
|
const startBtn = page.locator('button', { hasText: 'Start Working' });
|
|
if (await startBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
|
await startBtn.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
}
|
|
|
|
async function navigateTo(page: Page, view: string) {
|
|
// Try dock button by aria-label first (desktop OS paradigm), then sidebar fallback
|
|
const dockBtn = page.locator(`button[aria-label="${view}"]`);
|
|
if (await dockBtn.isVisible().catch(() => false)) {
|
|
await dockBtn.click();
|
|
await page.waitForTimeout(400);
|
|
return;
|
|
}
|
|
// Fallback: sidebar navigation
|
|
const sidebar = page.locator('[role="navigation"]');
|
|
const btn = sidebar.locator('button', { hasText: view });
|
|
if (await btn.isVisible().catch(() => false)) {
|
|
await btn.click();
|
|
await page.waitForTimeout(400);
|
|
return;
|
|
}
|
|
|
|
const routes: Record<string, string> = {
|
|
Chat: '/chat',
|
|
Memory: '/memory',
|
|
Settings: '/settings',
|
|
Cockpit: '/home',
|
|
};
|
|
const route = routes[view];
|
|
if (route) {
|
|
await page.goto(`${route}?skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`, {
|
|
waitUntil: 'domcontentloaded',
|
|
});
|
|
await page.waitForSelector('[role="navigation"], main', { timeout: 10_000 }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 1 — Core API Health
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('1. Core API Health', () => {
|
|
test('1.1 GET /health returns ok with required fields', async ({ request }) => {
|
|
const res = await request.get(`${API}/health`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.status).toBeDefined();
|
|
expect(data.llm).toBeDefined();
|
|
});
|
|
|
|
test('1.2 GET /api/workspaces returns array', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/workspaces`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data)).toBe(true);
|
|
});
|
|
|
|
test('1.3 GET /api/tier returns tier info with teamsServerAvailable', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/tier`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.tier).toBeDefined();
|
|
expect(['TRIAL','FREE','TEAMS','ENTERPRISE']).toContain(data.tier);
|
|
expect(typeof data.teamsServerAvailable).toBe('boolean');
|
|
});
|
|
|
|
test('1.4 GET /api/connectors returns connectors with new metadata fields', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/connectors`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.connectors).toBeDefined();
|
|
expect(Array.isArray(data.connectors)).toBe(true);
|
|
// Verify at least one connector has new metadata fields
|
|
if (data.connectors.length > 0) {
|
|
const c = data.connectors[0];
|
|
expect(c.id).toBeDefined();
|
|
expect(c.name).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('1.5 GET /api/personas returns 17 personas', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/personas`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data.personas)).toBe(true);
|
|
expect(data.personas.length).toBeGreaterThanOrEqual(17);
|
|
});
|
|
|
|
test('1.6 GET /api/skills returns skills list', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/skills`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data.skills)).toBe(true);
|
|
});
|
|
|
|
test('1.7 GET /api/memory/frames returns results', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/memory/frames?limit=5&workspace=default`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.results).toBeDefined();
|
|
expect(Array.isArray(data.results)).toBe(true);
|
|
});
|
|
|
|
test('1.8 GET /api/fleet returns sessions', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/fleet`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.sessions).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 2 — Tier Enforcement
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('2. Tier Enforcement (requireTier middleware)', () => {
|
|
test('2.1 POST /api/personas is free (Solo) — never a tier 403', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/personas`, {
|
|
data: { name: 'Test', description: 'test', systemPrompt: 'test' },
|
|
});
|
|
// Custom personas are a free (Solo) feature — creation is accessible on any tier.
|
|
// 200/201 = created; 400 = validation. A tier 403 must never fire.
|
|
expect([200, 201, 400]).toContain(res.status());
|
|
});
|
|
|
|
test('2.2 POST /api/fleet/spawn is free (agents generate memory) — never a tier 403', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/fleet/spawn`, {
|
|
data: { personaId: 'researcher' },
|
|
});
|
|
// Spawning agents is free for all tiers — no tier gate.
|
|
expect([200, 201, 400, 422]).toContain(res.status());
|
|
});
|
|
|
|
test('2.3 GET /api/costs requires TEAMS', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/costs`);
|
|
if (res.status() === 403) {
|
|
const data = await res.json();
|
|
expect(data.error).toBe('TIER_INSUFFICIENT');
|
|
expect(data.required).toBe('TEAMS');
|
|
} else {
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.today ?? data.allTime).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('2.4 GET /api/cost/by-workspace requires TEAMS', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/cost/by-workspace`);
|
|
if (res.status() === 403) {
|
|
const data = await res.json();
|
|
expect(data.error).toBe('TIER_INSUFFICIENT');
|
|
expect(data.required).toBe('TEAMS');
|
|
} else {
|
|
expect(res.ok()).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('2.5 POST /api/team/connect requires TEAMS', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/team/connect`, {
|
|
data: { teamServerUrl: 'http://test.example.com', token: 'test' },
|
|
});
|
|
if (res.status() === 403) {
|
|
const data = await res.json();
|
|
expect(data.error).toBe('TIER_INSUFFICIENT');
|
|
expect(data.required).toBe('TEAMS');
|
|
} else {
|
|
expect([200, 201, 400, 422]).toContain(res.status());
|
|
}
|
|
});
|
|
|
|
test('2.6 GET /api/admin/overview requires TEAMS', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/admin/overview`);
|
|
if (res.status() === 403) {
|
|
const data = await res.json();
|
|
expect(data.error).toBe('TIER_INSUFFICIENT');
|
|
expect(data.required).toBe('TEAMS');
|
|
} else {
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.workspaces).toBeDefined();
|
|
expect(data.usage).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('2.7 403 response has upgradeUrl', async ({ request }) => {
|
|
// Force a 403 by hitting a TEAMS route without credentials
|
|
const res = await request.get(`${API}/api/cloud-sync/toggle`);
|
|
// This route might not exist as GET — but tier gate should still fire
|
|
// Just verify if we get 403, the shape is correct
|
|
if (res.status() === 403) {
|
|
const data = await res.json();
|
|
expect(data.upgradeUrl).toBeDefined();
|
|
expect(data.upgradeUrl).toMatch(/waggle/);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 3 — Stripe Integration
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('3. Stripe Integration', () => {
|
|
test('3.1 POST /api/stripe/create-checkout-session — TEAMS tier returns URL or 503', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/stripe/create-checkout-session`, {
|
|
data: { tier: 'TEAMS', billingPeriod: 'monthly' },
|
|
});
|
|
// 200 = Stripe configured, returns checkout URL
|
|
// 503 = STRIPE_NOT_CONFIGURED (env not set)
|
|
// 400 = NO_PRICE_CONFIGURED (price env not set)
|
|
expect([200, 400, 503]).toContain(res.status());
|
|
if (res.status() === 200) {
|
|
const data = await res.json();
|
|
expect(data.url).toMatch(/stripe\.com|checkout/);
|
|
}
|
|
if (res.status() === 503) {
|
|
const data = await res.json();
|
|
expect(data.error).toBe('STRIPE_NOT_CONFIGURED');
|
|
}
|
|
});
|
|
|
|
test('3.2 POST /api/stripe/create-checkout-session — FREE tier is invalid', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/stripe/create-checkout-session`, {
|
|
data: { tier: 'FREE' },
|
|
});
|
|
expect([400, 503]).toContain(res.status());
|
|
if (res.status() === 400) {
|
|
const data = await res.json();
|
|
expect(data.error).toMatch(/INVALID_TIER|NO_PRICE/);
|
|
}
|
|
});
|
|
|
|
test('3.3 POST /api/stripe/webhook — invalid signature returns 400', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/stripe/webhook`, {
|
|
data: { type: 'checkout.session.completed' },
|
|
headers: { 'stripe-signature': 'invalid_sig' },
|
|
});
|
|
// 400 = invalid signature (expected when Stripe is configured)
|
|
// 503 = Stripe not configured
|
|
expect([400, 503]).toContain(res.status());
|
|
});
|
|
|
|
test('3.4 POST /api/stripe/create-portal-session is ungated (any authed user)', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/stripe/create-portal-session`, {
|
|
data: {},
|
|
});
|
|
// Ungated (requireTier('FREE') no-op floor) so a legacy sub can self-cancel:
|
|
// without a Stripe customer → 400 NO_STRIPE_CUSTOMER
|
|
// with a Stripe customer → 200
|
|
// Stripe not configured → 503
|
|
// A tier 403 must never fire here.
|
|
expect([200, 400, 503]).toContain(res.status());
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 4 — Marketplace
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('4. Marketplace', () => {
|
|
test('4.1 GET /api/marketplace/search returns packages', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/marketplace/search?query=&limit=10`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.packages).toBeDefined();
|
|
expect(Array.isArray(data.packages)).toBe(true);
|
|
expect(data.total).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
test('4.2 GET /api/marketplace/search with type filter works', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/marketplace/search?type=skill&limit=5`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data.packages)).toBe(true);
|
|
// All returned packages should be skills
|
|
for (const pkg of data.packages) {
|
|
expect(pkg.waggle_install_type).toBe('skill');
|
|
}
|
|
});
|
|
|
|
test('4.3 GET /api/marketplace/packs returns packs', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/marketplace/packs`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data.packs)).toBe(true);
|
|
});
|
|
|
|
test('4.4 GET /api/marketplace/categories returns category taxonomy', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/marketplace/categories`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data.categories)).toBe(true);
|
|
expect(data.categories.length).toBeGreaterThan(10);
|
|
// Each category should have id, name, icon
|
|
const cat = data.categories[0];
|
|
expect(cat.id).toBeDefined();
|
|
expect(cat.name).toBeDefined();
|
|
});
|
|
|
|
test('4.5 GET /api/marketplace/sources returns sources list', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/marketplace/sources`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data.sources)).toBe(true);
|
|
expect(data.total).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('4.6 GET /api/marketplace/installed returns installations', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/marketplace/installed`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data.installations)).toBe(true);
|
|
});
|
|
|
|
test('4.7 POST /api/marketplace/sources with invalid URL returns 400', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/marketplace/sources`, {
|
|
data: { name: 'test', url: 'not-a-url' },
|
|
});
|
|
expect(res.status()).toBe(400);
|
|
});
|
|
|
|
test('4.8 POST /api/marketplace/install is free (Solo) — never a tier 403', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/marketplace/install`, {
|
|
data: { packageId: 9999 },
|
|
});
|
|
// Personal marketplace install is a free (Solo) feature — no tier gate.
|
|
// 404 = package not found; 400 = bad request. A tier 403 must never fire.
|
|
expect([200, 400, 404]).toContain(res.status());
|
|
});
|
|
|
|
test('4.9 POST /api/marketplace/sync returns sync results', async ({ request }) => {
|
|
test.setTimeout(75_000); // sync makes real network calls — can take 30-60s under load
|
|
const res = await request.post(`${API}/api/marketplace/sync`, {
|
|
data: {},
|
|
timeout: 65_000,
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(typeof data.sourcesChecked).toBe('number');
|
|
expect(typeof data.packagesAdded).toBe('number');
|
|
});
|
|
|
|
test('4.10 GET /api/marketplace/security-status returns scanner status', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/marketplace/security-status`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(typeof data.ciscoScannerAvailable).toBe('boolean');
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 5 — Connectors
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('5. Connectors', () => {
|
|
test('5.1 GET /api/connectors returns 29+ connectors', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/connectors`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data.connectors)).toBe(true);
|
|
expect(data.connectors.length).toBeGreaterThanOrEqual(29);
|
|
});
|
|
|
|
test('5.2 Connectors have required fields (id, name, status, authType)', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/connectors`);
|
|
const data = await res.json();
|
|
for (const c of data.connectors.slice(0, 5)) {
|
|
expect(c.id).toBeDefined();
|
|
expect(c.name).toBeDefined();
|
|
expect(c.status).toBeDefined();
|
|
expect(c.authType).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('5.3 Composio connector is present', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/connectors`);
|
|
const data = await res.json();
|
|
const composio = data.connectors.find((c: ConnectorShape) => c.id === 'composio');
|
|
expect(composio).toBeDefined();
|
|
expect(composio.name).toMatch(/composio/i);
|
|
});
|
|
|
|
test('5.4 All connectors have a valid status field', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/connectors`);
|
|
const data = await res.json();
|
|
for (const c of data.connectors) {
|
|
expect(['connected', 'disconnected', 'expired', 'error']).toContain(c.status);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 6 — Personas
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('6. Personas', () => {
|
|
test('6.1 GET /api/personas returns exactly 17 personas', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/personas`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.personas.length).toBeGreaterThanOrEqual(17);
|
|
});
|
|
|
|
test('6.2 New personas exist (general-purpose, planner, verifier, coordinator)', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/personas`);
|
|
const data = await res.json();
|
|
const ids = data.personas.map((p: PersonaShape) => p.id);
|
|
expect(ids).toContain('general-purpose');
|
|
expect(ids).toContain('planner');
|
|
expect(ids).toContain('verifier');
|
|
expect(ids).toContain('coordinator');
|
|
});
|
|
|
|
test('6.3 Read-only personas exist (planner, verifier)', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/personas`);
|
|
const data = await res.json();
|
|
const ids = data.personas.map((p: PersonaShape) => p.id);
|
|
expect(ids).toContain('planner');
|
|
expect(ids).toContain('verifier');
|
|
// isReadOnly is a backend-only field not serialized to API — verify by name presence
|
|
});
|
|
|
|
test('6.4 Personas have failurePatterns (min 3 each)', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/personas`);
|
|
const data = await res.json();
|
|
for (const p of data.personas.slice(0, 5)) {
|
|
if (p.failurePatterns) {
|
|
expect(Array.isArray(p.failurePatterns)).toBe(true);
|
|
expect(p.failurePatterns.length).toBeGreaterThanOrEqual(3);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('6.5 POST /api/personas/generate is free (Solo) — never a tier 403', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/personas/generate`, {
|
|
data: { description: 'A helpful assistant' },
|
|
});
|
|
// AI-generated personas are a free (Solo) feature — no tier gate.
|
|
expect([200, 201, 400, 422]).toContain(res.status());
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 7 — Plugin System
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('7. Plugin System', () => {
|
|
test('7.1 GET /api/plugins returns installed plugins', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/plugins`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data.plugins)).toBe(true);
|
|
expect(typeof data.count).toBe('number');
|
|
expect(typeof data.directory).toBe('string');
|
|
});
|
|
|
|
test('7.2 GET /api/plugins/:name/tools — 404 for non-existent plugin', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/plugins/nonexistent-plugin-xyz/tools`);
|
|
expect(res.status()).toBe(404);
|
|
});
|
|
|
|
test('7.3 Path traversal protection on plugin name', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/plugins/../etc/passwd/tools`);
|
|
expect([400, 404]).toContain(res.status());
|
|
});
|
|
|
|
test('7.4 POST /api/plugins/install with missing sourceDir returns 400', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/plugins/install`, {
|
|
data: {},
|
|
});
|
|
expect(res.status()).toBe(400);
|
|
const data = await res.json();
|
|
expect(data.error).toMatch(/sourceDir|path/i);
|
|
});
|
|
|
|
test('7.5 DELETE /api/plugins/:name — 400 for path traversal attempt', async ({ request }) => {
|
|
const res = await request.delete(`${API}/api/plugins/..%2F..%2Fetc`);
|
|
expect([400, 404]).toContain(res.status());
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 8 — Hooks API
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('8. Hooks API', () => {
|
|
test('8.1 GET /api/hooks returns rules array', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/hooks`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data.rules)).toBe(true);
|
|
expect(typeof data.total).toBe('number');
|
|
});
|
|
|
|
test('8.2 POST /api/hooks — adds a deny rule', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/hooks`, {
|
|
data: { type: 'deny', tools: ['bash'], pattern: 'e2e-test-pattern-xyz' },
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.ok).toBe(true);
|
|
expect(Array.isArray(data.rules)).toBe(true);
|
|
// Find our rule
|
|
const added = data.rules.find((r: HookRuleShape) => r.pattern === 'e2e-test-pattern-xyz');
|
|
expect(added).toBeDefined();
|
|
expect(added.type).toBe('deny');
|
|
expect(added.tools).toContain('bash');
|
|
});
|
|
|
|
test('8.3 POST /api/hooks — missing type returns 400', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/hooks`, {
|
|
data: { tools: ['bash'], pattern: 'test' },
|
|
});
|
|
expect(res.status()).toBe(400);
|
|
});
|
|
|
|
test('8.4 POST /api/hooks — empty tools returns 400', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/hooks`, {
|
|
data: { type: 'deny', tools: [], pattern: 'test' },
|
|
});
|
|
expect(res.status()).toBe(400);
|
|
});
|
|
|
|
test('8.5 DELETE /api/hooks/:index — removes rule', async ({ request }) => {
|
|
// First add a rule
|
|
await request.post(`${API}/api/hooks`, {
|
|
data: { type: 'deny', tools: ['bash'], pattern: 'e2e-delete-test-xyz' },
|
|
});
|
|
|
|
// Get current rules
|
|
const listRes = await request.get(`${API}/api/hooks`);
|
|
const listData = await listRes.json();
|
|
const idx = listData.rules.findIndex((r: HookRuleShape) => r.pattern === 'e2e-delete-test-xyz');
|
|
|
|
if (idx >= 0) {
|
|
const delRes = await request.delete(`${API}/api/hooks/${idx}`);
|
|
expect(delRes.ok()).toBe(true);
|
|
const delData = await delRes.json();
|
|
expect(delData.ok).toBe(true);
|
|
// Rule should be gone
|
|
const notFound = delData.rules.find((r: HookRuleShape) => r.pattern === 'e2e-delete-test-xyz');
|
|
expect(notFound).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
test('8.6 DELETE /api/hooks/:index — out of range returns 404', async ({ request }) => {
|
|
const res = await request.delete(`${API}/api/hooks/9999`);
|
|
expect(res.status()).toBe(404);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 9 — Cost Dashboard
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('9. Cost Dashboard', () => {
|
|
test('9.1 GET /api/costs — accessible on TEAMS+', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/costs`);
|
|
if (res.ok()) {
|
|
const data = await res.json();
|
|
expect(data.today ?? data.allTime).toBeDefined();
|
|
expect(Array.isArray(data.daily)).toBe(true);
|
|
} else {
|
|
expect(res.status()).toBe(403); // below TEAMS
|
|
}
|
|
});
|
|
|
|
test('9.2 GET /api/cost/by-workspace — accessible on TEAMS+', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/cost/by-workspace`);
|
|
if (res.ok()) {
|
|
const data = await res.json();
|
|
expect(Array.isArray(data.workspaces)).toBe(true);
|
|
} else {
|
|
expect(res.status()).toBe(403);
|
|
}
|
|
});
|
|
|
|
test('9.3 GET /api/costs — returns budget info', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/costs`);
|
|
if (res.ok()) {
|
|
const data = await res.json();
|
|
expect(data.budget).toBeDefined();
|
|
} else {
|
|
expect(res.status()).toBe(403);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 10 — Admin + Cloud Sync
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('10. Admin & Cloud Sync', () => {
|
|
test('10.1 GET /api/admin/overview — TEAMS gate respected', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/admin/overview`);
|
|
if (res.ok()) {
|
|
const data = await res.json();
|
|
expect(data.workspaces).toBeDefined();
|
|
expect(Array.isArray(data.workspaces)).toBe(true);
|
|
expect(data.usage).toBeDefined();
|
|
expect(data.connectors).toBeDefined();
|
|
expect(data.plugins).toBeDefined();
|
|
expect(data.generatedAt).toBeDefined();
|
|
} else {
|
|
expect(res.status()).toBe(403);
|
|
}
|
|
});
|
|
|
|
test('10.2 GET /api/cloud-sync returns status', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/cloud-sync`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(typeof data.available).toBe('boolean');
|
|
expect(typeof data.enabled).toBe('boolean');
|
|
expect(data.tier).toBeDefined();
|
|
});
|
|
|
|
test('10.3 POST /api/cloud-sync/toggle requires TEAMS tier', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/cloud-sync/toggle`, {
|
|
data: { enabled: true },
|
|
});
|
|
if (res.status() === 403) {
|
|
const data = await res.json();
|
|
expect(data.error).toBe('TIER_INSUFFICIENT');
|
|
expect(data.required).toBe('TEAMS');
|
|
} else if (res.ok()) {
|
|
const data = await res.json();
|
|
expect(data.ok).toBe(true);
|
|
expect(typeof data.enabled).toBe('boolean');
|
|
}
|
|
});
|
|
|
|
test('10.4 GET /api/admin/audit-export requires TEAMS tier', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/admin/audit-export`);
|
|
if (res.status() === 403) {
|
|
const data = await res.json();
|
|
expect(data.error).toBe('TIER_INSUFFICIENT');
|
|
} else if (res.ok()) {
|
|
const data = await res.json();
|
|
expect(data.records).toBeDefined();
|
|
expect(Array.isArray(data.records)).toBe(true);
|
|
expect(data.exportedAt).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('10.5 GET /api/admin/audit-export?format=csv returns CSV', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/admin/audit-export?format=csv`);
|
|
if (res.ok()) {
|
|
const contentType = res.headers()['content-type'] ?? '';
|
|
expect(contentType).toContain('text/csv');
|
|
} else {
|
|
expect(res.status()).toBe(403);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 11 — Skills CRUD
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('11. Skills CRUD', () => {
|
|
const TEST_SKILL_NAME = `e2e-test-skill-${Date.now()}`;
|
|
|
|
test('11.1 GET /api/skills returns skills array', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/skills`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data.skills)).toBe(true);
|
|
});
|
|
|
|
test('11.2 POST /api/skills creates a new skill', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/skills`, {
|
|
data: {
|
|
name: TEST_SKILL_NAME,
|
|
content: `# ${TEST_SKILL_NAME}\n\nE2E test skill. Safe to delete.`,
|
|
},
|
|
});
|
|
expect([200, 201]).toContain(res.status());
|
|
const data = await res.json();
|
|
expect(data.ok ?? data.name).toBeTruthy();
|
|
});
|
|
|
|
test('11.3 GET /api/skills after create includes new skill', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/skills`);
|
|
const data = await res.json();
|
|
const found = data.skills.find((s: string | SkillShape) => {
|
|
const name = typeof s === 'string' ? s : s.name;
|
|
return name === TEST_SKILL_NAME || JSON.stringify(s).includes(TEST_SKILL_NAME);
|
|
});
|
|
// Note: skill may not be instantly visible depending on directory scan
|
|
expect(Array.isArray(data.skills)).toBe(true);
|
|
});
|
|
|
|
test('11.4 GET /api/skills/:name reads skill content', async ({ request }) => {
|
|
// Create first
|
|
await request.post(`${API}/api/skills`, {
|
|
data: { name: 'e2e-read-test', content: '# Read Test\n\nContent here.' },
|
|
});
|
|
const res = await request.get(`${API}/api/skills/e2e-read-test`);
|
|
if (res.ok()) {
|
|
const data = await res.json();
|
|
expect(data.content ?? data.name).toBeDefined();
|
|
} else {
|
|
expect([404]).toContain(res.status());
|
|
}
|
|
});
|
|
|
|
test('11.5 DELETE /api/skills/:name removes skill', async ({ request }) => {
|
|
await request.post(`${API}/api/skills`, {
|
|
data: { name: 'e2e-delete-skill', content: '# Delete Me' },
|
|
});
|
|
const res = await request.delete(`${API}/api/skills/e2e-delete-skill`);
|
|
expect([200, 204, 404]).toContain(res.status());
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 12 — Memory + Workspaces
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('12. Memory + Workspaces', () => {
|
|
test('12.1 GET /api/workspaces returns array with default workspace', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/workspaces`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(Array.isArray(data)).toBe(true);
|
|
});
|
|
|
|
test('12.2 GET /api/memory/frames returns paginated results', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/memory/frames?limit=10&workspace=default`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.results).toBeDefined();
|
|
expect(Array.isArray(data.results)).toBe(true);
|
|
expect(typeof data.count).toBe('number');
|
|
});
|
|
|
|
test('12.3 GET /api/memory/frames respects limit param', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/memory/frames?limit=3&workspace=default`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.results.length).toBeLessThanOrEqual(3);
|
|
});
|
|
|
|
test('12.4 GET /api/memory/frames with search returns results', async ({ request }) => {
|
|
// Memory search uses GET /api/memory/frames with query params
|
|
const res = await request.get(`${API}/api/memory/frames?limit=5&workspace=default`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.results).toBeDefined();
|
|
expect(Array.isArray(data.results)).toBe(true);
|
|
});
|
|
|
|
test('12.5 workspace context endpoint exists or returns expected status', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/workspaces/default/context`);
|
|
// 404 is acceptable — endpoint may not be implemented yet
|
|
// What matters: no 500 crash
|
|
expect(res.status()).not.toBe(500);
|
|
expect([200, 404, 405, 429]).toContain(res.status());
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 13 — Plugin Tool Files API
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('13. Plugin Tool Files API', () => {
|
|
const PLUGIN_NAME = 'e2e-test-plugin';
|
|
const TOOL_NAME = 'e2e-test-tool';
|
|
|
|
test('13.1 GET /api/plugins/:name/tools — 404 for missing plugin', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/plugins/definitely-not-real-xyz/tools`);
|
|
expect(res.status()).toBe(404);
|
|
});
|
|
|
|
test('13.2 Path traversal blocked on tool name', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/plugins/my-plugin/tools/..%2F..%2Fetc`);
|
|
expect([400, 404]).toContain(res.status());
|
|
});
|
|
|
|
test('13.3 PUT /api/plugins/:name/tools/:toolName — non-existent plugin creates dir or fails gracefully', async ({ request }) => {
|
|
const res = await request.put(`${API}/api/plugins/nonexistent-xyz/tools/my-tool`, {
|
|
data: { content: 'export async function execute(args) { return "ok"; }' },
|
|
});
|
|
// May create the dir (200) or fail (400/404/500) — both are acceptable
|
|
expect([200, 400, 404, 500]).toContain(res.status());
|
|
});
|
|
|
|
test('13.4 PUT /api/plugins/:name/tools/:toolName — missing execute() returns 400', async ({ request }) => {
|
|
// This only validates if plugin exists — skip if not
|
|
const listRes = await request.get(`${API}/api/plugins`);
|
|
const listData = await listRes.json();
|
|
if (listData.plugins.length === 0) {
|
|
// No plugins installed — test validation endpoint directly with bad content
|
|
const res = await request.put(`${API}/api/plugins/any-plugin/tools/my-tool`, {
|
|
data: { content: 'const x = 1; // no execute function' },
|
|
});
|
|
// Should get 400 (missing execute) or 404 (plugin doesn't exist)
|
|
expect([400, 404]).toContain(res.status());
|
|
if (res.status() === 400) {
|
|
const data = await res.json();
|
|
expect(data.error).toMatch(/execute/i);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('13.5 POST /api/plugins/:name/tools — validation: name must be alphanumeric', async ({ request }) => {
|
|
const listRes = await request.get(`${API}/api/plugins`);
|
|
const listData = await listRes.json();
|
|
if (listData.plugins.length > 0) {
|
|
const pluginName = listData.plugins[0].name;
|
|
const res = await request.post(`${API}/api/plugins/${pluginName}/tools`, {
|
|
data: { name: 'invalid name with spaces!', description: 'test' },
|
|
});
|
|
expect(res.status()).toBe(400);
|
|
const data = await res.json();
|
|
expect(data.error).toMatch(/alphanumeric|name/i);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 14 — UI Journey Tests
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('14. UI Journey Tests', () => {
|
|
// These tests require the frontend to be built (dist/) and served by the
|
|
// webServer. Skip only if the server returns JSON on '/' instead of HTML.
|
|
// NOTE: this guard previously probed :8080 (a dev server that never runs in
|
|
// this harness), so the entire UI suite silently skipped. Probe the real
|
|
// :3333 baseURL the webServer actually serves.
|
|
test.beforeEach(async ({ request, baseURL }, testInfo) => {
|
|
const root = baseURL ?? process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
|
|
try {
|
|
const res = await request.get(`${root}/`, { timeout: 5_000 });
|
|
const ct = res.headers()['content-type'] ?? '';
|
|
if (!ct.includes('text/html')) {
|
|
testInfo.skip(true, `Frontend not built (server returns ${ct || 'non-HTML'} on /) — skipping UI tests`);
|
|
}
|
|
} catch {
|
|
testInfo.skip(true, `Frontend not reachable on ${root} — skipping UI tests`);
|
|
}
|
|
});
|
|
test('14.1 App loads without blank screen or JS crash', async ({ page }) => {
|
|
const errors: string[] = [];
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
await page.goto('/');
|
|
await waitForApp(page);
|
|
const bodyText = await page.textContent('body');
|
|
expect(bodyText?.trim().length).toBeGreaterThan(10);
|
|
// No fatal JS errors that break rendering
|
|
const fatalErrors = errors.filter(e =>
|
|
e.includes('Cannot read') || e.includes('is not a function') || e.includes('undefined')
|
|
);
|
|
expect(fatalErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('14.2 App shows onboarding or main shell — no blank state', async ({ page }) => {
|
|
await page.goto('/');
|
|
await waitForApp(page);
|
|
const hasShell = await page.locator('.waggle-app-shell, .waggle-sidebar').isVisible().catch(() => false);
|
|
const hasOnboarding = await page.locator('[class*="onboarding"], text=Welcome, text=Get Started').isVisible().catch(() => false);
|
|
const hasContent = await page.locator('body').textContent();
|
|
expect(hasShell || hasOnboarding || (hasContent?.length ?? 0) > 50).toBe(true);
|
|
});
|
|
|
|
test('14.3 Sidebar navigation items are visible', async ({ page }) => {
|
|
await page.goto('/');
|
|
await skipOnboarding(page);
|
|
await page.reload();
|
|
await waitForApp(page);
|
|
const nav = page.locator('[role="navigation"]');
|
|
const isVisible = await nav.isVisible().catch(() => false);
|
|
if (isVisible) {
|
|
const buttons = nav.locator('button');
|
|
const count = await buttons.count();
|
|
expect(count).toBeGreaterThanOrEqual(5);
|
|
}
|
|
});
|
|
|
|
test('14.4 Settings view loads when clicking Settings', async ({ page }) => {
|
|
await page.goto('/');
|
|
await skipOnboarding(page);
|
|
await page.reload();
|
|
await waitForApp(page);
|
|
await dismissLoginBriefing(page);
|
|
await navigateTo(page, 'Settings');
|
|
// Wait for settings panel to fully render — under load, 500ms is insufficient
|
|
await page.waitForFunction(() =>
|
|
(document.body.textContent?.length ?? 0) > 200,
|
|
{ timeout: 15000 }
|
|
).catch(() => {});
|
|
const bodyText = await page.textContent('body') ?? '';
|
|
expect(
|
|
bodyText.includes('General') || bodyText.includes('Models') ||
|
|
bodyText.includes('Keys') || bodyText.includes('Settings')
|
|
).toBeTruthy();
|
|
});
|
|
|
|
test('14.5 Settings tabs are present (General, Models, Vault, Team)', async ({ page }) => {
|
|
await page.goto('/');
|
|
await skipOnboarding(page);
|
|
await page.reload();
|
|
await waitForApp(page);
|
|
await dismissLoginBriefing(page);
|
|
await navigateTo(page, 'Settings');
|
|
// Wait for tab labels to appear in DOM — not just a fixed timer
|
|
await page.waitForFunction(() => {
|
|
const t = document.body.textContent ?? '';
|
|
return t.includes('General') || t.includes('Models') || t.includes('Keys');
|
|
}, { timeout: 10000 }).catch(() => {});
|
|
const bodyText = await page.textContent('body') ?? '';
|
|
const hasTabs = bodyText.includes('General') || bodyText.includes('Models') || bodyText.includes('Keys');
|
|
expect(hasTabs).toBe(true);
|
|
});
|
|
|
|
test('14.6 Capabilities API is accessible', async ({ request }) => {
|
|
// Skills & Apps is a zone-child in power/admin tiers only, so test the API directly
|
|
const res = await request.get(`${API}/api/skills`);
|
|
expect(res.ok()).toBe(true);
|
|
const data = await res.json();
|
|
expect(data.skills).toBeDefined();
|
|
expect(Array.isArray(data.skills)).toBe(true);
|
|
});
|
|
|
|
test('14.7 Memory view loads', async ({ page }) => {
|
|
await page.goto('/');
|
|
await skipOnboarding(page);
|
|
await page.reload();
|
|
await waitForApp(page);
|
|
await dismissLoginBriefing(page);
|
|
await navigateTo(page, 'Memory');
|
|
await page.waitForTimeout(500);
|
|
const body = await page.textContent('body') ?? '';
|
|
expect(body.length).toBeGreaterThan(50);
|
|
});
|
|
|
|
test('14.8 Chat view shows message input', async ({ page }) => {
|
|
await page.goto('/');
|
|
await skipOnboarding(page);
|
|
await page.reload();
|
|
await waitForApp(page);
|
|
await dismissLoginBriefing(page);
|
|
await navigateTo(page, 'Chat');
|
|
// Wait for chat content to render — textarea OR chat text
|
|
await page.waitForFunction(() => {
|
|
const t = document.body.textContent ?? '';
|
|
return document.querySelector('textarea') !== null ||
|
|
t.includes('Ask') || t.includes('message') || t.includes('workspace');
|
|
}, { timeout: 8000 }).catch(() => {});
|
|
const hasInput = await page.locator('textarea').isVisible().catch(() => false);
|
|
const body = await page.textContent('body') ?? '';
|
|
expect(hasInput || body.includes('Ask') || body.includes('message') || body.includes('workspace')).toBe(true);
|
|
});
|
|
|
|
test('14.9 No 404 errors on static assets', async ({ page }) => {
|
|
const failed: string[] = [];
|
|
page.on('response', res => {
|
|
if (res.status() === 404 && !res.url().includes('/api/')) {
|
|
failed.push(res.url());
|
|
}
|
|
});
|
|
await page.goto('/');
|
|
await waitForApp(page);
|
|
expect(failed).toHaveLength(0);
|
|
});
|
|
|
|
test('14.10 Cockpit view loads without error', async ({ page }) => {
|
|
await page.goto('/');
|
|
await skipOnboarding(page);
|
|
await page.reload();
|
|
await waitForApp(page);
|
|
await navigateTo(page, 'Cockpit');
|
|
await page.waitForTimeout(500);
|
|
const body = await page.textContent('body') ?? '';
|
|
expect(body.length).toBeGreaterThan(50);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// SECTION 15 — Security
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
test.describe('15. Security', () => {
|
|
test('15.1 Path traversal blocked on skills', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/skills/..%2F..%2F..%2Fetc%2Fpasswd`);
|
|
expect([400, 404]).toContain(res.status());
|
|
if (res.ok()) {
|
|
const body = await res.text();
|
|
expect(body).not.toContain('root:');
|
|
}
|
|
});
|
|
|
|
test('15.2 Path traversal blocked on plugin names', async ({ request }) => {
|
|
const attacks = [
|
|
'../../../etc/passwd',
|
|
'..\\..\\windows\\system32',
|
|
'%2e%2e%2f%2e%2e%2f',
|
|
];
|
|
for (const attack of attacks) {
|
|
const res = await request.get(`${API}/api/plugins/${encodeURIComponent(attack)}/tools`);
|
|
expect([400, 404]).toContain(res.status());
|
|
}
|
|
});
|
|
|
|
test('15.3 SQL injection attempt in memory frames returns safe response', async ({ request }) => {
|
|
// Use GET endpoint (POST /api/memory/search doesn't exist)
|
|
const injection = encodeURIComponent("'; DROP TABLE memory_frames; --");
|
|
const res = await request.get(`${API}/api/memory/frames?limit=5&workspace=${injection}`);
|
|
// Should succeed safely or return error — never execute injection
|
|
expect([200, 400, 404]).toContain(res.status());
|
|
if (res.ok()) {
|
|
const data = await res.json();
|
|
expect(data.results).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('15.4 Large payload rejected gracefully', async ({ request }) => {
|
|
const bigPayload = 'x'.repeat(10 * 1024 * 1024); // 10MB
|
|
try {
|
|
const res = await request.post(`${API}/api/chat`, {
|
|
data: { message: bigPayload, workspace: 'default' },
|
|
timeout: 10000,
|
|
});
|
|
// Server should reject or handle without crashing
|
|
expect([200, 400, 413, 500]).toContain(res.status());
|
|
} catch {
|
|
// ECONNRESET is acceptable — server dropped the oversized connection
|
|
}
|
|
});
|
|
|
|
test('15.5 Unknown API routes return 404, not 500', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/definitely-does-not-exist-xyz-abc`);
|
|
expect(res.status()).toBe(404);
|
|
});
|
|
|
|
test('15.6 Stripe webhook rejects requests without signature', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/stripe/webhook`, {
|
|
data: { type: 'checkout.session.completed', data: { object: {} } },
|
|
});
|
|
// No stripe-signature header → should reject
|
|
expect([400, 503]).toContain(res.status());
|
|
});
|
|
|
|
test('15.7 Admin audit export path traversal blocked', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/admin/audit-export?from=../../../etc&to=passwd`);
|
|
// Should either succeed safely or return 403 (tier gate)
|
|
expect([200, 400, 403]).toContain(res.status());
|
|
if (res.ok()) {
|
|
const body = await res.text();
|
|
expect(body).not.toContain('root:');
|
|
}
|
|
});
|
|
});
|