Fix flaky emergency-token.spec.ts test that failed in CI Shard 4 with: "ACL verification failed - ACL not showing as enabled after retries" Root cause: Race condition where ACL was enabled before Cerberus middleware had fully propagated. The enable API returned 200 but the security status endpoint didn't reflect the change in time. Changes: Add STEP 1b: Cerberus verification loop after Cerberus enable Wait for cerberus.enabled=true before proceeding to ACL enable Use same retry pattern with CI_TIMEOUT_MULTIPLIER Fixes: Shard 4 E2E failures in PR #583
470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
/**
|
||
* Emergency Token Break Glass Protocol Tests
|
||
*
|
||
* Tests the 3-tier break glass architecture for emergency access recovery.
|
||
* Validates that the emergency token can bypass all security controls when
|
||
* an administrator is locked out.
|
||
*
|
||
* Reference: docs/plans/break_glass_protocol_redesign.md
|
||
*/
|
||
|
||
import { test, expect } from '@playwright/test';
|
||
import { EMERGENCY_TOKEN } from '../fixtures/security';
|
||
|
||
// CI-specific timeout multiplier: CI environments have higher I/O latency
|
||
const CI_TIMEOUT_MULTIPLIER = process.env.CI ? 3 : 1;
|
||
const BASE_PROPAGATION_WAIT = 5000;
|
||
const BASE_RETRY_INTERVAL = 1000;
|
||
const BASE_RETRY_COUNT = 15;
|
||
const BASE_CERBERUS_WAIT = 3000;
|
||
|
||
test.describe('Emergency Token Break Glass Protocol', () => {
|
||
/**
|
||
* CRITICAL: Ensure Cerberus AND ACL are enabled before running these tests
|
||
*
|
||
* WHY CERBERUS MUST BE ENABLED FIRST:
|
||
* - global-setup.ts disables ALL security modules including feature.cerberus.enabled
|
||
* - The Cerberus middleware is the master switch that gates ALL security enforcement
|
||
* - If Cerberus is disabled, the middleware short-circuits and ACL is never checked
|
||
* - Therefore: Cerberus must be enabled BEFORE ACL for security to actually be enforced
|
||
*/
|
||
test.beforeAll(async ({ request }) => {
|
||
console.log('🔧 Setting up test suite: Ensuring Cerberus and ACL are enabled...');
|
||
|
||
const emergencyToken = process.env.CHARON_EMERGENCY_TOKEN;
|
||
if (!emergencyToken) {
|
||
throw new Error('CHARON_EMERGENCY_TOKEN not set - cannot configure test environment');
|
||
}
|
||
|
||
// STEP 1: Enable Cerberus master switch FIRST
|
||
// Without this, the Cerberus middleware short-circuits and ACL is never enforced
|
||
const cerberusResponse = await request.patch('/api/v1/settings', {
|
||
data: { key: 'feature.cerberus.enabled', value: 'true' },
|
||
headers: {
|
||
'X-Emergency-Token': emergencyToken,
|
||
},
|
||
});
|
||
|
||
if (!cerberusResponse.ok()) {
|
||
throw new Error(`Failed to enable Cerberus: ${cerberusResponse.status()}`);
|
||
}
|
||
console.log(' ✓ Cerberus master switch enabled');
|
||
|
||
// Wait for Cerberus to activate (extended wait for Caddy reload)
|
||
await new Promise(resolve => setTimeout(resolve, BASE_CERBERUS_WAIT * CI_TIMEOUT_MULTIPLIER));
|
||
|
||
// STEP 1b: Verify Cerberus is actually active before enabling ACL
|
||
// This prevents race conditions where ACL enable succeeds but Cerberus isn't ready
|
||
let cerberusActive = false;
|
||
let cerberusRetries = BASE_RETRY_COUNT * CI_TIMEOUT_MULTIPLIER;
|
||
|
||
while (cerberusRetries > 0 && !cerberusActive) {
|
||
const statusResponse = await request.get('/api/v1/security/status', {
|
||
headers: { 'X-Emergency-Token': emergencyToken },
|
||
});
|
||
|
||
if (statusResponse.ok()) {
|
||
const status = await statusResponse.json();
|
||
if (status.cerberus?.enabled) {
|
||
cerberusActive = true;
|
||
console.log(' ✓ Cerberus verified as active');
|
||
} else {
|
||
console.log(` ⏳ Cerberus not yet active, retrying... (${cerberusRetries} left)`);
|
||
await new Promise(resolve => setTimeout(resolve, BASE_RETRY_INTERVAL * CI_TIMEOUT_MULTIPLIER));
|
||
cerberusRetries--;
|
||
}
|
||
} else {
|
||
console.log(` ⚠️ Status check failed: ${statusResponse.status()}, retrying...`);
|
||
await new Promise(resolve => setTimeout(resolve, BASE_RETRY_INTERVAL * CI_TIMEOUT_MULTIPLIER));
|
||
cerberusRetries--;
|
||
}
|
||
}
|
||
|
||
if (!cerberusActive) {
|
||
throw new Error('Cerberus verification failed - not active after retries');
|
||
}
|
||
|
||
// STEP 2: Enable ACL (now that Cerberus is verified active, this will actually be enforced)
|
||
const aclResponse = await request.patch('/api/v1/settings', {
|
||
data: { key: 'security.acl.enabled', value: 'true' },
|
||
headers: {
|
||
'X-Emergency-Token': emergencyToken,
|
||
},
|
||
});
|
||
|
||
if (!aclResponse.ok()) {
|
||
throw new Error(`Failed to enable ACL: ${aclResponse.status()}`);
|
||
}
|
||
console.log(' ✓ ACL enabled');
|
||
|
||
// Wait for security propagation (settings need time to apply to Caddy)
|
||
await new Promise(resolve => setTimeout(resolve, BASE_PROPAGATION_WAIT * CI_TIMEOUT_MULTIPLIER));
|
||
|
||
// STEP 3: Verify ACL is actually enabled with retry loop (extended intervals)
|
||
let verifyRetries = BASE_RETRY_COUNT * CI_TIMEOUT_MULTIPLIER;
|
||
let aclEnabled = false;
|
||
|
||
while (verifyRetries > 0 && !aclEnabled) {
|
||
const statusResponse = await request.get('/api/v1/security/status', {
|
||
headers: { 'X-Emergency-Token': emergencyToken },
|
||
});
|
||
|
||
if (statusResponse.ok()) {
|
||
const status = await statusResponse.json();
|
||
if (status.acl?.enabled) {
|
||
aclEnabled = true;
|
||
console.log(' ✓ ACL verified as enabled');
|
||
} else {
|
||
console.log(` ⏳ ACL not yet enabled, retrying... (${verifyRetries} left)`);
|
||
await new Promise(resolve => setTimeout(resolve, BASE_RETRY_INTERVAL * CI_TIMEOUT_MULTIPLIER));
|
||
verifyRetries--;
|
||
}
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!aclEnabled) {
|
||
throw new Error('ACL verification failed - ACL not showing as enabled after retries');
|
||
}
|
||
|
||
// STEP 4: Delete ALL access lists to ensure clean blocking state
|
||
// ACL blocking only happens when activeCount == 0 (no ACLs configured)
|
||
// If blacklist ACLs exist from other tests, requests from IPs NOT in them will pass
|
||
console.log(' 🗑️ Ensuring no access lists exist (required for ACL blocking)...');
|
||
try {
|
||
const aclsResponse = await request.get('/api/v1/access-lists', {
|
||
headers: { 'X-Emergency-Token': emergencyToken },
|
||
});
|
||
|
||
if (aclsResponse.ok()) {
|
||
const aclsData = await aclsResponse.json();
|
||
const acls = Array.isArray(aclsData) ? aclsData : (aclsData?.access_lists || []);
|
||
|
||
for (const acl of acls) {
|
||
const deleteResponse = await request.delete(`/api/v1/access-lists/${acl.id}`, {
|
||
headers: { 'X-Emergency-Token': emergencyToken },
|
||
});
|
||
if (deleteResponse.ok()) {
|
||
console.log(` ✓ Deleted ACL: ${acl.name || acl.id}`);
|
||
}
|
||
}
|
||
|
||
if (acls.length > 0) {
|
||
console.log(` ✓ Deleted ${acls.length} access list(s)`);
|
||
// Wait for ACL changes to propagate
|
||
await new Promise(resolve => setTimeout(resolve, 500 * CI_TIMEOUT_MULTIPLIER));
|
||
} else {
|
||
console.log(' ✓ No access lists to delete');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn(` ⚠️ Could not clean ACLs: ${error}`);
|
||
}
|
||
|
||
console.log('✅ Cerberus and ACL enabled for test suite');
|
||
});
|
||
|
||
/**
|
||
* Cleanup: Reset security state after all tests complete
|
||
* This ensures other test suites start with a clean slate
|
||
*/
|
||
test.afterAll(async ({ request }) => {
|
||
console.log('🧹 Cleaning up: Resetting security state...');
|
||
|
||
const emergencyToken = process.env.CHARON_EMERGENCY_TOKEN;
|
||
if (!emergencyToken) {
|
||
console.warn('⚠️ No emergency token available for cleanup');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await request.post('/api/v1/emergency/security-reset', {
|
||
headers: {
|
||
'X-Emergency-Token': emergencyToken,
|
||
},
|
||
});
|
||
|
||
if (response.ok()) {
|
||
console.log('✅ Security state reset successfully');
|
||
} else {
|
||
console.warn(`⚠️ Security reset returned status: ${response.status()}`);
|
||
}
|
||
} catch (error) {
|
||
console.warn(`⚠️ Cleanup error (non-fatal): ${error}`);
|
||
}
|
||
});
|
||
|
||
test('Test 1: Emergency token bypasses ACL', async ({ request }, testInfo) => {
|
||
// ACL is guaranteed to be enabled by beforeAll hook
|
||
console.log('🧪 Testing emergency token bypass with ACL enabled...');
|
||
|
||
// Note: Testing that ACL blocks unauthenticated requests without configured ACLs
|
||
// is handled by admin-ip-blocking.spec.ts. Here we focus on emergency token bypass.
|
||
|
||
// Step 1: Verify that ACL is enabled (precondition check with retry)
|
||
// Due to parallel test execution, ACL may have been disabled by another test
|
||
let statusCheck = await request.get('/api/v1/security/status', {
|
||
headers: { 'X-Emergency-Token': EMERGENCY_TOKEN },
|
||
});
|
||
|
||
if (!statusCheck.ok()) {
|
||
console.log('⚠️ Could not verify security status - API not accessible');
|
||
testInfo.skip(true, 'Could not verify security status - API not accessible');
|
||
return;
|
||
}
|
||
|
||
let statusData = await statusCheck.json();
|
||
|
||
// If ACL is not enabled, try to re-enable it (it may have been disabled by parallel tests)
|
||
if (!statusData.acl?.enabled) {
|
||
console.log(' ⚠️ ACL was disabled by parallel test, re-enabling...');
|
||
await request.patch('/api/v1/settings', {
|
||
data: { key: 'feature.cerberus.enabled', value: 'true' },
|
||
headers: { 'X-Emergency-Token': EMERGENCY_TOKEN },
|
||
});
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
await request.patch('/api/v1/settings', {
|
||
data: { key: 'security.acl.enabled', value: 'true' },
|
||
headers: { 'X-Emergency-Token': EMERGENCY_TOKEN },
|
||
});
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
|
||
// Retry verification
|
||
statusCheck = await request.get('/api/v1/security/status', {
|
||
headers: { 'X-Emergency-Token': EMERGENCY_TOKEN },
|
||
});
|
||
statusData = await statusCheck.json();
|
||
|
||
if (!statusData.acl?.enabled) {
|
||
console.log('⚠️ Could not re-enable ACL - skipping test');
|
||
testInfo.skip(true, 'ACL could not be re-enabled after parallel test interference');
|
||
return;
|
||
}
|
||
console.log(' ✓ ACL re-enabled successfully');
|
||
}
|
||
|
||
expect(statusData.acl?.enabled).toBeTruthy();
|
||
console.log(' ✓ Confirmed ACL is enabled');
|
||
|
||
// Step 2: Verify emergency token can access protected endpoints with ACL enabled
|
||
// This tests the core functionality: emergency token bypasses all security controls
|
||
const emergencyResponse = await request.get('/api/v1/security/status', {
|
||
headers: {
|
||
'X-Emergency-Token': EMERGENCY_TOKEN,
|
||
},
|
||
});
|
||
|
||
// Step 3: Verify emergency token successfully bypassed ACL (200)
|
||
expect(emergencyResponse.ok()).toBeTruthy();
|
||
expect(emergencyResponse.status()).toBe(200);
|
||
|
||
const status = await emergencyResponse.json();
|
||
expect(status).toHaveProperty('acl');
|
||
console.log(' ✓ Emergency token successfully accessed protected endpoint with ACL enabled');
|
||
|
||
console.log('✅ Test 1 passed: Emergency token bypasses ACL');
|
||
});
|
||
|
||
test('Test 2: Emergency endpoint has NO rate limiting', async ({ request }) => {
|
||
console.log('🧪 Verifying emergency endpoint has no rate limiting...');
|
||
console.log(' ℹ️ Emergency endpoints are "break-glass" - they must work immediately without artificial delays');
|
||
|
||
const wrongToken = 'wrong-token-for-no-rate-limit-test-32chars';
|
||
|
||
// Make 10 rapid attempts with wrong token to verify NO rate limiting applied
|
||
const responses = [];
|
||
for (let i = 0; i < 10; i++) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const response = await request.post('/api/v1/emergency/security-reset', {
|
||
headers: { 'X-Emergency-Token': wrongToken },
|
||
});
|
||
responses.push(response);
|
||
}
|
||
|
||
// ALL requests should be unauthorized (401), NONE should be rate limited (429)
|
||
for (let i = 0; i < responses.length; i++) {
|
||
expect(responses[i].status()).toBe(401);
|
||
const body = await responses[i].json();
|
||
expect(body.error).toBe('unauthorized');
|
||
}
|
||
|
||
console.log(`✅ Test 2 passed: No rate limiting on emergency endpoint (${responses.length} rapid requests all got 401, not 429)`);
|
||
console.log(' ℹ️ Emergency endpoints protected by: token validation + IP restrictions + audit logging');
|
||
});
|
||
|
||
test('Test 3: Emergency token requires valid token', async ({ request }) => {
|
||
console.log('🧪 Testing emergency token validation...');
|
||
|
||
// Test with wrong token
|
||
const wrongResponse = await request.post('/api/v1/emergency/security-reset', {
|
||
headers: { 'X-Emergency-Token': 'invalid-token-that-should-not-work-32chars' },
|
||
});
|
||
|
||
expect(wrongResponse.status()).toBe(401);
|
||
const wrongBody = await wrongResponse.json();
|
||
expect(wrongBody.error).toBe('unauthorized');
|
||
|
||
// Verify settings were NOT changed by checking status
|
||
const statusResponse = await request.get('/api/v1/security/status');
|
||
if (statusResponse.ok()) {
|
||
const status = await statusResponse.json();
|
||
// If security was previously enabled, it should still be enabled
|
||
console.log(' ✓ Security settings were not modified by invalid token');
|
||
}
|
||
|
||
console.log('✅ Test 3 passed: Invalid token properly rejected');
|
||
});
|
||
|
||
test('Test 4: Emergency token audit logging', async ({ request }) => {
|
||
console.log('🧪 Testing emergency token audit logging...');
|
||
|
||
// Use valid emergency token
|
||
const emergencyResponse = await request.post('/api/v1/emergency/security-reset', {
|
||
headers: { 'X-Emergency-Token': EMERGENCY_TOKEN },
|
||
});
|
||
|
||
expect(emergencyResponse.ok()).toBeTruthy();
|
||
|
||
// Wait for audit log to be written
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
// Check audit logs for emergency event
|
||
const auditResponse = await request.get('/api/v1/audit-logs');
|
||
expect(auditResponse.ok()).toBeTruthy();
|
||
|
||
const auditPayload = await auditResponse.json();
|
||
const auditLogs = Array.isArray(auditPayload)
|
||
? auditPayload
|
||
: Array.isArray(auditPayload?.audit_logs)
|
||
? auditPayload.audit_logs
|
||
: [];
|
||
|
||
// Look for emergency reset event
|
||
const emergencyLog = auditLogs.find(
|
||
(log: any) =>
|
||
log.action === 'emergency_reset_success' || log.details?.includes('emergency')
|
||
);
|
||
|
||
// Audit logging should capture the event
|
||
console.log(
|
||
` ${emergencyLog ? '✓' : '⚠'} Audit log ${emergencyLog ? 'found' : 'not found'} for emergency event`
|
||
);
|
||
|
||
if (emergencyLog) {
|
||
console.log(` ✓ Audit log action: ${emergencyLog.action}`);
|
||
console.log(` ✓ Audit log timestamp: ${emergencyLog.timestamp}`);
|
||
expect(emergencyLog).toBeDefined();
|
||
}
|
||
|
||
console.log('✅ Test 4 passed: Audit logging verified');
|
||
});
|
||
|
||
test('Test 5: Emergency token from unauthorized IP (documentation test)', async ({
|
||
request,
|
||
}) => {
|
||
// IP restriction testing requires requests to route through Caddy's middleware.
|
||
console.log(
|
||
' ℹ️ Manual test required: Verify production blocks IPs outside management CIDR'
|
||
);
|
||
});
|
||
|
||
test('Test 6: Emergency token minimum length validation', async ({ request }) => {
|
||
console.log('🧪 Testing emergency token minimum length validation...');
|
||
|
||
// The backend requires minimum 32 characters for the emergency token
|
||
// This is enforced at startup, not per-request, so we can't test it directly in E2E
|
||
|
||
// Instead, we verify that our E2E token meets the requirement
|
||
expect(EMERGENCY_TOKEN.length).toBeGreaterThanOrEqual(32);
|
||
console.log(` ✓ E2E emergency token length: ${EMERGENCY_TOKEN.length} chars (minimum: 32)`);
|
||
|
||
// Verify the token works
|
||
const response = await request.post('/api/v1/emergency/security-reset', {
|
||
headers: { 'X-Emergency-Token': EMERGENCY_TOKEN },
|
||
});
|
||
|
||
expect(response.ok()).toBeTruthy();
|
||
|
||
console.log('✅ Test 6 passed: Minimum length requirement documented and verified');
|
||
console.log(' ℹ️ Backend unit test required: Verify startup rejects short tokens');
|
||
});
|
||
|
||
test('Test 7: Emergency token header stripped', async ({ request }) => {
|
||
console.log('🧪 Testing emergency token header security...');
|
||
|
||
// Use emergency token
|
||
const response = await request.post('/api/v1/emergency/security-reset', {
|
||
headers: { 'X-Emergency-Token': EMERGENCY_TOKEN },
|
||
});
|
||
|
||
expect(response.ok()).toBeTruthy();
|
||
|
||
// The emergency bypass middleware should strip the token header before
|
||
// the request reaches the handler, preventing token exposure in logs
|
||
|
||
// Verify token doesn't appear in response headers
|
||
const responseHeaders = response.headers();
|
||
expect(responseHeaders['x-emergency-token']).toBeUndefined();
|
||
|
||
// Check audit logs to ensure token is NOT logged
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
const auditResponse = await request.get('/api/v1/audit-logs');
|
||
if (auditResponse.ok()) {
|
||
const auditPayload = await auditResponse.json();
|
||
const auditLogs = Array.isArray(auditPayload)
|
||
? auditPayload
|
||
: Array.isArray(auditPayload?.audit_logs)
|
||
? auditPayload.audit_logs
|
||
: [];
|
||
const recentLog = auditLogs[0];
|
||
|
||
if (!recentLog) {
|
||
console.log(' ⚠ No audit logs returned; skipping token redaction assertion');
|
||
console.log('✅ Test 7 passed: Emergency token properly stripped for security');
|
||
return;
|
||
}
|
||
|
||
// Verify token value doesn't appear in audit log
|
||
const logString = JSON.stringify(recentLog);
|
||
expect(logString).not.toContain(EMERGENCY_TOKEN);
|
||
console.log(' ✓ Token not found in audit log (properly stripped)');
|
||
}
|
||
|
||
console.log('✅ Test 7 passed: Emergency token properly stripped for security');
|
||
});
|
||
|
||
test('Test 8: Emergency reset idempotency', async ({ request }) => {
|
||
console.log('🧪 Testing emergency reset idempotency...');
|
||
|
||
// First reset
|
||
const firstResponse = await request.post('/api/v1/emergency/security-reset', {
|
||
headers: { 'X-Emergency-Token': EMERGENCY_TOKEN },
|
||
});
|
||
|
||
expect(firstResponse.ok()).toBeTruthy();
|
||
const firstBody = await firstResponse.json();
|
||
expect(firstBody.success).toBe(true);
|
||
console.log(' ✓ First reset successful');
|
||
|
||
// Wait a moment
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
// Second reset (should also succeed)
|
||
const secondResponse = await request.post('/api/v1/emergency/security-reset', {
|
||
headers: { 'X-Emergency-Token': EMERGENCY_TOKEN },
|
||
});
|
||
|
||
expect(secondResponse.ok()).toBeTruthy();
|
||
const secondBody = await secondResponse.json();
|
||
expect(secondBody.success).toBe(true);
|
||
console.log(' ✓ Second reset successful');
|
||
|
||
// Both should return success, no errors
|
||
expect(firstBody.success).toBe(secondBody.success);
|
||
console.log(' ✓ No errors on repeated resets');
|
||
|
||
console.log('✅ Test 8 passed: Emergency reset is idempotent');
|
||
});
|
||
});
|