Files
sencho/backend/src/__tests__/diagnostics-route.test.ts
T
Anso 5289f01bfd feat(onboarding): add first-run environment checker (#1290)
* feat(onboarding): add first-run environment checker

Add a preflight that checks whether the host can run Docker deploys before a
deploy fails for an avoidable reason. It verifies the Docker engine is reachable
and permitted, the Compose plugin is present, the compose directory is writable
and mounted at a matching host path, the dashboard is behind TLS, and the
compose volume has disk headroom. Each result that needs attention carries a
specific fix rather than a generic error, and the checks never block: an
operator who knows their setup can continue.

The checks run as the final step of first-boot setup and can be re-run any time
from the Recovery settings tab. A new admin-only endpoint,
GET /api/diagnostics/environment, backs both surfaces.

* fix(onboarding): distinguish unverified path mapping and support parent binds

Treat a container whose self-inspect fails as an unverified path-mapping warning
instead of a false "not containerized" pass, so an unverifiable mapping never
reads as healthy. Resolve the compose directory through the longest-prefix bind
mount and compare the host path it resolves to, so a parent bind such as
-v /opt:/opt correctly covers COMPOSE_DIR=/opt/compose instead of warning that
the directory is not bind-mounted.

* test(e2e): advance the setup wizard past the environment step in loginAs

The first-run setup helper clicked "Initialize console" and immediately waited
for the dashboard, but setup now shows an environment-preflight step before
landing the console. Click "Enter Sencho" to complete onboarding before
asserting the dashboard, so the first test on a fresh instance passes.
2026-06-02 21:40:38 -04:00

91 lines
3.9 KiB
TypeScript

/**
* Route-level tests for GET /api/diagnostics: auth required, admin-only,
* response shape, and no secret leakage in the payload.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let adminAuthHeader: string;
let viewerAuthHeader: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ DatabaseService } = await import('../services/DatabaseService'));
adminAuthHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
const viewerHash = await bcrypt.hash('viewerpass', 1);
DatabaseService.getInstance().addUser({ username: 'diag-viewer', password_hash: viewerHash, role: 'viewer' });
viewerAuthHeader = `Bearer ${jwt.sign({ username: 'diag-viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('GET /api/diagnostics', () => {
it('requires authentication', async () => {
const res = await request(app).get('/api/diagnostics');
expect(res.status).toBe(401);
});
it('rejects a non-admin', async () => {
const res = await request(app).get('/api/diagnostics').set('Authorization', viewerAuthHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('returns the diagnostics report for an admin', async () => {
const res = await request(app).get('/api/diagnostics').set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
expect(res.body.database).toBeDefined();
expect(res.body.encryptionKey).toBeDefined();
expect(res.body.docker).toBeDefined();
expect(res.body.auth.adminCount).toBeGreaterThanOrEqual(1);
expect(Array.isArray(res.body.auth.ssoProviders)).toBe(true);
});
it('does not leak secret settings in the payload', async () => {
// Use a non-auth secret so overwriting it cannot break token verification
// (auth_jwt_secret is what the admin token is signed against).
DatabaseService.getInstance().updateGlobalSetting('cloud_backup_secret_key', 'route-secret-value');
const res = await request(app).get('/api/diagnostics').set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
const serialized = JSON.stringify(res.body);
expect(serialized).not.toContain('route-secret-value');
expect(res.body.config.cloud_backup_secret_key).toBeUndefined();
});
});
describe('GET /api/diagnostics/environment', () => {
it('requires authentication', async () => {
const res = await request(app).get('/api/diagnostics/environment');
expect(res.status).toBe(401);
});
it('rejects a non-admin', async () => {
const res = await request(app).get('/api/diagnostics/environment').set('Authorization', viewerAuthHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('returns the environment report for an admin', async () => {
const res = await request(app).get('/api/diagnostics/environment').set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.checks)).toBe(true);
const ids = (res.body.checks as Array<{ id: string }>).map(c => c.id);
expect(ids).toEqual(['docker_socket', 'docker_compose', 'compose_dir', 'path_mapping', 'tls', 'disk_space']);
for (const c of res.body.checks as Array<{ status: string; detail: string }>) {
expect(['pass', 'warn', 'fail']).toContain(c.status);
expect(typeof c.detail).toBe('string');
}
});
});