mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
5289f01bfd
* 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.
91 lines
3.9 KiB
TypeScript
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');
|
|
}
|
|
});
|
|
});
|