Add HTTPS identity rotation support
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
const assert = require('node:assert/strict');
|
||||
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
|
||||
const path = '/api/settings/lifecycle', op = path + '-operation';
|
||||
const fixture = (extra = {}) => ({generation:7,running:true,transitioning:false,controllable:true,...extra});
|
||||
const fixture = (extra = {}) => ({generation:7,running:true,transitioning:false,controllable:true,identity_generation:11,fingerprint:'ab'.repeat(32),rotatable:true,...extra});
|
||||
const reply = (state='pending', id=42, status=200, action='stop') => new Response(JSON.stringify({id,action,state}), {status});
|
||||
const n = (b,id) => b.nodes['lifecycle-'+id], posts = b => b.calls.filter(c=>c.url===op && c.method==='POST');
|
||||
async function open(v=fixture()) { const b=await adminBrowser(); b.click('select-settings'); await tick(); b.queues[path].push(json(v)); b.click('settings-lifecycle'); await tick(); return b; }
|
||||
@@ -28,18 +28,51 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
assert.ok(n(b,'stop').disabled); await refresh(b); assert.equal(n(b,'stop').disabled,false); assert.equal(posts(b).length,1);
|
||||
}
|
||||
});
|
||||
await test('HTTPS identity rotation confirms public fingerprint and both generations, shares pending gate, and requires renewed trust/login',async()=>{
|
||||
const b=await open(); let confirmation=''; b.window.confirm=s=>{confirmation=s;return false;};
|
||||
b.click('lifecycle-rotate'); await tick(); assert.equal(posts(b).length,0);
|
||||
for(const text of ['ab'.repeat(32),'generation 11','generation 7','UART0','trust','ALL','SSH','USB','sign in freshly']) assert.ok(confirmation.includes(text),text);
|
||||
assert.match(n(b,'identity').textContent,/Stored HTTPS identity generation 11/);
|
||||
b.window.confirm=()=>true; await submit(b,'rotate');
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'rotate',generation:7,identity_generation:11});
|
||||
b.click('lifecycle-stop'); b.click('lifecycle-rotate'); await tick(); assert.equal(posts(b).length,1);
|
||||
b.queues[op].push(reply('failed',42,200,'rotate')); b.click('lifecycle-result'); await tick();
|
||||
assert.match(n(b,'operation-detail').textContent,/identity may already be persisted/);
|
||||
assert.match(n(b,'operation-detail').textContent,/No rollback/);
|
||||
assert.ok(n(b,'rotate').disabled); await refresh(b); assert.equal(n(b,'rotate').disabled,false);
|
||||
assert.equal(posts(b).length,1);
|
||||
});
|
||||
await test('HTTPS identity metadata rejects missing generations, secrets and malformed fingerprints; unavailable identity does not disable ordinary service controls',async()=>{
|
||||
for(const v of [fixture({identity_generation:undefined}),fixture({identity_generation:0}),fixture({identity_generation:4294967295}),fixture({fingerprint:'<script>'}),fixture({fingerprint:'a'.repeat(65)}),fixture({private_key:'secret'})]) {
|
||||
const b=await open(v); b.click('lifecycle-rotate'); await tick(); assert.ok(n(b,'rotate').disabled); assert.equal(posts(b).length,0);
|
||||
assert.doesNotMatch(n(b,'identity').textContent,/script|secret/);
|
||||
}
|
||||
const b=await open(fixture({identity_generation:0,fingerprint:'',rotatable:false}));
|
||||
assert.ok(n(b,'rotate').disabled); assert.equal(n(b,'stop').disabled,false);
|
||||
assert.doesNotMatch(html,/id="lifecycle-reset"/);
|
||||
});
|
||||
await test('HTTPS rotation lost ACK, role loss and post-admission login expiry never replay or restore',async()=>{
|
||||
const b=await open(); b.queues[op].push(()=>{throw Error('lost');}); b.click('lifecycle-rotate'); await tick();
|
||||
b.queues[op].push(reply('ok',42,200,'rotate')); b.click('lifecycle-result'); await tick();
|
||||
await refresh(b); assert.ok(n(b,'rotate').disabled && n(b,'stop').disabled); assert.equal(posts(b).length,1);
|
||||
b.click('select-serial'); b.queues[path].push(json(fixture())); b.click('select-settings'); await tick(); assert.equal(posts(b).length,1);
|
||||
const denied=await open(); denied.queues['/api/session'].push(session({role:'user'})); denied.click('lifecycle-rotate'); await tick(); assert.equal(posts(denied).length,0);
|
||||
const expired=await open(); await submit(expired,'rotate'); expired.queues[op].push(failure(401)); expired.click('lifecycle-result'); await tick();
|
||||
assert.deepEqual(expired.redirects,['/login']); assert.ok(expired.sockets.every(s=>s.closed));
|
||||
const fresh=await open(fixture({identity_generation:12,generation:10})); assert.equal(posts(fresh).length,0); assert.ok(!fresh.calls.some(c=>c.url===op));
|
||||
});
|
||||
await test('Lifecycle bounded snapshot schema rejects unavailable malformed transitioning saturated and contradictory state',async()=>{
|
||||
for(const v of [{},fixture({generation:0}),fixture({generation:4294967296}),fixture({running:1}),fixture({controllable:1}),fixture({extra:true}),fixture({transitioning:true}),fixture({running:false}),fixture({generation:4294967295})]) {
|
||||
const b=await open(v); assert.ok(n(b,'stop').disabled && n(b,'restart').disabled && n(b,'reboot').disabled); assert.equal(posts(b).length,0);
|
||||
}
|
||||
for(const v of [fixture({transitioning:true,controllable:false}),fixture({generation:4294967295,controllable:false})]) {const b=await open(v);assert.ok(n(b,'stop').disabled);}
|
||||
for(const v of [fixture({transitioning:true,controllable:false,rotatable:false}),fixture({generation:4294967295,controllable:false,rotatable:false})]) {const b=await open(v);assert.ok(n(b,'stop').disabled);}
|
||||
const b=await open(); b.queues[path].push(failure(503)); b.click('lifecycle-refresh'); await tick(); assert.ok(n(b,'stop').disabled); assert.equal(b.sockets.length,2);
|
||||
});
|
||||
await test('Lifecycle captures confirmation before delayed original-session validation and gates double click',async()=>{
|
||||
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); b.queues[op].push(reply('pending',42,202)); b.click('lifecycle-stop'); await tick();
|
||||
b.click('lifecycle-reboot'); b.click('lifecycle-refresh'); await tick(); assert.equal(posts(b).length,0);
|
||||
d.resolve(session({role:'admin'})); await tick(); assert.equal(posts(b).length,1); assert.equal(JSON.parse(posts(b)[0].body).generation,7);
|
||||
for(const state of ['failed','cancelled']) {b.queues[op].push(reply(state));b.click('lifecycle-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='failed'?/may already have occurred/:/before lifecycle admission/);}
|
||||
for(const state of ['failed','cancelled']) {b.queues[op].push(reply(state));b.click('lifecycle-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='failed'?/may already be persisted/:/before lifecycle admission/);}
|
||||
assert.equal(posts(b).length,1);
|
||||
});
|
||||
await test('Lifecycle lost ACK/replaced result never clears pending or adopts old action results',async()=>{
|
||||
|
||||
Reference in New Issue
Block a user