Add typed account and password settings

- Add admin account list, create, role, delete, and password workflows
- Execute identity-checked mutations through the existing dispatcher
- Bound queued credential lifetime and wipe transient secrets
- Add explicit password generation with saved-value acknowledgement
- Handle self-revocation and uncertain outcomes without automatic
  retries
- Register optional account routes without disrupting terminal
  transports
- Expand host regressions and document contracts and pending target
  checks

Validated host suites and pio run; hardware validation remains pending.
This commit is contained in:
2026-09-08 09:27:02 +02:00
parent 42548f6334
commit 94433ef975
30 changed files with 1864 additions and 48 deletions
+297 -4
View File
@@ -11,9 +11,9 @@ const serialSettings = (extra = {}) => ({running: true, baud: 230400, data_bits:
const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}});
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; };
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': []};
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': []};
const fits = [];
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
@@ -39,7 +39,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
location: {origin: 'https://sak.local', replace: path => redirects.push(path)}};
const context = vm.createContext({window, document: {getElementById(id) {
return nodes[id] ||= {textContent: '', dataset: {}, classList: {toggle() {}},
return nodes[id] ||= {textContent: '', value: '', checked: false, dataset: {}, classList: {toggle() {}},
setAttribute(k, v) { this[k] = v; },
getBoundingClientRect: () => ({width: 100, height: 100}),
addEventListener(k, fn) { this[k] = fn; }};
@@ -56,9 +56,10 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
calls.push({url, ...options});
const next = queues[url].shift();
if (next !== undefined) return typeof next === 'function' ? next(options) : next;
if (url === '/api/session') return session({role});
if (url === '/api/session') return session({role, username});
if (url === '/api/status') return json({});
if (url === '/api/settings/serial') return json(serialSettings());
if (url === '/api/settings/accounts') return json({users: [{username: 'alice', user_id: 1, auth_generation: 2, role: 'admin'}, {username: 'carol', user_id: 7, auth_generation: 2, role: 'user'}]});
if (url === '/api/ws-ticket') return ticket();
if (url === '/api/admin/ws-ticket') return json({ticket: '0123456789abcdef'.repeat(4), expires_in: 30});
throw new Error('network unavailable');
@@ -782,6 +783,94 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
assert.equal(b.nodes['setting-baud'].textContent, '230400');
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
});
async function accountsBrowser() {
const b = browser({role: 'admin', username: 'alice'}); b.start(); await tick(); b.sockets[0].emit('open');
b.click('select-admin'); b.click('admin-toggle'); await tick(); b.sockets[1].emit('open');
b.click('select-settings'); await tick(); b.click('settings-accounts'); await tick();
return b;
}
const accountPath = '/api/settings/account-operation';
const accountReply = (id, state, action = 'role') => json({id, state, action});
await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => {
const u = await connected(); u.click('settings-accounts'); await tick();
assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts'));
const b = await accountsBrowser();
assert.match(b.nodes['accounts-list'].textContent, /alice.*admin.*you/);
assert.match(b.nodes['accounts-list'].textContent, /carol.*user/);
assert.ok(!b.nodes['account-delete'].disabled);
const calls=b.calls.length; b.window.confirm=()=>false; b.click('account-delete'); await tick(); assert.equal(b.calls.length,calls); b.window.confirm=()=>true;
b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); assert.ok(!b.nodes['account-delete'].disabled);
for(let i=0;i<3;++i) { b.click('settings-serial'); await tick(); b.click('settings-accounts'); await tick(); }
assert.equal(b.sockets.length,2); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
for (const bad of [{users:[{username:'<img>',role:'user',user_id:1,auth_generation:1}]}, {users:Array(9).fill({})}, {users:[],password:'SECRET'}, {users:[{username:'safe',role:'admin',user_id:0,auth_generation:1}]}]) {
b.queues['/api/settings/accounts'].push(json(bad)); b.click('refresh-accounts'); await tick();
assert.match(b.nodes['accounts-detail'].textContent,/stale/); assert.ok(!b.nodes['accounts-list'].textContent.includes('SECRET'));
}
});
await test('Accounts role/delete confirmation, typed identity, automatic result and list refresh', async () => {
for (const action of ['role','delete']) {
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); b.nodes['account-role'].value='admin';
const button=action==='role'?'account-change-role':'account-delete', before=b.calls.length;
b.window.confirm=()=>false; b.click(button); await tick(); assert.equal(b.calls.length,before);
b.window.confirm=()=>true; b.queues[accountPath].push(accountReply(10,'pending',action)); b.click(button); await tick();
const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1);
assert.deepEqual(JSON.parse(posts[0].body),{action,username:'carol',user_id:7,auth_generation:2,...(action==='role'?{role:'admin'}:{})});
assert.equal(posts[0].headers['X-CSRF-Token'],token); assert.ok(b.nodes['account-target'].disabled);
for (const state of ['pending','ok']) { b.queues[accountPath].push(accountReply(10,state,action)); b.fire(1000); await tick(); }
assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/);
assert.match(b.nodes['accounts-detail'].textContent,/refreshed/);
assert.equal(b.calls.filter(c=>c.url==='/api/settings/accounts').length,2);
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
await test('Accounts bounded checks exhaust to manual recovery without POST retry', async () => {
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
b.queues[accountPath].push(accountReply(11,'pending')); b.click('account-change-role'); await tick();
for(let i=0;i<10;++i) { b.queues[accountPath].push(accountReply(11,'pending')); b.elapse(1000); b.fire(1000); await tick(); }
assert.match(b.nodes['account-operation-detail'].textContent,/stopped.*Check Result/); assert.ok(!b.nodes['account-result'].disabled);
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='GET').length,10);
b.queues[accountPath].push(accountReply(11,'ok')); b.click('account-result'); await tick();
assert.match(b.nodes['account-operation-detail'].textContent,/completed/);
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1);
});
await test('Accounts cancellation fences pending posts, checks and refreshes on domain/view/pagehide', async () => {
for (const mode of ['domain','view','pagehide']) {
const b=await accountsBrowser(), d=deferred(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
b.queues[accountPath].push(d.promise); b.click('account-change-role'); await tick();
const request=b.calls.filter(c=>c.url===accountPath).at(-1);
if(mode==='domain') b.click('settings-serial'); else if(mode==='view') b.click('select-serial'); else b.emit('pagehide');
assert.ok(request.signal.aborted); const detail=b.nodes['account-operation-detail'].textContent;
d.resolve(accountReply(12,'pending')); await tick(); assert.equal(b.nodes['account-operation-detail'].textContent,detail);
assert.equal(b.nodes['accounts-list'].textContent,'');
assert.ok(!b.calls.some(c=>c.url===accountPath && c.method==='GET'));
}
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
b.queues[accountPath].push(accountReply(13,'pending')); b.click('account-change-role'); await tick();
b.click('settings-serial'); await tick(); assert.ok(![...b.timers.values()].some(t=>t.ms===1000 || t.ms===15000));
});
await test('Accounts timeout, stale/protected/failed outcomes, failed refresh and unknown acknowledgement', async () => {
for(const state of ['stale','protected','failed','cancelled']) {
const b=await accountsBrowser(); b.queues[accountPath].push(accountReply(14,state)); b.queues['/api/settings/accounts'].push(failure(503));
b.click('account-result'); await tick(); assert.match(b.nodes['accounts-detail'].textContent,/stale/); assert.match(b.nodes['accounts-list'].textContent,/carol/);
}
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
b.queues[accountPath].push(()=>{throw new Error('lost');}); b.click('account-change-role'); await tick();
assert.match(b.nodes['account-operation-detail'].textContent,/unknown/);
for(let i=0;i<2;++i) { b.queues[accountPath].push(accountReply(15,'ok')); b.click('account-result'); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/Acknowledgement lost/); }
b.queues[accountPath].push(accountReply(16,'pending')); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin'; b.click('account-change-role'); await tick();
const d=deferred(); b.queues[accountPath].push(d.promise); b.fire(1000); await tick();
b.elapse(15000); b.fire(15000); await tick(); d.resolve(accountReply(16,'ok')); await tick();
assert.match(b.nodes['account-operation-detail'].textContent,/unknown/); assert.ok(!b.nodes['account-result'].disabled);
});
await test('Accounts 401 and identity changes close routes without adopting stale list', async () => {
for(const identity of [false,true]) {
const b=await accountsBrowser();
if(identity) b.queues['/api/session'].push(session({role:'admin',username:'replacement'}));
else b.queues['/api/settings/accounts'].push(failure(401));
b.click('refresh-accounts'); await tick(); assert.deepEqual(b.redirects,[identity?'/':'/login']);
assert.ok(b.sockets.every(s=>s.closed)); assert.equal(b.nodes['accounts-list'].textContent,'');
}
});
await test('Routine actions never confirm; Reset cancellation has no request or state change', async () => {
const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick();
const confirms = []; b.window.confirm = message => { confirms.push(message); return false; };
@@ -796,5 +885,209 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
assert.equal(confirms.length, 1); assert.match(confirms[0], /overwrites saved NVS configuration/);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 6);
});
const generatePath = '/api/settings/accounts/generate-password', secret = 'Abcdefghijklmnopqrst_-12';
function draft(b, purpose = 'create', password = ' typed "pass\\word ') {
b.nodes['account-purpose'].value = purpose; b.nodes['account-purpose'].change();
if (purpose === 'create') { b.nodes['account-username'].value = 'new_account'; b.nodes['account-username'].input(); }
b.nodes['account-password'].value = b.nodes['account-password-confirm'].value = password;
}
function cleanSecret(b) {
for (const id of ['account-password','account-password-confirm','account-generated']) assert.equal(b.nodes[id].value, '', id);
assert.equal(b.nodes['account-password-saved'].checked, false); assert.ok(b.nodes['account-generated-panel'].hidden);
}
function noSecretOutput(b, value = secret) {
for (const [id, node] of Object.entries(b.nodes)) assert.ok(!node.textContent.includes(value), id);
}
async function generated(b) {
b.queues[generatePath].push(json({password: secret})); b.click('account-generate'); await tick();
assert.equal(b.nodes['account-generated'].value, secret);
b.nodes['account-password-confirm'].value = secret; b.nodes['account-password-confirm'].input();
b.nodes['account-password-saved'].checked = true; b.nodes['account-password-saved'].change();
}
await test('Create/password exact JSON, escaped printable ASCII and bounds, CSRF and isolated sockets', async () => {
for (const purpose of ['create','password']) for (const password of [' '.repeat(12), ' typed "pass\\word ', '\\'.repeat(64)]) {
const b = await accountsBrowser(); draft(b, purpose, password);
b.nodes['account-target'].value = '1'; b.nodes['account-create-role'].value = 'admin';
b.queues[accountPath].push(accountReply(20, 'pending', purpose)); b.click('account-submit-password'); cleanSecret(b); await tick();
const post = b.calls.find(c => c.url === accountPath && c.method === 'POST'); assert.ok(post);
assert.deepEqual(JSON.parse(post.body), purpose === 'create' ? {action:purpose, username:'new_account', role:'admin', password} : {action:purpose, username:'carol', user_id:7, auth_generation:2, password});
assert.ok(Buffer.byteLength(post.body) <= 768); assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
b.queues[accountPath].push(accountReply(20, 'ok', purpose)); b.fire(1000); await tick();
noSecretOutput(b, password); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
await test('Credential validation rejects short/long/non-ASCII/mismatch and invalid usernames; every attempt wipes', async () => {
for (const password of ['a'.repeat(11), 'a'.repeat(65), 'é'.repeat(12), 'abcde\nfghijklm', 'abcde\u007ffghijklm']) {
const b=await accountsBrowser(); draft(b, 'create', password); b.click('account-submit-password'); await tick(); cleanSecret(b);
assert.ok(!b.calls.some(c=>c.url===accountPath));
}
for (const name of ['', 'Aname', 'a'.repeat(17), 'a b', '<img>']) {
const b=await accountsBrowser(); draft(b); b.nodes['account-username'].value=name; b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
const b=await accountsBrowser(); draft(b); b.nodes['account-password-confirm'].value='different password'; b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
});
await test('Explicit generation is bodyless CSRF, bounded, separate from account list and mutation slot', async () => {
const b=await accountsBrowser(); draft(b); const listReads=b.calls.filter(c=>c.url==='/api/settings/accounts').length;
assert.ok(!b.calls.some(c=>c.url===generatePath)); await generated(b);
const post=b.calls.find(c=>c.url===generatePath); assert.equal(post.body,''); assert.equal(post.headers['X-CSRF-Token'],token); assert.equal(post.headers['Content-Type'],undefined);
assert.equal(b.calls.filter(c=>c.url==='/api/settings/accounts').length,listReads); assert.ok(!b.calls.some(c=>c.url===accountPath)); noSecretOutput(b);
assert.ok([...b.timers.values()].some(t=>t.ms===60000));
b.queues[accountPath].push(accountReply(21,'pending','create')); b.click('account-submit-password'); cleanSecret(b); await tick();
assert.equal(JSON.parse(b.calls.find(c=>c.url===accountPath).body).password,secret);
});
await test('Generated acknowledgement binds exact value and intent; edits and regeneration reset it', async () => {
for (const edit of ['unchecked','password','confirm','username','role','target','purpose','regenerate','silent-target','silent-password']) {
const b=await accountsBrowser(); draft(b); await generated(b);
if(edit==='unchecked') b.nodes['account-password-saved'].checked=false;
if(edit==='password') { b.nodes['account-password'].value='different password'; b.nodes['account-password'].input(); }
if(edit==='confirm') b.nodes['account-password-confirm'].input();
if(edit==='username') { b.nodes['account-username'].value='another'; b.nodes['account-username'].input(); }
if(edit==='role') { b.nodes['account-create-role'].value='admin'; b.nodes['account-create-role'].change(); }
if(edit==='target' || edit==='silent-target') { b.nodes['account-target'].value='1'; if(edit==='target') b.nodes['account-target'].change(); }
if(edit==='purpose') { b.nodes['account-purpose'].value='password'; b.nodes['account-purpose'].change(); }
if(edit==='regenerate') { b.queues[generatePath].push(json({password:secret})); b.click('account-generate'); await tick(); }
if(edit==='silent-password') b.nodes['account-password'].value=b.nodes['account-password-confirm'].value='different password';
if(!edit.startsWith('silent')) assert.equal(b.nodes['account-password-saved'].checked,false);
b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
});
await test('Generated lifetime wipes at 60 seconds and expired delayed timer cannot authorize submission', async () => {
for(const timer of [true,false]) {
const b=await accountsBrowser(); draft(b); await generated(b); b.elapse(60000);
if(timer) b.fire(60000); else b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
});
await test('All secret lifecycle cleanup: view/domain/target/purpose/refresh/pagehide/logout/identity/401', async () => {
for(const mode of ['view','domain','target','purpose','refresh','pagehide','logout','identity','401']) {
const b=await accountsBrowser(); draft(b); await generated(b);
if(mode==='view') b.click('select-serial');
if(mode==='domain') b.click('settings-serial');
if(mode==='target') { b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); }
if(mode==='purpose') { b.nodes['account-purpose'].value='password'; b.nodes['account-purpose'].change(); }
if(mode==='refresh') b.click('refresh-accounts');
if(mode==='pagehide') b.emit('pagehide');
if(mode==='logout') b.click('sign-out');
if(mode==='identity' || mode==='401') { b.queues['/api/session'].push(mode==='identity'?session({role:'admin',username:'newadmin'}):failure(401)); b.click('refresh-accounts'); }
await tick(); cleanSecret(b); noSecretOutput(b); assert.ok(![...b.timers.values()].some(t=>t.ms===60000));
}
});
await test('Late generation headers and streamed bodies cannot resurrect secrets after form cancellation', async () => {
for(const body of [false,true]) for(const mode of ['target','edit','purpose','domain','pagehide','logout','refresh']) {
const b=await accountsBrowser(); draft(b); const d=deferred(); let stream;
b.queues[generatePath].push(body?new Response(new ReadableStream({start(c){stream=c;}})):d.promise);
b.click('account-generate'); await tick(); const post=b.calls.find(c=>c.url===generatePath);
if(mode==='target') b.nodes['account-target'].change();
if(mode==='edit') b.nodes['account-password'].input();
if(mode==='purpose') b.nodes['account-purpose'].change();
if(mode==='domain') b.click('settings-serial');
if(mode==='pagehide') b.emit('pagehide');
if(mode==='logout') b.click('sign-out');
if(mode==='refresh') b.click('refresh-accounts');
assert.ok(post.signal.aborted);
if(body) { stream.enqueue(new TextEncoder().encode(JSON.stringify({password:secret}))); stream.close(); } else d.resolve(json({password:secret}));
await tick(); cleanSecret(b); noSecretOutput(b); assert.equal(b.calls.filter(c=>c.url===generatePath).length,1); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
});
await test('Generation errors/schema/96-byte overflow/timeouts never echo response or retry', async () => {
for(const response of [failure(503), failure(403), json({password:secret,extra:1}),json({password:'!'.repeat(24)}),json({password:'x'.repeat(25)}),new Response(' '.repeat(97)),json({password:secret+' '.repeat(100)})]) {
const b=await accountsBrowser(); draft(b); b.queues[generatePath].push(response); b.click('account-generate'); await tick(); cleanSecret(b); noSecretOutput(b); noSecretOutput(b,'SECRET ERROR BODY');
assert.match(b.nodes['account-secret-detail'].textContent,/Nothing applied/); assert.equal(b.calls.filter(c=>c.url===generatePath).length,1);
}
const b=await accountsBrowser(); draft(b); const d=deferred(); b.queues[generatePath].push(d.promise); b.click('account-generate'); await tick(); b.fire(15000); d.resolve(json({password:secret})); await tick(); cleanSecret(b);
});
await test('Generation timeout releases controls on fetch abort; explicit retry is isolated from serial/admin', async () => {
const b = await accountsBrowser(); draft(b);
b.queues[generatePath].push(options => new Promise((_, reject) => {
options.signal.addEventListener('abort', () => reject(new Error('SECRET timeout')), {once:true});
}));
b.click('account-generate'); await tick(); assert.ok(b.nodes['account-generate'].disabled);
b.fire(15000); await tick(); cleanSecret(b); noSecretOutput(b, 'SECRET timeout');
assert.ok(!b.nodes['account-generate'].disabled && !b.nodes['account-submit-password'].disabled);
assert.match(b.nodes['account-secret-detail'].textContent, /Nothing applied/);
assert.equal(b.calls.filter(c => c.url === generatePath).length, 1);
assert.ok(!b.calls.some(c => c.url === accountPath));
await generated(b);
assert.equal(b.calls.filter(c => c.url === generatePath).length, 2);
assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
});
await test('Generation endpoint 401 wipes secrets and closes both routes once without a mutation', async () => {
const b = await accountsBrowser(); draft(b); await generated(b);
b.queues[generatePath].push(failure(401)); b.click('account-generate'); await tick();
cleanSecret(b); noSecretOutput(b); noSecretOutput(b, 'SECRET ERROR BODY');
assert.deepEqual(b.redirects, ['/login']); assert.ok(b.sockets.every(s => s.closed));
assert.equal(b.timers.size, 0); assert.ok(!b.calls.some(c => c.url === accountPath));
b.window.sakSessionExpired(); assert.deepEqual(b.redirects, ['/login']);
});
await test('Credential pre-submit session cancellation fences late responses and never sends the password', async () => {
for (const purpose of ['create', 'password']) for (const mode of ['view', 'pagehide', 'identity', '401']) {
const b = await accountsBrowser(); draft(b, purpose); await generated(b);
const pending = deferred(); b.queues['/api/session'].push(pending.promise);
b.click('account-submit-password'); cleanSecret(b); await tick();
const check = b.calls.filter(c => c.url === '/api/session').at(-1);
if (mode === 'view') b.click('select-serial');
if (mode === 'pagehide') b.emit('pagehide');
pending.resolve(mode === 'identity' ? session({role:'admin', username:'replacement'}) :
mode === '401' ? failure(401) : session({role:'admin', username:'alice'}));
await tick(); cleanSecret(b); noSecretOutput(b);
assert.ok(check.signal.aborted); assert.ok(!b.calls.some(c => c.url === accountPath));
assert.deepEqual(b.redirects, mode === 'identity' ? ['/'] : mode === '401' ? ['/login'] : []);
assert.ok(b.sockets.every(s => mode === 'view' ? !s.closed : s.closed));
}
});
await test('Self password/role/delete confirmation, no preemptive logout; 401 closes without success claim', async () => {
for(const action of ['password','role','delete']) for(const stage of ['post','poll']) {
const b=await accountsBrowser(); let warning=''; b.window.confirm=m=>{warning=m;return true;};
if(action==='password') { draft(b,'password'); await generated(b); }
const button=action==='password'?'account-submit-password':action==='role'?'account-change-role':'account-delete';
b.queues[accountPath].push(stage==='post'?failure(401):accountReply(25,'pending',action)); b.click(button); cleanSecret(b);
assert.ok(b.sockets.every(s=>!s.closed)); await tick();
assert.match(warning,/ALL.*web\/SSH.*browser serial\/admin/); assert.match(warning,/401.*NOT proof/); assert.match(warning,/even a no-op role/); assert.ok(!warning.includes(secret));
if(stage==='poll') { assert.ok(b.sockets.every(s=>!s.closed)); b.queues['/api/session'].push(failure(401)); b.fire(1000); await tick(); }
assert.deepEqual(b.redirects,['/login']); assert.ok(b.sockets.every(s=>s.closed)); cleanSecret(b); noSecretOutput(b);
assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/completed and saved/); assert.ok(!b.calls.some(c=>c.url==='/api/logout'));
}
});
await test('Cancelled confirmation and failed credential POST wipe; duplicate/full safe result messages', async () => {
for(const cancel of [true,false]) {
const b=await accountsBrowser(); draft(b); await generated(b); b.window.confirm=()=>!cancel;
b.queues[accountPath].push(failure(503)); b.click('account-submit-password'); cleanSecret(b); await tick(); noSecretOutput(b); assert.equal(b.calls.filter(c=>c.url===accountPath).length,cancel?0:1);
}
for(const state of ['duplicate','full']) { const b=await accountsBrowser(); b.queues[accountPath].push(accountReply(27,state,'create')); b.click('account-result'); await tick(); assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/undefined|unknown/); }
});
await test('Rejected self mutations retain both routes and lease without logout or success claims', async () => {
for (const action of ['password', 'role', 'delete']) for (const outcome of [403, 'protected', 'failed', 'stale']) {
const b = await accountsBrowser();
b.sockets[0].emit('message', {data:JSON.stringify({type:'hello', clientId:8, writerId:8, role:'writer'})});
if (action === 'password') { draft(b, 'password'); await generated(b); }
b.queues[accountPath].push(outcome === 403 ? failure(403) : accountReply(28, 'pending', action));
b.click(action === 'password' ? 'account-submit-password' : action === 'role' ? 'account-change-role' : 'account-delete');
await tick();
if (outcome !== 403) { b.queues[accountPath].push(accountReply(28, outcome, action)); b.fire(1000); await tick(); }
cleanSecret(b); noSecretOutput(b); assert.deepEqual(b.redirects, []);
assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
assert.equal(b.nodes['writer-id'].textContent, '8'); assert.equal(b.nodes['release-control'].disabled, false);
assert.doesNotMatch(b.nodes['account-operation-detail'].textContent, /completed and saved/);
assert.ok(!b.calls.some(c => c.url === '/api/logout'));
assert.equal(b.calls.filter(c => c.url === accountPath && c.method === 'POST').length, 1);
}
});
await test('Generation session checks cannot strand reconnect; newer admission fences old generation', async () => {
const b=await accountsBrowser(); draft(b); const old=deferred(); b.queues['/api/session'].push(old.promise);
b.click('account-generate'); await tick(); b.click('connection-toggle'); b.click('connection-toggle'); await tick();
old.resolve(session({role:'admin',username:'alice'})); await tick();
assert.equal(b.sockets.length,3); assert.ok(!b.sockets[1].closed); assert.ok(!b.calls.some(c=>c.url===generatePath)); cleanSecret(b);
await generated(b); assert.equal(b.nodes['account-generated'].value,secret);
const c=await accountsBrowser(); draft(c); const reconnect=deferred(); c.click('connection-toggle'); c.queues['/api/session'].push(reconnect.promise); c.click('connection-toggle'); await tick();
await generated(c); reconnect.resolve(session({role:'admin',username:'alice'})); await tick(); assert.equal(c.sockets.length,3); assert.equal(c.nodes['account-generated'].value,secret);
});
await test('Old generation/refresh responses cannot overwrite new target snapshot or generated value', async () => {
const b=await accountsBrowser(); draft(b); const old=deferred(); b.queues[generatePath].push(old.promise); b.click('account-generate'); await tick();
b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); await generated(b);
old.resolve(json({password:'x'.repeat(24)})); await tick(); assert.equal(b.nodes['account-generated'].value,secret); assert.equal(b.nodes['account-password-saved'].checked,true);
const stale=deferred(); b.queues['/api/settings/accounts'].push(stale.promise); b.click('refresh-accounts'); await tick(); cleanSecret(b);
b.click('settings-serial'); await tick(); b.click('settings-accounts'); await tick(); draft(b); await generated(b);
stale.resolve(json({users:[{username:'replaced',role:'user',user_id:90,auth_generation:99}]})); await tick();
assert.match(b.nodes['accounts-list'].textContent,/alice/); assert.doesNotMatch(b.nodes['accounts-list'].textContent,/replaced/); assert.equal(b.nodes['account-generated'].value,secret);
});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });