security: pre-release hardening, automated testing, and production readiness

SECURITY (critical fixes):
- Add authMiddleware to /api/system/console-token (was publicly accessible)
- Validate api_url on node create/update to prevent SSRF (rejects localhost/loopback)
- Add rate limiting (5 req/15 min/IP) to /api/auth/login and /api/auth/setup
- Fix path traversal in env_file resolution — absolute/escaping paths rejected
- Add stack name validation to GET routes (was only on PUT/POST)
- Add helmet security headers middleware
- Restrict CORS to FRONTEND_URL in production

PRODUCTION READINESS:
- Add GET /api/health public endpoint + HEALTHCHECK in Dockerfile
- Add SIGTERM/SIGINT graceful shutdown handler (drains connections, closes DB)
- Run container as non-root sencho user in Dockerfile

QUALITY:
- Fix 4 silent empty catch{} blocks in EditorLayout (now show toast.error)
- Connect ErrorBoundary to root App in main.tsx
- Replace WebSocket.Server with named WebSocketServer import (ESM compat)

TESTING (new automated test suite):
- Install Vitest; 38 backend tests across 4 suites covering validation utilities,
  health endpoint, auth middleware, login flows, SSRF protection, and path traversal
- Extract isValidStackName/isValidRemoteUrl/isPathWithinBase to utils/validation.ts
- Playwright E2E scaffolding: auth, stacks, nodes specs + shared login helper
- CI: run Vitest + ESLint on every PR
This commit is contained in:
SaelixCode
2026-03-21 21:59:44 -04:00
parent 94d6c8fc0f
commit ce50db0fde
22 changed files with 2445 additions and 30 deletions
+8
View File
@@ -37,3 +37,11 @@ jobs:
- name: Build Frontend (Vite/React)
working-directory: ./frontend
run: npm run build
- name: Run Backend Unit Tests (Vitest)
working-directory: ./backend
run: npm test
- name: Lint Frontend (ESLint)
working-directory: ./frontend
run: npm run lint
+24
View File
@@ -5,6 +5,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Security
- **Fixed:** `POST /api/system/console-token` was missing `authMiddleware` — any unauthenticated client could generate console session tokens. Fixed by adding `authMiddleware` to the route.
- **Fixed:** Remote node `api_url` was accepted without validation — an attacker could set it to `http://localhost:6379` to SSRF into internal services. Now validates: must be a well-formed `http://` or `https://` URL, and the hostname may not be `localhost`, `127.x.x.x`, `[::1]`, or `0.0.0.0`.
- **Fixed:** `env_file` paths in compose.yaml were accepted without boundary checking — absolute paths like `/etc/passwd` could be read/written. All resolved env file paths are now validated to stay within the stack directory.
- **Fixed:** Stack name was validated in write routes but not in GET routes (`/api/stacks/:stackName`, `/api/stacks/:stackName/env`, `/api/stacks/:stackName/envs`) — path-traversal names now return 400 on all routes.
- **Added:** Rate limiting on `/api/auth/login` and `/api/auth/setup` — 5 attempts per 15-minute window per IP, using `express-rate-limit`.
- **Added:** `helmet` middleware for security response headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, etc.).
- **Changed:** CORS is now restricted to `FRONTEND_URL` env var in production; development continues to allow any origin.
### Added
- **Added:** `GET /api/health` public endpoint — returns `{ status: "ok", uptime }`. Used by Docker `HEALTHCHECK` directive and external uptime monitors.
- **Added:** `HEALTHCHECK` directive in `Dockerfile` — Docker now polls `/api/health` every 30 s and restarts an unhealthy container.
- **Added:** Graceful shutdown — backend now listens for `SIGTERM`/`SIGINT`, drains HTTP connections, stops `MonitorService` and `ImageUpdateService`, and closes the SQLite connection before exiting. Prevents data loss when the container is stopped.
- **Added:** Automated backend test suite with Vitest — 38 tests covering validation utilities, health endpoint, authentication flows, auth middleware enforcement, console-token security fix, and SSRF validation on node URLs. Run with `cd backend && npm test`.
- **Added:** Playwright E2E test scaffolding (`e2e/`) — auth, stack management, and node management spec files with shared login helper. Run with `npm run test:e2e` from the repo root.
- **Added:** CI workflow now runs Vitest unit tests and ESLint on every PR.
- **Added:** Dockerfile now creates a non-root `sencho` system user and runs the process as that user instead of root.
- **Added:** `isValidStackName`, `isValidRemoteUrl`, `isPathWithinBase` extracted to `backend/src/utils/validation.ts` for reuse and testability.
### Fixed
- **Fixed:** Four empty `catch {}` blocks in `EditorLayout` (mark-all-read, delete notification, clear-all notifications, image update fetch) now surface errors via `toast.error()` instead of silently swallowing them.
- **Fixed:** `ErrorBoundary` component existed but was not connected — it now wraps the root `<App />` in `main.tsx`, catching crashes in any context provider or route component.
- **Fixed:** `WebSocket.Server` replaced with named import `WebSocketServer` from `ws` to fix ESM/CJS interop in test environments.
- **Added:** Cross-node notification aggregation — the notification bell now surfaces alerts from all connected remote nodes, not just the local instance. On mount and whenever the node list changes, `EditorLayout` fetches notification history from every registered node in parallel (using `fetchForNode` with targeted `x-node-id` headers). Each remote node also gets a dedicated real-time WebSocket connection (`/ws/notifications?nodeId=`) so alerts push instantly as they fire. Remote-sourced notifications display a node-name badge for quick identification. Mark-as-read, delete, and clear-all actions are routed to the correct node. The backend WS upgrade handler was updated to allow `/ws/notifications?nodeId=<remoteId>` to fall through to the existing proxy path (bare `/ws/notifications` with no nodeId continues to connect locally as before).
- **Fixed:** Remote node host console and container exec WebSocket connections now succeed — the gateway exchanges the long-lived `node_proxy` api_token for a short-lived `console_session` JWT (60 s TTL) via a new `POST /api/system/console-token` endpoint before forwarding the WS upgrade to the remote. Previously the remote's `isProxyToken` guard correctly blocked `node_proxy` tokens from interactive terminals, which also blocked legitimate user-initiated console sessions routed through the gateway.
- **Fixed:** `StackAlertSheet` now fetches notification agent status from the active node on open and displays a contextual banner: green checkmark with active channel names when agents are enabled, amber warning with a link to Settings → Notifications when none are configured, and a blue info callout on remote nodes explaining that alerts are evaluated and dispatched by that remote Sencho instance.
+13
View File
@@ -58,8 +58,21 @@ COPY --from=frontend-builder /app/frontend/dist ./public
# Set environment to production
ENV NODE_ENV=production
# Create a non-root user and ensure the data/compose directories are writable.
# The actual volume paths are mounted at runtime, so we only pre-create the
# default data dir here; the compose dir is user-supplied via COMPOSE_DIR.
RUN addgroup -S sencho && adduser -S -G sencho sencho \
&& mkdir -p /app/data \
&& chown -R sencho:sencho /app
USER sencho
# Expose port
EXPOSE 3000
# Health check — polls the public /api/health endpoint every 30s
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "const h=require('http');h.get('http://localhost:3000/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
# Start the server
CMD ["node", "dist/index.js"]
+1452 -1
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -7,7 +7,7 @@
"build": "tsc",
"start": "node dist/index.js",
"dev": "nodemon --watch src --ext ts,json --exec ts-node src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "vitest run"
},
"keywords": [],
"author": "",
@@ -20,10 +20,13 @@
"@types/http-proxy-middleware": "^0.19.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0",
"@types/supertest": "^7.2.0",
"@types/yaml": "^1.9.6",
"nodemon": "^3.1.13",
"supertest": "^7.2.2",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.1.0"
},
"dependencies": {
"@types/cors": "^2.8.19",
@@ -39,6 +42,8 @@
"cors": "^2.8.6",
"dockerode": "^4.0.9",
"express": "^5.2.1",
"express-rate-limit": "^8.3.1",
"helmet": "^8.1.0",
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^3.0.5",
"jsonwebtoken": "^9.0.3",
+112
View File
@@ -0,0 +1,112 @@
/**
* Tests for authentication: login, rate limiting, and auth middleware.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
// ─── Login ───────────────────────────────────────────────────────────────────
describe('POST /api/auth/login', () => {
it('returns 200 and sets a cookie on valid credentials', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.headers['set-cookie']).toBeDefined();
});
it('returns 401 on wrong password', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: TEST_USERNAME, password: 'wrong-password' });
expect(res.status).toBe(401);
});
it('returns 401 on unknown username', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'nobody', password: 'anything' });
expect(res.status).toBe(401);
});
it('returns 400 when credentials are missing', async () => {
const res = await request(app).post('/api/auth/login').send({});
expect(res.status).toBe(400);
});
});
// ─── Auth middleware ──────────────────────────────────────────────────────────
describe('authMiddleware', () => {
it('rejects requests with no token (401)', async () => {
const res = await request(app).get('/api/stacks');
expect(res.status).toBe(401);
});
it('rejects requests with an invalid token (401)', async () => {
const res = await request(app)
.get('/api/stacks')
.set('Authorization', 'Bearer this.is.not.valid');
expect(res.status).toBe(401);
});
it('accepts a valid Bearer token', async () => {
// Issue a real token using the known test secret
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app)
.get('/api/stacks')
.set('Authorization', `Bearer ${token}`);
// Will succeed (200) or fail with a docker/fs error (500) — but NOT 401
expect(res.status).not.toBe(401);
});
it('accepts a valid cookie token', async () => {
// First login to get the cookie
const loginRes = await request(app)
.post('/api/auth/login')
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
const cookies = loginRes.headers['set-cookie'] as string | string[];
const cookieHeader = Array.isArray(cookies) ? cookies[0] : cookies;
const res = await request(app)
.get('/api/stacks')
.set('Cookie', cookieHeader);
expect(res.status).not.toBe(401);
});
});
// ─── Protected endpoint: console-token ───────────────────────────────────────
describe('POST /api/system/console-token', () => {
it('returns 401 without authentication (was a security bug — C1 fix)', async () => {
const res = await request(app).post('/api/system/console-token');
expect(res.status).toBe(401);
});
it('returns a token when authenticated', async () => {
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app)
.post('/api/system/console-token')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Tests for the public /api/health endpoint.
* This endpoint must be reachable without authentication.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
beforeAll(async () => {
// setupTestDb must run before any app import so DATA_DIR is set first
tmpDir = await setupTestDb();
({ app } = await import('../index'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('GET /api/health', () => {
it('returns 200 with status ok', async () => {
const res = await request(app).get('/api/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
it('returns uptime as a number', async () => {
const res = await request(app).get('/api/health');
expect(typeof res.body.uptime).toBe('number');
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
});
it('does not require an auth token', async () => {
// No cookie, no Authorization header — must still return 200
const res = await request(app).get('/api/health');
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
});
@@ -0,0 +1,46 @@
/**
* Test DB helper — creates a temporary SQLite database, seeds it with a known
* admin credential, and sets process.env so DatabaseService uses it.
*
* Call this at the top of every test file *before* importing the app,
* because DatabaseService initialises its path on first getInstance() call.
*/
import os from 'os';
import path from 'path';
import fs from 'fs';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
export const TEST_USERNAME = 'testadmin';
export const TEST_PASSWORD = 'testpassword123';
export let TEST_JWT_SECRET = '';
export async function setupTestDb(): Promise<string> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-test-'));
process.env.DATA_DIR = tmpDir;
// Also point COMPOSE_DIR to a temp dir so FileSystemService doesn't fail on missing dir
const composeDir = path.join(tmpDir, 'compose');
fs.mkdirSync(composeDir, { recursive: true });
process.env.COMPOSE_DIR = composeDir;
// Initialise the DB (singleton will use DATA_DIR we just set)
const { DatabaseService } = await import('../../services/DatabaseService');
const db = DatabaseService.getInstance();
// Seed admin credentials
const passwordHash = await bcrypt.hash(TEST_PASSWORD, 1); // cost=1 for speed in tests
TEST_JWT_SECRET = crypto.randomBytes(32).toString('hex');
db.updateGlobalSetting('auth_username', TEST_USERNAME);
db.updateGlobalSetting('auth_password_hash', passwordHash);
db.updateGlobalSetting('auth_jwt_secret', TEST_JWT_SECRET);
return tmpDir;
}
export function cleanupTestDb(tmpDir: string): void {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Tests for node management API — focusing on api_url validation (SSRF fix C2).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
authHeader = `Bearer ${token}`;
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('POST /api/nodes — api_url SSRF validation (C2 fix)', () => {
it('rejects localhost api_url', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', authHeader)
.send({ name: 'bad-node', type: 'remote', api_url: 'http://localhost:6379' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/loopback/i);
});
it('rejects 127.0.0.1 api_url', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', authHeader)
.send({ name: 'bad-node-2', type: 'remote', api_url: 'http://127.0.0.1:5432' });
expect(res.status).toBe(400);
});
it('rejects non-http scheme', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', authHeader)
.send({ name: 'bad-node-3', type: 'remote', api_url: 'ftp://example.com' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/http/i);
});
it('rejects malformed URL', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', authHeader)
.send({ name: 'bad-node-4', type: 'remote', api_url: 'not-a-url' });
expect(res.status).toBe(400);
});
it('accepts valid LAN IP', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', authHeader)
.send({
name: 'lan-node',
type: 'remote',
api_url: 'http://192.168.1.50:3000',
api_token: 'sometoken',
});
// Should succeed (201 or 200) — not a validation error
expect(res.status).not.toBe(400);
});
it('requires api_url for remote nodes', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', authHeader)
.send({ name: 'missing-url', type: 'remote' });
expect(res.status).toBe(400);
});
});
describe('Stack name validation on GET routes (H3 fix)', () => {
it('rejects path traversal in GET /api/stacks/:stackName', async () => {
const res = await request(app)
.get('/api/stacks/..%2F..%2Fetc%2Fpasswd')
.set('Authorization', authHeader);
expect(res.status).toBe(400);
});
it('rejects dots in stack name', async () => {
const res = await request(app)
.get('/api/stacks/.hidden')
.set('Authorization', authHeader);
expect(res.status).toBe(400);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from '../utils/validation';
// ─── isValidStackName ────────────────────────────────────────────────────────
describe('isValidStackName', () => {
it('accepts alphanumeric names', () => {
expect(isValidStackName('mystack')).toBe(true);
expect(isValidStackName('MyStack123')).toBe(true);
});
it('accepts hyphens and underscores', () => {
expect(isValidStackName('my-stack')).toBe(true);
expect(isValidStackName('my_stack')).toBe(true);
});
it('rejects path separators', () => {
expect(isValidStackName('../etc')).toBe(false);
expect(isValidStackName('foo/bar')).toBe(false);
expect(isValidStackName('foo\\bar')).toBe(false);
});
it('rejects dots', () => {
expect(isValidStackName('.hidden')).toBe(false);
expect(isValidStackName('foo.bar')).toBe(false);
});
it('rejects spaces and special characters', () => {
expect(isValidStackName('my stack')).toBe(false);
expect(isValidStackName('foo;rm -rf /')).toBe(false);
expect(isValidStackName('')).toBe(false);
});
});
// ─── isValidRemoteUrl ────────────────────────────────────────────────────────
describe('isValidRemoteUrl', () => {
it('accepts valid http URLs', () => {
const result = isValidRemoteUrl('http://192.168.1.10:3000');
expect(result.valid).toBe(true);
});
it('accepts valid https URLs', () => {
const result = isValidRemoteUrl('https://sencho.example.com');
expect(result.valid).toBe(true);
});
it('rejects malformed URLs', () => {
const result = isValidRemoteUrl('not-a-url');
expect(result.valid).toBe(false);
});
it('rejects non-http schemes', () => {
expect(isValidRemoteUrl('ftp://example.com').valid).toBe(false);
expect(isValidRemoteUrl('file:///etc/passwd').valid).toBe(false);
expect(isValidRemoteUrl('javascript:alert(1)').valid).toBe(false);
});
it('rejects localhost', () => {
expect(isValidRemoteUrl('http://localhost:3000').valid).toBe(false);
expect(isValidRemoteUrl('http://LOCALHOST:3000').valid).toBe(false);
});
it('rejects loopback IPs', () => {
expect(isValidRemoteUrl('http://127.0.0.1:3000').valid).toBe(false);
expect(isValidRemoteUrl('http://127.1.2.3').valid).toBe(false);
// Node.js URL.hostname preserves brackets: new URL('http://[::1]').hostname === '[::1]'
expect(isValidRemoteUrl('http://[::1]:3000').valid).toBe(false);
});
it('rejects 0.0.0.0', () => {
expect(isValidRemoteUrl('http://0.0.0.0:3000').valid).toBe(false);
});
it('allows LAN/private IPs (users need these for local network nodes)', () => {
// Users legitimately run Sencho nodes on their LAN
expect(isValidRemoteUrl('http://192.168.1.100:3000').valid).toBe(true);
expect(isValidRemoteUrl('http://10.0.0.5:3000').valid).toBe(true);
});
});
// ─── isPathWithinBase ────────────────────────────────────────────────────────
describe('isPathWithinBase', () => {
it('accepts paths within the base directory', () => {
expect(isPathWithinBase('/app/compose/mystack/.env', '/app/compose/mystack')).toBe(true);
});
it('accepts the base directory itself', () => {
expect(isPathWithinBase('/app/compose/mystack', '/app/compose/mystack')).toBe(true);
});
it('rejects paths that escape via ..', () => {
expect(isPathWithinBase('/app/compose/mystack/../../../etc/passwd', '/app/compose/mystack')).toBe(false);
});
it('rejects sibling directories', () => {
expect(isPathWithinBase('/app/compose/other-stack/.env', '/app/compose/mystack')).toBe(false);
});
});
+109 -22
View File
@@ -1,7 +1,9 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import WebSocket from 'ws';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import WebSocket, { WebSocketServer } from 'ws';
import jwt from 'jsonwebtoken';
import DockerController, { globalDockerNetwork } from './services/DockerController';
import { FileSystemService } from './services/FileSystemService';
@@ -25,6 +27,7 @@ import { ImageUpdateService } from './services/ImageUpdateService';
import { templateService } from './services/TemplateService';
import { ErrorParser } from './utils/ErrorParser';
import { NodeRegistry } from './services/NodeRegistry';
import { isValidStackName, isValidRemoteUrl } from './utils/validation';
import YAML from 'yaml';
import fs, { promises as fsPromises } from 'fs';
@@ -64,8 +67,20 @@ const getCookieOptions = (req: Request) => ({
});
// Middleware
// Security headers (X-Frame-Options, X-Content-Type-Options, etc.)
// crossOriginEmbedderPolicy is disabled because the Monaco editor uses workers
// that don't set the required COEP headers.
app.use(helmet({ crossOriginEmbedderPolicy: false }));
// CORS — in production restrict to the configured frontend origin.
// In development, mirror the request origin so Vite's dev server works.
const corsOrigin = process.env.NODE_ENV === 'production' && process.env.FRONTEND_URL
? process.env.FRONTEND_URL
: true;
app.use(cors({
origin: true,
origin: corsOrigin,
credentials: true,
}));
// Conditionally parse JSON bodies. Remote proxy requests must NOT have their body
@@ -177,6 +192,22 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
}
};
// Rate limiter for auth endpoints — prevents brute-force attacks.
// 5 attempts per 15-minute window per IP. Applies to login and setup only;
// password-change is already protected by authMiddleware + old-password verification.
const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
});
// Public health endpoint - no auth required (used by Docker HEALTHCHECK and uptime monitors)
app.get('/api/health', (_req: Request, res: Response): void => {
res.json({ status: 'ok', uptime: process.uptime() });
});
// Auth Routes (no authentication required)
// Check if setup is needed
@@ -192,7 +223,7 @@ app.get('/api/auth/status', async (req: Request, res: Response): Promise<void> =
});
// Initial setup endpoint
app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> => {
app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const dbSvc = DatabaseService.getInstance();
const settings = dbSvc.getGlobalSettings();
@@ -243,7 +274,7 @@ app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> =
});
// Login endpoint
app.post('/api/auth/login', async (req: Request, res: Response): Promise<void> => {
app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
const { username, password } = req.body;
if (!username || !password) {
@@ -443,7 +474,7 @@ app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
const server = http.createServer(app);
// WebSocket server with authentication
const wss = new WebSocket.Server({ noServer: true });
const wss = new WebSocketServer({ noServer: true });
let terminalWs: WebSocket | null = null;
@@ -504,7 +535,7 @@ server.on('upgrade', async (req, socket, head) => {
// When a nodeId pointing to a remote node is provided, fall through to the
// proxy block below so the browser subscribes to that remote node's push stream.
if (pathname === '/ws/notifications' && (!node || node.type !== 'remote')) {
const notifWss = new WebSocket.Server({ noServer: true });
const notifWss = new WebSocketServer({ noServer: true });
notifWss.handleUpgrade(req, socket, head, (ws) => {
notifWss.close();
notificationSubscribers.add(ws);
@@ -568,7 +599,7 @@ server.on('upgrade', async (req, socket, head) => {
if (logsMatch) {
// Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs
const logsWss = new WebSocket.Server({ noServer: true });
const logsWss = new WebSocketServer({ noServer: true });
logsWss.handleUpgrade(req, socket, head, (ws) => {
// Close the per-connection server immediately after the upgrade is complete.
// The wss instance is only needed to negotiate the handshake; keeping it open
@@ -591,7 +622,7 @@ server.on('upgrade', async (req, socket, head) => {
socket.destroy();
return;
}
const hostConsoleWss = new WebSocket.Server({ noServer: true });
const hostConsoleWss = new WebSocketServer({ noServer: true });
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
hostConsoleWss.close();
let targetDirectory = '';
@@ -708,6 +739,9 @@ app.get('/api/stacks', async (req: Request, res: Response) => {
app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName);
res.send(content);
} catch (error) {
@@ -768,20 +802,22 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis
const service = parsed.services[serviceName];
if (!service?.env_file) continue;
const addEnvPath = (rawPath: string) => {
const resolved = path.resolve(stackDir, rawPath);
// Reject paths that escape the stack directory
if (!resolved.startsWith(path.resolve(stackDir) + path.sep) && resolved !== path.resolve(stackDir)) {
console.warn(`[Security] env_file path "${rawPath}" escapes stack directory — skipping`);
return;
}
envFiles.add(resolved);
};
if (typeof service.env_file === 'string') {
const resolvedPath = path.isAbsolute(service.env_file)
? service.env_file
: path.resolve(stackDir, service.env_file);
envFiles.add(resolvedPath);
addEnvPath(service.env_file);
} else if (Array.isArray(service.env_file)) {
for (const entry of service.env_file) {
const entryPath = typeof entry === 'string' ? entry : (entry?.path || '');
if (entryPath) {
const resolvedPath = path.isAbsolute(entryPath)
? entryPath
: path.resolve(stackDir, entryPath);
envFiles.add(resolvedPath);
}
if (entryPath) addEnvPath(entryPath);
}
}
}
@@ -801,6 +837,9 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis
app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
res.json({ envFiles: envPaths });
} catch (error) {
@@ -811,6 +850,9 @@ app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => {
app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const requestedFile = req.query.file as string | undefined;
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
@@ -1539,7 +1581,7 @@ app.post('/api/notifications/test', async (req: Request, res: Response) => {
// to a remote node, it calls this endpoint (authenticated with the long-lived api_token)
// to receive a short-lived token. The remote's WS upgrade handler allows 'console_session'
// tokens through its isProxyToken guard, keeping the long-lived api_token off interactive paths.
app.post('/api/system/console-token', (req: Request, res: Response): void => {
app.post('/api/system/console-token', authMiddleware, (req: Request, res: Response): void => {
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
@@ -1801,6 +1843,7 @@ app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Resp
// Node Management API
// =========================
// List all nodes
app.get('/api/nodes', async (req: Request, res: Response) => {
try {
@@ -1836,8 +1879,14 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
if (!type || !['local', 'remote'].includes(type)) {
return res.status(400).json({ error: 'Node type must be "local" or "remote"' });
}
if (type === 'remote' && (!api_url || typeof api_url !== 'string')) {
return res.status(400).json({ error: 'API URL is required for remote nodes' });
if (type === 'remote') {
if (!api_url || typeof api_url !== 'string') {
return res.status(400).json({ error: 'API URL is required for remote nodes' });
}
const urlCheck = isValidRemoteUrl(api_url);
if (!urlCheck.valid) {
return res.status(400).json({ error: urlCheck.reason });
}
}
const id = DatabaseService.getInstance().addNode({
@@ -1865,6 +1914,13 @@ app.put('/api/nodes/:id', async (req: Request, res: Response) => {
const id = parseInt(req.params.id as string);
const updates = req.body;
if (updates.api_url !== undefined && updates.api_url !== '') {
const urlCheck = isValidRemoteUrl(updates.api_url);
if (!urlCheck.valid) {
return res.status(400).json({ error: urlCheck.reason });
}
}
DatabaseService.getInstance().updateNode(id, updates);
// Evict cached Docker connection so it reconnects with new config
@@ -1948,4 +2004,35 @@ async function startServer() {
});
}
startServer();
// Only start the server when this file is the entry point (not when imported by tests).
if (require.main === module) {
startServer();
}
// Exports used by tests (supertest requires the http.Server instance).
export { app, server };
// Graceful shutdown — allows in-flight requests to finish, then cleanly stops
// background services and closes the SQLite connection before the process exits.
// Docker sends SIGTERM when the container stops; Ctrl-C sends SIGINT in dev.
const gracefulShutdown = (signal: string) => {
console.log(`[Shutdown] ${signal} received — shutting down gracefully…`);
server.close(() => {
console.log('[Shutdown] HTTP server closed');
try { MonitorService.getInstance().stop(); } catch { /* already stopped */ }
try { ImageUpdateService.getInstance().stop(); } catch { /* already stopped */ }
try { DatabaseService.getInstance().getDb().close(); } catch { /* already closed */ }
console.log('[Shutdown] Done — exiting');
process.exit(0);
});
// Force-exit after 10 s if connections refuse to drain
setTimeout(() => {
console.error('[Shutdown] Timed out waiting for connections — forcing exit');
process.exit(1);
}, 10_000).unref();
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
+52
View File
@@ -0,0 +1,52 @@
import path from 'path';
/**
* Stack name must only contain URL-safe characters with no path separators.
* Prevents path-traversal attacks when the name is used to build filesystem paths.
*/
export const isValidStackName = (name: string): boolean =>
/^[a-zA-Z0-9_-]+$/.test(name);
/**
* Validates that a remote node API URL is a safe, well-formed HTTP/HTTPS URL.
* Rejects loopback addresses to prevent SSRF against local services.
* Private/LAN IPs are allowed — users legitimately point Sencho at nodes on their LAN.
*/
export function isValidRemoteUrl(
raw: string,
): { valid: true; url: URL } | { valid: false; reason: string } {
let url: URL;
try {
url = new URL(raw);
} catch {
return {
valid: false,
reason: 'API URL must be a valid URL (e.g. https://my-server.example.com:3000)',
};
}
if (!['http:', 'https:'].includes(url.protocol)) {
return { valid: false, reason: 'API URL must use http:// or https://' };
}
// Node.js URL API preserves brackets for IPv6: new URL('http://[::1]').hostname === '[::1]'
const loopback = /^(localhost|127(\.\d+){3}|\[::1\]|0\.0\.0\.0)$/i;
if (loopback.test(url.hostname)) {
return {
valid: false,
reason: 'API URL cannot point to localhost or loopback — use the actual host address',
};
}
return { valid: true, url };
}
/**
* Asserts that a resolved file path stays within a given base directory.
* Returns true if the path is safe, false if it escapes the base.
*/
export function isPathWithinBase(resolvedPath: string, baseDir: string): boolean {
const normalizedBase = path.resolve(baseDir);
const normalizedPath = path.resolve(resolvedPath);
return (
normalizedPath === normalizedBase ||
normalizedPath.startsWith(normalizedBase + path.sep)
);
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
// Only run TypeScript sources — exclude the compiled dist/ output.
include: ['src/__tests__/**/*.test.ts'],
exclude: ['dist/**', 'node_modules/**'],
// Each test file gets its own worker so singletons are fresh between files.
pool: 'forks',
// Timeout generous for DB init and HTTP calls.
testTimeout: 15_000,
// Sequential within each file (DB state is shared per file).
sequence: { concurrent: false },
},
});
+49
View File
@@ -0,0 +1,49 @@
/**
* Authentication E2E tests.
* Tests login, logout, and unauthenticated redirect.
*/
import { test, expect } from '@playwright/test';
import { loginAs, TEST_USERNAME, TEST_PASSWORD } from './helpers';
test.describe('Authentication', () => {
test('login with valid credentials shows the dashboard', async ({ page }) => {
await loginAs(page);
// Should see the main editor/dashboard — not a login page
await expect(page).not.toHaveURL(/login/i);
await expect(page.getByRole('main')).toBeVisible();
});
test('login with wrong password shows an error', async ({ page }) => {
await page.goto('/');
// Skip setup if needed
const isSetup = await page.getByRole('heading', { name: /setup/i }).isVisible().catch(() => false);
if (isSetup) {
// Must complete setup before we can test wrong password
await loginAs(page);
await page.goto('/login');
}
await page.getByLabel(/username/i).fill(TEST_USERNAME);
await page.getByLabel(/password/i).fill('definitly-wrong-password');
await page.getByRole('button', { name: /login|sign in/i }).click();
await expect(page.getByText(/invalid|incorrect|wrong/i)).toBeVisible();
});
test('visiting a protected page without auth redirects to login', async ({ page }) => {
// Clear cookies to simulate logged-out state
await page.context().clearCookies();
await page.goto('/');
await expect(page).toHaveURL(/login|setup/i);
});
test('logout redirects to login', async ({ page }) => {
await loginAs(page);
// Find and click the logout button (varies by UI — adjust selector as needed)
const logoutBtn = page.getByRole('button', { name: /logout|sign out/i });
if (await logoutBtn.isVisible()) {
await logoutBtn.click();
await expect(page).toHaveURL(/login/i);
}
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Shared helpers for E2E tests.
*
* The dev backend must be running at localhost:3000 and seeded via the setup flow,
* OR use a fixed set of test credentials.
*/
import { Page, expect } from '@playwright/test';
export const TEST_USERNAME = process.env.E2E_USERNAME ?? 'admin';
export const TEST_PASSWORD = process.env.E2E_PASSWORD ?? 'password123';
/** Navigate to app, complete setup if needed, then log in. */
export async function loginAs(page: Page, username = TEST_USERNAME, password = TEST_PASSWORD) {
await page.goto('/');
// If setup page is shown, complete it first
const isSetup = await page.getByRole('heading', { name: /setup/i }).isVisible().catch(() => false);
if (isSetup) {
await page.getByLabel(/username/i).fill(username);
await page.getByLabel(/^password$/i).fill(password);
const confirmInput = page.getByLabel(/confirm password/i);
if (await confirmInput.isVisible()) await confirmInput.fill(password);
await page.getByRole('button', { name: /create account|setup|submit/i }).click();
await page.waitForURL(/login|dashboard|\//);
}
// Login if redirected to login page
const isLogin = await page.getByRole('heading', { name: /login|sign in/i }).isVisible().catch(() => false);
if (isLogin) {
await page.getByLabel(/username/i).fill(username);
await page.getByLabel(/password/i).fill(password);
await page.getByRole('button', { name: /login|sign in/i }).click();
// Wait for the dashboard to load
await expect(page.getByRole('main')).toBeVisible({ timeout: 10_000 });
}
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Node management E2E tests.
* Tests the SSRF validation we added (C2 fix) is surfaced in the UI.
*/
import { test, expect } from '@playwright/test';
import { loginAs } from './helpers';
test.describe('Node management', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page);
// Navigate to the nodes section (Settings / Node Manager)
const nodesBtn = page.getByRole('button', { name: /nodes|manage nodes|settings/i }).first();
if (await nodesBtn.isVisible()) await nodesBtn.click();
});
test('adding a node with localhost api_url shows a validation error', async ({ page }) => {
// Open "add node" dialog
const addBtn = page.getByRole('button', { name: /add node|new node|\+/i });
if (!await addBtn.isVisible()) {
test.skip();
return;
}
await addBtn.click();
await page.getByLabel(/node name/i).fill('bad-node');
// Select "remote" type if there's a type selector
const typeSelect = page.getByLabel(/type/i);
if (await typeSelect.isVisible()) await typeSelect.selectOption('remote');
await page.getByLabel(/api url/i).fill('http://localhost:6379');
await page.getByRole('button', { name: /add|save|create/i }).click();
// Should see an error about loopback/localhost
await expect(page.getByText(/loopback|localhost/i)).toBeVisible({ timeout: 3_000 });
});
test('adding a node with an invalid URL shows an error', async ({ page }) => {
const addBtn = page.getByRole('button', { name: /add node|new node|\+/i });
if (!await addBtn.isVisible()) {
test.skip();
return;
}
await addBtn.click();
await page.getByLabel(/node name/i).fill('bad-url-node');
const typeSelect = page.getByLabel(/type/i);
if (await typeSelect.isVisible()) await typeSelect.selectOption('remote');
await page.getByLabel(/api url/i).fill('not-a-url-at-all');
await page.getByRole('button', { name: /add|save|create/i }).click();
await expect(page.getByText(/valid url|invalid url|url/i)).toBeVisible({ timeout: 3_000 });
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* Stack management E2E tests — happy path CRUD.
*
* NOTE: These tests require Docker Compose to be installed on the host, because
* actual stack operations (up/down) spawn docker-compose processes.
* The create/edit/delete tests work without Docker being connected.
*/
import { test, expect } from '@playwright/test';
import { loginAs } from './helpers';
const TEST_STACK = `e2e-test-stack-${Date.now()}`;
const SIMPLE_COMPOSE = `services:\n web:\n image: nginx:alpine\n`;
test.describe('Stack management', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page);
});
test('create a new stack', async ({ page }) => {
// Find and click the "new stack" / "+" button
const newStackBtn = page.getByRole('button', { name: /new stack|create stack|\+/i }).first();
await newStackBtn.click();
// Fill in the stack name in the dialog
const nameInput = page.getByLabel(/stack name/i);
await nameInput.fill(TEST_STACK);
// Confirm
await page.getByRole('button', { name: /create|confirm|ok/i }).click();
// Stack should now appear in the list
await expect(page.getByText(TEST_STACK)).toBeVisible({ timeout: 5_000 });
});
test('edit the compose file of an existing stack', async ({ page }) => {
// Click on the test stack in the sidebar/list
await page.getByText(TEST_STACK).click();
// Wait for the editor to appear and type some content
const editor = page.locator('.monaco-editor').first();
await editor.click();
await page.keyboard.selectAll();
await page.keyboard.type(SIMPLE_COMPOSE);
// Save
const saveBtn = page.getByRole('button', { name: /save/i });
await saveBtn.click();
// Should show success indication (no error toast)
await expect(page.getByText(/error/i)).not.toBeVisible({ timeout: 3_000 }).catch(() => {
// If error text is already not there that's fine
});
});
test('delete the test stack', async ({ page }) => {
// Find the test stack and open its context menu / delete button
const stackRow = page.locator(`[data-testid="stack-${TEST_STACK}"], li:has-text("${TEST_STACK}")`).first();
// Hover to reveal action buttons
await stackRow.hover();
const deleteBtn = stackRow.getByRole('button', { name: /delete|remove/i });
await deleteBtn.click();
// Confirm deletion in dialog
const confirmBtn = page.getByRole('button', { name: /confirm|delete|yes/i });
if (await confirmBtn.isVisible()) await confirmBtn.click();
// Stack should no longer appear
await expect(page.getByText(TEST_STACK)).not.toBeVisible({ timeout: 5_000 });
});
});
+15 -4
View File
@@ -410,7 +410,9 @@ export default function EditorLayout() {
const data = await res.json();
setStackUpdates(data);
}
} catch (e) { }
} catch (e: unknown) {
console.error('[ImageUpdates] fetch failed:', e);
}
};
const markAllRead = async () => {
@@ -423,7 +425,10 @@ export default function EditorLayout() {
: fetchForNode('/notifications/read', nodeId, { method: 'POST' })
));
setNotifications(prev => prev.map(n => ({ ...n, is_read: 1 })));
} catch (e) { }
} catch (e: unknown) {
const err = e as { message?: string; error?: string };
toast.error(err?.message || err?.error || 'Failed to mark notifications as read');
}
};
const deleteNotification = async (notif: Notification) => {
@@ -435,7 +440,10 @@ export default function EditorLayout() {
await fetchForNode(`/notifications/${notif.id}`, notif.nodeId, { method: 'DELETE' });
}
setNotifications(prev => prev.filter(n => !(n.id === notif.id && n.nodeId === notif.nodeId)));
} catch (e) { }
} catch (e: unknown) {
const err = e as { message?: string; error?: string };
toast.error(err?.message || err?.error || 'Failed to delete notification');
}
};
const clearAllNotifications = async () => {
@@ -448,7 +456,10 @@ export default function EditorLayout() {
: fetchForNode('/notifications', nodeId, { method: 'DELETE' })
));
setNotifications([]);
} catch (e) { }
} catch (e: unknown) {
const err = e as { message?: string; error?: string };
toast.error(err?.message || err?.error || 'Failed to clear notifications');
}
};
useEffect(() => {
+4 -1
View File
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import ErrorBoundary from './components/ErrorBoundary.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
)
+79
View File
@@ -0,0 +1,79 @@
{
"name": "sencho",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sencho",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.58.2"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "sencho",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "cd backend && npm test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"repository": {
"type": "git",
"url": "git+https://github.com/AnsoCode/Sencho.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"bugs": {
"url": "https://github.com/AnsoCode/Sencho/issues"
},
"homepage": "https://github.com/AnsoCode/Sencho#readme",
"devDependencies": {
"@playwright/test": "^1.58.2"
}
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright E2E config for Sencho.
*
* Before running: ensure both dev servers are up:
* cd backend && npm run dev &
* cd frontend && npm run dev &
*
* Or use the webServer config below (which starts them automatically).
*/
export default defineConfig({
testDir: './e2e',
// Stop on first failure to save time during development
maxFailures: 1,
// How long to wait for a single test
timeout: 30_000,
// How long to wait for an expect() assertion
expect: { timeout: 5_000 },
// Run tests serially — Sencho is a single-user app and tests share DB state
workers: 1,
reporter: [['list'], ['html', { outputFolder: 'e2e/report', open: 'never' }]],
use: {
baseURL: 'http://localhost:5173',
// Persist auth state between tests in the same file
storageState: undefined,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});