This commit is contained in:
Mateusz Gruszczyński committed 2026-10-14 21:36:19 +02:00
1 parent c41d1e00a2
commit 6987b3ce33
98 files changed
+146 -10827

No files matched your search

+1 -2
View File
@@ -2,9 +2,8 @@
These files are not required by the running RustProxy proxy process.
- `checks/` - compiler-independent source/release/documentation contracts and UI checks.
- `dev/` - local mock services and diagnostic traffic/compression helpers.
- `audit/` - explicit operator audit/probe tools.
- `legacy/` - retained compatibility/release-test helpers; not the supported production workflow.
Production packages do not need this tree unless a packaging/test workflow intentionally ships it.
The removed `checks/` tree is not shipped. Use `./dev.sh check` and `./dev.sh test` for Rust validation; source/upgrade preflight lives in `scripts/preflight.py`.
@@ -1,52 +0,0 @@
// Test the real admin settings normalization helpers and visible form wiring;
// source-level UI test only, not a Rust or browser integration test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const js = fs.readFileSync('src/admin/web/settings.js','utf8');
const html = fs.readFileSync('src/admin/web/index.html','utf8');
const en = JSON.parse(fs.readFileSync('langs/en_US.json','utf8'));
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json','utf8'));
const api = JSON.parse(fs.readFileSync('src/admin/openapi.json','utf8'));
const begin = js.indexOf(' function pinnedAdminHostname(value) {');
const end = js.indexOf(' const normalizedRequestedTab', begin);
assert(begin >= 0 && end > begin, 'real admin form normalization helpers must exist');
const sandbox = vm.createContext({URL});
vm.runInContext(js.slice(begin,end),sandbox);
const normalize = (hosts, pin) => Array.from(vm.runInContext(
`additionalAdminHosts(${JSON.stringify(hosts)},${JSON.stringify(pin)})`,sandbox));
const cases = [
{name:'empty form',hosts:[],pin:null,expected:[]},
{name:'simple single origin',hosts:[],pin:'https://panel.example.test',expected:[]},
{name:'0.31.4 repeated host',hosts:['panel.example.test'],pin:'https://panel.example.test',expected:[]},
{name:'case and trailing dot',hosts:['PANEL.Example.Test.',' other.example.test '],pin:'https://panel.example.test:8443',expected:['other.example.test']},
{name:'multiple additional names',hosts:['foo.example.test','panel.example.test','bar.example.test'],pin:'https://panel.example.test',expected:['foo.example.test','bar.example.test']},
{name:'pin changed',hosts:['other.example.test'],pin:'https://new.example.test',expected:['other.example.test']},
{name:'no pin means independent allowlist',hosts:['panel.example.test'],pin:'',expected:['panel.example.test']},
{name:'invalid URL delegates to server validation',hosts:['other.example.test'],pin:'not a url',expected:['other.example.test']},
{name:'blank host lines removed',hosts:['',' panel.example.test ',' '],pin:null,expected:['panel.example.test']},
];
for (const item of cases) {
assert.deepEqual(normalize(item.hosts,item.pin),item.expected,item.name);
}
const admin = html.split('id="settings-page-administration"')[1].split('id="settings-page-system-info"')[0];
for (const id of ['admin-public-origin','admin-extra-hosts-details','admin-hosts'])
assert(admin.includes(`id="${id}"`),`missing ${id}`);
assert(admin.indexOf('id="admin-public-origin"') < admin.indexOf('id="admin-extra-hosts-details"'));
assert(admin.includes('<details id="admin-extra-hosts-details">'));
assert(js.includes('$("admin-extra-hosts-details").open = extraHosts.length > 0'));
assert(js.includes('additionalAdminHosts(admin.allowed_hosts || [], admin.public_origin)'));
assert(js.includes('allowed_hosts: additionalAdminHosts($("admin-hosts").value.split('));
assert(!js.includes('allowed_hosts: $("admin-hosts").value.split('));
for (const key of ['administrationHosts','administrationHostsAdvanced','administrationHostsHelp','administrationPublicOriginHelp']) {
assert.equal(typeof en.translations[key],'string'); assert.equal(typeof pl.translations[key],'string');
assert(en.translations[key].length > 15 && pl.translations[key].length > 15,key);
}
// Config contract remains compatible; the pin allows a host implicitly.
const spec = api.components?.schemas || api.definitions;
const specText=JSON.stringify(spec);
assert(specText.includes('implicitly allowed'));
assert(specText.includes('automatically allowed'));
console.log(`PASS: ${cases.length} real settings-helper scenarios, HTML/wiring, locale parity and OpenAPI descriptions`);
@@ -1,210 +0,0 @@
// Reconstruct ONLY the RustProxy 0.31.4 administration Origin guard decision.
// Real-source anchors guard against accidental model/source drift. This is NOT
// a Rust server, not a browser, and not an HTTP/2 or TLS integration test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import {fileURLToPath} from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
const source = {
settings: read('src/settings.rs') + read('src/settings/public_origin.rs'), access: read('src/admin/access.rs'),
session: read('src/admin/session.rs'), forwarding: read('src/forwarding.rs'),
};
for (const [part, tokens] of Object.entries({
settings: ['pub public_origin: Option<String>', 'fn normalize_public_admin_origin(public_origin: &str)', 'fn include_public_origin_host(&mut self)', 'fn explicitly_allowed_panel_host(&self, host: &str)', 'self.public_origin != active.public_origin'],
access: ['fn configured_admin_public_authority(', 'fn pinned_admin_proto_compatible(', 'hops > 8', 'scheme != "https"', 'trusted_admin_proxy(state, peer)'],
session: ['configured_admin_public_authority(&state, headers, peer, &raw_host, transport)', 'automation_credentials', 'pinned_origin_mismatch', '"origin_mismatch"', '"cross_site_fetch"'],
forwarding: ['self.trust_proto && self.trusted(ip)'],
})) for (const token of tokens) assert(source[part].includes(token), `Source contract missing: ${part}/${token}`);
const defaults = {host:'127.0.0.1:9090', 'x-forwarded-host':'panel.example.test',
'x-forwarded-proto':'https', 'sec-fetch-site':'same-origin'};
const context = {listenIp:'127.0.0.1', listenPort:9090, tls:false,
peerTrusted:true, trustProto:true, publicOrigin:null, allowedHosts:['panel.example.test'],
automation:false};
const normal = x => x.toLowerCase();
function authority(value) {
if (typeof value !== 'string' || !value || value.includes('@') || value.includes(',') || value.includes('/')) return null;
try {
const parsed = new URL(`https://${value}/`);
if (parsed.username || parsed.password || parsed.hash || parsed.search || parsed.pathname !== '/') return null;
const host = parsed.hostname.toLowerCase();
return {host, port:parsed.port ? Number(parsed.port) : null};
} catch {return null;}
}
function sameAuthority(a,b,scheme) {
const left=authority(a), right=authority(b);
if (!left || !right) return false;
const defaultPort = scheme === 'https' ? 443 : 80;
return left.host===right.host && (left.port ?? defaultPort)===(right.port ?? defaultPort);
}
function hostAllowed(value, tls, cfg) {
const actual=authority(value);
if (!actual) return false;
const port=actual.port ?? (tls ? 443 : 80);
// Effective runtime/desired allowlist also includes the public origin host;
// only extra names are persisted in allowed_hosts.
const pinnedHost=cfg.publicOrigin ? new URL(cfg.publicOrigin).hostname.toLowerCase() : null;
return (actual.host===cfg.listenIp && port===cfg.listenPort)
|| cfg.allowedHosts.includes(actual.host)
|| actual.host===pinnedHost;
}
const map = raw => {
const headers=new Map();
for (let i=0;i<raw.length;i+=2) {
const name=normal(raw[i]);
headers.set(name,[...(headers.get(name)||[]),raw[i+1]]);
}
return headers;
};
const all = (headers,name) => headers.get(name)||[];
const single = (headers,name) => all(headers,name).length===1 ? all(headers,name)[0] : null;
function protoCompatible(headers) {
let hops=0;
for (const value of all(headers,'x-forwarded-proto')) {
for (const part of value.split(',')) {
hops++;
const scheme=part.trim();
if (hops>8 || (hops===1 && scheme!=='https') || !['http','https'].includes(scheme)) return false;
}
}
return true;
}
function originMatches(value,scheme,external) {
if (typeof value!=='string' || value.includes('#') || value.includes('@')) return false;
try {
const uri=new URL(value);
if (uri.protocol!==`${scheme}:` || uri.pathname!=='/' || uri.search || uri.hash || uri.username || uri.password) return false;
return sameAuthority(uri.host, external, scheme);
} catch {return false;}
}
function guard(headers,cfg) {
const rawHost=single(headers,'host');
if (!rawHost) return {status:403,reason:'invalid_host'};
let scheme=cfg.tls ? 'https' : 'http', external=rawHost;
// 0.31.4 public pin only behind a trusted last hop and only for panel traffic.
if (cfg.publicOrigin && cfg.peerTrusted && !cfg.automation) {
let pinned;
try { pinned=new URL(cfg.publicOrigin); } catch {return {status:403,reason:'pinned_origin_mismatch'};}
if (pinned.protocol!=='https:' || !hostAllowed(rawHost,cfg.tls,cfg) || !protoCompatible(headers))
return {status:403,reason:'pinned_origin_mismatch'};
const xfh=all(headers,'x-forwarded-host');
if (xfh.length>1 || xfh.length===1 && !sameAuthority(xfh[0],pinned.host,'https'))
return {status:403,reason:'pinned_origin_mismatch'};
scheme='https'; external=pinned.host;
} else {
if (!cfg.tls && cfg.peerTrusted && cfg.trustProto && all(headers,'x-forwarded-proto').length) {
const proto=single(headers,'x-forwarded-proto');
if (proto!=='https' && proto!=='http') return {status:403,reason:'external_scheme'};
scheme=proto;
}
if (cfg.peerTrusted && all(headers,'x-forwarded-host').length)
external=single(headers,'x-forwarded-host');
if (!hostAllowed(external,scheme==='https',cfg))
return {status:403,reason:'invalid_host'};
}
const originValues=all(headers,'origin');
if (originValues.length>1 || originValues.length===1 && !originMatches(originValues[0],scheme,external))
return {status:403,reason:'origin_mismatch'};
if (all(headers,'sec-fetch-site').includes('cross-site'))
return {status:403,reason:'cross_site_fetch'};
return {status:200,reason:'pass',scheme};
}
const cases=[];
const add=(name,patch,opts,expected)=>cases.push({name,patch,opts,expected});
const PIN={publicOrigin:'https://panel.example.test',allowedHosts:[]};
add('legacy normal same-origin GET', {},{},'pass');
add('no pin and no DNS allowlist rejects proxied host', {},{allowedHosts:[]},'invalid_host');
add('legacy malformed multi-hop chain', {'x-forwarded-proto':'https, http'},{},'external_scheme');
add('legacy duplicated XFP lines', {'x-forwarded-proto':['https','http']},{},'external_scheme');
add('legacy explicit DNS host without pin', {'host':'other.example.test','x-forwarded-host':'other.example.test'},
{allowedHosts:['other.example.test']}, 'pass');
add('legacy extra host without pin still denies unknown', {'host':'evil.example.test','x-forwarded-host':'evil.example.test'},
{allowedHosts:['other.example.test']}, 'invalid_host');
add('pin: normal GET, persisted allowed_hosts empty', {},PIN,'pass');
add('pin: public Host implicitly allowed', {'host':'panel.example.test'},PIN,'pass');
add('pin: unknown Host still rejected', {'host':'unknown.example.test'},PIN,'pinned_origin_mismatch');
add('pin: old 0.31.4 explicit hostname remains compatible', {}, {...PIN,allowedHosts:['panel.example.test']},'pass');
add('pin: additional DNS host accepted only with matching forwarded public Host',
{'host':'other.example.test'}, {...PIN,allowedHosts:['other.example.test']},'pass');
add('pin: changing public origin does not allow previous hostname',
{'host':'panel.example.test','x-forwarded-host':'new.example.test'},
{...PIN,publicOrigin:'https://new.example.test'},'pinned_origin_mismatch');
add('pin: GET multi-hop https then http',{'x-forwarded-proto':'https, http'},PIN,'pass');
add('pin: duplicate XFP lines',{'x-forwarded-proto':['https','http']},PIN,'pass');
add('pin: 8-hop bounded chain',{'x-forwarded-proto':'https, http, http, http, http, http, http, http'},PIN,'pass');
add('pin: 9-hop rejected',{'x-forwarded-proto':'https, http, http, http, http, http, http, http, http'},PIN,'pinned_origin_mismatch');
add('pin: no XFP; public origin configured',{'x-forwarded-proto':null},PIN,'pass');
add('pin: no XFH; internal Host still validated',{'x-forwarded-host':null,'origin':'https://panel.example.test'},PIN,'pass');
add('pin: wrong XFH',{'x-forwarded-host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
add('pin: different allowed Host but wrong configured URL',{'x-forwarded-host':'panel.example.test:8443'},PIN,'pinned_origin_mismatch');
add('pin: XFH userinfo rejected',{'x-forwarded-host':'attacker@panel.example.test'},PIN,'pinned_origin_mismatch');
add('pin: duplicate XFH lines',{'x-forwarded-host':['panel.example.test','panel.example.test']},PIN,'pinned_origin_mismatch');
add('pin: first hop downgraded HTTP',{'x-forwarded-proto':'http, https'},PIN,'pinned_origin_mismatch');
add('pin: HTTP only',{'x-forwarded-proto':'http'},PIN,'pinned_origin_mismatch');
add('pin: unrecognized protocol',{'x-forwarded-proto':'ftp, http'},PIN,'pinned_origin_mismatch');
add('pin: empty protocol element',{'x-forwarded-proto':'https, '},PIN,'pinned_origin_mismatch');
add('pin: invalid XFP capitalization',{'x-forwarded-proto':'HTTPS'},PIN,'pinned_origin_mismatch');
add('pin: correct explicit browser Origin',{'origin':'https://panel.example.test','x-forwarded-proto':'https, http'},PIN,'pass');
add('pin: correct explicit Origin with :443',{'origin':'https://panel.example.test:443'},PIN,'pass');
add('pin: attacker Origin rejected',{'origin':'https://attacker.example.test'},PIN,'origin_mismatch');
add('pin: Origin null rejected',{'origin':'null'},PIN,'origin_mismatch');
add('pin: conflicting Origin port rejected',{'origin':'https://panel.example.test:8443'},PIN,'origin_mismatch');
add('pin: cross-site API request rejected',{'sec-fetch-site':'cross-site'},PIN,'cross_site_fetch');
add('pin: untrusted peer must not apply pin',{'x-forwarded-proto':'https, http','origin':'https://panel.example.test'}, {...PIN,peerTrusted:false},'origin_mismatch');
add('pin: credential automation remains strict',{'x-forwarded-proto':'https, http'}, {...PIN,automation:true},'external_scheme');
add('pin: Host cannot spoof unallowlisted identity',{'host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
add('pin: correct alt port 8443',{'x-forwarded-host':'panel.example.test:8443','origin':'https://panel.example.test:8443'}, {...PIN, publicOrigin:'https://panel.example.test:8443'},'pass');
function encodedHeaders(patch) {
const merged={...defaults,...patch};
const result={};
for (const [key,value] of Object.entries(merged)) if (value!==null) result[key]=value;
return result;
}
const pending=[];
const server=http.createServer((req,res)=>{
const item=pending.shift();
if (!item) return res.writeHead(500).end('no scenario');
const result=guard(map(req.rawHeaders),{...context,...item.opts});
res.writeHead(result.status, {'content-type':'application/json'}).end(JSON.stringify(result));
});
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
async function request(item) {
pending.push(item);
return await new Promise((resolve,reject)=>{
const req=http.request({hostname:'127.0.0.1',port:server.address().port,path:'/api/auth/session',
agent:false,headers:encodedHeaders(item.patch)},res=>{
let data='';res.setEncoding('utf8');res.on('data',part=>data+=part);
res.on('end',()=>resolve(JSON.parse(data)));
});
req.on('error',reject);req.end();
});
}
try {
for (const item of cases) {
const result=await request(item);
assert.equal(result.reason,item.expected,item.name);
assert.equal(result.status,item.expected==='pass' ? 200:403,item.name);
}
const repeats=[];
for (const configured of [false,true]) {
for (let attempt=1;attempt<=12;attempt++) {
const malformed=attempt%4===0;
const result=await request({patch:{'x-forwarded-proto':malformed?'https, http':'https'},
opts:configured? PIN : {}});
assert.equal(result.status,configured?200:(malformed?403:200));
repeats.push({pin:configured,attempt,forwardedProto:malformed?'https, http':'https',status:result.status,reason:result.reason});
}
}
const summary={cases:cases.length,repeated:repeats.length,unconfigured403:repeats.filter(x=>!x.pin&&x.status===403).length,
pinned403:repeats.filter(x=>x.pin&&x.status===403).length};
const output={release:'0.31.4',classification:'Node.js HTTP simulation of admin guard, not compiled RustProxy',
assertions:cases.length+repeats.length,scenarios:cases.map(({name,expected})=>({name,expected})),repeats,summary};
if (process.argv[2]) fs.writeFileSync(process.argv[2],JSON.stringify(output,null,2)+'\n');
console.log(`PASS: ${output.assertions} HTTP-simulation assertions: ${summary.cases} cases + ${summary.repeated} resumes; `+
`403 with no pin ${summary.unconfigured403}/12, 403 with pin ${summary.pinned403}/12`);
} finally {server.close();}
@@ -1,196 +0,0 @@
// Reconstruct ONLY the RustProxy 0.31.4 administration Origin guard decision.
// Real-source anchors guard against accidental model/source drift. This is NOT
// a Rust server, not a browser, and not an HTTP/2 or TLS integration test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import {fileURLToPath} from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
const source = {
settings: read('src/settings.rs') + read('src/settings/public_origin.rs'), access: read('src/admin/access.rs'),
session: read('src/admin/session.rs'), forwarding: read('src/forwarding.rs'),
};
for (const [part, tokens] of Object.entries({
settings: ['pub public_origin: Option<String>', 'fn include_public_origin_host(&mut self)', 'self.public_origin != active.public_origin'],
access: ['fn configured_admin_public_authority(', 'fn pinned_admin_proto_compatible(', 'hops > 8', 'scheme != "https"', 'trusted_admin_proxy(state, peer)'],
session: ['configured_admin_public_authority(&state, headers, peer, &raw_host, transport)', 'automation_credentials', 'pinned_origin_mismatch', '"origin_mismatch"', '"cross_site_fetch"'],
forwarding: ['self.trust_proto && self.trusted(ip)'],
})) for (const token of tokens) assert(source[part].includes(token), `Source contract missing: ${part}/${token}`);
const defaults = {host:'127.0.0.1:9090', 'x-forwarded-host':'panel.example.test',
'x-forwarded-proto':'https', 'sec-fetch-site':'same-origin'};
const context = {listenIp:'127.0.0.1', listenPort:9090, tls:false,
peerTrusted:true, trustProto:true, publicOrigin:null, allowedHosts:['panel.example.test'],
automation:false};
const normal = x => x.toLowerCase();
function authority(value) {
if (typeof value !== 'string' || !value || value.includes('@') || value.includes(',') || value.includes('/')) return null;
try {
const parsed = new URL(`https://${value}/`);
if (parsed.username || parsed.password || parsed.hash || parsed.search || parsed.pathname !== '/') return null;
const host = parsed.hostname.toLowerCase();
return {host, port:parsed.port ? Number(parsed.port) : null};
} catch {return null;}
}
function sameAuthority(a,b,scheme) {
const left=authority(a), right=authority(b);
if (!left || !right) return false;
const defaultPort = scheme === 'https' ? 443 : 80;
return left.host===right.host && (left.port ?? defaultPort)===(right.port ?? defaultPort);
}
function hostAllowed(value, tls, cfg) {
const actual=authority(value);
if (!actual) return false;
const port=actual.port ?? (tls ? 443 : 80);
const pinnedHost=cfg.publicOrigin ? new URL(cfg.publicOrigin).hostname.toLowerCase() : null;
// The validated public panel hostname is derived at runtime.
return (actual.host===cfg.listenIp && port===cfg.listenPort)
|| cfg.allowedHosts.includes(actual.host)
|| actual.host===pinnedHost;
}
const map = raw => {
const headers=new Map();
for (let i=0;i<raw.length;i+=2) {
const name=normal(raw[i]);
headers.set(name,[...(headers.get(name)||[]),raw[i+1]]);
}
return headers;
};
const all = (headers,name) => headers.get(name)||[];
const single = (headers,name) => all(headers,name).length===1 ? all(headers,name)[0] : null;
function protoCompatible(headers) {
let hops=0;
for (const value of all(headers,'x-forwarded-proto')) {
for (const part of value.split(',')) {
hops++;
const scheme=part.trim();
if (hops>8 || (hops===1 && scheme!=='https') || !['http','https'].includes(scheme)) return false;
}
}
return true;
}
function originMatches(value,scheme,external) {
if (typeof value!=='string' || value.includes('#') || value.includes('@')) return false;
try {
const uri=new URL(value);
if (uri.protocol!==`${scheme}:` || uri.pathname!=='/' || uri.search || uri.hash || uri.username || uri.password) return false;
return sameAuthority(uri.host, external, scheme);
} catch {return false;}
}
function guard(headers,cfg) {
const rawHost=single(headers,'host');
if (!rawHost) return {status:403,reason:'invalid_host'};
let scheme=cfg.tls ? 'https' : 'http', external=rawHost;
// 0.31.4 public pin only behind a trusted last hop and only for panel traffic.
if (cfg.publicOrigin && cfg.peerTrusted && !cfg.automation) {
let pinned;
try { pinned=new URL(cfg.publicOrigin); } catch {return {status:403,reason:'pinned_origin_mismatch'};}
if (pinned.protocol!=='https:' || !hostAllowed(rawHost,cfg.tls,cfg) || !protoCompatible(headers))
return {status:403,reason:'pinned_origin_mismatch'};
const xfh=all(headers,'x-forwarded-host');
if (xfh.length>1 || xfh.length===1 && !sameAuthority(xfh[0],pinned.host,'https'))
return {status:403,reason:'pinned_origin_mismatch'};
scheme='https'; external=pinned.host;
} else {
if (!cfg.tls && cfg.peerTrusted && cfg.trustProto && all(headers,'x-forwarded-proto').length) {
const proto=single(headers,'x-forwarded-proto');
if (proto!=='https' && proto!=='http') return {status:403,reason:'external_scheme'};
scheme=proto;
}
if (cfg.peerTrusted && all(headers,'x-forwarded-host').length)
external=single(headers,'x-forwarded-host');
if (!hostAllowed(external,scheme==='https',cfg))
return {status:403,reason:'invalid_host'};
}
const originValues=all(headers,'origin');
if (originValues.length>1 || originValues.length===1 && !originMatches(originValues[0],scheme,external))
return {status:403,reason:'origin_mismatch'};
if (all(headers,'sec-fetch-site').includes('cross-site'))
return {status:403,reason:'cross_site_fetch'};
return {status:200,reason:'pass',scheme};
}
const cases=[];
const add=(name,patch,opts,expected)=>cases.push({name,patch,opts,expected});
const PIN={publicOrigin:'https://panel.example.test',allowedHosts:[]};
add('legacy normal same-origin GET', {},{},'pass');
add('legacy malformed multi-hop chain', {'x-forwarded-proto':'https, http'},{},'external_scheme');
add('legacy duplicated XFP lines', {'x-forwarded-proto':['https','http']},{},'external_scheme');
add('pin: normal GET', {},PIN,'pass');
add('pin: GET multi-hop https then http',{'x-forwarded-proto':'https, http'},PIN,'pass');
add('pin: duplicate XFP lines',{'x-forwarded-proto':['https','http']},PIN,'pass');
add('pin: 8-hop bounded chain',{'x-forwarded-proto':'https, http, http, http, http, http, http, http'},PIN,'pass');
add('pin: 9-hop rejected',{'x-forwarded-proto':'https, http, http, http, http, http, http, http, http'},PIN,'pinned_origin_mismatch');
add('pin: no XFP; public origin configured',{'x-forwarded-proto':null},PIN,'pass');
add('pin: no XFH; internal Host still validated',{'x-forwarded-host':null,'origin':'https://panel.example.test'},PIN,'pass');
add('pin: wrong XFH',{'x-forwarded-host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
add('pin: different allowed Host but wrong configured URL',{'x-forwarded-host':'panel.example.test:8443'},PIN,'pinned_origin_mismatch');
add('pin: XFH userinfo rejected',{'x-forwarded-host':'attacker@panel.example.test'},PIN,'pinned_origin_mismatch');
add('pin: duplicate XFH lines',{'x-forwarded-host':['panel.example.test','panel.example.test']},PIN,'pinned_origin_mismatch');
add('pin: first hop downgraded HTTP',{'x-forwarded-proto':'http, https'},PIN,'pinned_origin_mismatch');
add('pin: HTTP only',{'x-forwarded-proto':'http'},PIN,'pinned_origin_mismatch');
add('pin: unrecognized protocol',{'x-forwarded-proto':'ftp, http'},PIN,'pinned_origin_mismatch');
add('pin: empty protocol element',{'x-forwarded-proto':'https, '},PIN,'pinned_origin_mismatch');
add('pin: invalid XFP capitalization',{'x-forwarded-proto':'HTTPS'},PIN,'pinned_origin_mismatch');
add('pin: correct explicit browser Origin',{'origin':'https://panel.example.test','x-forwarded-proto':'https, http'},PIN,'pass');
add('pin: correct explicit Origin with :443',{'origin':'https://panel.example.test:443'},PIN,'pass');
add('pin: attacker Origin rejected',{'origin':'https://attacker.example.test'},PIN,'origin_mismatch');
add('pin: Origin null rejected',{'origin':'null'},PIN,'origin_mismatch');
add('pin: conflicting Origin port rejected',{'origin':'https://panel.example.test:8443'},PIN,'origin_mismatch');
add('pin: cross-site API request rejected',{'sec-fetch-site':'cross-site'},PIN,'cross_site_fetch');
add('pin: untrusted peer must not apply pin',{'x-forwarded-proto':'https, http','origin':'https://panel.example.test'}, {...PIN,peerTrusted:false},'origin_mismatch');
add('pin: credential automation remains strict',{'x-forwarded-proto':'https, http'}, {...PIN,automation:true},'external_scheme');
add('pin: Host cannot spoof unallowlisted identity',{'host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
add('pin: correct alt port 8443',{'x-forwarded-host':'panel.example.test:8443','origin':'https://panel.example.test:8443'}, {...PIN, publicOrigin:'https://panel.example.test:8443'},'pass');
function encodedHeaders(patch) {
const merged={...defaults,...patch};
const result={};
for (const [key,value] of Object.entries(merged)) if (value!==null) result[key]=value;
return result;
}
const pending=[];
const server=http.createServer((req,res)=>{
const item=pending.shift();
if (!item) return res.writeHead(500).end('no scenario');
const result=guard(map(req.rawHeaders),{...context,...item.opts});
res.writeHead(result.status, {'content-type':'application/json'}).end(JSON.stringify(result));
});
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
async function request(item) {
pending.push(item);
return await new Promise((resolve,reject)=>{
const req=http.request({hostname:'127.0.0.1',port:server.address().port,path:'/api/auth/session',
agent:false,headers:encodedHeaders(item.patch)},res=>{
let data='';res.setEncoding('utf8');res.on('data',part=>data+=part);
res.on('end',()=>resolve(JSON.parse(data)));
});
req.on('error',reject);req.end();
});
}
try {
for (const item of cases) {
const result=await request(item);
assert.equal(result.reason,item.expected,item.name);
assert.equal(result.status,item.expected==='pass' ? 200:403,item.name);
}
const repeats=[];
for (const configured of [false,true]) {
for (let attempt=1;attempt<=12;attempt++) {
const malformed=attempt%4===0;
const result=await request({patch:{'x-forwarded-proto':malformed?'https, http':'https'},
opts:configured? PIN : {}});
assert.equal(result.status,configured?200:(malformed?403:200));
repeats.push({pin:configured,attempt,forwardedProto:malformed?'https, http':'https',status:result.status,reason:result.reason});
}
}
const summary={cases:cases.length,repeated:repeats.length,unconfigured403:repeats.filter(x=>!x.pin&&x.status===403).length,
pinned403:repeats.filter(x=>x.pin&&x.status===403).length};
const output={release:'0.31.4',classification:'Node.js HTTP simulation of admin guard, not compiled RustProxy',
assertions:cases.length+repeats.length,scenarios:cases.map(({name,expected})=>({name,expected})),repeats,summary};
if (process.argv[2]) fs.writeFileSync(process.argv[2],JSON.stringify(output,null,2)+'\n');
console.log(`PASS: ${output.assertions} HTTP-simulation assertions: ${summary.cases} cases + ${summary.repeated} resumes; `+
`403 with no pin ${summary.unconfigured403}/12, 403 with pin ${summary.pinned403}/12`);
} finally {server.close();}
-114
View File
@@ -1,114 +0,0 @@
#!/usr/bin/env python3
"""Fail-closed source regression checks. Not a replacement for running Rust tests."""
from pathlib import Path
import re
root = Path(__file__).resolve().parents[2]
def read(name):
return (root / name).read_text(encoding="utf-8")
def must(source, *text):
for item in text:
if item not in source:
raise AssertionError(f"missing security invariant: {item}")
router = read('src/admin/router.rs')
access = read('src/admin/access.rs')
session = read('src/admin/session.rs')
proxy = read('src/proxy/forward_entry.rs')
maintenance = read('src/proxy/cache_http_invalidation.rs')
operations = read('src/admin/operations.rs')
cache_policy = read('src/cache/model/http_invalidation.rs')
app = read('src/runtime/types.rs') + read('src/runtime/constructors.rs')
must(router,
'fn anonymous_allowed', 'panel_document_path(path)',
'/api/auth/status', '/api/auth/setup', '/api/auth/login',
'.layer(middleware::from_fn_with_state(state.clone(), guard))',
'"content-security-policy"', '"x-frame-options"',
'"x-content-type-options"', '"no-referrer"',
'"/api/system-info", get(system_info)',
'"/api/events", get(events)',
'"/api/cache/invalidate"',
'"/api/config", get(configuration).merge(put(update))')
must(session,
'if !public && session.is_none()',
'if unsafe_method && !external_certificate_api',
'single_header(headers, "origin")',
'single_header(headers, "x-rustproxy-request") != Some("1")',
'single_header(headers, "x-csrf-token")',
'auth::equal_secret',
'session_at_with_browser_lifetime',
'secure(next.run(request).await)',
'h == "cross-site"')
must(access, 'admin_request_authority', 'admin_origin_matches',
'trusted_admin_proxy', 'single_header(headers, "x-forwarded-host")',
'certificate_api_credentials', 'valid_certificate_api_token')
must(session, 'verify_certificate_api_credential')
# Only the intended bootstrap/discovery APIs may appear inside the anonymous allowlist.
allowlist = router.split('fn anonymous_allowed(', 1)[1].split('\n}', 1)[0]
allowed_api_paths = set(re.findall(r'"(/api/[^" ]+)"', allowlist))
assert allowed_api_paths == {'/api/auth/status', '/api/auth/setup', '/api/auth/login'}, allowed_api_paths
must(maintenance,
'permits_http_invalidation(client_ip)',
'snapshot.forwarding.trusted(peer_ip)',
'return error(StatusCode::FORBIDDEN, "RustProxy: missing trusted proxy client identity',
'cache_http_invalidation_request(request.method()',
'duplicate BAN/PURGE control header',
'request.body().is_end_stream()',
'get_all(http::header::CONTENT_LENGTH)',
'http::header::TRANSFER_ENCODING',
'X-RustProxy-Confirm-Wide: 1',
'"{scheme}://{authority}{target}"',
'cache_invalidation_slots.clone().try_acquire_owned()',
'state.store.audit(',
'tokio::task::spawn_blocking',
'let _permit = permit;',
'StatusCode::UNPROCESSABLE_ENTITY',
'StatusCode::TOO_MANY_REQUESTS')
# Axum 0.8 requires the `forward` fallback future to be Send. Its Body is
# not Sync, so an async helper must own Request<Body> rather than retain
# &Request<Body> across the audit/cache await points.
async_maintenance = maintenance.split('async fn cache_http_invalidation(', 1)[1].split(') -> Response {', 1)[0]
assert 'request: Request,' in async_maintenance, 'cache maintenance must own Request<Body> in its async future'
assert 'request: &Request' not in async_maintenance, 'borrowed Request<Body> makes fallback handler non-Send'
must(maintenance, 'let method = request.method().clone();', 'drop(request);')
assert maintenance.index('drop(request);') < maintenance.index('state.store.audit('), 'drop body before asynchronous audit'
assert 'frontend, &host, request, context.client_ip' in proxy, 'forward must transfer request ownership'
must(proxy, 'snapshot.host_allowed(&host, context.client_ip)',
'crate::acl::allows_with_country(',
'context.client_ip, peer.0.ip(), context.scheme',
'return cache_http_invalidation(')
assert proxy.find('snapshot.host_allowed(&host, context.client_ip)') < proxy.find('return cache_http_invalidation(')
assert proxy.find('if cache_http_invalidation_method(request.method())') < proxy.find('return cache_http_invalidation(')
must(cache_policy, 'http_invalidation_allowed_cidrs.len() <= 32',
'net.prefix_len() > 0', 'pub fn permits_http_invalidation')
must(app, 'cache_invalidation_slots: Arc<Semaphore>',
'cache_invalidation_slots: Arc::new(Semaphore::new(2))')
must(operations, 'cache_invalidation_slots.clone().try_acquire_owned()',
'tokio::task::spawn_blocking(move ||',
'"cache.invalidate"', '"cache.purge"',
'"cache.ban.remove"')
start = operations.index('async fn cache_ban_delete(')
end = operations.index('async fn active_tunnels(', start)
assert operations[start:end].index('.audit(') < operations[start:end].index('remove_ban(id)'), 'BAN deletion must audit before mutation'
# Frontend: no HTML-string DOM injection or dynamic code execution in first-party scripts.
js_files = list((root/'src/admin/web').rglob('*.js'))
for path in js_files:
text = path.read_text(encoding='utf-8')
dangerous = (r'\.innerHTML\s*=', r'\.outerHTML\s*=', r'\.insertAdjacentHTML\s*\(',
r'\beval\s*\(', r'\bnew\s+Function\s*\(')
for pattern in dangerous:
if re.search(pattern, text):
raise AssertionError(f'unsafe DOM/code sink: {path.relative_to(root)} matches {pattern}')
html = read('src/admin/web/index.html')
for fragment in ['class="metric-info cache-maintenance-info"',
'cacheHttpInvalidationCurlLabel',
'curl -i -X PURGE', 'curl -i -X BAN']:
must(html, fragment)
print(f'PASS: admin/HTTP maintenance security source contracts; scanned {len(js_files)} first-party JS files. Actual Rust/wire behavior not tested.')
@@ -1,77 +0,0 @@
// Offline UI/theme contract: selectors, cached appearance, and CSS readability.
// Does not substitute for a browser session or Rust API integration test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const base = 'src/admin/web/';
const css = fs.readFileSync(`${base}style.css`, 'utf8');
const html = fs.readFileSync(`${base}index.html`, 'utf8');
const login = fs.readFileSync(`${base}login.js`, 'utf8');
const app = fs.readFileSync(`${base}app/preferences.js`, 'utf8');
const rust = fs.readFileSync('src/admin/preferences.rs', 'utf8');
const schema = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
const themes = ['dark', 'ultra_dark', 'light', 'cream'];
const declaration = app.match(/^const ADMIN_THEMES = .*;$/m)?.[0];
const normalize = app.match(/^function normalizeAdminTheme\(value\) \{[^\n]*\}$/m)?.[0];
const apply = app.split('function applyAdminTheme(value, cache = true) {')[1]
?.split('function applyAdminLanguage(')[0];
assert(declaration && normalize && apply);
const stored = new Map();
const select = { value: '' };
const root = { dataset: {} };
const ctx = {
document: { documentElement: root },
$: id => id === 'ui-theme' ? select : null,
localStorage: { setItem: (k, v) => stored.set(k, v), getItem: k => stored.get(k) },
};
vm.createContext(ctx);
vm.runInContext(`${declaration}\n${normalize}\nfunction applyAdminTheme(value, cache = true) {${apply}`, ctx);
for (const theme of themes) {
assert(html.includes(`value="${theme}"`), `option ${theme}`);
assert(login.includes('"ultra_dark", "light", "cream"'), 'cached theme also applies at login');
assert(rust.includes('"dark" | "ultra_dark" | "light" | "cream"'));
assert.equal(vm.runInContext(`normalizeAdminTheme("${theme}")`, ctx), theme);
assert.equal(vm.runInContext(`applyAdminTheme("${theme}")`, ctx), theme);
assert.equal(root.dataset.theme, theme);
assert.equal(select.value, theme);
assert.equal(stored.get('rp-theme'), theme);
}
assert.equal(vm.runInContext('normalizeAdminTheme("unknown")', ctx), 'dark');
vm.runInContext('applyAdminTheme("ultra_dark", false)', ctx);
assert.equal(stored.get('rp-theme'), 'cream', 'preview without caching preserves selected preference');
assert(en.themeUltraDark && en.themeCream && pl.themeUltraDark && pl.themeCream);
assert.equal(schema.components.schemas.AdminPreferences.properties.theme.enum.length, 4);
function block(selector) {
const start = css.indexOf(`${selector} {`);
assert(start >= 0, selector);
return css.slice(start, css.indexOf('\n}', start) + 2);
}
function token(section, key) {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = section.match(new RegExp(`--${escaped}:\\s*(#[0-9a-fA-F]{6});`));
assert(match, `missing ${key}`);
return match[1];
}
function luminance(hex) {
const rgb = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255);
const [r, g, b] = rgb.map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a, b) {
const x = luminance(a), y = luminance(b);
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}
for (const theme of ['ultra_dark', 'cream']) {
const section = block(`html[data-theme="${theme}"]`);
const bg = token(section, 'bg'), panel = token(section, 'panel'), panel2 = token(section, 'panel2');
const text = token(section, 'text'), muted = token(section, 'muted'), accent = token(section, 'accent');
for (const [name, foreground, surface] of [
['text on background', text, bg], ['text on panel', text, panel],
['muted on card', muted, panel2], ['accent on background', accent, bg],
]) assert(contrast(foreground, surface) >= 4.5, `${theme}: ${name}`);
}
console.log('PASS: four account themes, persisted and cached selection, server API contract, and accessible black/cream palette contrast');
@@ -1,24 +0,0 @@
#!/usr/bin/env python3
"""Static regression for 0.32.7 follow-up; not a browser or Rust runtime test."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
css = (ROOT / 'src/admin/web/style.css').read_text()
html = (ROOT / 'src/admin/web/index.html').read_text()
response = (ROOT / 'src/proxy/forward/response.rs').read_text()
helper = (ROOT / 'src/proxy/h2_graceful.rs').read_text()
for button_id in ['overview-refresh', 'overview-layout-open', 'overview-layout-unlock',
'overview-layout-open-settings', 'dashboard-widget-expand']:
assert f'id="{button_id}"' in html
assert '.page-intro-title-row>:is(.quiet, .outline) {\n min-height:' not in css
assert 'dashboard-widget-settings-controls>button {\n min-height:' not in css
assert 'html[data-button-style=compact] body' in css
for mode in ['normal', 'compact', 'ultra']:
assert f'html[data-dashboard-density={mode}] #panel-overview .dashboard-overview-grid>.card:not(.pool-card)' in css
assert 'upstream_health_error_class(&cause, connect_failure, &active_backend)' in response
assert 'let graceful_h2_close = matches!(' in response
assert 'tracing::trace!(' in response
assert 'StatusCode::BAD_GATEWAY' in response
assert 'Some(detail.clone())' in response
assert 'recently_saw_graceful_goaway()' in helper
print('PASS: shared button CSS, density variants, guarded graceful-H2 logging (source only)')
@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""Optional Chromium regression: shared button sizing and Dashboard density.
Uses the production HTML button markup and stylesheet without a running API.
Run: python3 tools/checks/appearance_density_buttons_0327_browser.py
"""
from pathlib import Path
import re
import shutil
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
index = (ROOT / 'src/admin/web/index.html').read_text()
css = (ROOT / 'src/admin/web/style.css').read_text()
def button(button_id):
match = re.search(r'<button\b(?=[^>]*\bid="' + re.escape(button_id) + r'")[\s\S]*?</button>', index)
assert match, button_id
return match.group(0).replace(' hidden', '')
html = f'''<!doctype html><html data-theme="dark"><head><meta name="viewport" content="width=device-width, initial-scale=1"><style>{css}</style></head>
<body><main style="max-width:100%;padding:10px;box-sizing:border-box">
<div class="page-intro-title-row"><h1>Overview</h1>
{button('overview-refresh')}{button('overview-layout-open')}{button('overview-layout-unlock')}</div>
<div class="appearance-settings-grid dashboard-appearance-grid"><div class="dashboard-widget-settings"><div class="dashboard-widget-settings-controls">
{button('overview-layout-open-settings')}{button('dashboard-widget-expand')}
</div></div></div>
<div>{'<button class="quiet" id="reference-quiet">Reference</button><button class="outline" id="reference-outline">Reference</button>'}</div>
<section id="panel-overview"><div class="stats-grid dashboard-overview-grid is-aligned">
<article class="stat stat-chart" data-dashboard-widget="http_protocol"><span>Requests</span><strong>100</strong><small>Rate</small><svg viewBox="0 0 100 25"></svg></article>
<article class="card" data-dashboard-widget="diagnostics"><div class="card-heading"><h2>Diagnostics</h2></div><p>Metrics</p></article>
</div></section></main></body></html>'''
with sync_playwright() as p:
browser = p.chromium.launch(executable_path=shutil.which('chromium') or shutil.which('chromium-browser'), args=['--no-sandbox'])
for width in [320, 393, 1280]:
page = browser.new_page(viewport={'width': width, 'height': 900})
page.set_content(html)
for style in ['normal', 'compact']:
page.evaluate('(value) => document.documentElement.dataset.buttonStyle = value', style)
computed = page.evaluate('''() => {
const values = id => {
const s = getComputedStyle(document.getElementById(id));
return [s.fontSize, s.minHeight, s.paddingTop, s.paddingRight, s.paddingBottom, s.paddingLeft];
};
return Object.fromEntries(['overview-refresh', 'overview-layout-open', 'overview-layout-unlock',
'overview-layout-open-settings', 'dashboard-widget-expand', 'reference-quiet', 'reference-outline']
.map(id => [id, values(id)]));
}''')
for target, reference in [
('overview-refresh', 'reference-quiet'),
('overview-layout-open', 'reference-quiet'),
('overview-layout-unlock', 'reference-outline'),
('overview-layout-open-settings', 'reference-outline'),
('dashboard-widget-expand', 'reference-quiet'),
]:
assert computed[target] == computed[reference], (width, style, target, computed[target], computed[reference])
print('PASS buttons', width, style, computed['overview-refresh'])
measures = {}
for density in ['normal', 'compact', 'ultra']:
page.evaluate('(value) => document.documentElement.dataset.dashboardDensity = value', density)
measures[density] = page.evaluate('''() => {
const stat = document.querySelector('#panel-overview .stat');
const value = stat.querySelector('strong');
const svg = stat.querySelector('svg');
const card = document.querySelector('#panel-overview .card');
const title = card.querySelector('h2');
return {statPadding: parseFloat(getComputedStyle(stat).paddingTop),
valueSize: parseFloat(getComputedStyle(value).fontSize),
chartHeight: parseFloat(getComputedStyle(svg).height),
cardPadding: parseFloat(getComputedStyle(card).paddingTop),
titleSize: parseFloat(getComputedStyle(title).fontSize),
overflow: document.documentElement.scrollWidth > window.innerWidth};
}''')
assert not measures[density]['overflow'], (width, density)
for key in ['statPadding', 'valueSize', 'chartHeight', 'cardPadding', 'titleSize']:
values = [measures[d][key] for d in ['normal', 'compact', 'ultra']]
assert values[0] > values[1] > values[2], (width, key, values)
print('PASS densities', width, measures)
page.close()
browser.close()
@@ -1,87 +0,0 @@
#!/usr/bin/env python3
"""Optional Chromium checks for Appearance controls and global corner geometry.
Uses shipped CSS and the real Appearance list-rendering function with mocked
preferences/translation state. Not a test of the authenticated Rust service.
"""
from pathlib import Path
import json
import shutil
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
css = (WEB / 'style.css').read_text()
prefs = (WEB / 'app/preferences.js').read_text()
widgets = json.loads((WEB / 'dashboard-widgets.json').read_text())
translations = json.loads((ROOT / 'langs/en_US.json').read_text())['translations']
functions = prefs[:prefs.index('function browserLanguage()')] + '\n' + prefs[prefs.index('function dashboardWidgetLabel'):prefs.index('function refreshChartStylePalette')]
html = (f'<!doctype html><html data-theme="ultra_dark" data-corner-style="default"><head><style>{css}</style></head><body>'
'<div id="settings-page-appearance" class="settings-page" style="max-width:100%;padding:12px">'
'<div class="appearance-section"><div class="dashboard-widget-list" id="dashboard-widget-list"></div></div>'
'<div style="display:flex;gap:10px;margin-top:12px">'
'<div class="card" id="test-card">Card</div><div class="stat" id="test-stat">Stat</div>'
'<button class="outline" id="test-action">Action</button><input id="test-input" value="value"></div>'
'<a class="brand" href="#"><span class="brand-mark" id="test-logo">r<span>/</span>p</span>RustProxy</a></div>'
'<div class="overview-widget-type" id="editor-type-badge">Chart</div></body></html>')
js = f'''const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze({json.dumps(widgets)});
const translations = {json.dumps(translations)};
function t(key) {{ return translations[key] || key; }}
function $(id) {{ return document.getElementById(id); }}
function el(tag, text, klass) {{ const node = document.createElement(tag); if (text != null) node.textContent = text; if (klass) node.className = klass; return node; }}
{functions}
renderDashboardWidgetSettings();'''
with sync_playwright() as playwright:
binary = shutil.which('chromium') or shutil.which('chromium-browser')
browser = playwright.chromium.launch(headless=True, executable_path=binary, args=['--no-sandbox'])
page = browser.new_page(viewport={'width': 1320, 'height': 950})
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=js)
assert page.locator('#dashboard-widget-list .dashboard-widget-setting').count() == 62
assert page.locator('#settings-page-appearance .overview-widget-type').count() == 0
assert page.locator('#editor-type-badge').is_visible()
for width in (1320, 790, 450, 345):
page.set_viewport_size({'width': width, 'height': 950})
results = page.locator('.dashboard-widget-setting').evaluate_all('''rows => rows.map(row => {
const label = row.querySelector('.dashboard-widget-toggle').getBoundingClientRect();
const actions = row.querySelector('.dashboard-widget-setting-actions').getBoundingClientRect();
const box = row.getBoundingClientRect();
return { labelRight: label.right, actionsLeft: actions.left, actionsRight: actions.right, right: box.right };
})''')
for i, result in enumerate(results):
assert result['actionsLeft'] >= result['labelRight'] - 1, (width, i, result)
assert result['actionsRight'] <= result['right'] + 1, (width, i, result)
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth + 2'), width
for theme in ('dark', 'light', 'ultra_dark', 'cream'):
page.evaluate('theme => { document.documentElement.dataset.theme = theme; document.documentElement.dataset.cornerStyle = "default"; }', theme)
stat = page.locator('#test-stat').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius')
card = page.locator('#test-card').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius')
assert stat == card == '0px', (theme, stat, card)
for style, expected_surface, expected_control in (('square', '0px', '0px'), ('subtle','5px','4px'), ('rounded','12px','8px'),('extra','18px','12px')):
page.evaluate('style => document.documentElement.dataset.cornerStyle = style', style)
assert page.locator('#test-stat').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_surface, (theme, style, 'stat')
assert page.locator('#test-card').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_surface, (theme, style, 'card')
assert page.locator('#test-action').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_control, (theme, style, 'button')
assert page.locator('#test-input').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_control, (theme, style, 'input')
page.evaluate('document.documentElement.dataset.theme = "dark"; document.documentElement.dataset.cornerStyle = "default"')
# The last glyph must have room before the outlined logo's right edge,
# including the 34px mobile variant; the slash-to-p spacing is unchanged.
for width in (1320, 790, 450, 345):
page.set_viewport_size({'width': width, 'height': 950})
inset = page.locator('#test-logo').evaluate('''mark => {
const p = [...mark.childNodes].find(node => node.nodeType === 3 && node.textContent.trim() === 'p');
const range = document.createRange();
range.selectNodeContents(p);
return mark.getBoundingClientRect().right - range.getBoundingClientRect().right;
}''')
assert inset >= 4, (width, inset)
page.set_viewport_size({'width': 1320, 'height': 950})
original_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor')
page.evaluate('document.documentElement.dataset.theme = "ultra_dark"')
black_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor')
assert black_logo == original_logo == 'rgb(246, 155, 116)', (black_logo, original_logo)
out = ROOT.parent / 'appearance_ux_0323_preview.png'
page.screenshot(path=str(out))
browser.close()
print('PASS: 62 badge-free Appearance rows, right-pinned buttons at 4 widths, theme-independent corner geometry, original black-theme logo accent')
@@ -1,96 +0,0 @@
// Regression contract: Appearance list is badge-free, corners are account-scoped,
// and Settings opens Appearance/last active tab unless a valid URL tab wins.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const prefs = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
const settings = fs.readFileSync('src/admin/web/settings.js', 'utf8');
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
const login = fs.readFileSync('src/admin/web/login.js', 'utf8');
const rust = fs.readFileSync('src/admin/preferences.rs', 'utf8');
const tests = fs.readFileSync('src/admin/preferences_tests.rs', 'utf8');
const schema = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
const styles = ['default', 'square', 'subtle', 'rounded', 'extra'];
const appearance = prefs.slice(prefs.indexOf('function renderDashboardWidgetSettings()'), prefs.indexOf('function refreshChartStylePalette'));
assert(appearance.includes('dashboard-widget-setting-actions'));
assert(!appearance.includes('dashboardWidgetTypeBadge('), 'Appearance rows must not render type badges');
assert(prefs.includes('function dashboardWidgetTypeBadge('), 'visual editors retain type labels');
assert(html.includes('id="ui-corner-style"'));
assert(html.includes('id="dashboard-widget-list"'));
const contract = schema.components.schemas.AdminPreferences.properties.corner_style;
assert.deepEqual(contract.enum, styles);
assert.equal(contract.default, 'default');
assert(rust.includes('default_admin_corner_style'));
assert(rust.includes('"default" | "square" | "subtle" | "rounded" | "extra"'));
assert(tests.includes('fn corner_style_defaults_and_rejects_unknown_presets()'));
for (const key of ['cornerStyle', 'cornerStyleHelp', 'cornerStyleDefault', 'cornerStyleSquare', 'cornerStyleSubtle', 'cornerStyleRounded', 'cornerStyleExtra']) {
assert(en[key] && pl[key], `missing translation ${key}`);
}
assert(!/html:is\(\[data-theme="ultra_dark"\], \[data-theme="cream"\]\) :is\(\.card, \.stat, dialog\) \{/.test(css), 'theme may not override corner geometry');
assert(css.includes('--logo-accent: #f69b74;'));
assert(css.includes('--logo-secondary: #cfe0ac;'));
assert(css.includes('var(--logo-accent, var(--accent))'));
assert(login.includes('rp-corner-style'), 'cached corners must work on login too');
const decl = prefs.match(/^const ADMIN_CORNER_STYLES = .*;$/m)?.[0];
const normalize = prefs.match(/^function normalizeAdminCornerStyle\(value\) \{[^\n]*\}$/m)?.[0];
const body = prefs.split('function applyAdminCornerStyle(value, cache = true) {')[1]?.split('function applyAdminTableDensity(')[0];
assert(decl && normalize && body);
const storage = new Map();
const root = { dataset: {} }, input = { value: '' };
const ctx = { document: { documentElement: root }, $: id => id === 'ui-corner-style' ? input : null,
localStorage: { setItem: (key, value) => storage.set(key, value) } };
vm.createContext(ctx);
vm.runInContext(`${decl}\n${normalize}\nfunction applyAdminCornerStyle(value, cache = true) {${body}`, ctx);
for (const style of styles) {
assert.equal(vm.runInContext(`applyAdminCornerStyle('${style}')`, ctx), style);
assert.equal(root.dataset.cornerStyle, style);
assert.equal(storage.get('rp-corner-style'), style);
assert.equal(input.value, style);
}
vm.runInContext("applyAdminCornerStyle('invalid')", ctx);
assert.equal(root.dataset.cornerStyle, 'default');
assert(prefs.includes('corner_style: "default"'), 'old account defaults preserve original geometry');
assert(prefs.includes('corner_style: next'), 'choice persisted via API');
assert(prefs.includes('applyAdminCornerStyle(previous)'), 'failed update rolls back');
const setup = settings.slice(settings.indexOf(' const buttons ='), settings.indexOf(' const policyTabs ='));
assert(setup.length > 1000);
function boot(path, cached, query = '') {
const cache = new Map(cached ? [['rp-settings-last-tab', cached]] : []);
const names = ['appearance', 'listeners', 'cache', 'security', 'manual-config', 'logging'];
const buttons = names.map(name => ({ dataset: { settingsTab: name }, hidden: false,
textContent: name, addEventListener() {}, setAttribute() {}, focus() {} }));
const pages = names.map(name => ({ dataset: { settingsPage: name }, hidden: true }));
const url = new URL(`https://dashboard.example${path}${query}`);
const location = { href: url.href, pathname: url.pathname, search: url.search, hash: '', protocol: url.protocol };
const context = { location, URL, URLSearchParams, CSS: { escape: value => value },
localStorage: { getItem: key => cache.get(key) || null, setItem: (key, value) => cache.set(key, value) },
document: { querySelectorAll: selector => selector === '[data-settings-tab]' ? buttons : pages,
querySelector: () => null }, $: () => null,
history: { state: {}, replaceState(_state, _title, href) { location.href = 'https://dashboard.example' + href; } },
window: { rustProxyRefresh: { setSettingsTab() {} } }, t: key => key,
};
const vmctx = vm.createContext(context);
const result = vm.runInContext(`(() => { ${setup}\nreturn { current: () => active, select }; })()`, vmctx);
return { ...result, cache };
}
assert.equal(boot('/settings').current(), 'appearance', 'first Settings visit opens Appearance');
assert.equal(boot('/settings', 'cache').current(), 'cache', 'last Settings tab is restored');
assert.equal(boot('/settings', 'unknown').current(), 'appearance', 'invalid cached tab is ignored');
assert.equal(boot('/settings', 'cache', '?tab=security').current(), 'security', 'URL tab takes precedence');
assert.equal(boot('/configuration', 'cache').current(), 'manual-config', 'legacy /configuration keeps intent');
assert.equal(boot('/logging', 'cache').current(), 'logging', 'legacy /logging keeps intent');
const active = boot('/settings');
active.select('cache');
assert.equal(active.cache.get('rp-settings-last-tab'), 'cache');
active.select('security');
assert.equal(active.cache.get('rp-settings-last-tab'), 'security');
active.select('appearance', false, false);
assert.equal(active.cache.get('rp-settings-last-tab'), 'security', 'temporary selection does not override remembered tab');
console.log('PASS: badge-free Appearance, 5 global corner presets, logo colors, account/API defaults, and last Settings tab navigation');
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env node
// Exercise the shipped 0.29.7 backend monitor editor with a minimal DOM double.
// This checks browser form behavior, not health probing or compiled Rust code.
import fs from 'node:fs';
import vm from 'node:vm';
const root = process.cwd();
const core = fs.readFileSync(`${root}/src/admin/web/vhosts/core.js`, 'utf8');
const protocol = fs.readFileSync(`${root}/src/admin/web/vhosts/protocol.js`, 'utf8');
const editor = fs.readFileSync(`${root}/src/admin/web/vhosts/editor.js`, 'utf8');
const settings = fs.readFileSync(`${root}/src/admin/web/app/settings.js`, 'utf8');
const html = fs.readFileSync(`${root}/src/admin/web/index.html`, 'utf8');
const start = core.indexOf('function memberRow(');
const end = core.indexOf('function countryRateEditor(', start);
if (start < 0 || end <= start) throw new Error('Backend editor code not found');
const check = (condition, reason) => { if (!condition) throw new Error(reason); };
class Element {
constructor(tag = 'div', value = '', css = '') {
this.tag = tag;
this.value = value == null ? '' : String(value);
this.checked = false;
this.dataset = {};
this.hidden = false;
this.disabled = false;
this.name = '';
this.children = [];
this.listeners = new Map();
this.classes = new Set(css.split(/\s+/).filter(Boolean));
this.textContent = this.value;
}
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
addEventListener(type, handler) { this.listeners.set(type, handler); }
dispatch(type) { this.listeners.get(type)?.(); }
querySelectorAll() {
const found = [];
const walk = node => { for (const child of node.children) {
if (child.tag === 'input' || child.tag === 'select') found.push(child);
walk(child);
}};
walk(this);
return found;
}
}
const el = (tag, value, css) => new Element(tag, value, css);
const text = (tag, label, css) => el(tag, label, css);
const field = (parent, name, label, value, type = 'text') => {
const wrapper = el('label');
const input = el(type === 'select' ? 'select' : 'input', value);
input.name = name;
input.checked = !!value;
wrapper.append(input);
parent.append(wrapper);
return input;
};
const button = (label, handler) => { const node = el('button', label); node.addEventListener('click', handler); return node; };
const note = (parent, label) => { const node = text('span', label); parent.append(node); return node; };
const parent = el('div');
const value = {
id: 'error_404', url: 'http://127.0.0.1:8282', weight: 1, enabled: true,
pool_eligible: false, health_check_enabled: false, preserve_host: true,
health_check: { mode: 'http', path: '/ready', host: 'errors.example.test',
interval_ms: 10000, timeout_ms: 1000, slow_response_ms: 500, fall: 2,
rise: 1, expected_status_min: 200, expected_status_max: 399 }
};
vm.runInNewContext(`${protocol}\n${core.slice(start, end)}\nmemberRow(parent, value);`, {
parent, value, el, text, field, button, note,
t: key => key, toast: () => {}, changed: () => {},
appConfirm: async () => true, adminListenerBackendCandidate: () => false, backendHealthEditorControl: null
});
const row = parent.children[0];
const input = name => (name.startsWith('member-health-') && name !== 'member-health-enabled' ? row.memberHealthPanel : row).querySelectorAll().find(item => item.name === name);
const fields = [];
const visit = node => { if (node.classes.has('vh-backend-health-fields')) fields.push(node); for (const child of node.children) visit(child); };
visit(row.memberHealthPanel);
check(fields.length === 1, 'Custom backend health panel not created');
check(input('member-pool').checked === false, 'Internal 404 backend accidentally joined balanced pool');
check(input('member-enabled').checked === true, 'Backend was disabled when monitoring was disabled');
check(input('member-health-enabled').checked === false, 'Explicit monitoring off not restored');
check(input('member-health-custom').checked === true, 'Custom health not restored');
check(input('member-health-mode').value === 'http', 'Member health mode lost');
check(input('member-health-path').value === '/ready', 'Member health path lost');
check(input('member-health-interval').value === '10000', 'Member check interval lost');
check(fields[0].hidden === false && input('member-health-path').disabled, 'Unmonitored backend settings should be retained but disabled');
input('member-health-custom').checked = false;
input('member-health-custom').dispatch('change');
check(fields[0].hidden && input('member-health-path').disabled, 'Inherit switch did not hide/disable overrides');
input('member-health-custom').checked = true;
input('member-health-custom').dispatch('change');
check(!fields[0].hidden && input('member-health-path').disabled, 'Unmonitored backend unexpectedly became editable');
input('member-health-enabled').checked = true;
input('member-health-enabled').dispatch('change');
check(!input('member-health-path').disabled, 'Monitored custom fields failed to re-enable');
check(input('member-pool').checked === false, 'Monitoring switch changed balanced-pool eligibility');
for (const part of [
'health_check_enabled: yes("member-health-enabled", row)',
'health_check: yes("member-health-custom", row.memberHealthPanel)',
'expose_only_hits: tri("vh-cache-only-hits")',
]) check((editor + core).includes(part), `Missing saved field: ${part}`);
check(editor.includes('tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits)'), 'Vhost cache override does not roundtrip');
check(html.includes('id="cache-only-hits"') && settings.includes('expose_only_hits: $("cache-only-hits").checked'), 'Global cache checkbox does not save');
check(settings.includes('$("cache-only-hits").checked = !!c.expose_only_hits'), 'Global cache checkbox does not load');
console.log('PASS: 0.29.7 backend monitor and custom policy form state, independent pool status, global/vhost cache diagnostics toggles.');
@@ -1,173 +0,0 @@
// HTTP backend Overview widgets: offline simulated status feed, no Rust execution.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const names = ['backend_health', 'backend_activity', 'backend_errors', 'backend_latency', 'backend_traffic', 'backend_protocols', 'backend_requests', 'backend_capacity', 'backend_http5xx', 'backend_timeouts', 'backend_network', 'backend_retries', 'backend_queue', 'backend_rotations'];
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
for (const name of names) assert(registry.some(widget => widget.id === name && widget.default_hidden && widget.data_source === 'backend_status'));
const source = fs.readFileSync('src/admin/web/app/backend-dashboard.js', 'utf8');
assert(!/\b(fetch|api)\s*\(/.test(source), 'Overview must not fetch per-widget metrics');
const cards = new Map(names.map(name => [name, { hidden: true }]));
const nodes = new Map();
const get = id => {
if (!nodes.has(id)) nodes.set(id, { textContent: '-', style: {}, attrs: {}, children: [], setAttribute(k,v) { this.attrs[k] = v; } });
return nodes.get(id);
};
for (const type of ['h1', 'auto', 'compat', 'h2']) {
const node = get('backend-mode-' + type);
node.parentElement = get('backend-mode-bar');
node.parentElement.children.push(node);
}
let now = 1000;
const window = { rustProxyPreferences: { current: () => null } };
const ctx = {
window,
ADMIN_APPEARANCE_DEFAULTS: { dashboard_hidden_widgets: names },
document: { querySelector(selector) { return cards.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null; } },
$: get, t: key => key, fmt: (value, precision = 0) => Number(value).toFixed(precision),
bytes: v => `${v} B`, currentPanel: 'overview', performance: { now: () => now },
};
vm.createContext(ctx);
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), ctx);
vm.runInContext(source, ctx);
const module = window.rustProxyBackendDashboard;
const data = {
revision: 1,
backend_pools: [{ id: 'pool1', members: [
{ id: 'alpha', url: 'https://example.invalid', protocol: 'auto', status: 'up', active: 2, max_concurrent_requests: 4, capacity_rejections_total: 1, requests: 10, errors: 1, bytes_in: 1000, bytes_out: 2000, health_mode: 'tcp', last_probe_ms: 1234, latency_ms: 10, http_5xx_total: 2, timeouts_total: 1, connect_errors_total: 1, dns_errors_total: 1, tls_errors_total: 1, io_errors_total: 1, retry_attempts_total: 2, retry_successes_total: 1, queue_timeouts_total: 1, upstream_client_rotations_total: 2, upstream_client_generation: 3, upstream_client_generation_age_seconds: 500 },
{ id: 'beta', url: 'http://example.invalid', protocol: 'http2', status: 'down', active: 0, requests: 5, errors: 2, bytes_in: 100, bytes_out: 400, health_mode: 'http', last_probe_ms: 0, latency_ms: 0 },
{ id: 'gamma', url: 'http://example.invalid', protocol: 'http1', status: 'checking', active: 1, max_concurrent_requests: 2, requests: 0, errors: 0, bytes_in: 0, bytes_out: 0, health_mode: 'tcp', last_probe_ms: 0, latency_ms: 0 },
{ id: 'delta', url: 'http://example.invalid', status: 'draining', active: 0, requests: 0, errors: 0, bytes_in: 0, bytes_out: 0, health_mode: 'off', last_probe_ms: 0, latency_ms: 0 },
] }],
};
const text = id => get(`backend-dashboard-${id}`).textContent;
module.render(data); // Hidden by default: should not start collecting samples.
assert(names.every(name => cards.get(name).hidden));
assert.equal(text('health'), '-');
module.render(data, []);
assert(names.every(name => !cards.get(name).hidden));
assert.equal(text('health'), '1 / 3');
assert.match(text('health-detail'), /backendWidgetDown: 1/);
assert.equal(text('activity'), '3');
assert.equal(text('errors'), '3');
assert.equal(text('latency'), '10.0 ms');
assert.equal(text('traffic'), '0 B/s');
assert.equal(text('protocols'), '4');
assert.match(text('protocols-detail'), /H1 1.*Auto 1.*backendWidgetModeCompat 1.*backendWidgetModeStrict 0.*backendWidgetH2c: 1.*backendWidgetModeUnknown: 1/);
assert.equal(get('backend-mode-compat').style.flexGrow, '1');
assert.equal(get('backend-mode-h2').style.flexGrow, '0');
assert.equal(text('requests'), '0.0/s');
assert.match(text('requests-detail'), /backendWidgetRequestsTotal: 15/);
assert.equal(text('capacity'), '50.0%');
assert.match(text('capacity-detail'), /3\/6.*backendWidgetCapacityRejects: 1/);
assert.equal(text('http5xx'), '2');
assert.equal(text('timeouts'), '1');
assert.equal(text('network'), '4');
assert.equal(text('retries'), '2');
assert.equal(text('queue'), '2');
assert.equal(text('rotations'), '2');
assert.match(text('rotations-detail'), /backendWidgetPoolGeneration: 3.*backendWidgetPoolAge: 500s/);
now = 3000;
const next = structuredClone(data);
next.backend_pools[0].members[0].requests = 30;
next.backend_pools[0].members[0].errors = 3;
next.backend_pools[0].members[0].bytes_in = 3000;
next.backend_pools[0].members[0].bytes_out = 3000;
next.backend_pools[0].members[0].active = 5;
next.backend_pools[0].members[0].latency_ms = 22;
next.backend_pools[0].members[0].http_5xx_total = 4;
next.backend_pools[0].members[0].timeouts_total = 5;
next.backend_pools[0].members[0].connect_errors_total = 3;
next.backend_pools[0].members[0].retry_attempts_total = 4;
next.backend_pools[0].members[0].retry_successes_total = 2;
next.backend_pools[0].members[0].queue_timeouts_total = 3;
// New member starts with huge absolute counters: must NOT appear as a spike.
next.backend_pools[0].members.push({ id: 'new', url:'https://new.invalid', protocol: 'http1', status:'up', requests: 100000, errors: 1000, bytes_in: 10000000, bytes_out: 99999999, active: 0, health_mode: 'off', last_probe_ms: 0 });
module.render(next, []);
assert.equal(text('errors'), '1005');
assert.match(text('errors-detail'), /1\.0\/s/);
assert.match(text('errors-detail'), /10\.0%/);
assert.equal(text('traffic'), '1500 B/s');
assert.equal(get('backend-dashboard-in-rate').textContent, '1000 B/s');
assert.equal(get('backend-dashboard-out-rate').textContent, '500 B/s');
assert.equal(text('activity'), '6');
assert.equal(text('requests'), '10.0/s');
assert.equal(text('capacity'), '100.0%');
assert.equal(text('latency'), '22.0 ms');
assert.match(text('http5xx-detail'), /1\.0\/s/);
assert.match(text('timeouts-detail'), /2\.0\/s/);
assert.match(text('network-detail'), /1\.0\/s/);
assert.match(text('retries-detail'), /1\.0\/s.*backendWidgetRetriesSuccess: 2/);
assert.match(text('queue-detail'), /1\.0\/s.*backendWidgetQueueRejected: 1/);
// Repaint on toggles/translation must not produce additional rate samples.
now = 3500;
module.render(next, [], false);
assert.match(text('errors-detail'), /1\.0\/s/);
// Reset of a member's counters should never be treated as an error burst.
now = 5000;
const restarted = structuredClone(next);
restarted.backend_pools[0].members[0].requests = 2;
restarted.backend_pools[0].members[0].errors = 0;
restarted.backend_pools[0].members[0].bytes_in = 999999;
module.render(restarted, []);
assert.match(text('errors-detail'), /0\.0\/s/);
assert.equal(text('traffic'), '0 B/s');
assert.equal(text('requests'), '0.0/s');
// Reconfiguration invalidates prior generations, first sample is a baseline.
now = 8000;
restarted.revision = 2;
module.render(restarted, []);
assert.match(text('errors-detail'), /0\.0\/s/);
// Missing origin data must show neutral state and not previously cached values.
module.render({ revision: 3, backend_pools: [] }, []);
assert.equal(text('health'), '-');
assert.equal(text('traffic'), '-');
assert.equal(text('protocols-detail'), 'backendWidgetUnavailable');
assert.equal(text('requests'), '-');
assert.equal(text('capacity'), '-');
for (const name of ['http5xx', 'timeouts', 'network', 'retries', 'queue', 'rotations'])
assert.equal(text(name), '-', `neutral empty state for ${name}`);
module.render(data, [...names]);
assert(names.every(name => cards.get(name).hidden));
// Origin protocol editor must preserve legacy HTTPS fallback, expose strict
// H2 as a separate new option, and distinguish cleartext h2c.
const protocolHelp = fs.readFileSync('src/admin/web/vhosts/protocol.js', 'utf8');
const element = () => ({ dataset: {}, textContent: '', hidden: false });
const originCtx = { el: element, t: key => key };
vm.createContext(originCtx);
vm.runInContext(protocolHelp, originCtx);
const mounted = [];
const panel = { append(...items) { mounted.push(...items); } };
const select = { value: 'http2', events: {}, addEventListener(type, handler) { this.events[type] = handler; } };
const url = { value: 'https://origin.example', events: {}, addEventListener(type, handler) { this.events[type] = handler; } };
originCtx.attachOriginProtocolDetails(panel, select, url);
assert.equal(mounted.length, 2);
assert.equal(mounted[0].dataset.i18n, 'vhProtocolLegacyHttp2HttpsDetails');
assert.equal(mounted[1].hidden, false, 'Saved HTTPS H2 needs a compatibility warning');
select.value = 'auto'; select.events.change();
assert.equal(mounted[0].dataset.i18n, 'vhProtocolAutoHttpsDetails');
assert.equal(mounted[1].hidden, true);
url.value = 'http://origin.example'; url.events.input();
assert.equal(mounted[0].dataset.i18n, 'vhProtocolAutoHttpDetails');
select.value = 'http2'; select.events.change();
assert.equal(mounted[0].dataset.i18n, 'vhProtocolLegacyHttp2HttpDetails');
assert.equal(mounted[1].hidden, true);
url.value = 'https://origin.example'; url.events.input();
assert.equal(mounted[1].hidden, false);
select.value = 'h2'; select.events.change();
assert.equal(mounted[0].dataset.i18n, 'vhProtocolH2HttpsDetails');
assert.equal(mounted[1].hidden, true);
url.value = 'http://origin.example'; url.events.input();
assert.equal(mounted[0].dataset.i18n, 'vhProtocolH2HttpDetails');
// Strict H2 is tracked independently from the backwards-compatible value.
const strict = structuredClone(data);
strict.backend_pools[0].members[2].protocol = 'h2';
module.render(strict, [], false);
assert.match(text('protocols-detail'), /backendWidgetModeCompat 1.*backendWidgetModeStrict 1.*backendWidgetH2c: 2/);
assert.equal(get('backend-mode-h2').style.flexGrow, '1');
console.log('PASS: 14 opt-in HTTP backend cards, ALPN mode distinction, capacity, request rates, fallback UI');
@@ -1,129 +0,0 @@
#!/usr/bin/env node
// Simulated vhost DOM: selector eligibility, independent edits and lifecycle.
// This is not a browser rendering test or a Rust health-probe test.
import fs from 'node:fs';
import vm from 'node:vm';
const core = fs.readFileSync('src/admin/web/vhosts/core.js', 'utf8');
const protocol = fs.readFileSync('src/admin/web/vhosts/protocol.js', 'utf8');
const health = fs.readFileSync('src/admin/web/vhosts/health.js', 'utf8');
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
const render = fs.readFileSync('src/admin/web/vhosts/render.js', 'utf8');
const first = core.indexOf('function memberRow(');
const last = core.indexOf('function countryRateEditor(', first);
const assert = (condition, message) => { if (!condition) throw new Error(message); };
assert(first >= 0 && last > first, 'Vhost member editor missing');
class Element {
constructor(tag = 'div', value = '', css = '') {
this.tag = tag; this.value = String(value ?? ''); this.textContent = this.value;
this.children = []; this.parent = null; this.name = ''; this.checked = false; this.dataset = {};
this.disabled = false; this.hidden = false; this.listeners = new Map();
this.classes = new Set(css.split(/\s+/).filter(Boolean));
}
append(...children) {
for (const child of children) {
if (child.parent) child.remove();
this.children.push(child); child.parent = this;
}
}
replaceChildren(...children) { for (const child of [...this.children]) child.remove(); this.append(...children); }
remove() {
if (!this.parent) return;
this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null;
}
addEventListener(type, callback) { this.listeners.set(type, callback); }
dispatch(type) { this.listeners.get(type)?.(); }
querySelectorAll(selector) {
const result = [];
const walk = parent => { for (const child of parent.children) {
if ((selector === 'input,select' && ['input', 'select'].includes(child.tag)) ||
(selector === '*' || selector === undefined)) result.push(child);
walk(child);
}};
walk(this); return result;
}
querySelector(selector) {
const match = /^\[name="([^"]+)"\]$/.exec(selector);
return match ? this.querySelectorAll('*').find(node => node.name === match[1]) : null;
}
}
const el = (tag, value, css) => new Element(tag, value, css);
const text = (tag, key, css) => el(tag, key, css);
const field = (parent, name, key, value, type = 'text') => {
const wrapper = el('label');
const input = el(type === 'select' ? 'select' : 'input', value); input.name = name;
input.checked = type === 'checkbox' && !!value;
wrapper.append(input); parent.append(wrapper); return input;
};
const button = (key, callback) => { const node = el('button', key); node.addEventListener('click', callback); return node; };
const note = (parent, key) => { const node = text('p', key); parent.append(node); return node; };
const context = {
el, text, field, button, note, t: key => key,
str: (name, scope) => scope.querySelector(`[name="${name}"]`).value.trim(),
toast: () => { throw Error('Unexpected error toast'); }, changed: () => {},
appConfirm: async () => true, adminListenerBackendCandidate: () => false,
backendHealthEditorControl: null,
read: (name, scope) => scope ? scope.querySelector(`[name="${name}"]`) :
({ "health-mode": "http", "health-path": "/shared", "health-host": "shared.example",
interval: "8500", timeout: "1800", slow: "600", fall: "4", rise: "3",
"status-min": "201", "status-max": "398" }[name] != null ?
{ value: ({ "health-mode": "http", "health-path": "/shared", "health-host": "shared.example",
interval: "8500", timeout: "1800", slow: "600", fall: "4", rise: "3",
"status-min": "201", "status-max": "398" })[name] } : null),
};
const exports = vm.runInNewContext(`${protocol}\n${core.slice(first, last)}\n${health}\n({ memberRow, backendHealthEditor })`, context);
const members = el('div');
const probe = (mode, path) => ({ mode, path, host: null, interval_ms: 5000, timeout_ms: 1000, slow_response_ms: 500, fall: 2, rise: 1, expected_status_min: 200, expected_status_max: 399 });
const a = { id: 'alpha', url: 'http://192.0.2.1:9000', health_check_enabled: true, health_check: probe('http', '/a') };
const b = { id: 'disabled', url: 'http://192.0.2.2:9000', health_check_enabled: false, health_check: probe('http', '/b') };
const c = { id: 'charlie', url: 'http://192.0.2.3:9000', health_check_enabled: true, health_check: null };
for (const backend of [a, b, c]) exports.memberRow(members, { weight: 1, ...backend });
const [rowA, rowB, rowC] = members.children;
const wrapper = el('div'); context.backendHealthEditorControl = exports.backendHealthEditor(wrapper, members);
const select = wrapper.querySelector('[name="health-backend-select"]');
const control = (row, name) => row.memberHealthPanel.querySelector(`[name="${name}"]`);
assert(select.children.length === 2 && select.children[0].textContent.includes('alpha') && select.children[1].textContent.includes('charlie'), 'Unmonitored backend listed in selector');
assert(!rowA.memberHealthPanel.hidden && rowB.memberHealthPanel.hidden && rowC.memberHealthPanel.hidden, 'Selected panel visibility incorrect');
assert(control(rowB, 'member-health-path').disabled, 'Unmonitored override must be non-editable');
assert(!control(rowA, 'member-health-path').disabled, 'Monitored override unexpectedly disabled');
assert(control(rowA, 'member-health-path').value === '/a', 'Existing per-member override not loaded');
select.value = '1'; select.dispatch('change');
assert(rowA.memberHealthPanel.hidden && !rowC.memberHealthPanel.hidden, 'Switch did not change selected panel');
const customC = control(rowC, 'member-health-custom');
customC.checked = true; customC.dispatch('change');
assert(control(rowC, 'member-health-mode').value === 'http' &&
control(rowC, 'member-health-interval').value === '8500' &&
control(rowC, 'member-health-path').value === '/shared', 'First override did not inherit shared settings');
const pathC = control(rowC, 'member-health-path'); pathC.value = '/charlie-ready';
customC.checked = false; customC.dispatch('change');
customC.checked = true; customC.dispatch('change');
assert(pathC.value === '/charlie-ready', 'Toggling custom override discarded edits');
assert(!pathC.disabled, 'New custom override should become editable');
select.value = '0'; select.dispatch('change');
assert(control(rowA, 'member-health-path').value === '/a' && pathC.value === '/charlie-ready', 'Switch discarded unsaved member edits');
select.value = '1'; select.dispatch('change');
assert(pathC.value === '/charlie-ready' && !rowC.memberHealthPanel.hidden, 'Returning to backend lost its edits');
rowC.querySelector('[name="member-id"]').value = 'renamed';
rowC.querySelector('[name="member-id"]').dispatch('input');
assert(select.children[1].textContent.includes('renamed'), 'Backend rename not reflected in selector');
const monitorA = rowA.querySelector('[name="member-health-enabled"]');
monitorA.checked = false; monitorA.dispatch('change');
assert(select.children.length === 1 && select.children[0].textContent.includes('renamed'), 'Disabled monitor remains in selector');
assert(control(rowA, 'member-health-path').value === '/a' && control(rowA, 'member-health-path').disabled, 'Disabling monitor lost override or left active controls');
monitorA.checked = true; monitorA.dispatch('change');
assert(select.children.length === 2 && pathC.value === '/charlie-ready', 'Re-enabling monitor did not restore choice/edits');
const removeC = rowC.children[0].children[1]; // backend-card header's Remove button
removeC.dispatch('click');
assert(members.children.length === 2 && select.children.length === 1 && !rowA.memberHealthPanel.hidden, 'Deleting selected backend did not refresh selector');
assert(wrapper.querySelectorAll('*').includes(rowA.memberHealthPanel) && !wrapper.querySelectorAll('*').includes(rowC.memberHealthPanel), 'Removed backend panel remained retained in DOM');
monitorA.checked = false; monitorA.dispatch('change');
assert(select.disabled && select.children.length === 0 && rowB.memberHealthPanel.hidden, 'Empty monitored backend list not handled');
assert(editor.includes('health_check: yes("member-health-custom", row.memberHealthPanel)'), 'Per-member health settings not collected from selected panels');
assert(editor.includes('backendHealthEditorControl = backendHealthEditor(g, members)'), 'Selector not wired to Health tab');
assert(render.includes('backendHealthEditorControl?.reveal('), 'Invalid hidden member settings cannot be revealed');
assert(core.includes('backendHealthEditorControl = state.backendHealthEditorControl || null;') && core.includes('pagesEditor, backendHealthEditorControl,'), 'Draft switching lost selected backend editor');
assert(fs.readFileSync('src/admin/router.rs','utf8').includes('include_str!("web/vhosts/health.js")'), 'Bundled health editor missing');
console.log('PASS: 0.30.0 monitored-only backend selector, independent unsaved edits, monitor toggle, rename/removal, empty list and validation routing.');
@@ -1,54 +0,0 @@
// Verifies real-status-to-badge mapping without guessing a configured Auto result.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync('src/admin/web/vhosts/render.js', 'utf8');
const block = source.slice(source.indexOf('const VHOST_PROTOCOL_FRESH_MS = '), source.indexOf('function render() {'));
assert(block.includes('function renderUpstreamProtocolBadges(status)'));
const rustClient = fs.readFileSync('src/upstream/client.rs', 'utf8');
const rustPool = fs.readFileSync('src/balance/pool_view.rs', 'utf8');
assert(rustClient.includes('self.inner.observe_protocol(value.version())'));
assert(rustClient.includes('let response = client.request(request).await;'));
for (const name of ['upstream_protocol_observed','upstream_protocol_last_seen_ms','upstream_protocol_http1_total','upstream_protocol_http2_total']) {
assert(rustPool.includes(`"${name}"`), name);
}
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
for(const key of ['vhBackendProtocolRecent','vhBackendProtocolUnobserved']) assert(en[key] && pl[key]);
const badge = (vhost, backend) => ({dataset:{upstreamProtocolVhost:vhost,upstreamProtocolBackend:backend},textContent:'',title:''});
const visible = [badge('api','auto-backend'),badge('api','h1-backend'),badge('other','auto-backend')];
const envelope = {config:{vhosts:[
{id:'api',backends:[{id:'auto-backend',protocol:'auto'},{id:'h1-backend',protocol:'http1'}]},
{id:'other',backends:[{id:'auto-backend',protocol:'auto'}]},
]}};
const ctx = {Date, Number, String, Map, envelope, document:{querySelectorAll:()=>visible},t:key=>en[key]};
vm.createContext(ctx);
vm.runInContext(block, ctx);
const recent = Date.now() - 2000;
let status = {backend_pools:[{id:'api',members:[
{id:'auto-backend',upstream_protocol_observed:'h2',upstream_protocol_last_seen_ms:recent,upstream_protocol_http1_total:5,upstream_protocol_http2_total:9},
{id:'h1-backend',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:recent,upstream_protocol_http1_total:12},
]}]};
ctx.renderUpstreamProtocolBadges(status);
assert.equal(visible[0].textContent,'auto-backend · AUTO → H2');
assert.equal(visible[0].dataset.observed,'true');
assert(visible[0].title.includes('H1: 5; H2: 9'));
assert.equal(visible[1].textContent,'h1-backend · H1 → H1');
assert.equal(visible[2].textContent,'auto-backend · AUTO → ?');
assert.equal(visible[2].dataset.observed,'false');
// Old/stale response must not masquerade as a recent ALPN observation.
status.backend_pools[0].members[0].upstream_protocol_last_seen_ms = Date.now() - 3600000;
ctx.renderUpstreamProtocolBadges(status);
assert.equal(visible[0].textContent,'auto-backend · AUTO → ?');
assert.equal(visible[0].dataset.observed,'false');
assert(visible[0].title.startsWith(en.vhBackendProtocolUnobserved));
// Fresh data for a similarly named backend from another vhost must not leak.
ctx.renderUpstreamProtocolBadges({backend_pools:[{id:'other',members:[
{id:'auto-backend',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:recent}
]}]});
assert.equal(visible[0].textContent,'auto-backend · AUTO → ?');
assert.equal(visible[2].textContent,'auto-backend · AUTO → H1');
ctx.renderUpstreamProtocolBadges({backend_pools:[]});
assert.equal(visible[2].textContent,'auto-backend · AUTO → ?');
console.log('PASS: observed Auto/H1/H2 badges, freshness, cross-vhost isolation and unknown data');
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env python3
"""0.31.9 Certificate API source/OpenAPI/UI contracts; no Rust runtime claims."""
import json
from pathlib import Path
import tomllib
root = Path(__file__).resolve().parents[2]
read = lambda path: (root / path).read_text(encoding='utf-8')
version = tomllib.loads(read('Cargo.toml'))['package']['version']
assert version == '0.32.7', version
spec = json.loads(read('src/admin/openapi.json'))
assert spec['info']['version'] == version
paths = spec['paths']
assert set(paths['/api/certificate-api/{key_id}']) == {'get', 'patch', 'delete'}
assert set(paths['/api/certificate-api/{key_id}/regenerate']) == {'post'}
for method, path in [('get', '/api/certificate-api/{key_id}'), ('patch', '/api/certificate-api/{key_id}'),
('post', '/api/certificate-api/{key_id}/regenerate')]:
operation = paths[path][method]
assert operation['security'] == ([{'AdminSession': []}] if method == 'get' else [{'AdminSession': [], 'CsrfToken': []}])
assert operation['parameters'][0]['name'] == 'key_id'
assert '401' in operation['responses'] and '404' in operation['responses']
edit = spec['components']['schemas']['CertificateApiEdit']
assert edit['additionalProperties'] is False
assert set(edit['required']) == {'comment', 'certificate_ids', 'allow_deploy'}
assert not {'token', 'token_hash', 'key_id'} & edit['properties'].keys()
assert set(spec['components']['schemas']['CertificateApiDetailResult']['properties']) == {'credential'}
assert set(spec['components']['schemas']['CertificateApiRegenerateResult']['properties']) == {'credential', 'token'}
assert 'readOnly' in spec['components']['schemas']['CertificateApiRegenerateResult']['properties']['token']
backend = read('src/admin/certificate_api.rs')
router = read('src/admin/router.rs')
db = read('src/db/certificate_api.rs')
assert 'include!("certificate_api.rs")' in read('src/admin/mod.rs')
for symbol in ('certificate_api_detail', 'certificate_api_edit', 'certificate_api_regenerate'):
assert f'async fn {symbol}(' in backend
assert symbol in router
assert 'deny_unknown_fields' in backend
read_source = backend.split('async fn certificate_api_detail(')[1].split('async fn certificate_api_edit(')[0]
edit_source = backend.split('async fn certificate_api_edit(')[1].split('async fn certificate_api_regenerate(')[0]
regenerate_source = backend.split('async fn certificate_api_regenerate(')[1].split('async fn certificate_api_revoke(')[0]
assert '"token"' not in read_source
assert '"token"' not in edit_source and '"token_hash"' not in edit_source
assert '"token": token' in regenerate_source
assert 'auth::random_secret()' in regenerate_source
assert '"certificate_api.invalid_certificate_ids"' in edit_source
update_source = db.split('pub async fn edit_certificate_api_credential(')[1].split('pub async fn regenerate_certificate_api_token(')[0]
rotate_source = db.split('pub async fn regenerate_certificate_api_token(')[1].split('pub async fn revoke_certificate_api_credential(')[0]
assert 'SET comment = ?' in update_source
assert 'SET token_hash' not in update_source
assert 'SET token_hash = ?, created_at = ?, last_used_at = NULL' in rotate_source
assert 'certificate_api.edit' in update_source and 'certificate_api.regenerate' in rotate_source
assert 'WHERE key_id = ?' in rotate_source
assert 'certificate_api_operation(method: &Method' in router or 'fn certificate_api_operation(method: &Method' in read('src/admin/access.rs') + router
assert 'if method != Method::POST' in router
panel = read('src/admin/web/certificate-api.js')
assert 'include_str!("web/certificate-api.js")' in router
for marker in ('function certificateApiDetailNode(', 'beginCertificateApiEdit(', 'certificateApiEditingKey',
'method: "PATCH"', '/regenerate', 'showCertificateApiSecret(data.token || "")',
'clearCertificateApiSecret()', 'token-field', 'certificateApiTokenHidden'):
assert marker in panel, marker
for item in ('certificate-api-save', 'certificate-api-cancel-edit', 'certificate-api-secret', 'certificate-api-token-field'):
assert f'id="{item}"' in read('src/admin/web/index.html')
for language in ('en_US', 'pl_pl'):
strings = json.loads(read(f'langs/{language}.json'))['translations']
for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate',
'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'):
assert strings.get(key), (language, key)
assert '0.32.7' in read('docs/TEST_REPORT.md')
print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).')
@@ -1,169 +0,0 @@
#!/usr/bin/env node
// Execute the real 0.31.9 Certificate API panel code against a simulated DOM/API.
// This is not a compiled Rust proxy test.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync('src/admin/web/certificate-api.js', 'utf8');
const html = readFileSync('src/admin/web/index.html', 'utf8');
const english = JSON.parse(readFileSync('langs/en_US.json', 'utf8')).translations;
const polish = JSON.parse(readFileSync('langs/pl_pl.json', 'utf8')).translations;
for (const pack of [english, polish]) {
for (const key of ['certificateApiView', 'certificateApiHide', 'certificateApiEdit',
'certificateApiSave', 'certificateApiCancelEdit', 'certificateApiRegenerate',
'certificateApiRegenerateConfirm', 'certificateApiRegenerated', 'certificateApiTokenHidden']) {
assert.ok(pack[key], key);
}
}
for (const id of ['certificate-api-save', 'certificate-api-cancel-edit', 'certificate-api-token-field',
'certificate-api-secret', 'certificate-api-credentials', 'certificate-api-edit-note']) {
assert.ok(html.includes(`id="${id}"`), id);
}
class FakeNode {
constructor(tag = 'div', content = '', cls = '') {
this.tagName = tag; this.value = ''; this.content = String(content ?? '');
this.className = cls; this.children = []; this.events = {};
this.dataset = {}; this.hidden = false; this.disabled = false;
this.checked = false; this.readOnly = false;
this.classList = { add: cls => { this.className += ` ${cls}`; } };
}
get textContent() { return this.content + this.children.map(child => child.textContent).join(''); }
set textContent(value) { this.content = String(value ?? ''); this.children = []; }
addEventListener(type, callback) { this.events[type] = callback; }
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = [...children]; this.content = ''; }
scrollIntoView() { }
focus() { }
async fire(type, event = {}) { assert.ok(this.events[type], `${this.tagName} missing ${type}`); await this.events[type](event); }
findByText(value) { return [this, ...this.children.flatMap(child => child.findAll())].find(child => child.content === value); }
findAll() { return [this, ...this.children.flatMap(child => child.findAll())]; }
}
const fields = new Map();
const dollar = id => {
if (!fields.has(id)) fields.set(id, new FakeNode(id));
return fields.get(id);
};
const node = (tag, text, cls) => new FakeNode(tag, text, cls);
let persisted = [{ key_id: 'renew-prod', comment: 'Old owner', certificate_ids: ['first-cert'],
allow_deploy: false, created_at: 1690000000, last_used_at: 1700000000 }];
let activeToken = 'original-secret-not-readable';
const requests = [];
const makeStatus = () => ({ configured: persisted.length > 0, credentials: structuredClone(persisted),
max_credentials: 32, scopes: ['certificates:reload', 'certificates:deploy'] });
async function api(path, options = {}) {
const method = options.method || 'GET';
const data = options.body ? JSON.parse(options.body) : {};
requests.push({ path, method, data });
if (path === '/api/certificate-api' && method === 'GET') return makeStatus();
if (path === '/api/certificate-api' && method === 'DELETE') { persisted = []; return { revoked: 1 }; }
if (path === '/api/certificate-api' && method === 'PUT') {
activeToken = data.token || 'created-once-token';
const entry = { key_id: data.key_id || 'generated-id', comment: data.comment,
certificate_ids: data.certificate_ids, allow_deploy: data.allow_deploy,
created_at: 1800000000, last_used_at: null };
persisted.push(entry);
return { ...makeStatus(), credential: entry, token: activeToken, generated: !data.token };
}
const match = path.match(/^\/api\/certificate-api\/([^/]+)(\/regenerate)?$/);
assert.ok(match, `Unexpected ${method} ${path}`);
const credential = persisted.find(entry => entry.key_id === match[1]);
assert.ok(credential);
if (method === 'GET') return { credential: structuredClone(credential) };
if (method === 'PATCH') {
assert.deepEqual(Object.keys(data).sort(), ['allow_deploy', 'certificate_ids', 'comment']);
assert.ok(!('token' in data) && !('token_hash' in data));
Object.assign(credential, { comment: data.comment, certificate_ids: data.certificate_ids, allow_deploy: data.allow_deploy });
return { credential: structuredClone(credential) };
}
if (method === 'POST' && match[2] === '/regenerate') {
activeToken = 'replacement-secret-once';
credential.last_used_at = null;
credential.created_at = 1800000001;
return { credential: structuredClone(credential), token: activeToken };
}
if (method === 'DELETE') {
persisted = persisted.filter(entry => entry.key_id !== credential.key_id);
return { revoked: true };
}
throw new Error(`Unsupported ${method} ${path}`);
}
const toasts = [];
const ctx = {
document: {}, window: {}, $: dollar, el: node,
action: (text, cb) => { const result = node('button', text, 'quiet'); result.addEventListener('click', cb); result.type = 'button'; return result; },
t: key => english[key] || key,
location: { origin: 'https://admin.example.test' },
authenticated: true, api, appConfirm: async () => true,
formatDateTime: timestamp => new Date(timestamp).toISOString(),
toast: value => toasts.push(value),
fieldError: (_, message) => new Error(message), showFieldError: () => {},
};
vm.createContext(ctx);
vm.runInContext(source, ctx, { filename: 'src/admin/web/certificate-api.js' });
await new Promise(resolve => setImmediate(resolve));
const list = dollar('certificate-api-credentials');
const actionsFor = (rowIndex = 0) => list.children[rowIndex].children[0].children[1].children;
const click = async (label, rowIndex = 0) => {
const btn = actionsFor(rowIndex).find(item => item.content === label);
assert.ok(btn, `Missing button: ${label}`);
await btn.fire('click');
};
assert.equal(list.children.length, 1);
assert.ok(requests.some(request => request.path === '/api/certificate-api' && request.method === 'GET'));
await click(english.certificateApiView);
assert.equal(list.children[0].children.length, 2, 'details expanded');
const previewText = list.children[0].children[1].textContent;
assert.match(previewText, /renew-prod/);
assert.match(previewText, /Old owner/);
assert.match(previewText, /first-cert/);
assert.match(previewText, /Unavailable/);
assert.ok(!previewText.includes(activeToken), 'never show existing bearer token');
assert.ok(requests.some(request => request.path === '/api/certificate-api/renew-prod' && request.method === 'GET'));
await click(english.certificateApiEdit);
assert.equal(dollar('certificate-api-key-id').value, 'renew-prod');
assert.equal(dollar('certificate-api-key-id').readOnly, true);
assert.equal(dollar('certificate-api-comment').value, 'Old owner');
assert.equal(dollar('certificate-api-token-field').hidden, true);
assert.equal(dollar('certificate-api-save').content, english.certificateApiSave);
assert.equal(dollar('certificate-api-cancel-edit').hidden, false);
dollar('certificate-api-comment').value = 'New owner';
dollar('certificate-api-certificate-ids').value = 'second-cert,third-cert';
dollar('certificate-api-allow-deploy').checked = true;
await dollar('certificate-api-form').fire('submit', { preventDefault() {} });
assert.equal(persisted[0].comment, 'New owner');
assert.deepEqual(persisted[0].certificate_ids, ['second-cert', 'third-cert']);
assert.equal(persisted[0].allow_deploy, true);
assert.equal(persisted[0].created_at, 1690000000, 'editing must not rotate');
assert.equal(activeToken, 'original-secret-not-readable');
assert.equal(dollar('certificate-api-token-field').hidden, false);
assert.equal(dollar('certificate-api-save').content, english.certificateApiRotate);
assert.equal(dollar('certificate-api-secret-wrap').hidden, true);
await click(english.certificateApiRegenerate);
assert.equal(activeToken, 'replacement-secret-once');
assert.equal(dollar('certificate-api-secret').textContent, activeToken);
assert.equal(dollar('certificate-api-secret-wrap').hidden, false);
assert.equal(persisted[0].comment, 'New owner', 'metadata unchanged by regeneration');
assert.equal(persisted[0].allow_deploy, true);
assert.equal(persisted[0].last_used_at, null);
assert.equal(list.textContent.includes(activeToken), false, 'token not in persistent credential DOM');
await click(english.certificateApiHide);
assert.equal(dollar('certificate-api-secret').textContent, 'replacement-secret-once', 'collapsing is not a new API secret read');
await click(english.certificateApiView);
assert.equal(dollar('certificate-api-secret-wrap').hidden, true, 'reading settings does not re-expose the token');
assert.equal(dollar('certificate-api-secret').textContent, '');
assert.ok(!list.textContent.includes(activeToken));
ctx.window.rustProxyCertificateApi.reset();
assert.equal(dollar('certificate-api-secret').textContent, '');
assert.equal(list.children.length, 0);
await ctx.window.rustProxyCertificateApi.load();
assert.equal(list.children.length, 1);
assert.ok(!list.textContent.includes(activeToken));
assert.equal(requests.filter(item => item.path.endsWith('/regenerate')).length, 1);
assert.ok(toasts.includes(english.certificateApiSaved));
assert.ok(toasts.includes(english.certificateApiRegenerated));
console.log('PASS: 0.31.9 Certificate API view/edit/regenerate/one-time secret UI (real module, simulated DOM/API).');
File diff suppressed because it is too large. Load diff
@@ -1,91 +0,0 @@
#!/usr/bin/env python3
"""Optional real-CSS/JS Chromium regression for the horizontal Overview card ribbon.
Mocks authenticated preferences; no server or physical iOS device involved.
Run with: python tools/checks/dashboard_appearance_ribbon_0326_browser.py
"""
from pathlib import Path
from playwright.sync_api import sync_playwright
import shutil
import json
root=Path(__file__).resolve().parents[2]
html=(root/'src/admin/web/index.html').read_text()
css=(root/'src/admin/web/style.css').read_text()
prefs=(root/'src/admin/web/app/preferences.js').read_text()
registry=json.loads((root/'src/admin/web/dashboard-widgets.json').read_text())
translations=json.loads((root/'langs/pl_pl.json').read_text())['translations']
heading=html.index('data-i18n="dashboardLayout"')
section_start=html.rfind('<section class="appearance-section">',0,heading)
section_end=html.index('</section>',heading)+len('</section>')
assert section_start >= 0
source=html[section_start:section_end]
start=prefs.index('function renderDashboardWidgetSettings() {')
end=prefs.index('function refreshChartStylePalette(',start)
code=prefs[start:end]
stub='''
const $ = id => document.getElementById(id);
const el = (tag, txt, classes) => { let node=document.createElement(tag); if (txt!==undefined) node.textContent=String(txt); if (classes) node.className=classes; return node; };
const labels = __TRANSLATIONS__;
const registry = __REGISTRY__;
const ADMIN_DASHBOARD_BY_ID = new Map(registry.map(def=>[def.id,def]));
const order = registry.map(def=>def.id);
let adminPreferences = {dashboard_widgets:order, dashboard_hidden_widgets:registry.filter(def=>def.default_hidden).map(def=>def.id)};
const ADMIN_APPEARANCE_DEFAULTS = adminPreferences;
function t(key){ return labels[key] || key; }
function dashboardWidgetLabel(id) { return t(ADMIN_DASHBOARD_BY_ID.get(id)?.label_key || id); }
function normalizeDashboardWidgets(value){return Array.isArray(value)?value:order;}
function normalizeHiddenDashboardWidgets(value){return Array.isArray(value)?value:[];}
function applyDashboardLayout(order,hidden){ window.__state={order,hidden}; }
async function saveAppearancePreference(next){ Object.assign(adminPreferences,next);return {}; }
'''.replace('__TRANSLATIONS__',json.dumps(translations,ensure_ascii=False)).replace('__REGISTRY__',json.dumps(registry))
with sync_playwright() as p:
browser=p.chromium.launch(executable_path=shutil.which('chromium') or shutil.which('chromium-browser'),args=['--no-sandbox'],headless=True)
for width,height in [(1500,950),(393,850)]:
page=browser.new_page(viewport={'width':width,'height':height},device_scale_factor=1)
page.set_content('<!doctype html><html data-theme="dark" lang="pl"><head><style>'+css+'</style></head><body><main style="margin:0; padding:28px 16px; width:100%; max-width:100%; box-sizing:border-box">'+source+'</main></body></html>',wait_until='domcontentloaded')
page.add_script_tag(content=stub+code+'\nrenderDashboardWidgetSettings();')
page.wait_for_timeout(90)
metrics=page.evaluate('''() => {
const list=document.getElementById('dashboard-widget-list'), section=document.querySelector('.appearance-section');
const parent=list.closest('.dashboard-widget-settings');
return { total: list.children.length, listHeight:list.getBoundingClientRect().height,
listWidth:list.getBoundingClientRect().width, contentWidth:list.scrollWidth,
sectionHeight:section.getBoundingClientRect().height,
overflowX:getComputedStyle(list).overflowX,
firstWidth:list.firstElementChild.getBoundingClientRect().width,
density: document.querySelector('.dashboard-density-setting').getBoundingClientRect().width,
widget:parent.getBoundingClientRect().width,
viewportOverflow:document.documentElement.scrollWidth>window.innerWidth,
nextDisabled:document.getElementById('dashboard-widget-scroll-next').disabled};
}''')
print(width,'COMPACT',metrics)
assert metrics['total']==len(registry)
assert metrics['listHeight']<66,(width,metrics)
assert metrics['contentWidth']>metrics['listWidth'],(width,metrics)
assert metrics['sectionHeight'] < (320 if width>700 else 450), (width,metrics)
assert not metrics['viewportOverflow']
page.locator('#dashboard-widget-scroll-next').click()
page.wait_for_timeout(450)
pos=page.evaluate('document.getElementById("dashboard-widget-list").scrollLeft')
print(width,'scroll after next:',pos)
assert pos>10
page.locator('.dashboard-widget-setting').nth(7).locator('button.dashboard-widget-toggle').click(force=True)
page.wait_for_timeout(60)
state=page.evaluate('window.__state')
assert state and len(state['order'])==len(registry)
retained=page.evaluate('document.getElementById("dashboard-widget-list").scrollLeft')
assert retained>=pos-15,(pos,retained)
page.locator('#dashboard-widget-expand').click()
page.wait_for_timeout(60)
expanded=page.evaluate('''() => { const list = document.getElementById("dashboard-widget-list"); return { height:list.getBoundingClientRect().height, mode:getComputedStyle(list).display, count:list.children.length, expanded:document.getElementById('dashboard-widget-expand').getAttribute('aria-expanded'), prevHidden:document.getElementById('dashboard-widget-scroll-prev').hidden } }''')
print(width,'EXPANDED',expanded)
assert expanded['mode']=='grid'
assert expanded['expanded']=='true' and expanded['prevHidden']
assert expanded['height']>metrics['listHeight']*3
page.locator('#dashboard-widget-expand').click()
assert page.locator('#dashboard-widget-expand').get_attribute('aria-expanded')=='false'
outfile=str(root.parent / f'dashboard_appearance_0326_preview_{width}.png')
page.locator('.appearance-section').screenshot(path=outfile)
print('screenshot',outfile)
page.close()
browser.close()
@@ -1,36 +0,0 @@
// Appearance ribbon: compact by default, with full-width expansion and the same
// persisted visibility/reorder controls. No browser or Rust service required.
import assert from 'node:assert/strict';
import fs from 'node:fs';
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
const js = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const locales = ['en_US', 'pl_pl'].map(id => JSON.parse(fs.readFileSync(`langs/${id}.json`, 'utf8')).translations);
for (const id of ['dashboard-widget-list', 'dashboard-widget-expand', 'dashboard-widget-scroll-prev',
'dashboard-widget-scroll-next', 'overview-layout-open-settings', 'ui-dashboard-density']) {
assert(html.includes(`id="${id}"`), `missing ${id}`);
}
assert.match(html, /class="appearance-settings-grid dashboard-appearance-grid"/);
assert.match(css, /\.appearance-settings-grid\.dashboard-appearance-grid\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/s,
'dashboard setting layout must use the full available width');
assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*overflow-x:\s*auto;/s,
'the default card selector must be a horizontal ribbon, not a tall two-column list');
assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\.is-expanded\s*\{[^}]*display:\s*grid;/s);
assert.match(css, /\.dashboard-widget-settings-controls\s*>\s*\.dashboard-widget-scroll-button/);
assert(js.includes('list.scrollLeft = previousScroll'), 'rebuilding settings must preserve horizontal scroll position');
assert(js.includes('focusedId') && js.includes('preventScroll: true'), 'keyboard focus must survive updates');
assert(js.includes('saveAppearancePreference({ dashboard_hidden_widgets:') &&
js.includes('saveAppearancePreference({ dashboard_widgets:'), 'both APIs must retain persistence');
assert(js.includes('const up = el("button", "←"') && js.includes('const down = el("button", "→"'),
'horizontal order controls must align with the ribbon');
assert.equal(registry.length, 62, 'all configured Overview cards must remain available');
for (const translation of locales) {
for (const key of ['dashboardWidgetsShowAll', 'dashboardWidgetsCompact', 'dashboardWidgetsScrollBack',
'dashboardWidgetsScrollNext', 'dashboardWidgetsScrollHelp',
'dashboardWidgetsMoveEarlier', 'dashboardWidgetsMoveLater']) {
assert(translation[key], `missing ${key}`);
}
}
console.log('PASS: full-width compact Appearance ribbon with expandable 62-card layout, keyboard focus and preference persistence');
@@ -1,89 +0,0 @@
#!/usr/bin/env node
// Release-independent, real-source startup ordering and recovery contract.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const read = path => readFileSync(resolve(root, path), 'utf8');
const html = read('src/admin/web/index.html');
const runtime = read('src/admin/web/app/runtime.js');
const core = read('src/admin/web/app/core.js');
const router = read('src/admin/router.rs');
const bundle = read('tools/checks/web_assets.py');
const version = read('Cargo.toml').match(/^version\s*=\s*"([\d.]+)"/m)?.[1];
assert(version);
assert.equal((html.match(/<script defer="" src="\/assets\//g) || []).length, 1, 'one atomic dashboard script');
assert.match(html, /src="\/assets\/app___RUSTPROXY_ASSET_ID__\.js"/);
assert.match(html, /href="\/assets\/style___RUSTPROXY_ASSET_ID__\.css"/);
assert.match(router, /admin_vhosts_javascript\(\)/);
assert.match(bundle, /"integrations\.js", "vhosts\.js"/);
assert(router.indexOf('include_str!("web/integrations.js")') < router.indexOf('admin_vhosts_javascript(),'));
assert(router.indexOf('admin_vhosts_javascript(),') < router.indexOf('include_str!("web/host404.js")'));
const prefix = runtime.slice(0, runtime.indexOf('function certificateUsesExternalFiles'));
assert(prefix.includes('document.addEventListener("DOMContentLoaded", startAdminPanel'));
function simulate(complete) {
let bootstrap, calls = 0, activated = 0, failed = null;
const modules = {};
for (const [name, action] of [
['rustProxyIntegrations','bindings'], ['rustProxyVhosts','render'],
['rustProxySettings','render'], ['rustProxyCachePanel','refresh'],
['rustProxyTrafficPanel','refresh'], ['rustProxyPreferences','load'],
['rustProxyRefresh','start'],
]) if (complete || name !== 'rustProxyIntegrations') modules[name] = { [action]() {} };
const ctx = {
window: modules, document: { readyState: 'loading', addEventListener(name, fn) {
assert.equal(name, 'DOMContentLoaded'); bootstrap = fn;
} },
api: async () => { calls++; return { csrf_token: 'abc' }; },
clearInvalidOriginRecovery() {}, loadLanguages: async () => {},
translate() {}, syncAuthForm() {}, activate: async () => { activated++; },
recoverInvalidOrigin: () => false, showStartupFailure: e => { failed = e; },
location: { pathname: '/overview', search: '', replace() { throw new Error('unexpected redirect'); } },
};
runInNewContext(prefix, ctx);
assert.equal(calls, 0, 'zero API calls before all defer/module readiness');
assert.equal(activated, 0);
assert(bootstrap, 'registered DOMContentLoaded bootstrap');
return { bootstrap, state: () => ({ calls, activated, failed }) };
}
const success = simulate(true);
success.bootstrap();
for (let i = 0; i < 8; i++) await Promise.resolve();
assert.equal(success.state().calls, 1);
assert.equal(success.state().activated, 1);
assert.equal(success.state().failed, null);
const missing = simulate(false);
missing.bootstrap();
for (let i = 0; i < 8; i++) await Promise.resolve();
assert.equal(missing.state().calls, 0, 'do not initialize partially loaded modules');
assert.match(missing.state().failed.message, /rustProxyIntegrations/);
// Exercise real activate on temporary config API errors and on persistent failure.
const activation = core.slice(core.indexOf('async function activate(info) {'), core.indexOf('async function loadAudit()'));
assert(activation.startsWith('async function activate(info)'));
async function configRetry(failures) {
const nodes = new Map();
const node = key => { if (!nodes.has(key)) nodes.set(key, { hidden: true, value: '' }); return nodes.get(key); };
let config = 0, fallback = 0, status = 0;
const ctx = {
$: node, authenticated: false, csrf: '', authEpoch: 0, sessionInfo: null, pendingAuthDestination: null,
window: {}, document: { documentElement: { classList: { remove() {} } } },
authMessage() {}, renderSession() {}, rustProxyRefresh: { start() {} },
loadAdminPreferences: async () => ({}),
loadConfig: async () => { config++; if (config <= failures) throw new Error('Temporary proxy failure'); },
refresh: async () => { status++; },
setTimeout: fn => { fn(); return 1; },
showStartupFailure: () => { fallback++; },
toast() { throw new Error('A transient failure must not be hidden in a toast'); },
};
runInNewContext(activation, ctx);
await ctx.activate({ username: 'owner', csrf_token: 'token' });
for (let i=0;i<4;i++) await Promise.resolve();
return { config, fallback, status };
}
assert.deepEqual(await configRetry(2), { config: 3, fallback: 0, status: 1 });
assert.deepEqual(await configRetry(3), { config: 3, fallback: 1, status: 0 });
console.log(`PASS: ${version} one build-fingerprinted JS payload, DOM/module-gated startup, retries and visible failure`);
@@ -1,235 +0,0 @@
// Offline simulation of direct Overview arrangement. No authenticated browser
// or Rust networking is exercised by this test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const widgets = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const ids = widgets.map(w => w.id);
const hiddenDefault = widgets.filter(w => w.default_hidden).map(w => w.id);
const source = fs.readFileSync('src/admin/web/app/dashboard-inline-layout.js', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
for (const id of ['overview-layout-unlock', 'overview-inline-actions', 'overview-inline-save',
'overview-inline-cancel', 'overview-inline-hint', 'dashboard-overview-grid'])
assert(html.includes(`id="${id}"`), `missing ${id}`);
class Node {
constructor(tag = 'div', value = '', cls = '') {
this.tag = tag; this.textContent = value ?? ''; this.classes = new Set(cls.split(' ').filter(Boolean));
this.classList = { add: (...names) => names.forEach(name => this.classes.add(name)),
remove: (...names) => names.forEach(name => this.classes.delete(name)) };
this.children = []; this.listeners = new Map(); this.dataset = {};
this.attributes = {}; this.hidden = false; this.disabled = false; this.parent = null;
}
append(...children) {
for (const child of children) {
child.remove(); this.children.push(child); child.parent = this;
}
}
replaceChildren(...children) {
for (const child of this.children) child.parent = null;
this.children = [];
this.append(...children);
}
remove() {
if (this.parent) { const at = this.parent.children.indexOf(this); if (at >= 0) this.parent.children.splice(at, 1); }
this.parent = null;
}
closest(selector) {
for (let cur = this; cur; cur = cur.parent) {
if (selector === '[data-dashboard-widget]' && cur.dataset.dashboardWidget) return cur;
if (selector === '[data-overview-drag-handle]' && cur.dataset.overviewDragHandle) return cur;
}
return null;
}
contains(other) { return !!other && (other === this || this.children.some(child => child.contains(other))); }
querySelector(selector) {
for (const child of this.children) {
if (child.classes.has(selector.slice(1)) || child.tag === selector) return child;
const nested = child.querySelector(selector);
if (nested) return nested;
}
return null;
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getBoundingClientRect() { return { left: 0, width: 100 }; }
addEventListener(name, cb) { this.listeners.set(name, [...(this.listeners.get(name) || []), cb]); }
fire(name, event = {}) {
const e = { target: this, clientX: 0, prevented: false, preventDefault() { this.prevented = true; }, ...event };
for (const cb of this.listeners.get(name) || []) cb(e);
return e;
}
focus() { this.focused = true; }
}
const controls = new Map(['dashboard-overview-grid', 'overview-layout-unlock',
'overview-inline-actions', 'overview-inline-save', 'overview-inline-cancel',
'overview-inline-hint', 'overview-inline-hidden', 'overview-inline-hidden-list',
'overview-inline-hidden-count', 'overview-layout-open', 'overview-layout-dialog'].map(id => [id, new Node()]));
const grid = controls.get('dashboard-overview-grid');
controls.get('overview-inline-hidden').append(new Node('summary', 'Hidden blocks'));
const cards = new Map(ids.map(id => {
const node = new Node('article'); node.dataset.dashboardWidget = id;
node.hidden = hiddenDefault.includes(id);
node.setAttribute('id', `real-${id}`);
node.append(new Node('strong', id));
grid.append(node);
return [id, node];
}));
let current = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault], theme: 'light' };
const writes = [], notifications = [];
let reject = false;
const window = {
rustProxyPreferences: {
load: async () => current,
current: () => current,
update: async patch => {
if (reject) throw Error('server refused');
writes.push(patch);
current = { ...current, ...patch };
applyDashboardLayout(current.dashboard_widgets, current.dashboard_hidden_widgets);
return current;
},
},
};
function applyDashboardLayout(order, hidden) {
for (const id of order) { cards.get(id).hidden = hidden.includes(id); grid.append(cards.get(id)); }
}
const $ = id => controls.get(id);
const ctx = {
window, $, el: (tag, value, cls) => new Node(tag, value, cls),
t: key => `loc:${key}`, toast: msg => notifications.push(msg),
dashboardWidgetLabel: id => `label:${id}`,
dashboardWidgetTypeBadge: id => new Node('span', `kind:${widgets.find(w => w.id === id).display_type}`, 'overview-widget-type'),
applyDashboardLayout,
ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault] },
normalizeDashboardWidgets: order => [...new Set([...(Array.isArray(order) ? order.filter(id => ids.includes(id)) : []), ...ids])],
normalizeHiddenDashboardWidgets: hidden => [...new Set((hidden || []).filter(id => ids.includes(id)))],
};
vm.createContext(ctx);
vm.runInContext(source, ctx);
const editor = window.rustProxyInlineLayout;
const refresh = () => new Promise(resolve => setImmediate(resolve));
const visible = () => grid.children.filter(node => !node.hidden).map(node => node.dataset.dashboardWidget);
const toolbarCount = () => grid.children.filter(node => node.querySelector('.overview-inline-toolbar')).length;
const button = (id, cls) => cards.get(id).querySelector(cls);
const grip = id => button(id, '.overview-inline-grip');
const dataTransfer = () => ({ setData(k, value) { this[k] = value; }, effectAllowed: '', dropEffect: '' });
// Opening does not clone cards or trigger network writes. Only the handle drags.
controls.get('overview-layout-unlock').fire('click'); await refresh();
assert(editor.isEditing());
assert.equal(controls.get('overview-inline-actions').hidden, false);
assert.equal(toolbarCount(), 62);
assert.equal(cards.get('requests').attributes.id, 'real-requests');
assert.equal(grip('requests').dataset.overviewDragHandle, 'requests');
const accidental = grid.fire('dragstart', { target: cards.get('requests'), dataTransfer: dataTransfer() });
assert(accidental.prevented);
assert.equal(writes.length, 0);
// Hide removes the real block from the current view, not the registry; restore
// works immediately without an API call, and Cancel reverts an uncommitted hide.
button('requests', '.overview-inline-hide').fire('click');
assert(cards.get('requests').hidden);
assert(controls.get('overview-inline-hidden').open);
assert.equal(controls.get('overview-inline-hidden').hidden, false);
assert(controls.get('overview-inline-hidden-list').children.length > hiddenDefault.length);
assert.equal(writes.length, 0);
controls.get('overview-inline-hidden-list').children
.find(item => item.children.some(child => child.textContent === 'label:requests'))
.querySelector('.overview-inline-restore').fire('click');
assert(!cards.get('requests').hidden, 'restore makes the block visible in the same draft');
button('requests', '.overview-inline-hide').fire('click');
assert(cards.get('requests').hidden);
controls.get('overview-inline-cancel').fire('click');
assert(!cards.get('requests').hidden, 'cancel restores the original visibility');
assert.equal(controls.get('overview-inline-hidden').hidden, true);
assert.equal(writes.length, 0);
controls.get('overview-layout-unlock').fire('click'); await refresh();
const transfer = dataTransfer();
grid.fire('dragstart', { target: grip('requests'), dataTransfer: transfer });
assert.equal(transfer['text/plain'], 'requests');
grid.fire('dragover', { target: cards.get('active'), clientX: 70, dataTransfer: transfer });
grid.fire('drop', { target: cards.get('active'), clientX: 70, dataTransfer: transfer });
assert.deepEqual(visible().slice(0, 3), ['active','requests','bandwidth']);
assert.equal(writes.length, 0);
button('requests', '.overview-inline-up').fire('click');
assert.deepEqual(visible().slice(0, 3), ['requests','active','bandwidth']);
button('traffic_chart', '.overview-inline-up').fire('click');
assert.equal(writes.length, 0);
controls.get('overview-inline-cancel').fire('click');
assert(!editor.isEditing());
assert.deepEqual(visible(), ids.filter(id => !hiddenDefault.includes(id)));
assert.equal(toolbarCount(), 0);
assert.equal(writes.length, 0);
// Saving writes exactly one ordered/hidden patch and retains other preferences.
controls.get('overview-layout-unlock').fire('click'); await refresh();
grid.fire('dragstart', { target: grip('requests'), dataTransfer: dataTransfer() });
grid.fire('drop', { target: cards.get('active'), clientX: 90 });
reject = true;
controls.get('overview-inline-save').fire('click'); await refresh();
assert(editor.isEditing(), 'failed PUT preserves the draft');
assert.equal(controls.get('overview-inline-hint').textContent, 'server refused');
assert.equal(writes.length, 0);
reject = false;
controls.get('overview-inline-save').fire('click'); await refresh();
assert(!editor.isEditing());
assert.equal(toolbarCount(), 0);
assert.equal(writes.length, 1);
assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']);
assert.deepEqual([...current.dashboard_hidden_widgets], hiddenDefault);
assert.equal(current.theme, 'light');
assert.equal(notifications.length, 1);
// Locking without changes does not write again. Navigation discard and dialog guard.
controls.get('overview-layout-unlock').fire('click'); await refresh();
controls.get('overview-layout-unlock').fire('click'); await refresh();
assert(!editor.isEditing());
assert.equal(writes.length, 1);
controls.get('overview-layout-dialog').open = true;
controls.get('overview-layout-unlock').fire('click'); await refresh();
assert(!editor.isEditing(), 'do not edit actual cards underneath the open modal');
controls.get('overview-layout-dialog').open = false;
controls.get('overview-layout-unlock').fire('click'); await refresh();
grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() });
grid.fire('drop', { target: cards.get('requests'), clientX: 0 });
assert.notDeepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id)));
editor.cancel();
assert.deepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id)));
assert.equal(writes.length, 1);
console.log('PASS: direct Overview unlock, handles, move arrows, cancel, one save, retry, lock, navigation and modal guard');
// Hide persists across unlock sessions and the list allows recovery without
// touching unrelated account settings. The same API path handles a large panel.
controls.get('overview-layout-unlock').fire('click'); await refresh();
button('backend_table', '.overview-inline-hide').fire('click');
assert(cards.get('backend_table').hidden);
assert.equal(writes.length, 1);
controls.get('overview-inline-save').fire('click'); await refresh();
assert.equal(writes.length, 2);
assert(current.dashboard_hidden_widgets.includes('backend_table'));
assert(cards.get('backend_table').hidden);
assert.equal(current.theme, 'light');
controls.get('overview-layout-unlock').fire('click'); await refresh();
controls.get('overview-inline-hidden-list').children
.find(item => item.children.some(child => child.textContent === 'label:backend_table'))
.querySelector('.overview-inline-restore').fire('click');
assert(!cards.get('backend_table').hidden);
controls.get('overview-inline-save').fire('click'); await refresh();
assert.equal(writes.length, 3);
assert(!current.dashboard_hidden_widgets.includes('backend_table'));
assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']);
console.log('PASS: inline hide, unsaved cancel, hidden shelf restore and one-request persistence');
// In the real browser the row controller groups cards by footprint. A cross-
// group drop should not appear to move a card into an incompatible-height row.
window.rustProxyMasonry = {groupFor: id => id === 'traffic_chart' ? 1 : 0};
const beforeGroups = [...current.dashboard_widgets];
controls.get('overview-layout-unlock').fire('click'); await refresh();
const beforeCrossDrop = visible();
grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() });
grid.fire('drop', { target: cards.get('requests'), clientX: 50 });
assert.deepEqual(visible(), beforeCrossDrop, 'different-size cards must not switch groups');
controls.get('overview-inline-save').fire('click'); await refresh();
assert.equal(writes.length, 3, 'blocked cross-group move must not write');
assert.deepEqual([...current.dashboard_widgets], beforeGroups);
console.log('PASS: cross-size drops cannot break symmetric grid groups');
@@ -1,213 +0,0 @@
// Browser-independent UI simulation of the actual visual Overview modal.
// This does not replace an authenticated DOM/browser acceptance test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const ids = registry.map(widget => widget.id);
const initialHidden = registry.filter(widget => widget.default_hidden).map(widget => widget.id);
const source = fs.readFileSync('src/admin/web/app/dashboard-layout-editor.js', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
for (const id of ['overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden', 'overview-layout-open', 'overview-layout-open-settings', 'overview-layout-save', 'overview-layout-cancel', 'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel'])
assert(html.includes(`id="${id}"`), id);
class FakeNode {
constructor(tag = 'div', content = '', classes = '') {
this.tag = tag; this.textContent = String(content ?? ''); this.classes = new Set(classes.split(/\s+/).filter(Boolean));
this.classList = { add: (...args) => args.forEach(x => this.classes.add(x)), remove: (...args) => args.forEach(x => this.classes.delete(x)) };
this.children = []; this.attrs = {}; this.dataset = {}; this.listeners = new Map();
this.hidden = false; this.open = false; this.disabled = false; this.value = ''; this.draggable = false;
}
append(...nodes) { this.children.push(...nodes); }
replaceChildren(...nodes) { this.children = nodes; }
addEventListener(name, cb) { const list = this.listeners.get(name) || []; list.push(cb); this.listeners.set(name, list); }
fire(name, event = {}) { for (const cb of this.listeners.get(name) || []) cb({ preventDefault() {}, target: this, ...event }); }
setAttribute(k, v) { this.attrs[k] = String(v); }
removeAttribute(k) { delete this.attrs[k]; }
focus() { this.focused = true; }
showModal() { this.open = true; }
close() { this.open = false; this.fire('close'); }
querySelectorAll(selector) {
const nodes = [];
function visit(node) {
for (const child of node.children) {
if (selector === '[id]' && child.attrs.id) nodes.push(child);
else if (selector === '[aria-labelledby], [aria-describedby]' && (child.attrs['aria-labelledby'] || child.attrs['aria-describedby'])) nodes.push(child);
else if (selector === '.overview-layout-tile' && child.classes.has('overview-layout-tile')) nodes.push(child);
visit(child);
}
}
visit(this); return nodes;
}
querySelector(selector) {
return this.querySelectorAll('.overview-layout-tile').find(node => node.classes.has(selector.slice(1))) ||
(() => { const visited = [this]; while (visited.length) { const node = visited.shift(); if (node.classes.has(selector.slice(1))) return node; visited.push(...node.children); } return null; })();
}
cloneNode(deep = false) {
const copy = new FakeNode(this.tag, this.textContent, [...this.classes].join(' '));
copy.hidden = this.hidden; copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset };
if (deep) copy.children = this.children.map(node => node.cloneNode(true));
return copy;
}
}
const controls = Object.fromEntries([
'overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden',
'overview-layout-save', 'overview-layout-cancel', 'overview-layout-search', 'overview-layout-type-filter',
'overview-layout-message', 'overview-layout-open', 'overview-layout-open-settings',
'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel',
].map(id => [id, new FakeNode()]));
const originals = new Map(ids.map(id => {
const node = new FakeNode('article', 'metric value', 'stat');
node.attrs.id = `real-${id}`; node.dataset.dashboardWidget = id;
node.append(new FakeNode('strong', '42'));
node.children[0].attrs.id = `metric-${id}`;
node.hidden = initialHidden.includes(id);
return [id, node];
}));
let saved = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden], theme: 'light' };
const writes = [], notifications = [];
let failSave = false;
const window = { rustProxyPreferences: {
current: () => saved,
load: async () => saved,
update: async patch => {
if (failSave) throw Error('server refused');
writes.push(patch); saved = { ...saved, ...patch };
for (const id of ids) originals.get(id).hidden = patch.dashboard_hidden_widgets.includes(id);
return saved;
},
} };
const $ = id => controls[id];
const ctx = {
window, $, t: text => `loc:${text}`, toast: text => notifications.push(text),
el: (tag, content, cls) => new FakeNode(tag, content, cls),
ADMIN_DASHBOARD_BY_ID: new Map(registry.map(widget => [widget.id, widget])),
ADMIN_DASHBOARD_WIDGETS: [...ids],
ADMIN_DEFAULT_HIDDEN_WIDGETS: [...initialHidden],
ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden] },
normalizeDashboardWidgets: value => [...new Set([...(Array.isArray(value) ? value.filter(id => ids.includes(id)) : []), ...ids])],
normalizeHiddenDashboardWidgets: value => [...new Set((Array.isArray(value) ? value : []).filter(id => ids.includes(id)))],
dashboardWidgetType: id => registry.find(widget => widget.id === id)?.display_type,
dashboardWidgetTypeLabel: id => `loc:overviewBlockType${({
value: 'Value', value_chart: 'ValueChart', chart: 'Chart', panel: 'Panel', table: 'Table',
})[registry.find(widget => widget.id === id)?.display_type]}`,
dashboardWidgetTypeBadge: id => new FakeNode('span', `type:${registry.find(widget => widget.id === id)?.display_type}`, 'overview-widget-type'),
document: { querySelector: selector => originals.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null },
};
vm.createContext(ctx); vm.runInContext(source, ctx);
const module = window.rustProxyDashboardLayout;
const dialog = controls['overview-layout-dialog'];
const visible = controls['overview-layout-visible'];
const hidden = controls['overview-layout-hidden'];
const refresh = () => new Promise(resolve => setImmediate(resolve));
const previewIds = () => visible.children.filter(node => node.dataset.previewWidget).map(node => node.dataset.previewWidget);
// Cancel changes, no PUT or change of real dashboard.
controls['overview-layout-open'].fire('click'); await refresh();
assert.equal(dialog.open, true);
assert.equal(previewIds().length, ids.length - initialHidden.length);
assert.equal(hidden.children.length, initialHidden.length);
assert.equal(visible.children[0].children[0].children[1].textContent, 'type:value');
assert.equal(hidden.children[0].children[1].textContent, `type:${registry.find(w => w.id === initialHidden[0]).display_type}`);
controls['overview-layout-type-filter'].value = 'chart';
controls['overview-layout-type-filter'].fire('change');
assert.equal(hidden.children.filter(item => item.classes.has('overview-layout-hidden-item')).length, registry.filter(w => w.default_hidden && w.display_type === 'chart').length, 'type filter shows only hidden charts');
controls['overview-layout-type-filter'].value = 'all';
controls['overview-layout-type-filter'].fire('change');
assert.equal(hidden.children.length, initialHidden.length);
const previewCopy = visible.children[0].children[1];
assert.equal(previewCopy.attrs.id, undefined, 'preview clones must not duplicate real IDs');
assert.equal(previewCopy.children[0].attrs.id, undefined);
assert.equal(previewCopy.attrs['aria-hidden'], 'true');
const first = previewIds()[0];
visible.children[0].children[0].children[2].children[2].fire('click');
assert(!previewIds().includes(first));
assert.equal(originals.get(first).hidden, false, 'draft hide must not touch live cards');
assert.equal(writes.length, 0);
controls['overview-layout-cancel'].fire('click');
assert.equal(dialog.open, false);
assert.equal(writes.length, 0);
// Settings and Overview entry points are wired to the same editor.
controls['overview-layout-open-settings'].fire('click'); await refresh();
assert.equal(dialog.open, true);
const originalVisible = previewIds();
const from = visible.children[0], to = visible.children[2];
const dataTransfer = { setData(type, value) { this[type] = value; }, effectAllowed: '', dropEffect: '' };
from.fire('dragstart', { target: from, dataTransfer });
to.fire('dragover', { dataTransfer });
to.fire('drop', { dataTransfer });
assert.equal(previewIds()[2], originalVisible[0], 'dropping moves a tile after the drop target');
assert.equal(previewIds()[0], originalVisible[1]);
// Keyboard buttons move cards back, without rebuilding saved prefs prematurely.
visible.children[2].children[0].children[2].children[0].fire('click');
assert.equal(previewIds()[1], originalVisible[0]);
const firstHiddenId = initialHidden[0];
const hiddenItem = hidden.children.find(node => node.children[0].textContent === `loc:${registry.find(x => x.id === firstHiddenId).label_key}`);
hiddenItem.children[2].fire('click');
assert(previewIds().includes(firstHiddenId));
assert.equal(writes.length, 0);
controls['overview-layout-save'].fire('click'); await refresh();
assert.equal(writes.length, 1, 'one atomic preferences update');
assert.equal(dialog.open, false);
assert.equal(saved.theme, 'light', 'unrelated preferences are untouched');
assert.equal(new Set(saved.dashboard_widgets).size, ids.length);
assert.equal(saved.dashboard_widgets.length, ids.length);
assert(!saved.dashboard_hidden_widgets.includes(firstHiddenId));
assert.equal(notifications.length, 1);
// Failed save keeps the dialog open and the unsaved draft available for retry.
controls['overview-layout-open'].fire('click'); await refresh();
failSave = true;
const writeCount = writes.length;
controls['overview-layout-save'].fire('click'); await refresh();
assert.equal(dialog.open, true);
assert.equal(controls['overview-layout-message'].textContent, 'server refused');
assert.equal(writes.length, writeCount);
failSave = false;
controls['overview-layout-cancel'].fire('click');
assert.equal(dialog.open, false);
// Default view is only a draft until Save: no implicit writes.
controls['overview-layout-open'].fire('click'); await refresh();
const writesBeforeDefault = writes.length;
controls['overview-layout-default'].fire('click');
assert.deepEqual(previewIds(), ids.filter(id => !initialHidden.includes(id)));
assert(controls['overview-layout-message'].textContent.includes('overviewLayoutDefaultLoaded'));
assert.equal(writes.length, writesBeforeDefault);
assert(previewIds().includes('backend_table'), 'all seven full-width Overview panels belong to the editor');
assert.equal(visible.children.find(node => node.dataset.previewWidget === 'backend_table').classes.has('is-panel'), true);
// Reset first prompts for confirmation, cancellation must not change preferences.
controls['overview-layout-reset'].fire('click');
assert.equal(controls['overview-reset-confirm'].open, true);
assert.equal(writes.length, writesBeforeDefault);
controls['overview-reset-cancel'].fire('click');
assert.equal(controls['overview-reset-confirm'].open, false);
assert.equal(writes.length, writesBeforeDefault);
// A failed reset keeps the confirmation open and never claims success.
controls['overview-layout-reset'].fire('click'); failSave = true;
controls['overview-reset-apply'].fire('click'); await refresh();
assert.equal(controls['overview-reset-confirm'].open, true);
assert.equal(writes.length, writesBeforeDefault);
failSave = false;
controls['overview-reset-apply'].fire('click'); await refresh();
assert.equal(controls['overview-reset-confirm'].open, false);
assert.equal(dialog.open, false);
assert.equal(writes.length, writesBeforeDefault + 1);
assert.deepEqual([...saved.dashboard_widgets], ids);
assert.deepEqual([...saved.dashboard_hidden_widgets], initialHidden);
assert.equal(saved.theme, 'light', 'reset must not affect unrelated account preferences');
console.log('PASS: full Overview editor, drag/keyboard, legacy settings, default preview, confirmed reset, cancel, atomic save and failures');
// Group boundaries are a UX constraint in the real browser: the modal must
// not let a small tile replace the position of a tall diagnostic panel.
window.rustProxyMasonry = { groupFor: id => id === 'traffic_chart' ? 1 : id.endsWith('_diagnostics') ? 3 : 0 };
controls['overview-layout-open'].fire('click'); await refresh();
const beforeGroupMove = previewIds();
const small = visible.children.find(node => node.dataset.previewWidget === 'requests');
const big = visible.children.find(node => node.dataset.previewWidget === 'traffic_chart');
small.fire('dragstart', { target: small, dataTransfer: {setData() {},effectAllowed:''} });
big.fire('drop');
assert.deepEqual(previewIds(), beforeGroupMove, 'the modal must not mix incompatible tile groups');
controls['overview-layout-cancel'].fire('click');
console.log('PASS: visual editor respects symmetric height groups');
@@ -1,95 +0,0 @@
#!/usr/bin/env node
// Browser bootstrap contract: visible recovery and early shell.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const read = path => readFileSync(resolve(root, path), 'utf8');
const html = read('src/admin/web/index.html');
const css = read('src/admin/web/style.css');
const core = read('src/admin/web/app/core.js');
const runtime = read('src/admin/web/app/runtime.js');
const resume = read('src/admin/web/app/session-resume.js');
assert.match(html, /<html class="app-loading"/);
assert.match(html, /id="app-startup"[^>]*role="status"/);
assert.match(html, /id="app-startup-title"/);
assert.match(html, /class="app-startup-spinner"/);
assert.match(html, /class="app-startup-recovery"/);
assert.match(html, /class="outline" id="app-startup-reload" href="" data-i18n="startupRetry"/);
assert.match(html, /<noscript>/);
assert.doesNotMatch(css, /html\.app-loading\s+body\s*\{\s*visibility:\s*hidden/);
assert.match(css, /html\.app-loading \.shell/);
assert.match(css, /app-startup-reveal 0s linear 12s forwards/);
assert.match(css, /prefers-reduced-motion: reduce/);
assert.match(runtime, /showStartupFailure\(e\)/);
const titleRow = html.match(/<div class="page-intro-title-row">([\s\S]*?)<\/div>/)?.[1];
assert(titleRow, 'title row exists');
assert.match(titleRow, /id="page-title"/);
assert.match(titleRow, /id="overview-refresh"[^>]*hidden/);
assert.equal((html.match(/id="overview-refresh"/g) || []).length, 1);
assert.match(core, /\$\("overview-refresh"\)\.hidden = name !== "overview"/);
assert.match(css, /\.page-intro-title-row\s*\{\s*display:\s*flex/);
assert.match(css, /\.page-intro-title-row \.overview-refresh/);
// Exercise the real activate() and failure handler against a small DOM fake.
const activateSource = core.slice(core.indexOf('async function activate(info) {'), core.indexOf('async function loadAudit()'));
const failureSource = resume.slice(resume.indexOf('function displayStartupError(error, sessionResume = false)'));
assert(activateSource.startsWith('async function activate(info)'));
assert(failureSource.startsWith('function displayStartupError(error, sessionResume = false)'));
const nodes = new Map();
const node = key => {
if (!nodes.has(key)) nodes.set(key, { hidden: true, value: 'x', textContent: '', addEventListener() {} });
return nodes.get(key);
};
const classes = new Set(['app-loading']);
let startCount = 0, configCount = 0, statusCount = 0, prefResolve, configResolve;
const preferencesPromise = new Promise(resolve => { prefResolve = resolve; });
const configPromise = new Promise(resolve => { configResolve = resolve; });
const context = {
$: node,
document: { documentElement: { classList: {
add(...keys) { for (const key of keys) classes.add(key); },
remove(...keys) { for (const key of keys) classes.delete(key); },
toggle(key, force) { if (force) classes.add(key); else classes.delete(key); },
contains(key) { return classes.has(key); },
} } },
pendingAuthDestination: null,
authMessage() {},
authEpoch: 0, authenticated: false, csrf: '', sessionInfo: null,
renderSession() {},
rustProxyRefresh: { start() { startCount++; } },
loadAdminPreferences: () => preferencesPromise,
loadConfig: () => { configCount++; return configPromise; },
refresh: async () => { statusCount++; },
toast() { throw new Error('Unexpected toast'); },
window: { rustProxyRefresh: { stop() {} } }, languagePacks: new Map(), t: key => key,
};
runInNewContext(`${activateSource}\n${failureSource}`, context);
const activating = context.activate({ username: 'admin', csrf_token: 'CSRF' });
assert.equal(startCount, 1, 'SSE/polling starts without waiting for preferences');
assert.equal(context.authenticated, true);
assert.equal(node('workspace').hidden, false, 'shell is shown immediately');
assert(!classes.has('app-loading'), 'spinner is cleared before preferences/config/status');
assert.equal(configCount, 0, 'preferences request is still pending');
assert.equal(statusCount, 0);
prefResolve({ refresh: {} });
for (let i = 0; i < 5; i++) await Promise.resolve();
assert.equal(configCount, 1);
assert.equal(node('workspace').hidden, false, 'slow configuration does not hide shell');
configResolve({});
await activating;
for (let i = 0; i < 5; i++) await Promise.resolve();
assert.equal(statusCount, 1);
context.showStartupFailure(new Error('Offline'));
assert(classes.has('app-loading') && classes.has('app-startup-failed'));
assert.equal(node('app-startup-detail').textContent, 'Offline');
assert.equal(node('app-startup-detail').hidden, false);
assert.equal(node('workspace').hidden, true, 'failed shell cannot obscure retry UI');
console.log('PASS: startup spinner/retry, shell before slow API, error recovery and compact Overview title row.');
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env python3
"""Check the 0.31.9 dependency/source migration; optionally require a resolved lock.
This is not a substitute for rustc, cargo metadata or runtime tests.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import re
import tomllib
ROOT = Path(__file__).resolve().parents[2]
parser = argparse.ArgumentParser()
parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
args = parser.parse_args()
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
assert manifest['package']['version'] == '0.32.7'
assert manifest['package']['rust-version'] == '1.99'
deps = manifest['dependencies']
expected_requirements = {
'argon2': '0.6', 'rand': '0.10', 'sqlx': '0.9', 'rcgen': '0.14.10',
'tower-http': '0.7.1', 'brotli': '9', 'sysinfo': '0.39',
'h3': '=2.5.20', 'tokio': '1.53.2', 'hyper': '1.12.0',
}
for name, version in expected_requirements.items():
entry = deps[name]
found = entry if isinstance(entry, str) else entry['version']
assert found == version, f'{name} requirement changed: {found!r} != {version!r}'
assert deps['h3']['package'] == 'hpx-h3'
assert deps['h3']['features'] == ['quinn']
assert deps['sqlx']['default-features'] is False
assert deps['sqlx']['features'] == ['runtime-tokio', 'sqlite']
assert set(deps['sysinfo']['features']) == {'network','system'}
assert set(deps['rcgen']['features']) == {'crypto', 'pem', 'ring'}
assert 'tls12' in deps['rustls']['features'] and 'ring' in deps['rustls']['features']
assert 'tls12' in deps['tokio-rustls']['features']
for target in ('Dockerfile', 'integrations/routeros-oci-build/Dockerfile.routeros'):
assert 'FROM rust:1.99-trixie AS build' in (ROOT / target).read_text(), target
files = [p for p in (ROOT / 'src').rglob('*.rs')]
sources = '\n'.join(p.read_text() for p in files)
for old in ('rngs::OsRng', 'rand::thread_rng()', 'SaltString::generate(',
'distributions::Alphanumeric', '.key_pair.serialize_pem()',
'hash_password(password.as_bytes(), &salt)'):
assert old not in sources, f'obsolete dependency API: {old}'
for new in ('SysRng.try_fill_bytes(', 'phc::PasswordHash',
'hash_password(password.as_bytes())', 'verify_password(password.as_bytes()',
'signing_key.serialize_pem()', 'rand::rng()'):
assert new in sources, f'missing dependency migration: {new}'
assert 'VERSION = "5.33.1"' in (ROOT / 'scripts/build/swagger_assets.py').read_text()
lock = tomllib.loads((ROOT / 'Cargo.lock').read_text())
installed: dict[str,set[str]] = {}
for item in lock['package']:
installed.setdefault(item['name'], set()).add(item['version'])
latest_required = {
'argon2': '0.6.0', 'rand': '0.10.3', 'sqlx': '0.9.0',
'rcgen': '0.14.10', 'tower-http': '0.7.1', 'brotli': '9.0.0',
'sysinfo': '0.39.6', 'hpx-h3': '2.5.20', 'hyper': '1.12.0',
'tokio': '1.53.2',
}
def compatible_update(package: str, installed_version: str, minimum: str) -> bool:
candidate = tuple(int(x) for x in installed_version.split('.')[:3])
baseline = tuple(int(x) for x in minimum.split('.')[:3])
if package == 'hpx-h3':
return candidate == baseline # Intentionally exact-pinned for the QUIC integration.
if candidate[0] != baseline[0]:
return False
if baseline[0] == 0 and candidate[1] != baseline[1]:
return False
return candidate >= baseline
missing = {name: version for name,version in latest_required.items()
if not any(compatible_update(name, candidate, version)
for candidate in installed.get(name, set()))}
if missing:
details = ', '.join(f'{name} {version}' for name,version in missing.items())
if args.require_lock:
raise SystemExit(f'FAIL: Cargo.lock not resolved to current deps: {details}. Run cargo update first.')
print(f'PENDING: lockfile still requires cargo update ({details})')
else:
print('PASS: pinned key packages found in post-upgrade lock; cargo --locked verification is still required')
print('PASS: 0.31.9 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)')
@@ -1,167 +0,0 @@
#!/usr/bin/env node
// Runs the real Docker VHosts dashboard module against a minimal DOM + API fixture.
// This is a JS-only regression, not a compiled RustProxy or live agent test.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const dockerSource = readFileSync('src/admin/web/docker.js', 'utf8');
const coreSource = readFileSync('src/admin/web/app/core.js', 'utf8');
const english = JSON.parse(readFileSync('langs/en_US.json', 'utf8')).translations;
const polish = JSON.parse(readFileSync('langs/pl_pl.json', 'utf8')).translations;
for (const translations of [english, polish]) {
assert.ok(translations.dockerNoObservation);
assert.ok(translations.dockerAgentConnectionFailed);
assert.ok(translations.dockerUnknownPollError);
assert.doesNotMatch(translations.dockerNoObservation.toLowerCase(), /refresh|odśwież/);
}
class FakeNode {
constructor(tag, text = '', cls = '') {
this.tagName = tag;
this.content = String(text ?? '');
this.className = cls;
this.children = [];
this.dataset = {};
this.attrs = {};
this.events = {};
this.hidden = false;
this.open = false;
this.classList = {
toggle: (name, value) => {
const parts = new Set(this.className.split(' ').filter(Boolean));
if (value) parts.add(name); else parts.delete(name);
this.className = [...parts].join(' ');
},
add: name => { this.className += ` ${name}`; },
remove: name => { this.className = this.className.split(' ').filter(part => part !== name).join(' '); },
};
}
get textContent() { return this.content + this.children.map(child => child.textContent || '').join(''); }
set textContent(value) { this.content = String(value ?? ''); this.children = []; }
append(...nodes) { this.children.push(...nodes.filter(Boolean)); }
replaceChildren(...nodes) { this.children = nodes.filter(Boolean); }
setAttribute(name, value) { this.attrs[name] = value; }
addEventListener(name, fn) { this.events[name] = fn; }
descendants() { return this.children.flatMap(child => [child, ...child.descendants()]); }
querySelectorAll(selector) {
const part = selector.split(' ').at(-1);
if (part.startsWith('.')) return this.descendants().filter(child => child.className.split(' ').includes(part.slice(1)));
return [];
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
}
const node = (tag, text, cls) => new FakeNode(tag, text, cls);
const destination = node('div');
const elements = new Map([
['docker-discovery-list', destination],
['overview-refresh', node('button')],
['page-label', node('span')],
['page-title', node('h1')],
['page-description', node('p')]
]);
const agent = {
id: 'docker1', name: 'Docker One', enabled: true,
base_domain: 'apps.example.test', endpoint: 'https://192.0.2.2:9876',
tls_fingerprint_sha256: 'A'.repeat(64), poll_interval_seconds: 30,
assignments: []
};
const config = { docker_agents: { agents: [agent] }, listeners: { endpoints: [] }, tls: { certificates: [] } };
const storage = new Map();
const requests = [];
let nextResult = { observations: [], pools: [], pool_routes: [] };
let refreshSpec;
let syncs = 0, manuals = 0;
const coordinator = {
register(id, spec) { assert.equal(id, 'docker'); refreshSpec = spec; },
sync() { syncs++; },
manual(id) { assert.equal(id, 'docker'); manuals++; return Promise.resolve(); },
setPanel(name) { this.panel = name; },
};
const ctx = {
el: node,
$: name => elements.get(name) || null,
t: name => english[name] || name,
document: {
activeElement: null,
querySelectorAll(selector) {
if (selector === '#docker-discovery-list .docker-agent-card') return destination.children;
return [];
}
},
window: { rustProxyRefresh: coordinator, rustProxySettings: {} },
rustProxyRefresh: coordinator,
localStorage: { getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value) },
authenticated: true, authEpoch: 1,
currentPanel: 'docker-vhosts', envelope: { config },
settingsDirty: new Set(), busy: false,
api: async (path, args = {}) => { requests.push({ path, args }); return nextResult; },
toast(message) { throw new Error(`Unexpected toast: ${message}`); },
console,
};
vm.createContext(ctx);
vm.runInContext(dockerSource, ctx, { filename: 'src/admin/web/docker.js' });
assert(refreshSpec, 'register Docker refresh with central scheduler');
assert.equal(refreshSpec.interval(), 5);
ctx.window.rustProxyDocker.render();
assert.equal(manuals, 1, 'deep-link bootstrap fetches a snapshot after configuration loads');
assert.equal(syncs, 1, 'deep-link bootstrap syncs waiting-state scheduler');
assert.equal(refreshSpec.active(), true, 'bootstrap retries cached status without requiring local Live view');
assert.match(destination.textContent, /WAITING/);
assert.match(destination.textContent, /Waiting for the first agent check/);
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
assert.doesNotMatch(destination.textContent, /Agent connection failed/);
assert.equal(destination.querySelector('.docker-agent-error'), null, 'pending must not be styled as a failure');
let tlsPill = destination.querySelectorAll('.pill').find(item => item.textContent === 'TLS PINNED');
assert.ok(tlsPill);
assert.ok(!tlsPill.className.split(' ').includes('live'), 'configured fingerprint does not prove successful handshake');
await ctx.window.rustProxyDocker.refresh(false);
assert.equal(requests.at(-1).path, '/api/docker');
assert.notEqual(requests.at(-1).args.method, 'POST', 'automatic status fetch never polls the agent');
assert.equal(refreshSpec.active(), true, 'retry while the initial server-side poll is not complete');
nextResult = { observations: [{ agent_id: 'docker1', ok: false, error: 'connection refused', checked_at_unix_ms: 1, inventory: null }], pools: [], pool_routes: [] };
await ctx.window.rustProxyDocker.refresh(false);
assert.match(destination.textContent, /Last Docker agent check failed/);
assert.match(destination.textContent, /connection refused/);
assert.doesNotMatch(destination.textContent, /TLS certificate SHA-256:/, 'pinned fingerprint is configuration, not the reason for an arbitrary failure');
assert.ok(destination.querySelector('.docker-agent-error'), 'a genuine failed observation must be visible');
assert.equal(refreshSpec.active(), false, 'local Live-off stops automatic refresh after first status, even if it is a failure');
nextResult = { observations: [{ agent_id: 'docker1', ok: true, error: null, checked_at_unix_ms: Date.now(), inventory: {
agent_version: '0.22.3', hostname: 'docker1', addresses: [], services: [], public_ip: '192.0.2.2', certificate_sha256: 'A'.repeat(64)
} }], pools: [], pool_routes: [] };
await ctx.window.rustProxyDocker.refresh(false);
assert.match(destination.textContent, /CONNECTED/);
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
assert.equal(refreshSpec.active(), false, 'local Live-off remains off with a successful observation');
tlsPill = destination.querySelectorAll('.pill').find(item => item.textContent === 'TLS PINNED');
assert.ok(tlsPill.className.split(' ').includes('live'), 'pin may be marked verified after a successful poll');
storage.set('rustproxy.docker.live-view', '1');
assert.equal(refreshSpec.active(), true, 'Live view still polls normally');
storage.delete('rustproxy.docker.live-view');
// Execute the real panel-navigation function with side effects disabled.
const from = coreSource.indexOf('function setPanel(name, navigate = true) {');
const to = coreSource.indexOf('function bytes(value)', from);
assert(from >= 0 && to > from);
ctx.PANEL_PATHS = { overview: '/', 'docker-vhosts': '/docker-vhosts' };
vm.runInContext(coreSource.slice(from, to), ctx);
ctx.currentPanel = 'overview';
ctx.setPanel('docker-vhosts', false);
assert.equal(manuals, 2, 'opening Docker VHosts requests a new cached snapshot even with Live view off');
ctx.setPanel('docker-vhosts', false);
assert.equal(manuals, 2, 'rendering the same panel does not duplicate one-shot requests');
ctx.setPanel('overview', false);
ctx.setPanel('docker-vhosts', false);
assert.equal(manuals, 3, 're-entering the page reads the newest cached status');
ctx.currentPanel = 'overview';
assert.equal(refreshSpec.active(), false, 'no bootstrap activity outside Docker VHosts');
ctx.currentPanel = 'docker-vhosts';
config.docker_agents.agents = [{ ...agent, enabled: false }];
ctx.window.rustProxyDocker.render();
assert.equal(refreshSpec.active(), false, 'disabled agents do not cause polling');
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
console.log('PASS: 0.31.9 Docker startup awaiting/poll failure/success, pinned TLS semantics, Live-off bootstrap and return-navigation refresh (real JS, simulated DOM/API).');
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env python3
"""Rust-independent documentation/Reader consistency checks for the 0.31.9 release."""
from pathlib import Path
import re
from rust_source import rust_source
ROOT = Path(__file__).resolve().parents[2]
DOCS = ROOT / "docs"
required = [
ROOT / "README.md",
ROOT / "ROADMAP.md",
ROOT / "CHANGELOG.md",
DOCS / "README.md",
DOCS / "WEB_SERVER.md",
DOCS / "TODO.md",
DOCS / "TEST_REPORT.md",
DOCS / "CHANGELOG_TECHNICAL_ARCHIVE.md",
DOCS / "ROADMAP_HISTORY.md",
]
for path in required:
assert path.is_file(), f"missing required documentation: {path.relative_to(ROOT)}"
index = (DOCS / "README.md").read_text(encoding="utf-8")
for marker in ["Functional documentation", "Development and testing", "Technical documentation", "Historical or version-labelled context", "TODO.md", "SECURITY_AUDIT_0.31.0.md", "CHANGELOG_TECHNICAL_ARCHIVE.md", "ROADMAP_HISTORY.md", "/docs"]:
assert marker in index, marker
for document in sorted(DOCS.glob("*.md")):
if document.name == "README.md":
continue
assert document.name in index, f"documentation index does not classify {document.name}"
reader_html = (ROOT / "src/admin/web/docs.html").read_text(encoding="utf-8")
reader_js = (ROOT / "src/admin/web/docs.js").read_text(encoding="utf-8")
build = (ROOT / "build.rs").read_text(encoding="utf-8")
admin = rust_source(ROOT, "src/admin/mod.rs")
for marker in ["docs-tab-reader", "docs-reader-list", "docs-reader-content", "docs-tab-api"]:
assert marker in reader_html, marker
for marker in ["renderMarkdown", "selectDocument", 'get("/docs/content.json")', "docsByPath", "docsCategoryHistorical"]:
assert marker in reader_js, marker
documentation_rs = rust_source(ROOT, "src/admin/documentation.rs") + (ROOT / "src/admin/documentation/documents.rs").read_text(encoding="utf-8")
assert "READER_DOCUMENTS" in documentation_rs and "reader_bundle" in documentation_rs
assert 'include_str!(concat!(env!("CARGO_MANIFEST_DIR")' in documentation_rs
assert 'docs-content.json' not in documentation_rs and 'documentation_bundle' not in build
for document in sorted(DOCS.glob("*.md")):
marker = f'"{document.relative_to(ROOT).as_posix()}"'
assert marker in documentation_rs, f"Reader does not embed {document.relative_to(ROOT)}"
for path in set(re.findall(r'"(docs/[A-Za-z0-9_.-]+\.md)"', documentation_rs)):
assert (ROOT / path).is_file(), f"Reader embeds missing document: {path}"
assert '"/docs/content.json"' in admin
readme = (ROOT / "README.md").read_text(encoding="utf-8")
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
assert readme.startswith("# RustProxy 0.32.7")
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
assert roadmap.startswith("# RustProxy roadmap") and "## 0.30.0" and "## 0.29.1" in roadmap and "## 0.29.0" in roadmap and "## 0.28.2" in roadmap and "## 0.28.1" in roadmap and "## 0.27.3" in roadmap and "## 0.27.2" in roadmap and "## 0.27.1" in roadmap and "## 0.27.0" in roadmap and "## 0.26.7" in roadmap and "## 0.26.6" in roadmap and "## 0.26.5" in roadmap and "## 0.26.4" in roadmap and "## 0.26.0" in roadmap and "## 0.25.5" in roadmap and "## 0.25.4" in roadmap and "## 0.25.3" in roadmap and "## 0.25.2" in roadmap and "## 0.25.1" in roadmap and "## 0.25.0" in roadmap
assert "## 0.24." not in roadmap and "## Web Server follow-up" in roadmap and "docs/ROADMAP_HISTORY.md" in roadmap
assert changelog.startswith("# Changelog\n")
for release in ["0.29.1", "0.29.0", "0.28.2", "0.28.1", "0.28.0", "0.27.3", "0.27.2", "0.27.1", "0.27.0", "0.26.7", "0.26.6", "0.26.5", "0.26.4", "0.26.1", "0.26.0", "0.25.6", "0.25.5", "0.25.4", "0.25.3"]:
assert f"## {release}" in changelog, release
assert changelog.index("## 0.29.1") < changelog.index("## 0.29.0") < changelog.index("## 0.28.2") < changelog.index("## 0.28.1") < changelog.index("## 0.28.0") < changelog.index("## 0.27.3") < changelog.index("## 0.27.2") < changelog.index("## 0.27.1") < changelog.index("## 0.27.0") < changelog.index("## 0.26.7") < changelog.index("## 0.26.6") < changelog.index("## 0.26.5") < changelog.index("## 0.26.4") < changelog.index("## 0.26.1") < changelog.index("## 0.26.0") < changelog.index("## 0.25.6") < changelog.index("## 0.25.5")
assert "CHANGELOG_TECHNICAL_ARCHIVE.md" in changelog
for developer_only in ["Clean the remaining compiler warnings", "Update package/source/UI/OpenAPI/release identity", "source regressions and update release identity"]:
assert developer_only not in changelog, f"developer-only CHANGELOG detail: {developer_only}"
link_re = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
paths = [ROOT / "README.md", ROOT / "ROADMAP.md", ROOT / "CHANGELOG.md", *sorted(DOCS.glob("*.md"))]
for source in paths:
text = source.read_text(encoding="utf-8")
assert "Run compiler-enabled `cargo fmt --all`" not in text, f"obsolete release-host checklist remains in {source.relative_to(ROOT)}"
for raw in link_re.findall(text):
target = raw.strip().split()[0].strip("<>")
if not target or target.startswith(("http://", "https://", "mailto:", "#", "/")):
continue
target = target.split("#", 1)[0].split("?", 1)[0]
if not target:
continue
resolved = (source.parent / target).resolve()
assert resolved.exists(), f"broken documentation link: {source.relative_to(ROOT)} -> {target}"
install = (DOCS / "INSTALL.md").read_text(encoding="utf-8")
for marker in ["https://rustproxy.linuxiarz.pl/", "https://rustproxy-opn.linuxiarz.pl/", "Optional: Debian 13+ one-line source install/update"]:
assert marker in install, marker
print("PASS: documentation index, Reader/API split, release docs and local Markdown links are consistent.")
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env python3
"""Static source contract for DDNS ACL 0.32.7; NOT a Rust test."""
from pathlib import Path
import json
import tomllib
ROOT = Path(__file__).resolve().parents[2]
read = lambda p: (ROOT / p).read_text(encoding="utf-8")
acl = read('src/acl.rs')
dns = read('src/acl/dynamic.rs')
js = read('src/admin/web/management.js')
checks = {
'keep static IP/CIDR checks': 'RuleMatch::Ip(net) => net.contains(&peer)' in acl,
'dynamic DNS only allows explicit allow rules': 'dynamic DNS ACL rules only support allow' in acl,
'deny precedence retained': 'return Some(Action::Deny)' in acl,
'strict hostname validation': 'pub(crate) fn hostname(' in dns,
'scope scan covers vhosts, locations, profiles, host policies and docker': all(f in dns for f in [
'config.host_policies', 'config.acl_profiles', 'config.vhosts', 'vhost.routes', 'config.docker_agents.agents', 'agent.assignments'
]),
'bounded asynchronous DNS work': all(x in dns for x in ['REFRESH_SECONDS: u64 = 60', 'MAX_AGE_SECONDS: u64 = 180', 'MAX_NAMES: usize = 128', 'MAX_ADDRESSES: usize = 32', 'PARALLEL_LOOKUPS: usize = 8', 'LOOKUP_SECONDS: u64 = 4', 'buffer_unordered(PARALLEL_LOOKUPS)']),
'lookup on refresh, not on request': 'lookup_host' in dns and 'lookup_host' not in acl and 'dynamic::matches' in acl,
'resolver owns the cache key independently of the DNS future (E0505)': 'let lookup_name = name.clone();' in dns and 'lookup_host((lookup_name.as_str(), 0u16))' in dns and 'lookup_host((name.as_str(), 0u16))' not in dns,
'fail closed on empty or failed resolution': 'CACHE.store(Arc::new(results))' in dns and 'return (name, None);' in dns and 'entry.valid_until > Instant::now()' in dns,
'update after configuration commit': all('crate::acl::dynamic::config_changed()' in read(p) for p in ['src/runtime/updates.rs', 'src/runtime/docker.rs', 'src/runtime/certificates.rs']),
'startup background refresh': 'state.start_dns_acl_updates();' in read('src/main/admin_runtime.rs'),
'visual selector and disabled DNS deny': '.acl-profile-rule-type' in js and 'aclDnsAllowOnly' in js,
'old network field persisted': 'network: normalizeNetwork(' in js,
'PL/EN translations': all(k in json.loads(read(f'langs/{loc}.json'))['translations'] for loc in ('en_US','pl_pl') for k in ('aclRuleType','aclRuleDns','aclDnsHelp','aclDnsAllowOnly')),
'package version': tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.7',
}
for name, ok in checks.items():
if not ok: raise AssertionError(name)
print(f'PASS: {len(checks)} DNS ACL 0.32.7 static source checks (not compiled or network-tested)')
@@ -1,51 +0,0 @@
#!/usr/bin/env node
// Execute the real ACL editor initializer, not just parse it: an uninitialized
// i18n helper used to stop the entire fingerprinted dashboard bundle at load.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
const source = readFileSync('src/admin/web/management.js', 'utf8');
const start = source.lastIndexOf('(() => {');
assert(start > 0 && source.slice(start).includes('window.rustProxyAclProfiles ='), 'ACL editor IIFE not found');
function element(tag = 'div') {
return {
tag, children: [], dataset: {}, value: '', checked: false, hidden: false,
classList: { add() {}, remove() {}, toggle() {} },
append(...nodes) { this.children.push(...nodes); },
after(...nodes) { this.insertedAfter = nodes; },
replaceChildren(...nodes) { this.children = nodes; },
addEventListener() {},
reset() {},
querySelector() { return element(); },
querySelectorAll() { return []; },
};
}
const nodes = new Map();
const get = id => {
if (!nodes.has(id)) nodes.set(id, element());
return nodes.get(id);
};
const modes = ['allow_exceptions', 'block_exceptions', 'custom'].map((value, i) => ({ value, checked: i === 0 }));
get('acl-profile-form').querySelectorAll = selector => selector === 'input[name="acl-profile-mode"]' ? modes : [];
const window = {};
const context = {
$: get, window, envelope: null,
t: key => `localized:${key}`,
el: (tag, text, cls) => {
const node = element(tag);
if (text !== undefined) node.textContent = text;
if (cls) node.className = cls;
return node;
},
};
runInNewContext(source.slice(start), context, { filename: 'src/admin/web/management.js' });
assert.equal(typeof window.rustProxyAclProfiles?.render, 'function', 'ACL panel initializer must finish');
assert.equal(typeof window.rustProxyAclProfiles?.refreshTranslations, 'function');
const hint = get('acl-profile-ip-help').insertedAfter?.[0];
assert.equal(hint?.tag, 'p', 'DDNS hint must be rendered');
assert.equal(hint?.dataset.i18n, 'aclDnsHelp', 'DDNS hint must remain translatable');
assert.equal(hint?.textContent, 'localized:aclDnsHelp');
window.rustProxyAclProfiles.reset();
console.log('PASS: ACL/DDNS real initializer runs without i18n temporal dead zone');
@@ -1,28 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
const read = path => fs.readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8');
const html = read('src/admin/web/index.html');
const js = read('src/admin/web/file-logs.js');
const css = read('src/admin/web/style.css');
const en = JSON.parse(read('langs/en_US.json')).translations;
const pl = JSON.parse(read('langs/pl_pl.json')).translations;
const header = html.indexOf('class="file-logs-heading-actions"');
const filter = html.indexOf('id="file-logs-filter"');
const toggle = html.indexOf('id="file-log-consolidate-vhosts"');
assert.ok(header >= 0 && header < toggle && toggle < filter, 'compact consolidation switch belongs in header');
assert.equal((html.match(/id="file-log-consolidate-vhosts"/g) || []).length, 1);
for (const view of ['entries','counts']) {
assert.match(html, new RegExp(`id="file-log-tab-${view}"[^>]+role="tab"`));
assert.match(html, new RegExp(`id="file-log-view-${view}"[^>]+role="tabpanel"`));
}
assert.match(html, /id="file-log-view-counts"[^>]+hidden/);
assert.ok(html.indexOf('id="file-log-aggregate-form"') > html.indexOf('id="file-log-view-counts"'));
assert.ok(html.indexOf('id="file-log-aggregate-form"') < html.indexOf('id="file-log-view-entries"'));
assert.match(js, /function setView\(view\)/);
assert.match(js, /aria-selected/);
assert.match(js, /\$\("file-log-view-" \+ name\)\.hidden = !selected/);
assert.match(js, /activeView === "counts" && aggregateField/);
assert.match(js, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
assert.match(css, /#file-log-view-counts\[hidden\]\s*,\s*#file-log-view-entries\[hidden\]/);
for (const strings of [en, pl]) for (const key of ['fileLogsConsolidateShort', 'fileLogsEntriesTab','fileLogsCountsTab']) assert.ok(strings[key], key);
console.log('PASS: Stored Logs separate tabs, shared filter, compact persisted consolidation toggle, localized UI.');
@@ -1,78 +0,0 @@
// Source contract tests only: no Rust compilation, live H3 peer or UI browser.
// Keep these checks aligned with Rust unit tests in src/http3/tests.rs and
// src/runtime/tests.rs; do not interpret source checks as wire acceptance.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const read = path => fs.readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8');
const h3 = read('src/http3.rs');
const identity = read('src/http3/identity.rs');
const body = read('src/http3/body.rs');
const h3Tests = read('src/http3/tests.rs');
const cache = read('src/runtime/types.rs');
const cacheTests = read('src/runtime/tests.rs');
const css = read('src/admin/web/style.css');
const html = read('src/admin/web/index.html');
const logs = read('src/admin/web/file-logs.js');
const en = JSON.parse(read('langs/en_US.json')).translations;
const pl = JSON.parse(read('langs/pl_pl.json')).translations;
// A peer-initiated RESET_STREAM or STOP_SENDING must not be logged as a
// fabricated HTTP 400 when the browser aborts a prior navigation on F5.
assert.match(identity, /fn client_cancelled_code\(code: Code\)/);
assert.match(identity, /code == Code::H3_REQUEST_CANCELLED \|\| code == Code::H3_NO_ERROR/);
assert.match(identity, /StreamError::RemoteTerminate \{ code, \.\. \} if client_cancelled_code\(\*code\)/);
assert.match(h3, /Ok\(Err\(error\)\)\s*=>\s*\{\s*if client_cancelled_request\(&error\)/);
assert.match(h3, /\} else \{\s*log_protocol_error\(\s*&state, "http3_request_error", error, peer, local, 400/);
assert.match(h3, /"http3_invalid_headers"[^\n]*\n\s*peer, local, 400/);
assert.match(h3, /error\.downcast_ref::<h3::error::StreamError>\(\)\s*\.is_some_and\(client_cancelled_request\)/);
assert.match(h3, /if !cancelled \{[\s\S]*?send\.stop_stream\(Code::H3_INTERNAL_ERROR\)/);
const bodyDrop = body.slice(body.indexOf('impl Drop for ReceiveBody'));
assert.ok(!bodyDrop.includes('stop_sending('), 'Do not reintroduce a receive-stream Drop panic');
assert.match(h3Tests, /client_cancelled_code\(Code::H3_MESSAGE_ERROR\)/);
// H2/H3 client transport selection is not a cached representation property.
const revision = cache.slice(cache.indexOf('fn cache_revision('), cache.indexOf('fn cache_invalidation('));
assert.match(revision, /for vhost in &mut vhosts/);
assert.match(revision, /vhost\.http2_enabled = true;/);
assert.match(revision, /vhost\.http3_enabled = true;/);
assert.ok(revision.indexOf('vhost.http3_enabled = true;') < revision.indexOf('serde_json::to_vec('));
assert.match(cacheTests, /cache_invalidation\(&base, &protocol_only\), CacheInvalidation::None/);
assert.match(cacheTests, /cache_invalidation\(&protocol_only, &base\), CacheInvalidation::None/);
assert.match(cacheTests, /cache_revision\(&both_protocols\), identity/);
assert.match(cacheTests, /CacheInvalidation::All\(value\) if value == cache_identity/);
// Every row is still keyboard-expandable and all selected columns are
// represented on the mobile card rather than removed by display:none.
assert.match(html, /id="panel-filelogs"/);
assert.match(logs, /row\.tabIndex = 0/);
assert.match(logs, /row\.setAttribute\("aria-expanded"/);
assert.match(logs, /event\.key === "Enter" \|\| event\.key === " "/);
assert.match(logs, /cell\.dataset\.mobileColumn = id/);
assert.match(logs, /cell\.dataset\.mobileLabel = label/);
assert.match(logs, /if \(selectedIndex === index\) nodes\.push\(detailRow\(entry\)\)/);
assert.match(css, /@media \(max-width: 700px\)/);
assert.match(css, /#panel-filelogs \.file-logs-table tbody tr:not\(\.file-log-detail-row\) \{\s*display: grid/);
assert.match(css, /#panel-filelogs \.file-logs-table tbody tr:not\(\.file-log-detail-row\)\s*>\s*td \{\s*display: block/);
assert.match(css, /overflow-wrap: anywhere/);
assert.match(css, /#panel-filelogs \.file-logs-table,\s*#panel-filelogs \.file-log-aggregate-table \{[^}]*overflow-x: auto/s);
assert.match(css, /#panel-filelogs \.file-log-inline-detail pre \{[^}]*max-width:\s*100%;[^}]*overflow-x:\s*auto;[^}]*\}/s);
// Exercise the actual browser code's entry-summary function with a logged
// H3 cancellation and a normal access event; this is a JS unit simulation.
const start = logs.indexOf(' function entrySummary(entry) {');
const end = logs.indexOf(' function nestedValue(', start);
assert.ok(start >= 0 && end > start, 'entrySummary function exists');
const context = vm.createContext({});
vm.runInContext(logs.slice(start, end), context);
assert.equal(vm.runInContext('entrySummary({reason:"http3_request_error",error:"Remote reset: H3_REQUEST_CANCELLED"})', context), 'http3_request_error');
assert.equal(vm.runInContext('entrySummary({method:"GET",host:"rp.example.test",path:"/admin"})', context), 'GET rp.example.test/admin');
for (const [language, pack] of [['en_US', en], ['pl_pl', pl]]) {
assert.match(pack.administrationPublicOriginHelp, /HTTPS/i, `${language}: origin scheme`);
assert.match(pack.administrationPublicOriginHelp, /Origin\/CSRF/i, `${language}: security purpose`);
assert.ok(pack.administrationHostsAdvanced.length > 10, `${language}: advanced DNS hosts`);
}
console.log('PASS: H3 cancellation classification, cache stability, mobile log-card/keyboard contracts and origin help.');
console.log('NOT VERIFIED: Rust build, actual QUIC navigation or full authenticated mobile UI.');
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env python3
"""Compiler-independent guard for the 0.31.7 H3 navigation patch."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
html = (ROOT / 'src/admin/web/index.html').read_text()
assert '0.32.7' in (ROOT / "Cargo.toml").read_text()
assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
assert 'let navigation_method = method == Method::GET || method == Method::HEAD;' in body
assert body.count('std::time::Duration::from_millis(35)') >= 2
assert 'let bounded_h3_navigation = request.version() == Version::HTTP_3' in body
assert 'Body::new(body::ReplayBody::new(frame, tail))' in body
assert '|| method == Method::GET' not in body.split('async fn normalize_h3_h1_unknown_body',1)[1].split('// Ignore a small number',1)[0]
h3 = (ROOT / 'src/http3.rs').read_text()
assert '"http3_peer_cancelled_before_headers"' in h3
assert '"http3_peer_cancelled_after_headers"' in h3
assert '"http3_response_write_error"' in h3
assert 'stream_id={stream_id}' in h3 and 'body_bytes_sent={sent_body_bytes}' in h3
diagnostics = (ROOT / 'src/http3/diagnostics.rs').read_text()
assert 'http3_navigation_complete' in diagnostics and 'http3_navigation_started' in diagnostics
assert 'Err(error)=>{requests.abort_all();return Err(error.into());}' not in h3
rust_tests = (ROOT / 'src/proxy/tests/h3_navigation_tests.rs').read_text()
for marker in ('h3_bodyless_get_and_head_are_normalized_for_http1_navigation',
'h3_get_with_payload_retains_payload_for_http1_upstream',
'h3_stalled_empty_get_does_not_block_navigation',
'h3_stalled_cache_probe_bypasses_without_blocking_navigation',
'h3_cache_probe_replays_nonempty_get_without_dropping_bytes'):
assert marker in rust_tests, marker
print('PASS: RustProxy 0.31.7 original logo, H3 empty-GET receive lifecycle, bounded probe, per-stream diagnostics and drain invariants')
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env python3
"""Offline high-cardinality invalidation replay + HTTP load-driver smoke test.
The replay models bookkeeping cardinality, not a running RustProxy heap. The HTTP
requests target a Python test peer solely to validate the staging load harness.
"""
from __future__ import annotations
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import os
from pathlib import Path
import sys
import threading
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
from memory_load import run_load # noqa: E402
def purge_replay(operations: int = 300_000, windows: int = 6) -> list[tuple[int, int]]:
old_generations = {}
new_generations = {}
counts = []
assert operations % windows == 0
for i in range(operations):
key = i.to_bytes(32, "big")
# Legacy: each already-removed key keeps a generation slot forever.
old_generations[key] = 1
# Same-version fix: the global generation barrier cancels all older
# tickets, so only the current purge's per-key metadata is needed.
new_generations.clear()
new_generations[key] = 1
if (i + 1) % (operations // windows) == 0:
counts.append((len(old_generations), len(new_generations)))
assert counts == [(operations // windows * i, 1) for i in range(1, windows + 1)]
return counts
class TestPeer(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self) -> None:
body = b"HTTP-load-driver-ok"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_: object) -> None:
pass
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--http-requests", type=int, default=2_000, help="HTTP requests against the local Python test peer (default: 2000)")
args = parser.parse_args()
if not 1 <= args.http_requests <= 1_000_000:
parser.error("--http-requests must be 1..1000000")
checkpoints = purge_replay()
print("PASS: 300,000 simulated distinct PURGEs; cumulative vs reset per-key generation slots:", checkpoints)
peer = ThreadingHTTPServer(("127.0.0.1", 0), TestPeer)
runner = threading.Thread(target=peer.serve_forever, daemon=True)
runner.start()
try:
url = f"http://127.0.0.1:{peer.server_port}/"
report = run_load(url=url, requests=args.http_requests, concurrency=24, unique_paths=min(1000, args.http_requests),
timeout=5, pid=os.getpid(), sample_interval=0.05, cooldown=0.15)
assert report["requests_sent"] == args.http_requests, report
assert report["statuses"] == {"200": args.http_requests}, report
assert report["response_bytes_read"] == args.http_requests * len(b"HTTP-load-driver-ok"), report
assert report["rss_peak_mib"] is not None, report
print(f"PASS: load tool generated {args.http_requests:,} real HTTP requests with 24 connections and {min(1000, args.http_requests):,} paths against a Python test peer")
print("NOTE: peer/load-tool RSS is NOT RustProxy RSS; live RustProxy profiling remains mandatory")
finally:
peer.shutdown()
peer.server_close()
runner.join(timeout=5)
if __name__ == "__main__":
main()
-148
View File
@@ -1,148 +0,0 @@
#!/usr/bin/env python3
"""Regression for live Network diagnostic DOM stability beside Process.
Runs the actual renderer and the shipped dashboard CSS with mocked sample data
in Chromium touch emulation. This does NOT certify Safari/iOS 27 behavior.
"""
from pathlib import Path
import shutil
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser')
process = soup.select_one('[data-dashboard-widget="process_diagnostics"]')
network = soup.select_one('[data-dashboard-widget="network_diagnostics"]')
assert process and network
source = (WEB / 'app/runtime.js').read_text()
start = source.index('function renderNetworkInterfaceCharts(interfaces, appendSample) {')
start = source.rfind('let latestNetworkInterfaceSnapshot', 0, start)
assert start >= 0
renderer = source[start:source.index('function renderDiagnostics(', start)]
helper = source[source.index('function setChartText('):start]
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact">'
'<head><meta name="viewport" content="width=device-width, initial-scale=1">'
'<style>' + (WEB / 'style.css').read_text() + '</style></head>'
'<body style="margin:0"><div style="height:1020px">Overview metrics</div>'
'<section id="panel-overview"><div class="stats-grid dashboard-overview-grid is-aligned" '
'id="dashboard-overview-grid">' + str(process) + str(network) +
'</div></section><div style="height:160px"></div></body></html>')
stubs = '''
const networkInterfaceSamples = new Map();
const hiddenNetworkInterfaces = new Set();
const $ = id => document.getElementById(id);
const t = s => ({networkInterfacesVisibleCount:'{visible} / {total}',
networkInterfaceRate:'Rate', networkInterfacesHistory:'Traffic',
hideMetric:'Hide', showMetric:'Show',hiddenInterfaces:'Hidden',
networkNoInterfaces:'No interfaces',networkNoVisibleInterfaces:'None visible'})[s] || s;
const fmtBytes = n => `${n} B`;
const el = (tag, text, cls) => {const node=document.createElement(tag);
if(text !== undefined) node.textContent=text;if(cls) node.className=cls;return node;};
const action = (label, fn) => {const node=el('button',label);node.addEventListener('click',fn);return node;};
const svgNode = (tag, attrs={}) => {const node=document.createElementNS('http://www.w3.org/2000/svg',tag);
Object.entries(attrs).forEach(([key,value])=>node.setAttribute(key,value));return node;};
const sparklinePoints = (values,width=300,height=60,padding=5,peakOverride=null) => {
const peak=Math.max(1,peakOverride??Math.max(0,...values));
return values.map((value,index)=>`${index*width/Math.max(1,values.length-1)},${height-padding-Math.max(0,value)/peak*(height-padding*2)}`).join(' ');
};
const saveHiddenNetworkInterfaces = () => {};
'''
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
context = browser.new_context(viewport={'width':393,'height':844},
device_scale_factor=3, is_mobile=True, has_touch=True)
page = context.new_page()
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=stubs + helper + renderer)
baseline = page.evaluate('''() => {
const items = [{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:20},
{name:'lo',rx_bytes_per_second:3,tx_bytes_per_second:3}];
renderNetworkInterfaceCharts(items,true);
const panel = document.querySelector('[data-dashboard-widget="process_diagnostics"]');
const network = document.querySelector('[data-dashboard-widget="network_diagnostics"]');
const target = document.getElementById('network-interface-charts');
window.testNodes = {first:target.firstElementChild, svg:target.querySelector('svg'),
path:target.querySelector('.network-rx-line')};
window.testInterfaces = items;
window.scrollTo(0,Math.round(network.getBoundingClientRect().top+window.scrollY+180));
return {processHeight:panel.getBoundingClientRect().height, y:scrollY,
network:network.getBoundingClientRect().top, count:target.children.length,
scrollHeight:document.documentElement.scrollHeight};
}''')
assert baseline['y'] > 0 and baseline['count'] == 2, baseline
count = page.evaluate('''() => {
const target = document.getElementById('network-interface-charts');
let mutations=0;
const obs = new MutationObserver(list=>mutations+=list.filter(item=>item.type==='childList').length);
obs.observe(target,{childList:true,subtree:true});
const beforeY=scrollY;
const beforeHeight=document.documentElement.scrollHeight;
const beforeProcess=document.querySelector('[data-dashboard-widget="process_diagnostics"]').getBoundingClientRect().height;
for(let i=0;i<45;i++){
renderNetworkInterfaceCharts([{name:'ens18',rx_bytes_per_second:i*109,tx_bytes_per_second:12},
{name:'lo',rx_bytes_per_second:i*2,tx_bytes_per_second:3}],true);
if(target.firstElementChild!==window.testNodes.first ||
target.querySelector('svg')!==window.testNodes.svg ||
target.querySelector('.network-rx-line')!==window.testNodes.path) throw Error('Network DOM replaced');
}
obs.takeRecords().forEach(r=> {if(r.type==='childList')mutations++});obs.disconnect();
return {mutations, y:scrollY, beforeY, count:target.children.length,
scrollHeight:document.documentElement.scrollHeight, beforeHeight,
processHeight:document.querySelector('[data-dashboard-widget="process_diagnostics"]').getBoundingClientRect().height, beforeProcess,
changed:target.querySelector('.network-interface-rate').textContent.includes('4796')};
}''')
assert count['mutations'] == 0, count
assert abs(count['y'] - count['beforeY']) < 2, count
assert count['count'] == 2 and count['changed'], count
assert abs(count['scrollHeight'] - count['beforeHeight']) < 2, count
assert abs(count['processHeight'] - count['beforeProcess']) < 2, count
print('PASS: 45 updates preserve SVG nodes, Process height, document height and scroll')
# Interface charts advance at every Overview live tick like the aggregate
# network chart, repeating the last observed rate until system-info updates.
# A system-info-only refresh or Hide/Show redraw never adds history points.
history = page.evaluate('''() => {
networkInterfaceSamples.clear();
const one=[{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:200}];
renderNetworkInterfaceCharts(one,true);
const before=networkInterfaceSamples.get('ens18').rx.length;
for(let i=0;i<45;i++) renderNetworkInterfaceCharts(one,true);
const live=networkInterfaceSamples.get('ens18').rx.length;
const measured=[{name:'ens18',rx_bytes_per_second:500,tx_bytes_per_second:250}];
renderNetworkInterfaceCharts(measured,false); // only new system-info data
const afterSystem=networkInterfaceSamples.get('ens18').rx.length;
const label=document.querySelector('.network-interface-rate').textContent;
renderNetworkInterfaceCharts(measured,true); // next live tick
const afterLive=networkInterfaceSamples.get('ens18').rx.length;
const newest=networkInterfaceSamples.get('ens18').rx.at(-1);
return {before,live,afterSystem,afterLive,label,newest};
}''')
assert history['before'] == 1 and history['live'] == 46, history
assert history['afterSystem'] == 46 and history['afterLive'] == 47, history
assert history['newest'] == 500 and '500 B' in history['label'], history
assert 'renderNetworkInterfaceCharts(live.network_interfaces || [], appendSample);' in source
refresh = (WEB / 'app/refresh.js').read_text()
assert 'interval: () => options.system_info_seconds' in refresh
assert 'renderDiagnostics(latestStatus, false, true);' in refresh
print('PASS: frequent interface chart history, configured system-info cadence, no extra samples on system-only redraw')
page.evaluate('''() => renderNetworkInterfaceCharts([
{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:200},
{name:'lo',rx_bytes_per_second:3,tx_bytes_per_second:3}],false)''')
# The Hide / Show controls retain the newest sample on click, not the array
# captured when a chart node was created.
page.locator('.network-interface-hide').first.click()
assert page.locator('.network-interface-row').count() == 1
assert page.locator('.network-interface-hidden button').count() == 1
page.locator('.network-interface-hidden button').click()
assert page.locator('.network-interface-row').count() == 2
assert page.locator('.network-interface-hidden').count() == 0
page.evaluate('''() => renderNetworkInterfaceCharts([{name:'eth0',rx_bytes_per_second:9,tx_bytes_per_second:11}],true)''')
assert page.locator('.network-interface-row').count() == 1
assert page.locator('.network-interface-row').first.get_attribute('data-interface-name') == 'eth0'
page.evaluate('''() => renderNetworkInterfaceCharts([],true)''')
assert page.locator('.network-interface-empty').count() == 1
print('PASS: Hide/Show actions, hotplug/removal and empty state still work')
browser.close()
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env python3
"""Validate language-pack content and emit the same-shaped bundle used by UI QA.
Rust build.rs performs the actual compile-time discovery and embedding.
"""
from pathlib import Path
import argparse
import json
import re
ROOT = Path(__file__).resolve().parents[2]
def bundle(root=ROOT):
languages=[];locales=set()
for path in sorted((root/'langs').glob('*.json')):
assert re.fullmatch(r'[A-Za-z0-9_-]{1,64}',path.stem),path
assert path.stat().st_size<=262144,path
pack=json.loads(path.read_text(encoding='utf-8'));assert set(pack)=={'meta','translations'},f'unexpected top-level language-pack keys: {set(pack)-{"meta","translations"}} in {path}';meta=pack['meta'];translations=pack['translations']
assert all(isinstance(meta.get(key),str) and 0<len(meta[key].encode())<=128 for key in ['locale','name','native_name','direction']),path
assert meta['direction'] in ('ltr','rtl'),path
assert re.fullmatch(r'[A-Za-z0-9]{1,8}(?:-[A-Za-z0-9]{1,8})*',meta['locale']),path
assert meta['locale'].lower() not in locales, 'duplicate locale'
locales.add(meta['locale'].lower())
assert isinstance(translations,dict) and len(translations)<=4096,path
assert all(isinstance(v,str) and 0<len(k.encode())<=128 and len(v.encode())<=16384 for k,v in translations.items()),path
languages.append({'id':path.stem,**pack})
assert 1<=len(languages)<=128
fallback=next(pack for pack in languages if pack['id']=='en_US')['translations']
html=(root/'src/admin/web/index.html').read_text()
keys=set(re.findall(r'data-i18n(?:-placeholder|-aria)?="([^"]+)"',html))
js='\n'.join(p.read_text() for p in (root/'src/admin/web').glob('**/*.js'))
keys.update(re.findall(r'\bt\("([^"\n]+)"\)',js))
keys.update(re.findall(r'"(vh[A-Z][A-Za-z]+)"',js))
assert keys<=fallback.keys(),f'Missing fallback keys: {keys-fallback.keys()}'
for language in languages:
translations=language['translations']
assert keys<=translations.keys(),f"Missing UI keys in {language['id']}: {keys-translations.keys()}"
result={'default':'en_US','languages':languages}
assert len(json.dumps(result,ensure_ascii=False).encode())<=8388608
return result
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--output',type=Path);args=parser.parse_args()
data=bundle()
if args.output:args.output.write_text(json.dumps(data,ensure_ascii=False)+'\n')
print(f"PASS: {len(data['languages'])} language packs, metadata, fallback UI keys and bounds.")
@@ -1,133 +0,0 @@
#!/usr/bin/env python3
"""Real diagnostic renderer/CSS in Chromium mobile emulation (not iOS Safari)."""
from pathlib import Path
import shutil
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
source = (WEB / 'app/runtime.js').read_text()
soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser')
process = soup.select_one('[data-dashboard-widget="process_diagnostics"]')
network = soup.select_one('[data-dashboard-widget="network_diagnostics"]')
assert process and network
# Run the real renderDiagnostics and interface component as shipped, without
# creating a fake SSE server or depending on Rust/Cargo in this test environment.
start = source.index('let systemSamples =')
end = source.index('function renderHeaderRules(', start)
renderer = source[start:end]
html = ('<!doctype html><html data-theme="ultra_dark" data-corner-style="rounded" '
'data-dashboard-density="compact"><head><meta name="viewport" '
'content="width=device-width, initial-scale=1"><style>'
+ (WEB / 'style.css').read_text() + '</style></head>'
'<body style="margin:0"><div style="height:1100px">Earlier dashboard cards</div>'
'<section id="panel-overview"><div id="dashboard-overview-grid" '
'class="stats-grid dashboard-overview-grid is-aligned">'
+ str(process) + str(network) + '</div></section>'
'<div class="vhost-policy-pills"><small class="pill">H1</small>'
'<small class="vhost-backend-protocol-badge" data-observed="true">AUTO → H2</small></div>'
'<div style="height:900px">Footer</div></body></html>')
stubs = '''
const dummy = new Map();
const $ = id => document.getElementById(id) || (() => {
if (!dummy.has(id)) dummy.set(id, { textContent:'-', value:'', hidden:false,
classList:{ toggle(){} }, setAttribute(){}, removeAttribute(){}, append(){},
replaceChildren(){}, closest(){ return {hidden:false}; } });
return dummy.get(id);
})();
const envelope = {config:{cache:{enabled:true,cold_enabled:false}}};
const t = key => ({networkInterfacesVisibleCount:'{visible} / {total}',
networkInterfaceRate:'Rate',networkInterfacesHistory:'Traffic',hideMetric:'Hide',
showMetric:'Show',hiddenInterfaces:'Hidden',networkNoInterfaces:'None',
networkNoVisibleInterfaces:'None visible',notEnabled:'Disabled',
activeListeners:'Listeners',restartPending:'Restart',realSample:'Sampled',
threads:'threads',unavailable:'Unavailable',unlimited:'Unlimited'})[key] || key;
const bytes = value => `${Number(value)} B`;
const locale = () => 'en-US';
const uptime = seconds => `${seconds}s`;
const formatDateTime = seconds => String(seconds);
const action = (text, fn) => { const node=document.createElement('button'); node.textContent=text;
node.addEventListener('click',fn); return node; };
const el = (tag, text, cls) => {const node=document.createElement(tag);
if(text!==undefined) node.textContent=text; if(cls) node.className=cls; return node;};
const updateCacheMemorySizing = () => {};
const rustProxyRateSampling = {cadence:()=>3,difference:(previous,values,at)=>({next:{values,at},
deltas:previous?values.map((v,i)=>v-previous.values[i]):null,seconds:3})};
'''
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
ctx = browser.new_context(viewport={'width':393,'height':844}, device_scale_factor=3,
is_mobile=True, has_touch=True)
page = ctx.new_page()
page.set_content(html, wait_until='domcontentloaded')
page.on('pageerror', lambda error: print('BROWSER ERROR:', error))
page.add_script_tag(content=stubs + renderer)
result = page.evaluate('''() => {
const system = {source:'sysinfo', platform:'Linux', process_threads:5,
process_cpu_percent:5,process_rss_bytes:100,cpu_percent:15,
network_rx_bytes_per_second:20, network_tx_bytes_per_second:10,
sampled_at_ms:Date.now(),network_interfaces:[
{name:'eth0',rx_bytes_per_second:10,tx_bytes_per_second:5}]};
const s = {system,cache:{hits:50,misses:30,bypasses:10,resident_bytes:4096},
metrics:{http3_requests:2,mean_duration_ms:0.5},uptime_seconds:100,
listeners:{active_endpoints:[]},proxy_listen:null,tls_listen:null};
renderDiagnostics(s,true,true);
const process = document.querySelector('[data-dashboard-widget="process_diagnostics"]');
const network = document.querySelector('[data-dashboard-widget="network_diagnostics"]');
const target = document.getElementById('network-interface-charts');
const firstRow = target.firstElementChild;
const initialDetail = document.getElementById('process-cpu').textContent;
const initialHistory = processCpuSamples.length;
window.scrollTo(0, Math.round(network.getBoundingClientRect().top + window.scrollY + 125));
const y = window.scrollY, height = process.getBoundingClientRect().height;
for (let i=0; i<35; i++) {
s.live_system = {sampled_at_ms:Date.now(),process_cpu_percent:7+i,
process_rss_bytes:150+i, cpu_percent:22+i,
network_rx_bytes_per_second:100+i*20,network_tx_bytes_per_second:10+i,
network_interfaces:[{name:'eth0',rx_bytes_per_second:100+i*20,
tx_bytes_per_second:10+i}]};
renderDiagnostics(s,true,false);
}
const unchangedDetails = document.getElementById('process-cpu').textContent === initialDetail;
const recentChart = document.getElementById('process-cpu-chart-value').textContent;
const iface = target.querySelector('.network-interface-rate').textContent;
const beforeSystem = processCpuSamples.length;
const y2 = window.scrollY;
s.system = {...system, process_cpu_percent:93,process_rss_bytes:3000,
network_rx_bytes_per_second:9000, network_tx_bytes_per_second:900};
renderDiagnostics(s,false,true);
const slowlyUpdated = document.getElementById('process-cpu').textContent;
const pill = document.querySelector('.pill');
const origin = document.querySelector('.vhost-backend-protocol-badge');
const roundedOrigin = getComputedStyle(origin).borderTopLeftRadius;
document.documentElement.dataset.cornerStyle = 'default';
const defaultPillRadius = getComputedStyle(pill).borderTopLeftRadius;
const defaultOriginRadius = getComputedStyle(origin).borderTopLeftRadius;
document.documentElement.dataset.cornerStyle = 'rounded';
return {initialDetail,initialHistory,unchangedDetails,recentChart,iface,
beforeSystem,afterSystem:processCpuSamples.length,slowlyUpdated,
rowStable:target.firstElementChild===firstRow,
heightDelta:process.getBoundingClientRect().height-height,
scrollDelta:y2-y,postSystemScrollDelta:window.scrollY-y,
nestedScroll:getComputedStyle(target).overflowY,
nestedMaxHeight:getComputedStyle(target).maxHeight,
pillRadius:getComputedStyle(pill).borderTopLeftRadius,
originRadius:roundedOrigin,defaultPillRadius,defaultOriginRadius,
originColor:getComputedStyle(origin).color};
}''')
assert result['initialDetail'] == '5%', result
assert result['initialHistory'] == 1, result
assert result['unchangedDetails'] and result['recentChart'] == '41%', result
assert result['beforeSystem'] == 36 == result['afterSystem'], result
assert result['slowlyUpdated'] == '93%', result
assert result['rowStable'] and '780 B' in result['iface'], result
assert abs(result['heightDelta']) < 2 and abs(result['scrollDelta']) < 2, result
assert abs(result['postSystemScrollDelta']) < 2, result
assert result['nestedScroll'] == 'visible' and result['nestedMaxHeight'] == 'none', result
assert result['pillRadius'] == '8px' and result['originRadius'] == '8px', result
assert result['defaultPillRadius'] == result['defaultOriginRadius'], result
assert result['originColor'] != '', result
print('PASS: 35 live CPU/RSS/network samples, slow Process metadata, stable mobile layout/scroll, badge radii', result)
browser.close()
-251
View File
@@ -1,251 +0,0 @@
#!/usr/bin/env python3
"""Static regression checks for bounded-memory/background-worker contracts.
This is intentionally Rust-toolchain independent. It does not replace runtime RSS/
heap profiling; it prevents the source-level bounds audited for this release from
silently disappearing before compiler-enabled acceptance runs.
"""
from __future__ import annotations
from pathlib import Path
from rust_source import rust_source
ROOT = Path(__file__).resolve().parents[2]
def read(path: str) -> str:
target = ROOT / path
return rust_source(ROOT, path) if target.suffix == ".rs" else target.read_text(encoding="utf-8")
def require(path: str, *markers: str) -> None:
text = read(path)
for marker in markers:
assert marker in text, f"{path}: missing memory contract: {marker}"
def forbid(path: str, *markers: str) -> None:
text = read(path)
for marker in markers:
assert marker not in text, f"{path}: forbidden memory pattern returned: {marker}"
# Transform path: hard aggregate/decode/concurrency budgets and no JSON DOM tree.
require(
"src/transform/mod.rs",
"DEFAULT_MEMORY_BUDGET_BYTES: usize = 128 * 1024 * 1024",
"pub memory_budget_bytes: usize",
"pub max_inflight: usize",
"try_reserve_transform_memory(required_memory, settings.memory_budget_bytes)",
"try_reserve_transform_slot(settings.max_inflight)",
"TRANSFORM_MEMORY_IN_USE_BYTES: AtomicUsize",
"TRANSFORM_INFLIGHT: AtomicUsize",
"pub struct ResourceUsage",
"pub fn resource_usage(settings: &Settings)",
"memory_reserved_bytes: TRANSFORM_MEMORY_IN_USE_BYTES.load",
"Limited::new(body, settings.max_body_bytes)",
".try_reserve_exact(read)",
'"memory_pressure"',
'"concurrency_pressure"',
"aggregation_is_bounded_even_when_content_length_is_false",
"transform_memory_reservation_obeys_runtime_budget",
"transform_inflight_reservation_obeys_runtime_limit",
)
require("src/transform/json.rs", "IgnoredAny::deserialize", "Vec::with_capacity(body.len())")
forbid("src/transform/json.rs", "use serde_json::Value", "from_slice::<Value>")
require("src/transform/css.rs", "let mut last_char: Option<char> = None")
require("src/transform/html.rs", "enum RawElement", "fn raw_tag(tag: &str)")
# Cache background work: all previously high-retention paths have hard bounds.
require(
"src/cache/model.rs",
"COLD_DELETE_FALLBACKS: usize = 2",
"COLD_CLEANUP_SLOTS: usize = 1",
"BACKGROUND_REVALIDATION_LIMIT: usize = 8",
"pub cold_limit_bytes: u64",
"pub cold_limit_entries: usize",
"pub hot_auto_trim_enabled: bool",
"pub hot_auto_trim_after_seconds: u64",
"pub hot_auto_trim_target_percent: u8",
)
require(
"src/admin/mod.rs",
'payload["transform_resources"]',
'payload["memory_accounting"]',
'"non_cache_rss_includes_transform": true',
)
require(
"src/cache/cold.rs",
"BinaryHeap::with_capacity(requested_limit.saturating_add(1))",
"self.cold_delete_fallbacks.clone().try_acquire_owned()",
"self.cleanup_slots.clone().try_acquire_owned()",
"std::mem::take(&mut delete)",
)
require(
"src/cache/tickets.rs",
"active.len() >= BACKGROUND_REVALIDATION_LIMIT",
)
require(
"src/cache/collapse.rs",
".saturating_mul(64)",
".clamp(64, 256)",
"settings.proactive_refresh_max_inflight as u64",
)
require(
"src/cache/lifecycle.rs",
"mpsc::channel(COLD_QUEUE)",
"cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS))",
"cleanup_slots: Arc::new(Semaphore::new(COLD_CLEANUP_SLOTS))",
"cache.trim_hot_idle();",
"fn trim_hot_idle(&self)",
)
# COLD bodies stay disk-backed and the long-lived index must not retain full
# response header/validator allocations for every object.
cache_model = read("src/cache/model/cache.rs")
cold_entry = cache_model.split("struct ColdEntry {", 1)[1].split("#[derive(Default)]", 1)[0]
assert "headers: HeaderMap" not in cold_entry, "COLD index retains full response headers"
assert "origin_etag: Option<HeaderValue>" not in cold_entry, "COLD index retains ETag HeaderValue"
assert "origin_last_modified: Option<HeaderValue>" not in cold_entry, "COLD index retains Last-Modified HeaderValue"
assert "body_sha256: Key" in cold_entry, "COLD digest must use the compact 32-byte representation"
assert "has_validator: bool" in cold_entry, "COLD index must retain validator presence without full values"
require(
"src/cache/model/cold_metadata.rs",
"tokio::fs::read(&entry.meta_path).await",
"pairs_to_headers(&file.headers)",
"body_sha256 != entry.body_sha256",
)
# Rate-limit client state stores a vhost allocation once and reclaims expired
# fixed-window buckets without waiting for the global cardinality ceiling.
require(
"src/rate_limit.rs",
"buckets: HashMap<String, HashMap<IpAddr, Bucket>>",
"fn sweep_expired(&mut self, now: Instant, force: bool)",
"now.duration_since(bucket.started) < Duration::from_secs(bucket.window_seconds)",
"state.sweep_expired(now, false)",
"state.sweep_expired(now, true)",
"clients.shrink_to_fit()",
"self.buckets.shrink_to_fit()",
"pub fn maintenance(&self)",
"MAX_BUCKETS: usize = 65_536",
)
require(
"src/runtime/constructors.rs",
"app.rate_limiter.maintenance()",
"spawn_blocking(crate::diagnostics::trim_allocator)",
)
# Compression remains response-streaming; no complete-body collection is added.
require(
"src/compression.rs",
"No full-body aggregation is introduced.",
"CompressionLevel::Fastest",
"Compression::new(service)",
)
# One JSONL buffer is reused across local file and Logstash sends (Loki-only
# sinks should not allocate the JSONL line). Both output queues remain bounded.
logging_sink = read("src/logging/runtime.rs")
assert logging_sink.count("serde_json::to_vec(&payload)") == 1
assert "if config.file.enabled || config.logstash.enabled" in logging_sink
assert "opened.write_all(&jsonl_line)" in logging_sink
assert "state.stream.write_all(&jsonl_line)" in logging_sink
assert "PER_VHOST_OPEN_FILES: usize = 64" in logging_sink
# Stored Logs file discovery must also be cardinality bounded before sort:
# rotated files and per-vhost directories may contain arbitrary historical files.
require("src/admin/file_logs.rs", "newest_log_files(std::fs::read_dir(&directory)", ", 256);")
require("src/admin/file_logs_io.rs", "BinaryHeap::with_capacity(limit)", "newest_log_files(std::fs::read_dir(parent)", "), 64);")
# A per-key invalidation generation is useful only inside the current PURGE
# publication epoch; retaining each purged URL after eviction is an unbounded
# lifetime-memory leak on high-cardinality cache invalidation workloads.
invalidation = read("src/cache/invalidation/invalidate.rs")
start = invalidation.index("InvalidationOperation::Purge =>")
barrier = invalidation.index("store.generation = store.generation.wrapping_add(1);", start)
reset = invalidation.index("store.key_generations.clear();", barrier)
assert "store.key_generations.capacity() > 256" in invalidation[barrier:reset]
matcher = invalidation.index("let affected = self.purge_matcher(&matcher);", reset)
assert start < barrier < reset < matcher
require("src/cache/tests/v022_cache_tests.rs", "many_distinct_selective_purges_do_not_retain_tombstones_forever")
# Repeated hot-path analytics labels should use borrowed lookups instead of
# allocating a String for every request in an already populated top table.
require("src/traffic.rs", "fn truncate(value: &str, max_bytes: usize) -> &str", "self.values.get_mut(value)", "self.values.insert(value.to_owned(), weight)")
forbid("src/web_server/serve.rs", "Body::from(listing.clone())")
require("src/web_server/serve.rs", "entries.retain(|entry|", "let listing_bytes = listing.len();", "Body::from(listing)")
require("src/logging/runtime.rs", "String::from_utf8_lossy(&jsonl_line[..jsonl_line.len() - 1])")
# Other long-lived/background facilities: queue/task/worker cardinality is bounded
# or owned by a cancellation-aware supervisor/TaskTracker.
require(
"src/logging.rs",
"HISTORY_CAPACITY: usize = 4096",
"LIVE_STREAM_CAPACITY: usize = 4096",
"mpsc::channel(SINK_QUEUE)",
"self.sink.try_send(entry)",
"pub capture_user_agent: bool",
"pub capture_country: bool",
)
require(
"src/traffic.rs",
"QUEUE_CAPACITY: usize = 2048",
"TOP_CAPACITY: usize = 32",
"MAX_VHOSTS: usize = 64",
"self.tx.try_send(sample)",
"client_ips: TopTable",
)
require(
"src/diagnostics.rs",
"tokio::task::spawn_blocking(move || system_stats::sample(previous)).await",
"stop.cancelled()",
"pub fn trim_allocator() -> bool",
)
require(
"src/runtime.rs",
"Duration::from_secs(60)",
"spawn_blocking(crate::diagnostics::trim_allocator)",
)
require(
"src/upstream.rs",
"Semaphore::new(16)",
".try_acquire_owned()",
"tokio::time::timeout(DNS_TIMEOUT, lookup)",
)
require(
"src/upstream/client.rs",
"pool_recycle_requests",
"pool_recycle_seconds",
"http1_max_buf_size(keepalive.http1_max_buffer_bytes)",
"ArcSwap<RawProxyClient>",
"self.current.store(Arc::new(client))",
'"recycled upstream client pool"',
)
require(
"src/balance.rs",
"if checks.len() >= 16 { break; }",
"checks.abort_all()",
)
require(
"src/stream.rs",
"Semaphore::new(host.max_connections)",
"mpsc::channel(64)",
"JoinSet::new()",
)
require(
"src/server.rs",
"Semaphore::new(max_connections)",
"let mut tasks = JoinSet::new()",
)
require(
"src/runtime.rs",
"tokio_util::task::TaskTracker::new()",
"update_slots: Arc::new(Semaphore::new(8))",
"inflight: Arc::new(Semaphore::new(max_inflight))",
)
require("src/acme.rs", "slot: Arc::new(Semaphore::new(1))", ".try_acquire_owned()")
require("src/docker.rs", "shutdown.cancelled()", "Duration::from_secs(5)")
require("src/update_check.rs", "shutdown.cancelled()", "tokio::time::sleep(interval)")
print("PASS: bounded-memory transform/cache contracts and background-worker limits are present.")
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env python3
"""Source-only mobile/observed-backend UI invariants, no browser required."""
from pathlib import Path
root=Path(__file__).resolve().parents[2]
css=(root/'src/admin/web/style.css').read_text()
preferences=(root/'src/admin/web/app/preferences.js').read_text()
masonry=(root/'src/admin/web/app/overview-masonry.js').read_text()
html=(root/'src/admin/web/index.html').read_text()
render=(root/'src/admin/web/vhosts/render.js').read_text()
client=(root/'src/upstream/client.rs').read_text()
pool=(root/'src/balance/pool_view.rs').read_text()
assert 'touch-action: pan-y pinch-zoom' in css
assert 'overflow-anchor: none' in css
assert 'grid-template-columns: repeat(2, minmax(0, 1fr))' in css
assert '@container overview-canvas (max-width: 700px)' in css
assert 'overview-canvas (max-width: 440px)' not in css
assert '.vhost-backend-protocol-badge' in css
assert '.vhost-card' in css[css.index('html:is([data-corner-style=square]'):]
assert '.draft-dock,' not in css[css.index('html:is([data-corner-style=square]'):]
assert 'grid.append(...orderedNodes)' in preferences
assert 'orderedNodes.some((node, index) => node !== currentNodes[index])' in preferences
assert 'if (ordered.some((node, i) => node !== previous[i])) grid.append(...ordered);' in masonry
assert html.count('data-dashboard-widget=') >= 62
assert 'function renderUpstreamProtocolBadges(status)' in render
assert 'const VHOST_PROTOCOL_FRESH_MS = 5 * 60 * 1000' in render
# Protocol badges live in the existing Vhost policy-pill group, not above the
# live traffic metrics. The class and observed-color state remain unchanged.
policy_start=render.index('const policy = el("div", undefined, "vhost-policy-pills");')
policy_end=render.index('if (policy.children.length) box.append(policy);', policy_start)
policy_block=render[policy_start:policy_end]
assert 'policy.append(badge);' in policy_block
assert 'vhost-backend-protocol-badge' in policy_block
assert 'box.append(badges)' not in render
assert '.vhost-backend-protocol-badge[data-observed=true]' in css
assert 'color: var(--green)' in css[css.index('.vhost-backend-protocol-badge[data-observed=true]'):]
assert 'self.inner.observe_protocol(value.version())' in client
assert '"upstream_protocol_observed"' in pool
print('PASS: mobile grid, shape, stable reorder and observed backend protocol source contracts')
@@ -1,126 +0,0 @@
#!/usr/bin/env python3
"""Mobile Dashboard scroll/layout and appearance browser regression.
Uses the shipped static web assets in a Chromium touch-device emulation, not
physical Safari, the authenticated server or a running Rust backend.
"""
from pathlib import Path
import json
import shutil
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser')
widgets = soup.select('#dashboard-overview-grid > [data-dashboard-widget]')
assert len(widgets) == 62
widget_ids = [widget['data-dashboard-widget'] for widget in widgets]
css = (WEB / 'style.css').read_text()
source = (WEB / 'app/preferences.js').read_text()
layout_code = source.split('function applyDashboardLayout(order, hidden) {')[1].split('function dashboardWidgetLabel(')[0]
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact" '
'data-corner-style="default"><head><meta name="viewport" '
'content="width=device-width,initial-scale=1"><style>' + css +
'</style></head><body style="margin:0">'
'<div style="height:80px">Mobile navigation</div>'
'<section id="panel-overview" style="width:auto;padding:0 10px;box-sizing:border-box">'
'<div id="dashboard-overview-grid" class="stats-grid dashboard-overview-grid">' +
''.join(map(str, widgets)) + '</div></section>'
'<div id="radii" style="padding:10px"><article class="vhost-card" id="vhost-test">Vhost</article>'
'<div class="entity" id="entity-test">Entity</div>'
'<span class="vhost-backend-protocol-badge" id="badge-test">AUTO → H2</span></div>'
'</body></html>')
with sync_playwright() as browser_tool:
chromium = browser_tool.chromium.launch(headless=True,
executable_path=shutil.which('chromium') or shutil.which('chromium-browser'),
args=['--no-sandbox'])
context = chromium.new_context(viewport={'width': 393, 'height': 844},
device_scale_factor=3, is_mobile=True, has_touch=True)
page = context.new_page()
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=(WEB / 'app/overview-masonry.js').read_text())
preference_fixture = (
'const ADMIN_DASHBOARD_WIDGETS=' + json.dumps(widget_ids) + ';' +
'const ADMIN_DEFAULT_HIDDEN_WIDGETS=[];' +
'const latestStatus=null; const currentPanel="overview";' +
'const normalizeDashboardWidgets=(ids)=>ids;' +
'const normalizeHiddenDashboardWidgets=(ids)=>ids;' +
'function applyDashboardLayout(order, hidden) {' + layout_code
)
page.add_script_tag(content=preference_fixture)
for width in (320, 375, 393, 402, 430, 550):
page.set_viewport_size({'width': width, 'height': 844})
snapshot = page.evaluate('''() => {
const grid=document.getElementById('dashboard-overview-grid');
const cards=[...grid.querySelectorAll('[data-dashboard-widget].stat')]
.filter(node => !node.hidden).slice(0,6).map(node => {
const r=node.getBoundingClientRect();
return {left:r.left,top:r.top,right:r.right,bottom:r.bottom,width:r.width};
});
return {columns:getComputedStyle(grid).gridTemplateColumns.split(' ').length,
cards,gridWidth:grid.clientWidth,gridScroll:grid.scrollWidth,
screenScroll:document.documentElement.scrollWidth,
screen:innerWidth,
touch:getComputedStyle(grid.querySelector('.stat')).touchAction};
}''')
a,b,c,d,e,f = snapshot['cards']
assert snapshot['columns'] == 2, (width, snapshot)
assert abs(a['top']-b['top']) < 1 and abs(c['top']-d['top']) < 1
assert abs(e['top']-f['top']) < 1 and c['top'] > a['top'] and e['top'] > c['top']
assert b['left'] >= a['right'] and d['left'] >= c['right']
assert a['width'] > 50 and b['width'] > 50
assert snapshot['gridScroll'] <= snapshot['gridWidth']+2, (width,snapshot)
assert snapshot['screenScroll'] <= snapshot['screen']+2, (width,snapshot)
assert 'pan-y' in snapshot['touch']
action = page.evaluate('''() => {
const tile=document.querySelector('.stat-with-action');
const label=tile.querySelector(':scope > span').getBoundingClientRect();
const button=tile.querySelector('.stat-corner-action').getBoundingClientRect();
const value=tile.querySelector('strong').getBoundingClientRect();
const card=tile.getBoundingClientRect();
return {labelBottom:label.bottom,buttonTop:button.top,
buttonBottom:button.bottom,valueTop:value.top,
buttonRight:button.right,cardRight:card.right};
}''')
assert action['buttonTop'] >= action['labelBottom'] - 2, (width,action)
assert action['valueTop'] >= action['buttonBottom'] - 2, (width,action)
assert action['buttonRight'] <= action['cardRight'] + 1, (width,action)
page.evaluate('window.scrollTo(0,350)')
before = page.evaluate('window.scrollY')
assert before > 0, width
page.evaluate('''() => {
const grid=document.getElementById('dashboard-overview-grid');
window.rustProxyMasonry.refresh();
for(const [index,node] of [...grid.querySelectorAll('.stat strong')].entries()) {
node.textContent=index%2 ? '26216' : '49,750';
}
}''')
after = page.evaluate('window.scrollY')
assert abs(after - before) <= 2, (width, before, after)
# Reapplying an unchanged preference must NOT reparent the grid children.
moves = page.evaluate('''() => {
const grid=document.getElementById('dashboard-overview-grid');
const order=[...grid.children].map(n=>n.dataset.dashboardWidget);
let moves=0;
const observer=new MutationObserver(items => { moves+=items.filter(i=>i.type==='childList').length; });
observer.observe(grid,{childList:true});
applyDashboardLayout(order,[]);
observer.takeRecords().forEach(i=>{if(i.type==='childList')moves++});
observer.disconnect();
return moves;
}''')
assert moves == 0, (width, moves)
assert abs(page.evaluate('window.scrollY')-before) <= 2
print(f'PASS: {width}px touch emulation, two metric columns, no overflow or scroll jumps')
for style,surface,control in (('square','0px','0px'), ('subtle','5px','4px'),
('rounded','12px','8px'), ('extra','18px','12px')):
page.evaluate('(s) => document.documentElement.dataset.cornerStyle=s', style)
for selector in ('#vhost-test','#entity-test','.stat'):
radius = page.locator(selector).first.evaluate('node=>getComputedStyle(node).borderTopLeftRadius')
assert radius == surface, (style,selector,radius)
radius = page.locator('#badge-test').evaluate('node=>getComputedStyle(node).borderTopLeftRadius')
assert radius == control, (style,'origin protocol badge',radius)
print('PASS: vhost, entity, badge/control and metric corner presets')
chromium.close()
-207
View File
@@ -1,207 +0,0 @@
#!/usr/bin/env node
// Real-source simulation of mobile tab restore, BFCache, Origin 403 and retry.
// This does not replace a real Safari/Chrome session or a compiled Rust backend.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const read = path => readFileSync(resolve(root, path), 'utf8');
const core = read('src/admin/web/app/core.js');
const refresh = read('src/admin/web/app/refresh.js');
const resume = read('src/admin/web/app/session-resume.js');
const runtime = read('src/admin/web/app/runtime.js');
const html = read('src/admin/web/index.html');
const server = read('src/admin/session.rs');
assert.match(html, /id="app-session-retry"[^>]*data-i18n="sessionRetry"/);
assert.match(server, /origin_mismatch/);
assert.match(server, /cross_site_fetch/);
assert.match(server, /administration origin rejected/);
function sourceBetween(source, start, end) {
const a = source.indexOf(start), b = source.indexOf(end, a);
assert(a >= 0 && b > a, `Missing source slice: ${start}`);
return source.slice(a, b);
}
const actualApi = sourceBetween(core, 'function apiArea(path)', 'function setPanel(name');
const actualLive = sourceBetween(core, 'function renderLiveStatus(value)', 'async function refreshCertificateInventory()');
const actualResume = resume;
const recovery = sourceBetween(core, 'const ORIGIN_RECOVERY_KEY', 'const PANEL_PATHS');
const startup = sourceBetween(runtime, 'function verifyAdminModules()', 'function certificateUsesExternalFiles');
function fixture() {
const events = new Map(), windows = new Map(), pending = [], streams = [], nodes = new Map(), timers = new Map();
const classes = new Set(), originMarker = new Map();
let timerId = 0, reloads = 0, lockCount = 0, renderSessionCalls = 0;
const node = id => {
if (!nodes.has(id)) {
const handlers = new Map();
nodes.set(id, { hidden: id !== 'workspace', disabled: false, textContent: '',
addEventListener(type, fn) { handlers.set(type, fn); },
click() { handlers.get('click')?.(); },
});
}
return nodes.get(id);
};
class FakeEventSource {
constructor(url) { this.url = url; this.closed = false; streams.push(this); }
addEventListener() {}
close() { this.closed = true; }
}
const classList = {
contains(k) { return classes.has(k); },
add(...keys) { keys.forEach(k => classes.add(k)); },
remove(...keys) { keys.forEach(k => classes.delete(k)); },
toggle(k, forced) { if (forced) classes.add(k); else classes.delete(k); },
};
const document = {
hidden: false, body: {}, documentElement: { classList },
addEventListener(name, handler) { assert(!events.has(name)); events.set(name, handler); },
removeEventListener(name, handler) { assert.equal(events.get(name), handler); events.delete(name); },
};
const window = {
rustProxySettings: { current: () => 'listeners', render() {} },
addEventListener(name, handler) { assert(!windows.has(name)); windows.set(name, handler); },
removeEventListener(name, handler) { assert.equal(windows.get(name), handler); windows.delete(name); },
};
const ctx = {
document, window, $: node, AbortController, EventSource: FakeEventSource,
MutationObserver: class { observe() {} },
location: { origin: 'https://panel.example', pathname: '/overview', search: '',
reload() { reloads++; }, replace() { throw new Error('Unexpected replace'); } },
sessionStorage: { getItem: key => originMarker.get(key), setItem: (key, value) => originMarker.set(key, value), removeItem: key => originMarker.delete(key) },
performance: { now: () => 1 },
fetch: (path, options) => new Promise((resolve, reject) => pending.push({ path, options, resolve, reject })),
setTimeout(fn, ms) { const id = ++timerId; timers.set(id, { fn, ms }); return id; },
clearTimeout(id) { timers.delete(id); },
console: { warn() {} },
csrf: 'old', authenticated: true, authEpoch: 1, sessionInfo: null, sessionCheckPromise: null,
languagePacks: new Map([['en_US', {}]]), latestStatus: null,
currentPanel: 'overview', liveEvents: null, liveInterval: null, liveConnected: false, liveFallback: false,
t: key => key === 'sessionValidationFailed' ? 'Could not verify your session' : key,
renderSession() { renderSessionCalls++; },
renderFooter() {}, renderStatus() {}, renderLogs() {}, renderDiagnostics() {},
toast() { throw new Error('Resume errors must not be transient toasts'); },
loadTunnels: async () => {},
lock() { lockCount++; ctx.authEpoch++; ctx.authenticated = false; ctx.window.rustProxyRefresh.stop(); },
};
runInNewContext(`${recovery}\n${actualApi}\n${actualLive}\n${actualResume}\n${refresh}`, ctx,
{ filename: 'actual-rustproxy-admin-source.js' });
const tick = async () => { for (let i = 0; i < 25; i++) await Promise.resolve(); };
const answer = (status, value) => {
const request = pending.shift();
assert(request && request.path === '/api/auth/session', `Unexpected request: ${request?.path}`);
request.resolve({ ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(value) });
return request;
};
const visibility = hidden => { document.hidden = hidden; events.get('visibilitychange')?.(); };
const pagehide = () => windows.get('pagehide')?.({});
const pageshow = persisted => windows.get('pageshow')?.({ persisted });
const activeStreams = () => streams.filter(stream => !stream.closed);
return { ctx, events, windows, pending, streams, timers, originMarker, nodes, classes, tick, answer,
visibility, pagehide, pageshow, activeStreams, node, get reloads() { return reloads; },
get lockCount() { return lockCount; }, get renderSessionCalls() { return renderSessionCalls; } };
}
const normal = fixture();
normal.ctx.window.rustProxyRefresh.start();
assert.equal(normal.activeStreams().length, 1, 'authenticated shell starts one SSE stream');
normal.visibility(true);
assert.equal(normal.activeStreams().length, 0, 'hidden document pauses SSE');
normal.visibility(false);
normal.pageshow(true);
assert.equal(normal.pending.length, 1, 'pageshow and visibilitychange share one session GET');
assert.equal(normal.activeStreams().length, 0, 'SSE cannot resume before session verification');
assert.equal(normal.ctx.window.rustProxyRefresh.diagnostics().filter(task => task.scheduled).length, 0);
normal.answer(403, { error: 'auth.invalid_origin' }); await normal.tick();
assert.equal(normal.reloads, 0, '403 during resume must not reload');
assert.equal(normal.originMarker.size, 0, 'resume must not update startup reload limit');
assert(normal.classes.has('app-loading') && normal.classes.has('app-startup-failed'));
assert(normal.classes.has('app-session-failed'), 'show an actionable resume failure');
assert.equal(normal.node('workspace').hidden, true);
assert.equal(normal.node('app-session-retry').hidden, false);
assert.equal(normal.node('app-startup-reload').hidden, true);
assert.equal(normal.node('app-startup-title').textContent, 'Could not verify your session');
assert.equal(normal.activeStreams().length, 0);
assert.equal(normal.ctx.window.rustProxyRefresh.diagnostics().filter(task => task.scheduled).length, 0);
normal.pageshow(true);
assert.equal(normal.pending.length, 0, 'no automatic error-loop retries on duplicate resume events');
normal.node('app-session-retry').click();
assert.equal(normal.pending.length, 1, 'user may explicitly retry without a reload');
assert.equal(normal.node('app-session-retry').disabled, true);
normal.answer(200, { username: 'admin', csrf_token: 'fresh' }); await normal.tick();
assert.equal(normal.ctx.csrf, 'fresh');
assert.equal(normal.node('workspace').hidden, false);
assert.equal(normal.classes.has('app-loading'), false);
assert.equal(normal.node('app-session-retry').hidden, true);
assert.equal(normal.node('app-session-retry').disabled, false);
assert.equal(normal.activeStreams().length, 1, 'SSE resumes only after verified session');
assert.equal(normal.reloads, 0);
// A real 401 follows the existing lock/redirect flow, never displaying stale data.
normal.visibility(true);
normal.visibility(false);
assert.equal(normal.pending.length, 1);
normal.answer(401, { error: 'auth.required' }); await normal.tick();
assert.equal(normal.lockCount, 1);
assert.equal(normal.activeStreams().length, 0);
assert.equal(normal.classes.has('app-session-failed'), false);
const restored = fixture();
restored.ctx.window.rustProxyRefresh.start();
restored.pagehide();
assert.equal(restored.activeStreams().length, 0);
restored.pageshow(true);
restored.visibility(false);
assert.equal(restored.pending.length, 1, 'BFCache pagehide/pageshow + visibility triggers one session check');
restored.answer(200, { username: 'admin', csrf_token: 'bfcache' }); await restored.tick();
assert.equal(restored.activeStreams().length, 1);
// Ten tab cycles must not add listeners or duplicate SSE sessions.
for (let i = 0; i < 10; i++) {
restored.visibility(true); restored.visibility(false); restored.pageshow(true);
assert.equal(restored.pending.length, 1);
assert.equal(restored.activeStreams().length, 0);
restored.answer(200, { username: 'admin', csrf_token: `cycle-${i}` }); await restored.tick();
assert.equal(restored.activeStreams().length, 1);
assert.equal(restored.windows.size, 2);
assert.equal(restored.events.size, 1);
}
// A response from a prior auth epoch may not restore the dashboard.
restored.visibility(true); restored.visibility(false);
assert.equal(restored.pending.length, 1);
restored.ctx.authEpoch++;
restored.ctx.authenticated = false;
restored.ctx.window.rustProxyRefresh.stop();
restored.answer(200, { username: 'stale', csrf_token: 'stale' }); await restored.tick();
assert.equal(restored.activeStreams().length, 0);
assert.equal(restored.ctx.csrf, 'cycle-9');
assert.equal(restored.windows.size, 0);
assert.equal(restored.events.size, 0);
// Startup reload-marker is released only after the complete panel activation,
// never merely when /api/auth/session happened to return successfully.
async function checkStartup(activationResult, failLanguage) {
let bootstrap, clears = 0, failures = 0;
const modules = Object.fromEntries([
['rustProxyIntegrations', 'bindings'], ['rustProxyVhosts', 'render'], ['rustProxySettings', 'render'],
['rustProxyCachePanel', 'refresh'], ['rustProxyTrafficPanel', 'refresh'],
['rustProxyPreferences', 'load'], ['rustProxyRefresh', 'start'],
].map(([name, method]) => [name, { [method]() {} }]));
const ctx = {
window: modules, document: { readyState: 'loading', addEventListener(_name, cb) { bootstrap = cb; } },
api: async () => ({ csrf_token: 'valid' }), loadLanguages: async () => { if (failLanguage) throw new Error('Language assets missing'); },
translate() {}, setupRequired: false, syncAuthForm() {}, activate: async () => activationResult,
clearInvalidOriginRecovery() { clears++; }, recoverInvalidOrigin: () => false,
showStartupFailure() { failures++; },
location: { pathname: '/overview', search: '', replace() { throw new Error('unexpected redirect'); } },
};
runInNewContext(startup, ctx);
bootstrap(); for (let i = 0; i < 12; i++) await Promise.resolve();
return { clears, failures };
}
assert.deepEqual(await checkStartup(true, false), { clears: 1, failures: 0 });
assert.deepEqual(await checkStartup(false, false), { clears: 0, failures: 0 });
assert.deepEqual(await checkStartup(true, true), { clears: 0, failures: 1 });
console.log('PASS: 0.31.3 real-source resume race, 403 retry, BFCache, 401 lock, SSE pause, stale epochs, startup recovery limit and repeated cycles');
@@ -1,56 +0,0 @@
// 0.32.1 regression: restore pre-0.32.0 content-driven tile heights and
// align inter-tile gaps with the same spacing as other Dashboard sections.
// These are source/CSS contracts, not a browser or authenticated-UI test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
function rule(selector) {
// Target precise blocks rather than accepting an unrelated matching rule.
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(`${escaped}\\s*\\{([^{}]*)\\}`));
assert(match, `Missing CSS rule: ${selector}`);
return match[1];
}
for (const density of ['normal', 'compact', 'ultra']) {
const declarations = rule(`html[data-dashboard-density="${density}"] #panel-overview .stats-grid`);
assert.match(declarations, /gap:\s*var\(--ui-gap\)\s*;/,
`${density} tiles must use the same density-aware gap as Dashboard sections`);
}
const sharedGap = rule('.overview-grid,\n.diagnostics-grid,\n.settings-grid,\n.config-columns');
assert.match(sharedGap, /gap:\s*var\(--ui-gap\)/);
for (const density of ['dense', 'comfortable', 'compact']) {
assert.match(css, new RegExp(`body\\[data-density="${density}"\\]\\s*\\{[^}]*--ui-gap:`),
`UI density ${density} must provide the shared gap variable`);
}
assert.match(css, /@media \(min-width: 1201px\)\s*\{\s*#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);/,
'six desktop columns are preserved');
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*>\s*\.stat\s*\{[^}]*\bmin-height\s*:/s,
'desktop cards should grow naturally to their legend/content height');
assert.doesNotMatch(css, /#panel-overview \.stats-grid \.stat:is\(:hover, :focus-visible, :focus-within\)/,
'card descriptions must remain visible, not hover-only');
const heading = rule('html[data-dashboard-density="compact"] #panel-overview .stat>span');
const metric = rule('html[data-dashboard-density="compact"] #panel-overview .stat strong');
const caption = rule('html[data-dashboard-density="compact"] #panel-overview .stat small');
const payload = rule('html[data-dashboard-density="compact"] #panel-overview .payload-split');
assert.match(heading, /font-size:\s*11px/);
assert.match(metric, /font-size:\s*25px/);
assert.match(caption, /font-size:\s*10px/);
assert.match(payload, /font-size:\s*10px\s*!important/);
assert.match(css, /#panel-overview \.stats-grid \.stat-chart>\.stat-chart-legend\s*\{[^}]*flex-wrap:\s*wrap/s);
const overview = html.slice(html.indexOf('id="panel-overview"'), html.indexOf('id="panel-vhosts"'));
const widgets = [...overview.matchAll(/data-dashboard-widget="([^"]+)"/g)].map(match => match[1]);
assert.equal(widgets.length, 62, '49 existing widgets plus 6 small and 7 large opt-in charts');
assert.equal(widgets.filter(id => !id.startsWith('backend_') && !id.startsWith('extra_') && !id.startsWith('wide_') && !['traffic_chart','system_chart','connection_chart','cache_diagnostics','process_diagnostics','network_diagnostics','backend_table'].includes(id)).length, 16,
'all 16 pre-existing metric/Stream cards must be retained');
assert.match(overview, /id="metric-bytes-split"/);
assert.match(overview, /id="http-protocol-chart"/);
assert.match(overview, /id="ws-protocol-chart"/);
assert.match(overview, /id="dashboard-overview-grid"/);
assert.doesNotMatch(overview, /class="overview-grid"|class="diagnostics-grid"/, 'all Overview widgets must share one arrangement grid');
console.log('PASS: 0.32.1 Overview six-column content height, 90% compact typography, shared tile spacing and inline legends.');
@@ -1,96 +0,0 @@
// Existing status snapshots only: no API calls, bounded histories and honest no-data.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync('src/admin/web/app/overview-extra-metrics.js', 'utf8');
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const ids = registry.filter(widget => widget.category === 'performance').map(widget => widget.id);
assert.equal(ids.length, 18);
const widgets = new Map(ids.map(id => [id, { hidden: false }]));
const fields = new Map();
const field = id => {
if (!fields.has(id)) fields.set(id, { textContent: '-', points: '', setAttribute(name, value) { this[name] = value; } });
return fields.get(id);
};
let now = 1000;
const context = {
window: {}, currentPanel: 'overview', performance: { now: () => now },
t: key => key, fmt: (v, places = 0) => Number(v).toFixed(places),
fmtBytes: v => `${v} B`, $: field,
document: { querySelector: selector => widgets.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null },
};
vm.createContext(context);
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), context);
vm.runInContext(source, context);
const render = context.window.rustProxyExtraOverview.render;
const text = id => field(`overview-extra-${id}`).textContent;
const points = id => field(`overview-extra-${id}-line`).points;
const start = {
metrics: { requests: 100, http1_requests: 70, http2_requests: 30, http3_requests: 0, tls_handshake_errors: 3, errors: 10, active_connections: 5, active: 2, websocket_active: 1, mean_duration_ms: 25 },
system: { process_cpu_percent: 12, process_rss_bytes: 1000, open_fds: 10, max_fds: 500 },
cache: { hits: 10, misses: 10, resident_bytes: 10, hot_limit_bytes: 100 },
backend_pools: [{ members: [{status: 'up'}, {status: 'down'}] }],
};
render(start);
assert.equal(text('extra_process_cpu'), '12.0%');
assert.equal(text('extra_process_rss'), '1000 B');
assert.equal(text('extra_backend_unhealthy'), '1');
for (const id of ['extra_tls_error_rate','extra_request_error_rate','extra_cache_hit_rate','extra_h2_share'])
assert.equal(text(id), '-', `initial ${id} delta must not be invented`);
now = 3000;
const next = structuredClone(start);
next.metrics = { ...start.metrics, requests: 130, http1_requests: 84, http2_requests: 45, http3_requests: 1, tls_handshake_errors: 5, errors: 16, active_connections: 6, active: 4, websocket_active: 3, mean_duration_ms: 40 };
next.system = { ...start.system, process_cpu_percent: 22, process_rss_bytes: 2000, open_fds: 20 };
next.cache = { ...start.cache, hits: 20, misses: 15, resident_bytes: 25 };
next.backend_pools[0].members[0].status = 'down';
render(next);
assert.equal(text('extra_tls_error_rate'), '1.0/s');
assert.equal(text('extra_request_error_rate'), '3.0/s');
assert.equal(text('extra_cache_hit_rate'), '66.7%');
assert.equal(text('extra_h2_share'), '50.0%');
assert.equal(text('extra_cache_memory'), '25.0%');
assert.equal(text('extra_response_latency'), '40.0 ms');
assert.equal(text('extra_backend_unhealthy'), '2');
assert.equal(text('extra_total_rps'), '15.0/s');
assert.equal(text('extra_active_exchanges'), '4');
assert.equal(text('extra_h1_share'), '46.7%');
assert.equal(text('extra_h3_share'), '3.3%');
assert.equal(text('extra_cache_miss_rate'), '2.5/s');
assert.equal(text('extra_backend_active'), '0');
assert.equal(points('extra_process_cpu').trim().split(/\s+/).length, 2);
// Hiding a chart stops sampling; stale cumulative counters are never used as an instant rate.
widgets.get('extra_tls_error_rate').hidden = true;
now = 5000;
render({ ...next, metrics: { ...next.metrics, tls_handshake_errors: 50 } });
assert.equal(text('extra_tls_error_rate'), '1.0/s');
widgets.get('extra_tls_error_rate').hidden = false;
now = 6000;
render({ ...next, metrics: { ...next.metrics, tls_handshake_errors: 60 } });
assert.equal(text('extra_tls_error_rate'), '-', 'first fresh visible sample establishes a new baseline');
// After a reset, don't return a negative rate or denominator percentage.
now = 7000;
render({ ...next, metrics: { ...next.metrics, requests: 1, http2_requests: 0, tls_handshake_errors: 0, errors: 0 }, cache: { ...next.cache, hits: 0, misses: 0 } });
assert.equal(text('extra_h2_share'), '-');
assert.equal(text('extra_request_error_rate'), '-');
assert.equal(text('extra_tls_error_rate'), '-');
// Explicit unavailability is not represented as a measured zero.
now = 8000;
render({ metrics: {}, system: {}, cache: {} });
assert.equal(text('extra_process_cpu'), '-');
assert.equal(text('extra_websocket_active'), '-');
assert.equal(text('extra_backend_unhealthy'), '-');
context.window.rustProxyExtraOverview.reset();
now = 9000;
for (let index = 0; index < 50; index++) {
now += 1000;
render({ ...next, system: { ...next.system, process_cpu_percent: index } });
}
assert.equal(points('extra_process_cpu').trim().split(/\s+/).length, 30, 'bounded history');
context.currentPanel = 'vhosts';
const pointBefore = points('extra_process_cpu');
now += 1000;
render(next);
assert.equal(points('extra_process_cpu'), pointBefore, 'no extra sampling outside Overview');
assert.match(source, /if \(!tile \|\| tile.hidden\)/);
console.log('PASS: 18 extra metric series, time-based deltas, no-data, counter resets, hidden opt-ins and 30-sample bounds');
@@ -1,206 +0,0 @@
#!/usr/bin/env python3
"""Overview row symmetry regression using the shipped CSS/JS and simulated status.
Browser DOM geometry only; does not exercise the running RustProxy backend.
"""
from pathlib import Path
import shutil
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2] / 'src/admin/web'
soup = BeautifulSoup((ROOT / 'index.html').read_text(), 'html.parser')
widgets = list(soup.select('#dashboard-overview-grid > [data-dashboard-widget]'))
assert len(widgets) == 62
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact">'
'<head><meta charset="utf-8"><style>' + (ROOT / 'style.css').read_text() +
'</style></head><body style="margin:0"><div id="test-shell" '
'style="padding:8px;box-sizing:border-box"><div class="brand">'
'<div class="brand-mark" id="logo-test">R<span>P</span></div></div>'
'<section id="panel-overview"><div id="dashboard-overview-grid" '
'class="stats-grid dashboard-overview-grid">' +
''.join(str(widget) for widget in widgets) + '</div></section></div></body></html>')
SNAPSHOT = """() => {
const grid = document.getElementById('dashboard-overview-grid');
const style = getComputedStyle(grid), outer = grid.getBoundingClientRect();
const group = window.rustProxyMasonry.groupFor;
return {
packed: grid.classList.contains('is-packed'),
gap: parseFloat(style.columnGap),
columns: style.gridTemplateColumns.trim().split(/\\s+/).length,
grid: {left:outer.left,right:outer.right,width:outer.width},
scroll: grid.scrollWidth, client: grid.clientWidth,
cards: [...grid.children].filter(node=>node.dataset.dashboardWidget && !node.hidden).map(node=>{
const r=node.getBoundingClientRect();return {id:node.dataset.dashboardWidget,
group:group(node.dataset.dashboardWidget),x:r.left,y:r.top,right:r.right,
bottom:r.bottom,width:r.width,height:r.height};
})
};
}"""
def validate(data, zoom, width):
"""Peers in each row must have one top edge and one bottom edge."""
cards = data['cards']; gap = data['gap'] * zoom
assert cards and not data['packed'], (width, 'absolute/masonry mode is forbidden')
assert data['scroll'] <= data['client'] + 2, (width, 'horizontal overflow')
seen_groups = []
rows = {}
for card in cards:
assert card['width'] > 40 and card['height'] > 26, (width, card)
assert card['x'] >= data['grid']['left'] - 1, (width, card)
assert card['right'] <= data['grid']['right'] + 1, (width, card)
if not seen_groups or seen_groups[-1] != card['group']:
assert card['group'] not in seen_groups, (width, 'noncontiguous size groups')
seen_groups.append(card['group'])
row = round(card['y'], 1)
rows.setdefault(row, []).append(card)
for members in rows.values():
assert len({m['group'] for m in members}) == 1, (width, 'mixed sizes in a row', members)
assert max(m['bottom'] for m in members) - min(m['bottom'] for m in members) <= 1.5, (width, 'ragged bottom', members)
assert max(m['height'] for m in members) - min(m['height'] for m in members) <= 1.5, (width, 'uneven heights', members)
for a,b in zip(members,members[1:]):
assert abs(b['x']-a['right']-gap) <= 2.0, (width,'wrong horizontal gutter',a,b,gap)
line_groups = list(rows.values())
for above, below in zip(line_groups,line_groups[1:]):
a = above[0]; b=below[0]
assert b['y'] >= max(v['bottom'] for v in above) + gap - 2, (width,'vertical gap',a,b)
return rows
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True,
executable_path=shutil.which('chromium') or shutil.which('chromium-browser'),
args=['--no-sandbox'])
page = browser.new_page(viewport={'width':1720,'height':950})
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=(ROOT / 'app/overview-masonry.js').read_text())
page.evaluate("""() => window._overviewOriginal = [...document.querySelectorAll(
'#dashboard-overview-grid > [data-dashboard-widget]')].map(n=>[n.dataset.dashboardWidget,n.hidden])""")
data=page.evaluate(SNAPSHOT)
assert data['columns']==12
assert [card['id'] for card in data['cards'][:6]] == [
'requests','active','bandwidth','errors','cache','memory']
validate(data,1,1720)
assert len({round(v['height']) for v in data['cards'][:6]}) == 1
page.locator('#dashboard-overview-grid').screenshot(
path=str(Path(__file__).parents[3] / 'overview_uniform_default_preview.png'))
# Screenshot regression: custom ordering previously produced 3 distinctly
# sized tiles on the same horizontal band (a metric + two tall charts).
ids=['backend_connection_errors','process_diagnostics','network_diagnostics','cache_diagnostics',
'connection_chart','traffic_chart','system_chart','backend_table',
'requests','active','http_protocol','websocket_protocol',
'wide_cpu','wide_memory']
page.evaluate("""ids => {
const grid=document.getElementById('dashboard-overview-grid');
for(const node of grid.children) if(node.dataset.dashboardWidget)
node.hidden=!ids.includes(node.dataset.dashboardWidget);
grid.prepend(...ids.map(id=>grid.querySelector(`[data-dashboard-widget="${id}"]`)));
window.rustProxyMasonry.refresh();
}""", ids)
changed=page.evaluate(SNAPSHOT)
validate(changed,1,1720)
assert [card['group'] for card in changed['cards']] == sorted(card['group'] for card in changed['cards'])
assert not any(card['id']=='backend_connection_errors' and card['y'] == next(v['y'] for v in changed['cards'] if v['id']=='process_diagnostics') for card in changed['cards'])
# 3 uniform diagnostic panels per desktop row, 2 large charts per row.
panels=[card for card in changed['cards'] if card['group']==3]
assert len({round(card['y']) for card in panels[:3]}) == 1
assert len({round(card['height']) for card in panels[:3]}) == 1
wides=[card for card in changed['cards'] if card['group']==2]
assert len(wides)==2 and abs(wides[0]['height']-wides[1]['height']) < 2
page.screenshot(path=str(Path(__file__).parents[3] / 'overview_uniform_preview.png'), full_page=True)
# Runtime content growth automatically expands all peers in its Grid row,
# without ResizeObserver, manual coordinates or animation loops.
old_height=panels[0]['height']
page.evaluate("""() => {
const panel=document.querySelector('[data-dashboard-widget="process_diagnostics"]');
const child=document.createElement('div');child.id='expanded';child.style.height='175px';panel.append(child);
}""")
updated=page.evaluate(SNAPSHOT)
validate(updated,1,1720)
assert next(card for card in updated['cards'] if card['id']=='process_diagnostics')['height'] > old_height + 80
page.evaluate("() => document.getElementById('expanded').remove()")
validate(page.evaluate(SNAPSHOT),1,1720)
radii=page.evaluate("""() => {
const node=document.querySelector('[data-dashboard-widget="process_diagnostics"]');
const bar=document.createElement('div');bar.className='overview-inline-toolbar';
bar.innerHTML='<button class="quiet overview-inline-up">←</button><button class="quiet overview-inline-hide">×</button>';
node.append(bar);const out=[];
for(const preset of ['default','square','subtle','rounded','extra']) {
document.documentElement.dataset.cornerStyle=preset;
out.push([getComputedStyle(document.getElementById('logo-test')).borderTopLeftRadius,
getComputedStyle(bar.querySelector('.overview-inline-up')).borderTopLeftRadius,
getComputedStyle(bar.querySelector('.overview-inline-hide')).borderTopLeftRadius]);
}bar.remove();return out;
}""")
assert len({item[0] for item in radii})==1
assert all(item[1]==item[2] for item in radii)
page.evaluate("""() => {
const grid=document.getElementById('dashboard-overview-grid');
for(const node of grid.children) if(node.dataset.dashboardWidget) node.hidden=false;
window.rustProxyMasonry.refresh();
}""")
cases=[(1720,0,1,'normal',12),(1350,180,1,'compact',12),
(1200,260,1,'normal',6),(900,0,1,'compact',6),
(900,190,1,'normal',6),(720,190,1,'ultra',2),
(620,0,1,'compact',2),(430,0,1,'ultra',2),
(1140,200,.9,'compact',6),(1140,200,1.1,'normal',6),
(1140,200,1.3,'normal',6)]
for width,sidebar,zoom,density,max_columns in cases:
page.set_viewport_size({'width':width,'height':950})
page.evaluate("""({sidebar,zoom,density})=>{
const shell=document.getElementById('test-shell');
shell.style.width=`${(innerWidth-sidebar)/zoom}px`;
shell.style.marginLeft=`${sidebar}px`;shell.style.zoom=String(zoom);
document.documentElement.dataset.dashboardDensity=density;
window.rustProxyMasonry.refresh();
}""", {'sidebar':sidebar,'zoom':zoom,'density':density})
result=page.evaluate(SNAPSHOT)
assert len(result['cards'])==62
assert result['columns']<=max_columns, (width, result['columns'], max_columns)
validate(result,zoom,width)
print(f"PASS: {width}px, zoom={zoom}, groups={len(set(c['group'] for c in result['cards']))}, "
f"columns={result['columns']}, gap={result['gap']}px")
# Unlock mode adds editing toolbars/padding; rows must remain uniform.
page.set_viewport_size({'width':900,'height':950})
page.evaluate("""() => {
const grid=document.getElementById('dashboard-overview-grid');
const shell=document.getElementById('test-shell');
shell.style.width='auto';shell.style.zoom='1';shell.style.marginLeft='0';
grid.classList.add('is-unlocked');
for(const node of grid.children) if(node.dataset.dashboardWidget) {
const toolbar=document.createElement('div');toolbar.className='overview-inline-toolbar';
toolbar.innerHTML='<button class="quiet overview-inline-grip">⠿</button>'+
'<span class="overview-widget-type">Wykres</span>'+
'<button class="quiet overview-inline-up">←</button>'+
'<button class="quiet overview-inline-down">→</button>'+
'<button class="quiet overview-inline-hide">×</button>';
node.append(toolbar);
}
}""")
validate(page.evaluate(SNAPSHOT),1,900)
page.evaluate("""() => {
const grid=document.getElementById('dashboard-overview-grid');
grid.classList.remove('is-unlocked');
grid.querySelectorAll('.overview-inline-toolbar').forEach(toolbar=>toolbar.remove());
}""")
page.set_viewport_size({'width':1720,'height':950})
page.evaluate("""() => {
const grid=document.getElementById('dashboard-overview-grid'),shell=document.getElementById('test-shell');
shell.style.width='auto';shell.style.marginLeft='0';shell.style.zoom='1';
document.documentElement.dataset.dashboardDensity='compact';
grid.append(...window._overviewOriginal.map(([id,hidden])=>{
const node=grid.querySelector(`[data-dashboard-widget="${id}"]`);
node.hidden=hidden;return node;
}));window.rustProxyMasonry.refresh();
}""")
final=page.evaluate(SNAPSHOT)
validate(final,1,1720)
assert [node['id'] for node in final['cards'][:6]]==['requests','active','bandwidth','errors','cache','memory']
browser.close()
print('PASS: 62 cards in aligned rows; no floating heights, safe resize and zoom')
@@ -1,82 +0,0 @@
#!/usr/bin/env python3
"""Browser regression: compact, matching Overview gutters in both directions.
Fixture uses live CSS and the real grid grouping script, but mocked content.
"""
from pathlib import Path
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
root = Path(__file__).resolve().parents[2] / "src/admin/web"
soup = BeautifulSoup((root / "index.html").read_text(), "html.parser")
widgets = soup.select("#dashboard-overview-grid > [data-dashboard-widget]")
assert len(widgets) == 62, len(widgets)
css = (root / "style.css").read_text()
html = ("<!DOCTYPE html><html data-theme='dark' data-dashboard-density='normal'>"
"<head><meta charset='utf-8'><style>" + css + "</style></head>"
"<body><section id='panel-overview' style='width:1680px'>"
"<div id='dashboard-overview-grid' class='stats-grid dashboard-overview-grid'>"
+ "".join(map(str, widgets)) + "</div></section></body></html>")
snapshot = """() => {
const grid = document.getElementById('dashboard-overview-grid');
const style = getComputedStyle(grid);
const parentGap = parseFloat(getComputedStyle(document.body).getPropertyValue('--ui-gap'));
const rows = [];
for (const node of grid.children) {
if (!node.dataset.dashboardWidget || node.hidden) continue;
const rect = node.getBoundingClientRect();
const group = window.rustProxyMasonry.groupFor(node.dataset.dashboardWidget);
let row = rows.find(r => Math.abs(r.top - rect.top) < 0.75);
if (!row) {
row = {top: rect.top, bottom: rect.bottom, group, cards: []};
rows.push(row);
}
row.bottom = Math.max(row.bottom, rect.bottom);
row.cards.push({left: rect.left, right: rect.right, bottom: rect.bottom});
}
return {
rowGap: parseFloat(style.rowGap), columnGap: parseFloat(style.columnGap),
parentGap, margin: style.marginBottom,
rows: rows.sort((a, b) => a.top - b.top)
};
}"""
with sync_playwright() as playwright:
browser = playwright.chromium.launch(
headless=True, executable_path="/usr/bin/chromium", args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1800, "height": 1100})
page.set_content(html)
page.add_script_tag(content=(root / "app/overview-masonry.js").read_text())
checks = 0
for width in (900, 1220, 1680):
page.evaluate("w => document.getElementById('panel-overview').style.width = w + 'px'", width)
for ui_density, old_gap in (("normal", 5), ("dense", 4), ("comfortable", 13)):
page.evaluate("d => {document.body.dataset.density = d === 'normal' ? '' : d;}", ui_density)
for card_density in ("normal", "compact", "ultra"):
page.evaluate("d => document.documentElement.dataset.dashboardDensity = d", card_density)
data = page.evaluate(snapshot)
expected = old_gap
assert abs(data["parentGap"] - old_gap) < 0.01, data
assert abs(data["rowGap"] - expected) < 0.01, data
assert abs(data["columnGap"] - expected) < 0.01, data
assert data["margin"] in ("0px", "0"), data
rows = data["rows"]
assert len(rows) >= 4, (width, ui_density, card_density)
for before, after in zip(rows[:10], rows[1:11]):
distance = after["top"] - before["bottom"]
assert abs(distance - expected) < 1.05, (width, ui_density, card_density, distance, expected)
for row in rows[:10]:
cards = sorted(row["cards"], key=lambda card: card["left"])
assert max(c["bottom"] for c in cards) - min(c["bottom"] for c in cards) < 1.1
for a, b in zip(cards, cards[1:]):
assert abs(b["left"] - a["right"] - expected) < 1.1, (
width, ui_density, card_density, a, b, expected)
first_large = next((i for i, row in enumerate(rows) if row["group"] != 0), None)
assert first_large is not None and first_large > 0
transition = rows[first_large]["top"] - rows[first_large-1]["bottom"]
assert abs(transition - expected) < 1.05, (width, ui_density, card_density, transition)
checks += 1
print(f"PASS: {width}px/{ui_density}: uniform {expected}px gutters, all Dashboard densities")
browser.close()
print(f"PASS: {checks} Chromium layout checks including small-to-large transitions")
@@ -1,72 +0,0 @@
// Regression for shared server/browser Overview Cards metadata and future opt-in widgets.
import fs from 'node:fs';
import vm from 'node:vm';
import assert from 'node:assert/strict';
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json','utf8'));
const prefs = fs.readFileSync('src/admin/web/app/preferences.js','utf8');
const constants = prefs.slice(prefs.indexOf('const ADMIN_DASHBOARD_WIDGETS'), prefs.indexOf('const CHART_STYLE_KEYS'));
const normalize = prefs.slice(prefs.indexOf('function normalizeDashboardWidgets'),prefs.indexOf('function browserLanguage'));
const label = prefs.slice(prefs.indexOf('function dashboardWidgetLabel'),prefs.indexOf('function renderDashboardWidgetSettings'));
assert(constants.startsWith('const ADMIN_DASHBOARD_WIDGETS'));
assert(normalize.startsWith('function normalizeDashboardWidgets'));
assert(label.startsWith('function dashboardWidgetLabel'));
function client(definitions) {
const context = {t: key => `loc:${key}`};
vm.createContext(context);
vm.runInContext(`const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${JSON.stringify(definitions)});\n${constants}\n${normalize}\n${label}\n`,context);
return {
order: value => [...vm.runInContext(`normalizeDashboardWidgets(${JSON.stringify(value)})`,context)],
hidden: (value,saved) => [...vm.runInContext(`normalizeHiddenDashboardWidgets(${JSON.stringify(value)},${JSON.stringify(saved)})`,context)],
label: id => vm.runInContext(`dashboardWidgetLabel(${JSON.stringify(id)})`,context),
};
}
const original = client(registry);
const previous = registry.filter(widget => widget.category === 'overview').map(widget => widget.id);
assert.deepEqual(original.order(previous),registry.map(widget => widget.id));
assert.deepEqual(original.hidden([],previous),registry.filter(widget=>widget.default_hidden).map(widget=>widget.id));
assert.equal(original.label('stream_overview'),'loc:streamWidgetOverview');
assert.equal(original.label('backend_health'),'loc:backendWidgetHealth');
assert.equal(original.label('backend_table'),'loc:vhHealthTitle');
assert.equal(registry.length, 62);
const types = new Set(['value', 'value_chart', 'chart', 'panel', 'table']);
assert(registry.every(widget => types.has(widget.display_type)), 'Every Overview block needs explicit type metadata');
assert.equal(registry.find(w => w.id === 'requests').display_type, 'value');
assert.equal(registry.find(w => w.id === 'backend_requests').display_type, 'value_chart');
assert.equal(registry.find(w => w.id === 'traffic_chart').display_type, 'chart');
assert.equal(registry.find(w => w.id === 'cache_diagnostics').display_type, 'panel');
assert.equal(registry.find(w => w.id === 'backend_table').display_type, 'table');
// Upgrading from the previous 30-widget 0.32.3 archive keeps its exact
// visible/order preferences: only twelve new metrics are opt-in, while
// seven existing large panels remain visible in the factory layout.
const previouslySaved = registry.slice(0, 30).map(widget => widget.id);
const migratedOrder = original.order(previouslySaved);
const migratedHidden = original.hidden(registry.slice(0, 30).filter(w => w.default_hidden).map(w => w.id), previouslySaved);
assert.deepEqual(migratedOrder.slice(0, 30), previouslySaved);
assert.deepEqual(migratedOrder.slice(30), registry.slice(30).map(widget => widget.id));
for (const widget of registry.filter(widget => widget.category === 'performance'))
assert(migratedHidden.includes(widget.id), `${widget.id} must be opt-in after upgrade`);
for (const widget of registry.filter(widget => widget.category === 'panels' && !widget.default_hidden))
assert(!migratedHidden.includes(widget.id), `${widget.id} must retain default visibility`);
for (const widget of registry.filter(widget => widget.id.startsWith('wide_')))
assert(migratedHidden.includes(widget.id), `${widget.id} is a new opt-in chart`);
assert.equal(registry.filter(widget => widget.category === 'panels').length, 14);
for (const widget of registry.filter(widget => widget.category === 'performance')) assert(widget.default_hidden);
for (const id of ['backend_health','backend_activity','backend_errors','backend_latency','backend_traffic','backend_protocols','backend_requests','backend_capacity','backend_http5xx','backend_timeouts','backend_network','backend_retries','backend_queue','backend_rotations'])
assert(original.hidden([],previous).includes(id), `${id} must remain opt-in for older preferences`);
assert.deepEqual(original.order(['errors','unknown-0.33.0','requests','errors']).slice(0,2),['errors','requests']);
assert(original.hidden(['unknown-0.33.0','http3'],registry.map(widget=>widget.id)).includes('http3'));
assert(!original.hidden(['unknown-0.33.0','http3'],registry.map(widget=>widget.id)).includes('unknown-0.33.0'));
// Adding a new registry card, with an existing UI element and translation key,
// automatically offers it in the ordered preference list and hides it by default.
const extra={...registry[0], id:'future_upstream', label_key:'futureUpstreamProtocol',
description_key:null, element_selector:'[data-dashboard-widget="future_upstream"]', default_hidden:true};
const expanded=client([...registry,extra]);
assert.equal(expanded.order(previous).at(-1),'future_upstream');
assert(expanded.hidden([],previous).includes('future_upstream'));
assert.equal(expanded.label('future_upstream'),'loc:futureUpstreamProtocol');
assert(!expanded.hidden([],registry.map(widget=>widget.id).concat('future_upstream')).includes('future_upstream'),
'explicitly opted-in widget must not become hidden after a later release');
console.log(`PASS: shared widget registry UI preserves order, visibility and ${registry.length} existing cards`);
@@ -1,15 +0,0 @@
// Source-level guard against reintroducing broken absolute/masonry layout.
import fs from 'node:fs';
import assert from 'node:assert/strict';
const source = fs.readFileSync('src/admin/web/app/overview-masonry.js','utf8');
const css = fs.readFileSync('src/admin/web/style.css','utf8');
assert.match(source,/\.sort\(\(a, b\) => group\(a\.node\) - group\(b\.node\)/);
assert.match(source,/grid\.classList\.remove\("is-packed"\)/);
assert.match(source,/node\.classList\.add\("overview-row-start"\)/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*align-items:\s*stretch;/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\s*\{[^}]*align-self:\s*stretch;/);
assert(!/style\.position\s*=\s*["']absolute["']|requestAnimationFrame\(|new ResizeObserver\(/.test(source),
'No absolute positioning, animation loop or ResizeObserver should manage Overview geometry');
assert(!/\.dashboard-overview-grid\.is-aligned\.is-packed\s*\{/.test(css));
assert(!source.includes('fetch(') && !source.includes('setInterval('));
console.log('PASS: regular symmetric CSS Grid for all 62 Overview blocks');
@@ -1,78 +0,0 @@
// Optional large Overview charts: real status schema, counter resets, no polling.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync('src/admin/web/app/overview-wide-metrics.js','utf8');
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json','utf8'));
const ids = registry.filter(w => w.id.startsWith('wide_')).map(w => w.id);
assert.equal(ids.length, 7);
assert(ids.every(id => registry.find(w => w.id === id).default_hidden));
const nodes = new Map(ids.map(id => [id,{ hidden: false }]));
const fields = new Map();
function $(id) {
if (!fields.has(id)) fields.set(id,{ textContent:'-', d:'', setAttribute(name,value) {this[name]=value;} });
return fields.get(id);
}
let now = 1000;
const ctx = {window:{},currentPanel:'overview',performance:{now:()=>now},fmt:(n,places=0)=>Number(n).toFixed(places),fmtBytes:n=>`${n} B`,$,
document:{querySelector:selector=>nodes.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1])||null}};
vm.createContext(ctx);vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), ctx);
vm.runInContext(source,ctx);
const render=ctx.window.rustProxyWideOverview.render;
const text=id=>$(id).textContent;
const baseline = { revision:1,
metrics:{ http1_requests:50,http2_requests:30,http3_requests:20,active_connections:9,websocket_active:3,mean_duration_ms:15 },
system:{cpu_percent:45,process_cpu_percent:16,process_rss_bytes:1500},
cache:{hits:20,misses:10,resident_bytes:220},
backend_pools:[{members:[{status:'up',active:1,last_probe_ms:100,latency_ms:12,health_mode:'active'},{status:'down',active:0}]}]};
render(baseline);
assert.equal(text('overview-wide-wide_protocol_rates-summary'),'-');
assert.equal(text('overview-wide-wide_cpu-system-value'),'45.0%');
assert.equal(text('overview-wide-wide_memory-process-value'),'1500 B');
assert.equal(text('overview-wide-wide_backend_health-down-value'),'1');
assert.equal(text('overview-wide-wide_latency-probe-value'),'12.0 ms');
now = 3000;
const second=structuredClone(baseline);
second.metrics.http1_requests=70; second.metrics.http2_requests=40; second.metrics.http3_requests=22;
second.cache.hits=30; second.cache.misses=18;
render(second);
assert.equal(text('overview-wide-wide_protocol_rates-h1-value'),'10.0/s');
assert.equal(text('overview-wide-wide_protocol_rates-h2-value'),'5.0/s');
assert.equal(text('overview-wide-wide_protocol_rates-h3-value'),'1.0/s');
assert.equal(text('overview-wide-wide_cache_activity-hits-value'),'5.0/s');
assert.equal(text('overview-wide-wide_cache_activity-misses-value'),'4.0/s');
assert.equal(text('overview-wide-wide_connections-open-value'),'9');
now = 4000;
render({...second,metrics:{...second.metrics,http1_requests:0,http2_requests:0,http3_requests:0},cache:{...second.cache,hits:0,misses:0}});
assert.equal(text('overview-wide-wide_protocol_rates-h1-value'),'-', 'counter resets must not create negative traffic');
assert.equal(text('overview-wide-wide_cache_activity-hits-value'),'-');
nodes.get('wide_cpu').hidden=true;
now=5000;render({...second, system:{}});
assert.equal(text('overview-wide-wide_cpu-system-value'),'45.0%', 'hidden chart should not re-render');
nodes.get('wide_cpu').hidden=false;
now=6000;render({metrics:{},system:{},cache:{}});
assert.equal(text('overview-wide-wide_cpu-system-value'),'-', 'unavailable metrics are not zero');
assert.equal(text('overview-wide-wide_backend_health-up-value'),'-', 'missing backend array is no data');
ctx.window.rustProxyWideOverview.reset();
for(let i=0;i<75;i++){now+=1000;render({...baseline,system:{...baseline.system,cpu_percent:i}});}
assert(source.includes('const limit = 60'));
assert(source.includes('if (!node || node.hidden)'));
assert(!/fetch\s*\(|setInterval\s*\(/.test(source),'no extra API calls or polling');
const css=fs.readFileSync('src/admin/web/style.css','utf8');
const aligned = fs.readFileSync('src/admin/web/app/overview-masonry.js', 'utf8');
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*grid-auto-rows:\s*auto;/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*grid-auto-flow:\s*row;/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*--overview-gap:\s*max\(3px, calc\(var\(--ui-gap\) - 1px\)\);/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*gap:\s*var\(--overview-gap\);/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\.stats-grid\s*\{[^}]*gap:\s*var\(--overview-gap\);/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*align-items:\s*stretch;/);
assert.match(css, /#panel-overview\s*\{\s*container:\s*overview-canvas \/ inline-size;/);
assert.match(css, /@container overview-canvas \(max-width:\s*700px\)/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\s*\{[^}]*align-self:\s*stretch;/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\.overview-row-start\s*\{[^}]*grid-column-start:\s*1;/);
assert.match(aligned, /node\.classList\.remove\("overview-row-start"\)/);
assert.match(aligned, /node\.classList\.add\("overview-row-start"\)/);
assert.match(aligned, /grid\.classList\.remove\("is-packed"\)/);
assert(!/style\.position\s*=\s*["']absolute["']/.test(aligned));
assert(!aligned.includes('fetch(') && !aligned.includes('setInterval('));
console.log('PASS: 7 optional wide charts, regular rows, no uneven masonry');
@@ -1,121 +0,0 @@
#!/usr/bin/env node
// Real browser form callbacks with fake DOM; protocol/security checks are
// source-level assertions only. Rust unit and live HTTP tests need Cargo.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const read = path => readFileSync(resolve(root, path), 'utf8');
const html = read('src/admin/web/index.html');
const css = read('src/admin/web/style.css');
const administration = read('src/admin/web/settings.js');
const cache = read('src/admin/web/app/settings.js');
const router = read('src/admin/router.rs');
const assets = read('src/admin/panel_assets.rs');
const compression = read('src/admin/panel_content.rs');
const ip = read('src/cache/model/http_invalidation.rs');
const cacheMethods = read('src/proxy/cache_http_invalidation.rs');
for (const codec of ['zstd', 'br', 'gzip']) {
assert.match(html, new RegExp(`id="admin-compression-${codec}"[^>]*checked`));
assert(assets.includes('"/assets/app.js"'));
}
for (const path of ['app.js','management.js','vhosts.js','settings.js','cache-panel.js','traffic-panel.js','style.css','langs.json']) {
if (path === "langs.json") assert(router.includes(`"/assets/${path}", get(panel_assets::serve)`), path);
else assert(router.includes('"/assets/{fingerprinted}"'), path);
assert(assets.includes(`"/assets/${path}"`), path);
}
assert(compression.includes('preferred_encoding(headers, &settings)'));
assert(compression.includes('HeaderValue::from_static("Accept-Encoding")'));
assert(compression.includes('header::CONTENT_LENGTH'));
assert(compression.includes('header::CONTENT_ENCODING'));
assert(compression.includes('crate::compression::no_transform(headers)'));
assert(html.includes('id="cache-http-invalidation-cidrs"'));
assert(html.includes('class="metric-info cache-maintenance-info"'));
assert(html.includes('data-i18n-aria="cacheHttpInvalidationCurlLabel"'));
for (const snippet of ["curl -i -X PURGE", "curl -i -X BAN", "X-RustProxy-Confirm-Wide: 1"]) {
assert(html.includes(snippet), snippet);
}
assert(css.includes('.cache-maintenance-info .metric-info-popover'));
assert(cache.includes('http_invalidation_allowed_cidrs: $("cache-http-invalidation-cidrs")'));
assert(ip.includes('http_invalidation_allowed_cidrs.len() <= 32'));
assert(ip.includes('net.prefix_len() > 0'));
assert(cacheMethods.includes('snapshot.envelope.config.cache.permits_http_invalidation(client_ip)'));
assert(cacheMethods.includes('state.store.audit('));
assert(cacheMethods.includes('spawn_blocking'));
assert(cacheMethods.includes('x-rustproxy-confirm-wide'));
assert(cacheMethods.includes('InvalidationSelector::Exact'));
assert(cacheMethods.includes('InvalidationSelector::Prefix'));
assert(read('src/proxy/forward_entry.rs').includes('return cache_http_invalidation('));
const heading = html.lastIndexOf('data-i18n="systemInformation"');
assert(heading > 0);
const headerArea = html.slice(heading, heading + 600);
assert(headerArea.includes('<span class="pill">API</span>'));
assert(headerArea.indexOf('<span class="pill">API</span>') < headerArea.indexOf('id="system-info-refresh"'));
assert.match(css, /\.system-info-heading-actions\s*\{[^}]*display:\s*flex/);
for (const lang of ['en_US','pl_pl']) {
const strings = JSON.parse(read(`langs/${lang}.json`));
const t = strings.translations;
assert(t.panelCompressionTitle && t.panelCompressionHelp && t.cacheHttpInvalidationHelp, lang);
assert(t.cacheHttpInvalidationCurlLabel && t.cacheHttpInvalidationCurlIntro && t.cacheHttpInvalidationCurlNote, lang);
}
// Execute the actual callbacks captured from the shipped frontend settings JS.
const fields = new Map();
const field = name => {
if (!fields.has(name)) fields.set(name, { checked: false, value: '' });
return fields.get(name);
};
for (const name of ['zstd', 'br', 'gzip']) field(`admin-compression-${name}`).checked = true;
field('admin-listen').value = '127.0.0.1:9090';
field('admin-session-days').value = '30';
let handlers = new Map();
const bindSettingsForm = (id, handler) => handlers.set(id, handler);
const adminStart = administration.indexOf('bindSettingsForm("administration-form", next => {');
const adminEnd = administration.indexOf('bindSettingsForm("tls-policy-form",', adminStart);
assert(adminStart > 0 && adminEnd > adminStart);
// Use the shipped helper in the extracted callback environment as well.
const hostHelperStart = administration.indexOf(' function pinnedAdminHostname(value) {');
const hostHelperEnd = administration.indexOf(' const normalizedRequestedTab', hostHelperStart);
assert(hostHelperStart > 0 && hostHelperEnd > hostHelperStart);
runInNewContext(administration.slice(hostHelperStart, hostHelperEnd) + administration.slice(adminStart, adminEnd), {
$: field, bindSettingsForm, latestStatus: { administration: { listen_locked: false } }, URL,
});
const adminNext = {};
handlers.get('administration-form')(adminNext);
assert.deepEqual(JSON.parse(JSON.stringify(adminNext.administration.panel_compression)),
{ zstd: true, br: true, gzip: true });
field('admin-public-origin').value = 'https://panel.example.test';
field('admin-hosts').value = 'panel.example.test\nother.example.test';
const pinnedNext = {};
handlers.get('administration-form')(pinnedNext);
assert.deepEqual(JSON.parse(JSON.stringify(pinnedNext.administration.allowed_hosts)), ['other.example.test']);
field('admin-compression-br').checked = false;
field('admin-compression-zstd').checked = false;
const offNext = {};
handlers.get('administration-form')(offNext);
assert.deepEqual(JSON.parse(JSON.stringify(offNext.administration.panel_compression)),
{ zstd: false, br: false, gzip: true });
const cacheStart = cache.indexOf('bindSettingsForm("cache-form", next => {');
const cacheEnd = cache.indexOf('bindSettingsForm("transform-form",', cacheStart);
assert(cacheStart > 0 && cacheEnd > cacheStart);
handlers = new Map();
field('cache-http-invalidation-cidrs').value = ' 192.0.2.50\n 2001:db8:2::/64, 198.51.100.9 \n';
field('cache-sie-statuses').value = '500,502,503,504';
runInNewContext(cache.slice(cacheStart, cacheEnd), {
$: field, bindSettingsForm, selectedCacheMemoryPreset: null, selectedCacheColdPreset: null,
});
const cacheNext = { cache: { max_old_setting: 'kept' } };
handlers.get('cache-form')(cacheNext);
assert.deepEqual(JSON.parse(JSON.stringify(cacheNext.cache.http_invalidation_allowed_cidrs)),
['192.0.2.50', '2001:db8:2::/64', '198.51.100.9']);
assert.equal(cacheNext.cache.max_old_setting, 'kept');
field('cache-http-invalidation-cidrs').value = '\n';
handlers.get('cache-form')(cacheNext);
assert.deepEqual(JSON.parse(JSON.stringify(cacheNext.cache.http_invalidation_allowed_cidrs)), []);
console.log('PASS: cache CIDR/administration codec UI roundtrip, headers, security source wiring, API badge layout');
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env python3
"""Source-only regression assertions; NOT a compiled Rust or live H2 test."""
from pathlib import Path
import tomllib
root = Path(__file__).resolve().parents[2]
read = lambda name: (root / name).read_text(encoding='utf-8')
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.7'
client = read('src/upstream/client.rs')
forward = read('src/proxy/forward/upstream.rs')
helper = read('src/proxy/h2_graceful.rs')
lease = read('src/balance/lease_runtime.rs')
health = read('src/balance/health.rs')
tests = read('src/balance/tests.rs')
for marker in ('GoAway(', 'NO_ERROR', 'Remote', 'note_graceful_goaway(generation)', 'is_graceful_h2_goaway(error)'):
assert marker in client, marker
for marker in ('graceful_retry_done', 'retry_safe_method(&retry_method)', 'request_body_empty', 'handshake.is_none()', 'retry_request(', 'graceful_retry_done = true', 'continue;'):
assert marker in forward, marker
for marker in ('is_related_h2_close', 'recently_saw_graceful_goaway()', 'h2_graceful_goaway', 'h2_graceful_close'):
assert marker in helper, marker
for marker in ('first_error_detail', 'first_error_at_ms', 'trigger_error_detail', 'backend excluded from traffic', 'tracing::warn!(', 'h2_graceful_goaway'):
assert marker in lease, marker
for marker in ('passive_first_failure', 'active_first_failure', 'passive_probe_successes', 'passive_ejected_at', 'Duration::from_millis(250)', 'fn clear_passive_ejection', 'pub fn healthy'):
assert marker in health, marker
for marker in ('passive_ejection_remembers_first_real_error_and_trigger', 'passive_io_burst_is_one_observation', 'passive_only_quarantine_expires', 'active_probe_successes_recover'):
assert marker in tests, marker
print('PASS: version, H2 graceful recycle/retry, passive health, first-error WARN source contracts (NOT compiled)')
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env python3
"""Detect mixed source trees and preserve the known obsolete 0.0.1 auth module."""
from __future__ import annotations
import argparse
import hashlib
import re
from pathlib import Path
import sys
LEGACY_AUTH = "4df9d473c9214365d91c83072f97aafab6ede4edcf4e1369b5c3679ded5cff85"
def check(root: Path, repair_known: bool = False) -> list[str]:
messages: list[str] = []
# 0.25.1 split-source migration: these monoliths are obsolete when the new
# bounded module tree is present. Preserve any local edits instead of silently
# deleting them when a source archive is extracted over an older checkout.
obsolete_split_files = (
"src/admin/web/app.js",
"src/admin/web/vhosts.js",
"src/web_server.rs",
)
split_markers = {
"src/admin/web/app.js": "src/admin/web/app/core.js",
"src/admin/web/vhosts.js": "src/admin/web/vhosts/core.js",
"src/web_server.rs": "src/web_server/mod.rs",
}
for relative in obsolete_split_files:
obsolete = root / relative
marker = root / split_markers[relative]
if not obsolete.exists() or not marker.is_file():
continue
if not repair_known:
raise ValueError(
f"Obsolete 0.25.0 source monolith {relative} remains beside the 0.25.1 split tree. "
"Run ./dev.sh repair (or any normal ./dev.sh command) to preserve it under .upgrade-backup/."
)
backup_root = root / ".upgrade-backup" / "0.25.1-split"
target = backup_root / relative
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
# Never overwrite a prior backup; if the bytes match, the current stale
# copy can simply be removed. Otherwise force the operator to reconcile it.
if target.read_bytes() != obsolete.read_bytes():
raise ValueError(
f"Backup target {target.relative_to(root)} already contains different data; "
f"move {relative} outside the source tree manually before rebuilding."
)
obsolete.unlink()
else:
obsolete.rename(target)
messages.append(
f"Preserved obsolete {relative} as {target.relative_to(root)}"
)
legacy = root / "src/auth.rs"
if legacy.exists() and (root / "src/auth/mod.rs").is_file():
if legacy.is_symlink() or hashlib.sha256(legacy.read_bytes()).hexdigest() != LEGACY_AUTH:
raise ValueError("Both src/auth.rs and src/auth/mod.rs exist. The single-file module is not the known 0.0.1 file. Preserve your edits and move it outside src before rebuilding. See docs/MIGRATION.md.")
if not repair_known:
raise ValueError("Known obsolete src/auth.rs from 0.0.1 found. Run ./dev.sh repair or start ./dev.sh to preserve it in .upgrade-backup/legacy-auth/.")
directory = root / ".upgrade-backup/legacy-auth"
directory.mkdir(parents=True, exist_ok=True)
target = directory / "auth.rs.0.0.1"
if target.exists():
if hashlib.sha256(target.read_bytes()).hexdigest() != LEGACY_AUTH:
raise ValueError("Legacy backup path contains a different file; refusing to overwrite it.")
legacy.unlink() # Byte-identical content was already preserved.
else:
legacy.rename(target)
messages.append("Preserved obsolete src/auth.rs as .upgrade-backup/legacy-auth/auth.rs.0.0.1")
for module in (root / "src").rglob("mod.rs"):
flat = module.parent.with_suffix(".rs")
if flat.is_file():
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.31.9 source tree: missing {path}")
# The Reader uses compile-time include_str! for its bundled Markdown. A
# dangling registration is a real compiler error, unlike an omitted old
# release audit. Match both reader_doc! and the expanded include_str! form.
registry = root / "src/admin/documentation/documents.rs"
if not registry.is_file():
raise ValueError("Incomplete RustProxy 0.31.9 source tree: missing src/admin/documentation/documents.rs")
embedded_docs = set(re.findall(
r'"(docs/[A-Za-z0-9_.-]+\.md)"', registry.read_text(encoding="utf-8")
))
for doc_path in sorted(embedded_docs):
if not (root / doc_path).is_file():
raise ValueError(f"Missing embedded Reader document: {doc_path}; restore the file or remove its Reader registration")
text = (root / "src/lib.rs").read_text(encoding="utf-8")
if '#[path = "auth/mod.rs"]' not in text:
raise ValueError("src/lib.rs is not the 0.31.9 entry point; extract the complete package, not individual files.")
return messages
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
parser.add_argument("--repair-known", action="store_true")
args = parser.parse_args()
try:
for line in check(args.root.resolve(), args.repair_known):
print(line, file=sys.stderr)
except (OSError, ValueError) as error:
parser.exit(2, f"Source preflight failed: {error}\n")
if __name__ == "__main__":
main()
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env python3
"""Verify the security-sensitive transport split without claiming a Rust execution."""
from pathlib import Path
import json
import re
root=Path(__file__).resolve().parents[2]
read=lambda file:(root/file).read_text(encoding='utf-8')
backend=read('src/config/backend.rs')
forward=read('src/proxy/forward/upstream.rs')
proxy=read('src/proxy/types.rs')
client=read('src/upstream/client.rs')
conn=read('src/upstream.rs')
assert not re.search(r'ensure!\([^;]*!backend\.preserve_host',backend)
assert 'preserve_host: backend.preserve_host' in backend
assert 'fn upstream_request_target(' in proxy
assert 'parts.authority = Some(authority)' in proxy
assert 'if strict_h2_request' in proxy and 'headers.remove(HOST)' in proxy
assert 'let Some(upstream_target) = upstream_request_target(' in forward
assert 'retry_headers = request.headers().clone()' in forward
assert 'upstream_request_target(&uri, request.headers_mut(), backend, false, false)' in proxy
assert 'let destination: http::Uri' in client
assert 'let uri = self.destination.clone()' in conn
assert 'fn call(&mut self, _request_uri: Uri)' in conn
assert 'connect_host' in client and 'connect_host' in conn
assert 'policy.check(ip)' in conn
assert '.retry_canceled_requests(false)' in client
assert 'preserve_host_is_valid_with_every_upstream_protocol' in read('src/config/tests.rs')
for test in (
'preserve_host_sets_http2_authority_without_changing_network_origin',
'retry_prepares_authority_for_the_selected_backend_not_the_previous_one',
'invalid_outgoing_host_is_rejected_instead_of_used_for_dialing',
):
assert test in read('src/proxy/tests/authority_tests.rs'),test
for test in (
'strict_h2_preserves_public_authority_while_tcp_and_sni_use_private_origin',
'auto_h1_fallback_preserves_public_host_without_resolving_it',
'auto_h2_alpn_retains_same_public_host_and_pinned_tls_origin',
'cleartext_h2c_preserves_public_authority_and_pins_socket',
):
assert test in read('src/upstream/protocol_tests.rs'),test
css=read('src/admin/web/style.css')
match=re.search(r'#panel-overview \.dashboard-overview-grid\.is-aligned\s*\{([^}]*)\}',css)
assert match
rules=match[1]
for marker in ['margin: 0;', '--overview-gap: var(--ui-gap);',
'row-gap: var(--overview-gap);', 'column-gap: var(--overview-gap);',
'gap: var(--overview-gap);']:
assert marker in rules, marker
# Regression guard for E0382: the retry builder must not consume the URI
# before upstream_request_target() borrows it to prepare HTTP/2 authority.
retry = re.search(r'fn retry_request\([\s\S]*?\n}\n', proxy)
assert retry and '.uri(uri.clone())' in retry[0]
assert 'upstream_request_target(&uri, request.headers_mut()' in retry[0]
assert json.loads(read('src/admin/openapi.json'))['info']['version']=='0.32.7'
assert 'version = "0.32.7"' in read('Cargo.toml')
print('PASS: 0.32.7 origin pinning, authority, retry, CSS gutter source contracts (not Rust runtime)')
@@ -1,42 +0,0 @@
import fs from 'node:fs';
import vm from 'node:vm';
import assert from 'node:assert/strict';
const ui = fs.readFileSync('src/admin/web/vhosts/core.js', 'utf8');
const head = 'function debugOriginIssues(members) {';
const tail = '\nfunction aclEditor(';
const start = ui.indexOf(head);
const end = ui.indexOf(tail, start);
assert(start >= 0 && end > start, 'debug origin verification helper is present');
const warn = vm.runInNewContext('(' + ui.slice(start, end).trim() + ')', { URL });
function member(protocol, more={}) { return {
url:'https://10.87.20.2:443',protocol,preserve_host:true,
pool_eligible:true,allow_private:true,tls_verify:false,...more,
}; }
for (const protocol of ['http1', 'auto', 'http2', 'h2']) {
assert.equal(warn([member(protocol)]).length, 0,
`${protocol} is accepted without forcing H1 or disabling preserved Host`);
assert.equal(warn([member(protocol, {tls_server_name:'unitraklub.pl',tls_verify:true})]).length, 0,
`${protocol} supports explicitly verified certificate DNS names`);
}
assert.equal(warn([member('h2',{pool_eligible:false,allow_private:false})]).length,0,
'pool and private-origin flags must not be mistaken for protocol requirements');
assert.equal(warn([member('auto',{tls_verify:true})]).length,1,
'HTTPS-by-IP without TLS identity receives a nonblocking warning');
assert.equal(warn([{...member('h2',{tls_verify:true}),url:'https://site.example'}]).length,0,
'DNS HTTPS origin needs no IP-certificate warning');
assert.equal(warn([{...member('h2',{tls_verify:true}),url:'http://10.87.20.2:80'}]).length,0);
assert(!/preserve\.disabled\s*=|preserve\.checked\s*=\s*false/.test(ui),
'Protocol selector may not disable or clear Preserve Host');
assert(ui.includes('value.preserve_host === true'), 'Existing backend preference remains visible');
assert(ui.includes('protocol.value = "http1"; preserve.checked = true'),
'Optional explicit H1 troubleshooting preset is retained');
assert(!ui.includes('syncProtocol()'), 'Do not call removed protocol-only Host limiter');
assert(ui.includes('protocol.dispatchEvent(new Event("change"))'), 'Preset refreshes protocol help text');
for (const lang of ['en_US','pl_pl']) {
const t=JSON.parse(fs.readFileSync(`langs/${lang}.json`,'utf8')).translations;
for (const key of ['vhPreserveHost','vhDebugOriginPreset','vhOriginPresetHelp','vhDebugOriginMismatch'])
assert(t[key] && t[key].length>30,`${lang}.${key}`);
}
console.log('PASS: Preserve Host is available for four modes; IP/TLS hint is conditional and optional');
-126
View File
@@ -1,126 +0,0 @@
// Counter-derived chart regression: pauses must not become fake 0/s samples.
// Real Cache / Transform chart functions run against simulated DOM and clock.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const samplingSource = fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8');
const panels = {
cache: {
path: 'src/admin/web/cache-panel.js', stop: ' function configure()',
counters: (hit, miss) => ({ hits: hit, misses: miss, bypasses: 0 }),
graph: 'cache-live-hit-line', rate: 'cache-live-hit-rate',
keys: ['hits', 'misses', 'bypasses'], seconds: 5,
},
transform: {
path: 'src/admin/web/transform-panel.js', stop: ' function countersFor(data)',
counters: (hit, miss) => ({ applied: hit, cache_hits: miss, skipped: 0, errors: 0 }),
graph: 'transform-live-applied-line', rate: 'transform-live-applied-rate',
keys: ['applied', 'cache_hits', 'skipped', 'errors'], seconds: 3,
},
};
function fixture(name, config) {
const source = fs.readFileSync(config.path, 'utf8');
const end = source.indexOf(config.stop);
assert(end > 0, `unable to isolate ${name} live chart`);
const documentListeners = new Map(), windowListeners = new Map(), nodes = new Map();
let now = 1000;
const node = id => {
if (!nodes.has(id)) nodes.set(id, {
value: id.endsWith('-scope') ? '*' : '', textContent: '',
attrs: {}, setAttribute(key, value) { this.attrs[key] = value; },
});
return nodes.get(id);
};
const document = {
hidden: false,
addEventListener(name, listener) { documentListeners.set(name, listener); },
};
const window = {
addEventListener(name, listener) { windowListeners.set(name, listener); },
rustProxyRefresh: { current: () => ({ overview_seconds: 2, cache_seconds: config.seconds }) },
};
const ctx = {
document, window, $: node, performance: { now: () => now },
sparklinePoints: samples => samples.join(','),
};
vm.createContext(ctx);
vm.runInContext(samplingSource, ctx);
vm.runInContext(source.slice(0, end) + '\nwindow.liveChartTest = { renderLiveChart, resetLiveChart, liveChartSamples };\n})();', ctx);
const chart = window.liveChartTest;
const line = () => node(config.graph).attrs.points;
const rate = () => node(config.rate).textContent;
const length = () => chart.liveChartSamples[config.keys[0]].length;
const sample = (stamp, hits, misses) => {
now = stamp;
chart.renderLiveChart(config.counters(hits, misses));
};
return { ctx, chart, line, rate, length, sample, document, documentListeners, windowListeners };
}
for (const [name, config] of Object.entries(panels)) {
const f = fixture(name, config);
f.sample(1000, 100, 50); // no deltas exist before the first observation
assert.equal(f.length(), 0, `${name}: first reading is a baseline, not 0/s`);
assert.equal(f.rate(), '-');
f.sample(1000 + config.seconds * 1000, 130, 65);
assert.equal(f.rate(), `${(30 / config.seconds).toFixed(1)}/s`);
assert.equal(f.length(), 1);
f.sample(1000 + config.seconds * 2000, 130, 65);
assert.equal(f.rate(), '0.0/s', `${name}: real idle interval is still zero`);
assert.equal(f.length(), 2);
const graphBefore = f.line();
const delayed = 1000 + config.seconds * 2000 + 120000;
f.sample(delayed, 500, 200);
assert.equal(f.rate(), '-', `${name}: long pause cannot be averaged into a misleading rate`);
assert.equal(f.length(), 2, `${name}: do not append fake drop`);
assert.equal(f.line(), graphBefore, `${name}: keep old chart path`);
f.sample(delayed + config.seconds * 1000, 530, 215);
assert.equal(f.rate(), `${(30 / config.seconds).toFixed(1)}/s`, `${name}: next fresh interval resumes`);
assert.equal(f.length(), 3);
f.document.hidden = true;
f.documentListeners.get('visibilitychange')?.();
f.document.hidden = false;
f.documentListeners.get('visibilitychange')?.();
const afterBackground = delayed + config.seconds * 1000 + 200;
f.sample(afterBackground, 533, 219);
assert.equal(f.length(), 3, `${name}: even a short background hide must not append a rate`);
assert.equal(f.rate(), '-');
f.sample(afterBackground + config.seconds * 1000, 533, 219);
assert.equal(f.rate(), '0.0/s', `${name}: genuine zero after resume`);
assert.equal(f.length(), 4);
f.windowListeners.get('pagehide')?.();
f.sample(afterBackground + config.seconds * 2000, 540, 220);
assert.equal(f.length(), 4, `${name}: pagehide / BFCache must rebase`);
f.sample(afterBackground + config.seconds * 3000, 550, 230);
assert.equal(f.length(), 5);
f.sample(afterBackground + config.seconds * 4000, 1, 1);
assert.equal(f.length(), 5, `${name}: counters reset must not be interpreted as 0/s`);
f.sample(afterBackground + config.seconds * 5000, 3, 3);
assert.equal(f.length(), 6, `${name}: counter reset recovery`);
}
// All remaining rate charts share this exact continuity check, including
// configured 30-second Cache refresh (not falsely treated as a pause).
{
const listeners = new Map(), document = { hidden: false, addEventListener(key, fn) { listeners.set(key, fn); } };
const context = { window: {}, document };
vm.createContext(context);
vm.runInContext(samplingSource, context);
const sample = vm.runInContext('rustProxyRateSampling', context);
let baseline = sample.difference(null, [100], 1000, 30).next;
let value = sample.difference(baseline, [130], 31000, 30);
assert.equal(value.deltas[0], 30, '30-second chart interval is valid');
baseline = value.next;
value = sample.difference(baseline, [200], 124000, 30);
assert.equal(value.deltas, null, 'large delay is a gap');
document.hidden = true;
listeners.get('visibilitychange')?.();
document.hidden = false;
assert.equal(sample.continuous(value.next, 124001, 30), false, 'hide invalidates even a short elapsed period');
}
console.log('PASS: Cache/Transform history survives hidden tabs, long pauses, BFCache and counter resets; real 0/s and configured cadence remain intact.');
-238
View File
@@ -1,238 +0,0 @@
#!/usr/bin/env node
// Deterministic browser-coordinator regression without a production browser or network.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const scheduler = readFileSync(resolve(root, "src/admin/web/app/refresh.js"), "utf8");
const core = readFileSync(resolve(root, "src/admin/web/app/core.js"), "utf8");
const bridge = core.slice(core.indexOf("function renderLiveStatus(value)"), core.indexOf("async function refreshCertificateInventory()"));
assert.match(bridge, /function startLive\(/);
function fixture({ withoutSse = false } = {}) {
let time = 0, nextId = 1, renderCount = 0, logCount = 0, diagCount = 0;
const timers = new Map(), listeners = new Map(), windowListeners = new Map(), streams = [], requests = [], rendered = [], failures = [];
let response = async () => ({});
class FakeEventSource {
constructor(url) { this.url = url; this.closed = false; this.handlers = new Map(); streams.push(this); }
addEventListener(type, callback) { this.handlers.set(type, callback); }
close() { this.closed = true; }
emit(type, value) { this.handlers.get(type)?.({ data: JSON.stringify(value) }); }
fail() { this.onerror?.({ type: "error" }); }
}
const nodes = { "connection-label": { textContent: "" } };
const document = {
hidden: false,
addEventListener(type, callback) { assert(!listeners.has(type)); listeners.set(type, callback); },
removeEventListener(type, callback) { assert.equal(listeners.get(type), callback); listeners.delete(type); }
};
const ctx = {
document, AbortController, URLSearchParams,
EventSource: withoutSse ? undefined : FakeEventSource,
setTimeout(fn, ms) { const id = nextId++; timers.set(id, { at: time + ms, fn }); return id; },
clearTimeout(id) { timers.delete(id); },
console: { warn(...args) { failures.push(args); } },
authenticated: true, authEpoch: 1, currentPanel: "overview", liveConnected: false, liveFallback: false,
liveEvents: null, liveInterval: null, latestStatus: null,
window: {
rustProxySettings: { current: () => "listeners", render: () => undefined },
addEventListener(type, callback) { assert(!windowListeners.has(type)); windowListeners.set(type, callback); },
removeEventListener(type, callback) { assert.equal(windowListeners.get(type), callback); windowListeners.delete(type); },
},
$: name => nodes[name] || null,
t: name => name,
api: (url, args = {}) => { requests.push({ url, args, time }); return response(url, args); },
renderStatus(value) { renderCount++; rendered.push(value); },
renderLogs() { logCount++; },
renderDiagnostics() { diagCount++; },
renderLiveStatus() { throw new Error("bridge not loaded"); },
revalidateSession: async () => {
ctx.window.rustProxyRefresh.suspendForSessionValidation();
await Promise.resolve();
ctx.window.rustProxyRefresh.completeSessionValidation(true);
},
loadTunnels: async () => undefined
};
runInNewContext(bridge + "\n" + scheduler, ctx, { filename: "coordinator-browser-bundle.js" });
const refresh = ctx.window.rustProxyRefresh;
const settled = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
async function advance(ms = 0) {
const until = time + ms;
for (let i = 0; i < 1000; i++) {
const due = [...timers.entries()].filter(([, timer]) => timer.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
if (!due) { time = until; await settled(); return; }
const [id, timer] = due; time = timer.at; timers.delete(id); timer.fn(); await settled();
}
throw new Error("unbounded polling timer loop");
}
function switchPanel(name) { ctx.currentPanel = name; refresh.setPanel(name); }
function changeVisibility(hidden) { document.hidden = hidden; listeners.get("visibilitychange")?.(); }
function count(url) { return requests.filter(item => item.url.startsWith(url)).length; }
function registerViews() {
for (const name of ["traffic", "cache"]) {
refresh.register(name, {
interval: () => refresh.current()[`${name}_seconds`],
active: () => ctx.currentPanel === name,
run: async ({ signal, isCurrent }) => {
const value = await ctx.api(`/api/${name}`, { signal });
if (isCurrent()) rendered.push({ name, value });
}
});
}
}
return {
ctx, refresh, requests, rendered, streams, listeners, windowListeners, timers, failures, advance, switchPanel, count,
registerViews, changeVisibility,
setResponse(fn) { response = fn; },
get renderCount() { return renderCount; },
get logCount() { return logCount; },
get diagCount() { return diagCount; },
};
}
const h = fixture();
h.registerViews();
assert.equal(h.refresh.current().overview_seconds, 3);
assert.equal(h.refresh.current().system_info_seconds, 60);
assert.equal(h.refresh.current().traffic_seconds, 5);
assert.equal(h.refresh.current().cache_seconds, 5);
assert.equal(h.refresh.current().live_enabled, true);
assert.equal(h.streams.length, 0);
h.refresh.start(); await h.advance();
assert.equal(h.streams.length, 1, "one shared SSE connection");
assert.equal(h.streams[0].url, "/api/events?interval=3");
assert.equal(h.count("/api/traffic"), 0, "no Traffic on Overview");
assert.equal(h.count("/api/cache"), 0, "no Cache on Overview");
assert.equal(h.count("/api/system-info"), 1, "Overview uses slow diagnostics");
assert.equal(h.count("/api/status"), 0, "no duplicate status polling while SSE connects");
assert.equal(h.count("/api/logs"), 0);
await h.advance(10_000);
assert.equal(h.count("/api/system-info"), 1, "diagnostics not collected at live interval");
h.switchPanel("traffic"); await h.advance();
assert.equal(h.count("/api/traffic"), 1);
assert.equal(h.count("/api/cache"), 0);
for (let i = 0; i < 20; i++) { h.switchPanel("overview"); h.switchPanel("traffic"); }
assert.equal(h.refresh.diagnostics().filter(row => row.id === "traffic").length, 1);
await h.advance(0);
assert.equal(h.count("/api/traffic"), 2, "tab switches do not multiply immediate requests");
await h.advance(15_000);
assert.equal(h.count("/api/traffic"), 5, "one request each 5 seconds");
assert.equal(h.count("/api/cache"), 0);
h.refresh.update({ traffic_seconds: 3, cache_seconds: 0, system_info_seconds: 0, overview_seconds: 5 });
assert(h.streams[0].closed, "SSE is replaced when the administrator updates their interval");
assert.equal(h.streams.at(-1).url, "/api/events?interval=5");
await h.advance();
assert.equal(h.count("/api/traffic"), 6);
await h.advance(6_000);
assert.equal(h.count("/api/traffic"), 8);
h.switchPanel("cache"); await h.advance(20_000);
assert.equal(h.count("/api/cache"), 0, "Cache manual mode cancels polling");
await h.refresh.manual("cache");
assert.equal(h.count("/api/cache"), 1, "manual Cache works with interval=0");
await h.advance(40_000);
assert.equal(h.count("/api/cache"), 1);
assert.equal(h.count("/api/traffic"), 8, "Traffic is stopped in Cache view");
// SSE failures activate both fallbacks and a recovered snapshot switches them off.
h.streams.at(-1).fail(); await h.advance();
assert.equal(h.count("/api/status?view=live"), 1);
assert.equal(h.count("/api/logs"), 1);
await h.advance(5_000);
assert.equal(h.count("/api/status?view=live"), 2);
h.streams.at(-1).emit("snapshot", { status: { metrics: { requests: 5 } }, logs: { entries: [] } });
assert.equal(h.ctx.liveConnected, true);
await h.advance(20_000);
assert.equal(h.count("/api/status?view=live"), 2, "SSE recovery stops fallback polling");
assert.equal(h.count("/api/logs"), 2);
assert.equal(h.renderCount, 3, "two fallback status renders followed by recovered SSE");
// Hidden browser tabs pause even previously active sources, then resume cleanly.
h.refresh.update({ traffic_seconds: 3, cache_seconds: 3, system_info_seconds: 15 });
h.switchPanel("traffic"); await h.advance();
const beforeHidden = h.count("/api/traffic");
h.changeVisibility(true);
assert(h.streams.at(-1).closed, "SSE closes when hidden");
assert.equal(h.refresh.diagnostics().filter(row => row.scheduled).length, 0);
await h.advance(120_000);
assert.equal(h.count("/api/traffic"), beforeHidden);
h.changeVisibility(false); await h.advance(); await h.advance();
assert.equal(h.count("/api/traffic"), beforeHidden + 1);
assert.equal(h.streams.filter(stream => !stream.closed).length, 1);
// Off pauses all scheduled work, but retains explicit manual requests.
h.refresh.update({ live_enabled: false, traffic_seconds: 3 });
const requestsBeforeOff = h.requests.length;
await h.advance(120_000);
assert.equal(h.requests.length, requestsBeforeOff);
await h.refresh.manual("traffic");
assert.equal(h.count("/api/traffic"), beforeHidden + 2);
assert.equal(h.streams.filter(stream => !stream.closed).length, 0);
h.refresh.update({ live_enabled: true, traffic_seconds: 3 }); await h.advance();
assert.equal(h.streams.filter(stream => !stream.closed).length, 1);
// Aborting an old view prevents the obsolete response from modifying UI.
let resolveDelayed;
h.setResponse(url => url === "/api/traffic" ? new Promise(resolve => { resolveDelayed = resolve; }) : Promise.resolve({ ok: true }));
await h.advance(3_000);
const staleCount = h.rendered.length;
const request = h.requests.at(-1);
assert.equal(request.url, "/api/traffic");
h.switchPanel("cache");
assert.equal(request.args.signal.aborted, true);
resolveDelayed({ stale: true }); await h.advance();
assert.equal(h.rendered.filter(v => v.name === "traffic").length, h.rendered.slice(0, staleCount).filter(v => v.name === "traffic").length);
assert.equal(h.count("/api/cache"), 2);
// Manual diagnostics still work when their own automatic interval is zero.
h.refresh.update({ live_enabled: false, system_info_seconds: 0 });
const diagnosticRequests = h.count("/api/system-info");
await h.refresh.manual("system-info");
assert.equal(h.count("/api/system-info"), diagnosticRequests + 1);
h.refresh.update({ live_enabled: true });
// A failed source does not stop other source registrations.
const e = fixture({ withoutSse: true });
e.registerViews();
e.setResponse(url => url === "/api/traffic" ? Promise.reject(new Error("Traffic disconnected")) : Promise.resolve({ ok: true }));
e.refresh.start(); e.switchPanel("traffic"); await e.advance();
assert.equal(e.count("/api/traffic"), 1);
assert.equal(e.count("/api/status?view=live"), 1, "browsers without EventSource use status polling");
assert.equal(e.count("/api/logs"), 1, "browsers without EventSource use logs polling");
e.switchPanel("cache"); await e.advance();
assert.equal(e.count("/api/cache"), 1);
assert.equal(e.failures.length, 1);
await e.advance(5_000);
assert.equal(e.count("/api/cache"), 2);
// Multiple registrations with identical IDs must be rejected.
assert.throws(() => h.refresh.register("traffic", { interval: () => 5, run: async () => {} }), /Duplicate refresh task/);
// Logout clears timers, active SSE streams, observer and pending fetches.
h.refresh.stop();
assert.equal(h.listeners.has("visibilitychange"), false);
assert.equal(h.windowListeners.size, 0, "pagehide/pageshow listeners are removed on logout");
assert.equal(h.streams.filter(stream => !stream.closed).length, 0);
assert.equal(h.refresh.diagnostics().filter(row => row.scheduled).length, 0);
const afterLogout = h.requests.length;
h.ctx.authenticated = false;
await h.advance(600_000);
assert.equal(h.requests.length, afterLogout);
await h.refresh.manual("traffic");
assert.equal(h.requests.length, afterLogout);
// Validate incoming preferences in the browser, including malformed partial records.
const invalid = runInNewContext(scheduler + ";normalizeRefreshPreferences({ live_enabled: 1, overview_seconds: 0, traffic_seconds: 17, cache_seconds: 0, system_info_seconds: 300 });", {
document: { hidden: true, addEventListener() {}, removeEventListener() {} },
AbortController, window: {}, authenticated: false, authEpoch: 0, currentPanel: "overview", liveConnected: false,
liveFallback: false, stopLive() {}, startLive() {}, $: () => null
});
assert.equal(invalid.live_enabled, true);
assert.equal(invalid.overview_seconds, 3);
assert.equal(invalid.traffic_seconds, 5);
assert.equal(invalid.cache_seconds, 0);
assert.equal(invalid.system_info_seconds, 300);
console.log("PASS: per-account refresh validation, SSE/fallback, 5/3/0-s scheduling, tab/visibility/logout lifecycle, isolation, no duplicate/inflight/stale requests, manual mode.");
-101
View File
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
"""0.31.7 offline update-retention regression without systemd or root privileges."""
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parents[2]
UPDATER = ROOT / "update.sh"
def mkdir_age(path: Path, days: int) -> None:
path.mkdir(parents=True)
stamp = time.time() - days * 86400
os.utime(path, (stamp, stamp))
def call(update_prefix: Path, cargo_target: Path, mockbin: Path, *, fail: bool = False, plan: bool = False) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["PATH"] = f"{mockbin}:{env.get('PATH', '')}"
env["CARGO_TARGET_DIR"] = str(cargo_target)
env["RP_TEST_UPDATE_FAIL"] = "1" if fail else "0"
args = ["bash", str(UPDATER), "--prefix", str(update_prefix)]
if plan:
args.append("--plan")
return subprocess.run(args, env=env, capture_output=True, text=True, timeout=15, check=False)
def main() -> None:
subprocess.run(["bash", "-n", str(UPDATER)], check=True)
with tempfile.TemporaryDirectory(prefix="rp-release-0317-") as tmp:
root = Path(tmp)
prefix = root / "installed rustproxy"
releases = prefix / "releases"
backups = prefix / "backups"
releases.mkdir(parents=True)
backups.mkdir()
for name, age in [("r-new", 0), ("r-second", 2), ("r-live", 8), ("r-ancient", 30), ("r-older extra", 40)]:
mkdir_age(releases / name, age)
(prefix / "current").symlink_to(releases / "r-live", target_is_directory=True)
for days in range(1, 6):
mkdir_age(backups / f"before-{days}", days)
target = root / "target"
for profile in ["release", "debug"]:
(target / profile / "deps").mkdir(parents=True)
(target / profile / ".fingerprint").mkdir()
(target / profile / "build").mkdir()
(target / profile / "rust-proxy").write_text("compiled binary")
mkdir_age(target / profile / "incremental" / "old", 50)
mkdir_age(target / profile / "incremental" / "new", 1)
mockbin = root / "bin"
mockbin.mkdir()
stub = mockbin / "python3"
stub.write_text("#!/bin/sh\n[ \"${RP_TEST_UPDATE_FAIL:-0}\" != 1 ] || exit 17\nexit 0\n")
stub.chmod(0o755)
result = call(prefix, target, mockbin, fail=True)
assert result.returncode == 17, result.stderr
assert (releases / "r-older extra").exists(), "failed update cleaned releases"
assert (target / "release" / "incremental" / "old").exists(), "failed update cleaned target"
result = call(prefix, target, mockbin, plan=True)
assert result.returncode == 0, result.stderr
assert (releases / "r-older extra").exists(), "planned update cleaned releases"
result = call(prefix, target, mockbin)
assert result.returncode == 0, (result.stdout, result.stderr)
assert sorted(path.name for path in releases.iterdir()) == ["r-live", "r-new"], result.stdout
assert sorted(path.name for path in backups.iterdir()) == ["before-1", "before-2", "before-3"], result.stdout
assert (prefix / "current").resolve() == releases / "r-live", "current symlink changed"
for profile in ["debug", "release"]:
assert not (target / profile / "incremental" / "old").exists(), "old incremental retained"
assert (target / profile / "incremental" / "new").exists(), "recent incremental removed"
for artifact in ["deps", ".fingerprint", "build", "rust-proxy", ".cargo-lock"]:
assert (target / profile / artifact).exists(), f"removed Cargo artifact: {profile}/{artifact}"
# Safety: do not touch target if a parent directory is a symlink.
target_symlink_root = root / "symlink"
target_symlink_root.symlink_to(target, target_is_directory=True)
mkdir_age(target / "release" / "incremental" / "symlink-test", 50)
result = call(prefix, target_symlink_root, mockbin)
assert result.returncode == 0, result.stderr
assert (target / "release" / "incremental" / "symlink-test").exists(), "followed symlinked target"
# Static source contracts for the managed-update cache handoff.
deploy = (ROOT / "scripts/deploy/deploy_runtime.py").read_text()
runtime = (ROOT / "src/main/admin_runtime.rs").read_text()
demote = (ROOT / "src/cache/cold/demotion.rs").read_text()
assert "stop_for_managed_update(data)" in deploy
assert "run(['systemctl', 'stop', SERVICE])" in deploy
assert "data_dir.join(\".managed-update\").is_file()" in runtime
assert "state.cache.flush_hot_to_cold_for_update()" in runtime
assert "self.flush_hot_to_cold_inner(true).await" in demote
print("PASS: 0.31.7 failed/plan/successful update, two releases + three backups, Cargo safety/symlink guards, managed-update flush wiring.")
if __name__ == "__main__":
main()
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env node
// 0.31.7 DOM/source + executable revision-diff tests (no Rust runtime).
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const read = path => fs.readFileSync(path, 'utf8');
const html = read('src/admin/web/index.html');
const css = read('src/admin/web/style.css');
const settings = read('src/admin/web/settings.js');
const hist = read('src/admin/web/app/config-history.js');
const management = read('src/admin/web/management.js');
const listener = read('src/admin/web/integrations.js');
const router = read('src/admin/router.rs');
const core = read('src/admin/web/vhosts/core.js');
const editor = read('src/admin/web/vhosts/editor.js');
const rates = read('src/admin/web/vhosts/rates.js');
const model = read('src/rate_limit.rs');
const api = JSON.parse(read('src/admin/openapi.json'));
assert.match(html, /data-settings-tab="policies"/);
for (const name of ['user-agent', 'headers', 'response']) {
assert.match(html, new RegExp(`data-policy-settings-tab="${name}"`));
assert.match(html, new RegExp(`data-policy-settings-page="${name}"`));
}
for (const form of ['global-ua-block-form', 'headers-form', 'response-form']) {
assert.equal(html.split(`id="${form}"`).length, 2, `${form} must be unique`);
}
assert.match(settings, /selectPolicyTab\("user-agent"\)/);
assert.match(html, /id="config-history-json-dialog"/);
assert.match(html, /id="config-history-compare-open"/);
const actionStart = html.indexOf('<div class="config-history-detail-actions">');
const actionEnd = html.indexOf('</div>', actionStart);
const restorePos = html.indexOf('id="config-history-restore"', actionStart);
const comparePos = html.indexOf('id="config-history-compare-open"', actionStart);
assert.ok(actionStart > -1 && restorePos > actionStart && comparePos > restorePos && comparePos < actionEnd,
'JSON compare button must appear directly under restore inside the same action group');
assert.equal(html.split('id="config-history-compare-open"').length, 2);
assert.match(html, /id="config-json-compare-title"[\s\S]*?<div class="window-tools">[\s\S]*?id="config-history-json-close"/);
assert.match(css, /dialog\.config-history-dialog,\s*dialog\.config-history-json-dialog\s*\{/);
assert.match(css, /\.config-history-dialog\[open\]\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)/);
assert.match(css, /\.config-history-diff\s*\{[^}]*overflow-y:\s*auto/);
assert.match(css, /\.config-history-layout\s*\{[^}]*overflow:\s*hidden/);
assert.match(css, /@media \(max-width:\s*860px\)\s*\{[\s\S]*?\.config-history-layout\s*\{[^}]*overflow-y:\s*auto/);
assert.match(css, /\.config-history-json-columns pre\s*\{[^}]*border:\s*0/);
assert.match(hist, /"config-history-json-before"\)\.textContent/);
assert.match(hist, /"config-history-json-after"\)\.textContent/);
assert.match(css, /\.listener-protocol-settings-actions\s*\{[^}]*gap:\s*14px/);
assert.match(listener, /listener-protocol-settings-actions/);
assert.match(management, /el\("details", undefined, "acl-profile-attached"\)/);
assert.doesNotMatch(management, /attached\.open\s*=\s*true/);
assert.match(router, /include_str!\("web\/vhosts\/rates\.js"\)/);
assert.match(editor, /user_agent_limits: uaRatesReader\(\)/);
assert.match(editor, /userAgentRateEditor\(g, rate\.user_agent_limits \|\| \[\]\)/);
assert.match(core, /uaRatesReader/);
assert.match(rates, /function userAgentRateEditor/);
assert.match(model, /pub user_agent_limits: Vec<UserAgentLimit>/);
assert.ok(api.components.schemas.VhostRateLimit.properties.user_agent_limits);
const start = hist.indexOf('function configDiff('), end = hist.indexOf('function renderConfigRevisionDetail(', start);
assert.ok(start > 0 && end > start, 'executable diff function must exist');
const context = { before: null, after: null };
vm.createContext(context);
vm.runInContext(`${hist.slice(start, end)}\nglobalThis.diffResult = () => configDiff(before, after);`, context);
const diff = (before, after) => { context.before=before; context.after=after; return JSON.parse(JSON.stringify(context.diffResult())); };
let result = diff({port:80, x:{a:true, b:2}, vhosts:[{id:'v1', host:'a'},{id:'v2', host:'b'}]},
{port:443, x:{a:true, b:3}, vhosts:[{id:'v2', host:'b'},{id:'v1', host:'a'}]});
assert.deepEqual(result.map(x => x.path), ['$["port"]','$["x"]["b"]']);
result = diff({a:1, headers:[]}, {a:1, headers:[{id:'one',value:'ok'}], policy:true});
assert.deepEqual(result.map(x => x.path), ['$["headers"]["one"]["id"]','$["headers"]["one"]["value"]','$["policy"]']);
result = diff({items: [{id:'one', active: true}]}, {items: [{id:'one',active:false}]});
assert.equal(result.length,1);
assert.equal(result[0].path,'$["items"]["one"]["active"]');
assert.ok(diff({}, Object.fromEntries(Array.from({length:350},(_,i) => [i, i]))).length <= 200);
console.log('PASS: 0.31.7 Settings sections, vhost UA rate editor/API, ACL/listener layout, changed-field revision diff, bounded scrolling, conventional full JSON modal.');
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env python3
"""0.31.9 source-only regression; does NOT claim Rust compilation or memory profiling."""
from pathlib import Path
import json
import re
import tomllib
from html.parser import HTMLParser
ROOT = Path(__file__).resolve().parents[2]
def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.7'
assert 'name = "rust-proxy"\nversion = "0.32.7"' in read('Cargo.lock')
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.7'
pages = read('src/pages.rs')
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
file = f'src/admin/web/error_{code}.html'
markup = read(file)
assert markup.lstrip().startswith('<!doctype html>'), file
assert 'background: #131615' in markup and 'max-width: 620px' in markup, file
assert re.search(rf'<h1>{code} - [^<]+</h1>', markup), file
assert f'include_str!("admin/web/error_{code}.html")' in pages, file
assert 'status_200.html' in pages and '<h1>200 - OK</h1>' in read('src/admin/web/status_200.html')
assert 'RustProxy generated this response.' in pages and 'background: #131615' in pages
assert 'matches!(status, StatusCode::NO_CONTENT | StatusCode::RESET_CONTENT | StatusCode::NOT_MODIFIED)' in pages
geoip = read('src/geoip.rs')
assert 'ipv4: Box<[V4Range]>, ipv6: Box<[V6Range]>' in geoip
assert 'ipv4.into_boxed_slice()' in geoip and 'ipv6.into_boxed_slice()' in geoip
assert 'start: [u8; 16]' in geoip and 'end: [u8; 16]' in geoip
assert 'u128::from(ip).to_be_bytes()' in geoip and 'ip <= item.end' in geoip
assert 'size_of::<V6Range>(), 34' in read('src/geoip/tests.rs')
cached = read('src/cache/fill.rs') + read('src/cache/lookup/tiered.rs')
for marker in ('bytes.try_into_mut()', 'mutable.capacity()', 'candidate.shared = Some(bytes.clone())',
'shared_allocated', 'candidate.fill_gauge.grow(allocation', 'if transformed { 0 }',
'candidate.data.extend_from_slice(data)'):
assert marker in cached, marker
q = read('src/admin/file_logs.rs') + read('src/admin/file_logs_query.rs')
for marker in ('sort_by: Option<String>', 'sort_descending: Option<bool>', 'valid_raw_field_key(field)',
'fn sort_file_log_records(', 'stored_log_sort_key(&record, field)',
'file_log_field(record, field)', 'None, Some(_)', 'sort_file_log_records(&mut records'):
assert marker in q, marker
assert q.index('sort_file_log_records(&mut records') < q.index('let page = records')
print('PASS: 0.31.9 versions, 13 matching HTML pages, GeoIP packed/boxed sources, cache ownership/accounting and bounded server sorting (static only).')
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env node
// JS-only regression of the Stored Logs sorting interaction (no Rust server).
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const read = name => fs.readFileSync(name, 'utf8');
const ui = read('src/admin/web/file-logs.js');
const css = read('src/admin/web/style.css');
const api = JSON.parse(read('src/admin/openapi.json'));
const params = api.paths['/api/file-logs/query'].get.parameters.map(param => param.name);
assert.ok(params.includes('sort_by') && params.includes('sort_descending'));
assert.ok(api.components.schemas.AdminPreferences.properties.file_log_sort);
assert.ok(api.components.schemas.AdminPreferences.properties.file_log_descending);
assert.match(ui, /params\.set\("sort_by", sortBy\)/);
assert.match(ui, /params\.set\("sort_descending", String\(sortDescending\)\)/);
assert.match(css, /file-log-sort-button/);
class FakeNode {
constructor(tag, text = '', cls = '') { this.tag = tag; this.textContent = text; this.className = cls; this.children = []; this.dataset = {}; this.attrs = {}; this.events = {}; }
setAttribute(name, value) { this.attrs[name] = value; }
append(...nodes) { this.children.push(...nodes); }
replaceChildren(...nodes) { this.children = nodes; }
addEventListener(name, handler) { this.events[name] = handler; }
click() { this.events.click?.(); }
}
const row = new FakeNode('tr');
const sort = ui.slice(ui.indexOf(' function syncHeaders() {'), ui.indexOf(' function syncColumnPicker()'));
assert.ok(sort.startsWith(' function syncHeaders() {') && sort.includes('file-log-sort-button'));
const ctx = {
el: (tag, text, cls) => new FakeNode(tag, text, cls),
$: () => ({querySelector: () => row}),
orderedColumns: () => ['time', 'status'],
visibleColumns: new Set(['time', 'status']),
specialLabels: new Map([['time', 'time'], ['status', 'status']]),
columnLabel: id => id,
t: key => key,
sortBy: 'time', sortDescending: true, selectedIndex: 3, offset: 100,
saved: 0, refreshed: 0,
savePreferences() { this.saved++; },
refresh() { this.refreshed++; },
};
// Methods capture explicit counters rather than relying on vm's `this` binding.
ctx.savePreferences = () => { ctx.saved++; };
ctx.refresh = reset => { if (reset) ctx.refreshed++; };
vm.createContext(ctx);
vm.runInContext(`${sort}\nglobalThis.draw = syncHeaders;`, ctx);
ctx.draw();
assert.equal(row.children.length, 2);
assert.equal(row.children[0].attrs['aria-sort'], 'descending');
assert.equal(row.children[1].attrs['aria-sort'], 'none');
row.children[1].children[0].click();
assert.equal(ctx.sortBy, 'status');
assert.equal(ctx.sortDescending, false);
assert.equal(ctx.offset, 0);
assert.equal(ctx.saved, 1);
assert.equal(ctx.refreshed, 1);
assert.equal(row.children[1].attrs['aria-sort'], 'ascending');
row.children[1].children[0].click();
assert.equal(ctx.sortDescending, true);
assert.equal(row.children[1].attrs['aria-sort'], 'descending');
assert.equal(ctx.saved, 2);
assert.equal(ctx.refreshed, 2);
console.log('PASS: 0.31.9 Stored Logs headers sort by column, toggle direction, reset pagination, persist preferences and request server sorting.');
File diff suppressed because it is too large. Load diff
-192
View File
@@ -1,192 +0,0 @@
#!/usr/bin/env python3
"""Static structure contracts for the split Rust source tree.
These checks are intentionally compiler-independent. They prevent the module split
from regressing into large implementation monoliths, allow full-sized test files,
and catch the class of dangling-attribute/broken-include mistakes that can otherwise
surface only at cargo check time.
"""
from __future__ import annotations
from pathlib import Path
import re
from rust_source import rust_source
ROOT = Path(__file__).resolve().parents[2]
SRC = ROOT / "src"
MAX_LINES = 500
def is_test_source(path: Path) -> bool:
relative = path.relative_to(ROOT)
if relative.parts[0] == "tests":
return True
return relative.parts[0] == "src" and (
path.name == "tests.rs" or path.stem.endswith("_tests") or "tests" in relative.parts[1:]
)
include_re = re.compile(r'include!\(\s*"([^"]+\.rs)"\s*\)')
test_attr_re = re.compile(r'(?m)^\s*#\[(?:tokio::)?test(?:\([^\]]*\))?\]')
dangling_doc_before_close_re = re.compile(r'(?ms)(?:^\s*///[^\n]*\n){1,8}\s*}')
dangling_attr_before_close_re = re.compile(r'(?ms)^\s*#\[[^\n]+\]\s*\n\s*}')
def delimiters_balanced(text: str) -> bool:
stack: list[str] = []
pairs = {"}": "{", ")": "(", "]": "["}
i = 0
state = "code"
block_depth = 0
raw_hashes = 0
while i < len(text):
char = text[i]
nxt = text[i + 1] if i + 1 < len(text) else ""
if state == "code":
if char == "/" and nxt == "/":
state = "line"; i += 2; continue
if char == "/" and nxt == "*":
state = "block"; block_depth = 1; i += 2; continue
if char == '"':
state = "string"; i += 1; continue
if char == "r":
raw = re.match(r'r(#{0,16})"', text[i:])
if raw:
raw_hashes = len(raw.group(1)); state = "raw"; i += len(raw.group(0)); continue
if char == "'":
# Lifetimes are identifiers; only enter char mode when a nearby closing quote exists.
cursor = i + 1
closing = False
while cursor < min(len(text), i + 8):
if text[cursor] == "\\": cursor += 2; continue
if text[cursor] == "'": closing = True; break
if text[cursor] in "\r\n": break
cursor += 1
if closing:
state = "char"; i += 1; continue
if char in "({[": stack.append(char)
elif char in "})]":
if not stack or stack[-1] != pairs[char]: return False
stack.pop()
i += 1
elif state == "line":
if char == "\n": state = "code"
i += 1
elif state == "block":
if char == "/" and nxt == "*": block_depth += 1; i += 2
elif char == "*" and nxt == "/":
block_depth -= 1; i += 2
if block_depth == 0: state = "code"
else: i += 1
elif state == "string":
if char == "\\": i += 2
elif char == '"': state = "code"; i += 1
else: i += 1
elif state == "char":
if char == "\\": i += 2
elif char == "'": state = "code"; i += 1
else: i += 1
elif state == "raw":
terminator = '"' + "#" * raw_hashes
if text.startswith(terminator, i): i += len(terminator); state = "code"
else: i += 1
return not stack and state in {"code", "line"}
# The 500-line rule applies to production Rust sources, not unit/integration tests.
for path in sorted(ROOT.rglob("*.rs")):
if any(part in {"target", ".git"} for part in path.parts):
continue
if is_test_source(path):
continue
lines = path.read_text(encoding="utf-8").splitlines()
assert len(lines) <= MAX_LINES, f"{path.relative_to(ROOT)} has {len(lines)} lines (max {MAX_LINES})"
for path in sorted(SRC.rglob("*.rs")):
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
assert delimiters_balanced(text), f"unbalanced delimiters in split Rust source: {path.relative_to(ROOT)}"
nonblank = [line.strip() for line in lines if line.strip()]
if nonblank:
assert not nonblank[-1].startswith(("#[", "///", "//!")), f"dangling Rust attribute/doc comment at EOF: {path.relative_to(ROOT)}"
assert not dangling_doc_before_close_re.search(text), f"doc comment before closing brace does not document an item: {path.relative_to(ROOT)}"
assert not dangling_attr_before_close_re.search(text), f"attribute before closing brace does not decorate an item: {path.relative_to(ROOT)}"
for target in include_re.findall(text):
included = path.parent / target
assert included.is_file(), f"broken include! in {path.relative_to(ROOT)}: {target}"
included_text = included.read_text(encoding="utf-8")
# include! expands into the caller's existing module/item scope. Inner module docs
# (`//!`) in a split fragment are therefore invalid once another item precedes them.
assert not re.search(r'(?m)^\s*//!', included_text), (
f"inner module doc comment in include! fragment: {included.relative_to(ROOT)}"
)
if not is_test_source(path):
assert not test_attr_re.search(text), f"unit test remains in implementation source: {path.relative_to(ROOT)}"
# Test-only implementation must live in a separate file and be pulled in explicitly.
for match in re.finditer(r'(?m)^\s*#\[cfg\(test\)\]\s*$', text):
tail = text[match.end():].lstrip()
assert tail.startswith("include!(") or tail.startswith("mod tests;"), (
f"test cfg in implementation must target a separate test file: {path.relative_to(ROOT)}"
)
# Unit tests are intentionally consolidated rather than mechanically numbered.
for folder in (
"admin/tests", "balance/tests", "config/tests", "web_server/tests",
"cache/tests/tests", "cache/tests/v022_cache_tests", "cache/tests/cold_tier_tests",
):
assert not (SRC / folder).exists(), f"obsolete split-test directory: {folder}"
for relative in (
"admin/tests.rs", "balance/tests.rs", "config/tests.rs", "web_server/tests.rs",
"cache/tests/tests.rs", "cache/tests/v022_cache_tests.rs", "cache/tests/cold_tier_tests.rs",
):
assert (SRC / relative).is_file(), f"missing consolidated test file: {relative}"
assert 'include_str!("web/login-keys.json")' in (SRC / "admin/tests.rs").read_text(), (
"admin test asset path must be relative to the consolidated test file"
)
assert (SRC / "admin/web/login-keys.json").is_file()
# Explicit regressions from the first Web Server split.
response = (SRC / "web_server/response.rs").read_text(encoding="utf-8")
assert not response.rstrip().endswith("#[derive(Debug, Clone)]"), "dangling derive returned in Web Server response module"
fastcgi = rust_source(ROOT, "src/web_server/fastcgi.rs")
request = (SRC / "web_server/request.rs").read_text(encoding="utf-8")
assert "use anyhow::{Context" in fastcgi or "Context," in fastcgi or ", Context" in fastcgi
assert "htaccess," in fastcgi
assert "request::server_protocol" in fastcgi
assert "pub(super) fn server_protocol" in request
headers = (SRC / "web_server/response/headers.rs").read_text(encoding="utf-8")
assert "htaccess," in headers, "Web Server response headers must import sibling htaccess after module split"
assert "ACCEPT_ENCODING," not in headers, "stale ACCEPT_ENCODING import reintroduced in response headers"
# Regressions exposed by a real `cargo check --locked --all-targets` after the 0.25.1 split.
assert "pub(super) trait FastCgiIo" in fastcgi, "FastCgiIo visibility must match sibling-visible FastCGI helpers"
for relative in ("src/proxy/websocket/tests.rs", "tests/http3.rs"):
text = (ROOT / relative).read_text(encoding="utf-8")
assert 'parse::<h3::ext::Protocol>().unwrap()' not in text, (
f"{relative} uses Result::unwrap() for h3::ext::Protocol even though InvalidProtocol has no Debug"
)
server_tests = (SRC / "server/serve/tests.rs").read_text(encoding="utf-8")
assert server_tests.count("serve_inner(") == server_tests.count("Http2Connections::default()"), (
"direct serve_inner test calls must pass the Http2Connections argument"
)
cache_policy_tests = (SRC / "cache/tests/tests.rs").read_text(encoding="utf-8")
# Older cache tests legitimately use `let plan = plan(...)`. The regression
# applies to the Vary/revalidation group formerly stored in part_03.rs.
revalidation_tests = cache_policy_tests.split(
"async fn vary_selects_variants_without_fragmenting_the_primary_hash", 1
)[1]
assert "let plan = plan(" not in revalidation_tests, "cache policy test shadows the plan() function"
cold_tests = (SRC / "cache/tests/cold_tier_tests.rs").read_text(encoding="utf-8")
for field in ("scheme:", "authority:", "target:", "tags:", "created_at:"):
assert field in cold_tests, f"ColdEntry test fixture is missing current field {field}"
admin_tests = (SRC / "admin/tests.rs").read_text(encoding="utf-8")
assert "let configuration = router\n .clone()\n .oneshot(" in admin_tests, (
"admin persistence test must retain Router before Tower::oneshot consumes a clone"
)
web_tests_1 = (SRC / "web_server/tests.rs").read_text(encoding="utf-8")
assert "ETAG, EXPIRES," not in web_tests_1, "unused EXPIRES import returned in Web Server tests"
print(f"PASS: production Rust modules are <= {MAX_LINES} lines (tests exempt), numbered test fragments are absent, include! targets resolve, and module regressions are guarded.")
-43
View File
@@ -1,43 +0,0 @@
"""Helpers for source-level contracts over split Rust modules.
The project intentionally keeps Rust source files small and composes several modules
with include!("..."). Static contracts must inspect the logical source, not only the
thin facade file.
"""
from __future__ import annotations
from pathlib import Path
import re
_INCLUDE = re.compile(r'include!\(\s*"([^"]+\.rs)"\s*\)\s*;?')
def expanded_source(path: Path, _stack: tuple[Path, ...] = ()) -> str:
path = path.resolve()
if path in _stack:
chain = " -> ".join(str(item) for item in (*_stack, path))
raise RuntimeError(f"cyclic Rust include!: {chain}")
text = path.read_text(encoding="utf-8")
def replace(match: re.Match[str]) -> str:
child = (path.parent / match.group(1)).resolve()
if not child.is_file():
raise FileNotFoundError(f"{path}: include target does not exist: {match.group(1)}")
return expanded_source(child, (*_stack, path))
return _INCLUDE.sub(replace, text)
def rust_source(root: Path, relative: str) -> str:
"""Read one logical Rust source file, recursively expanding include! fragments."""
return expanded_source(root / relative)
def rust_tree(root: Path, relative_dir: str) -> str:
"""Read every Rust file beneath a module directory (including mod-declared children)."""
directory = root / relative_dir
return "\n".join(
expanded_source(path)
for path in sorted(directory.rglob("*.rs"))
if path.is_file()
)
-106
View File
@@ -1,106 +0,0 @@
#!/usr/bin/env python3
"""Source-level security invariants that must hold even before Rust runtime tests."""
from __future__ import annotations
from pathlib import Path
from rust_source import rust_source, rust_tree
ROOT = Path(__file__).resolve().parents[2]
def read(path: str) -> str:
target = ROOT / path
return rust_source(ROOT, path) if target.suffix == ".rs" else target.read_text(encoding="utf-8")
def need(text: str, *needles: str) -> None:
for needle in needles:
if needle not in text:
raise AssertionError(f"missing security invariant: {needle}")
web = rust_tree(ROOT, "src/web_server")
htaccess = read("src/web_server/htaccess.rs")
acl = read("src/acl.rs")
admin = read("src/admin/mod.rs")
auth = read("src/auth/mod.rs")
web_ui = read("src/admin/web/web-server.js")
# Preset regression: selecting a preset must actually materialize normal config fields.
need(web_ui,
"function presetChanged()",
'if ($("web-service-preset").value !== "custom") applyPreset();',
'$("web-service-preset").addEventListener("change", presetChanged);')
# Request target/path canonicalization: reject double decoding, encoded separators and dot traversal.
need(acl,
"ambiguous encoded path character",
"ambiguous path segments",
"!matches!(decoded, b'/' | b'\\\\' | b'%' | b'?' | b'#' | b';')",
"path.len() <= 8192")
# FastCGI must not inherit client proxy settings (httpoxy) and must not control HTTP framing.
need(web,
"HTTP_PROXY is a well-known CGI/FastCGI injection vector",
'if name.as_str() == "proxy"',
'"content-length"',
'"transfer-encoding"',
'"connection"',
'"upgrade"',
'"proxy-authenticate"',
'"proxy-authorization"',
"(200..=599).contains(&code)",
"unsupported informational/invalid status",
"reserved_fastcgi_param",
'"PHP_ADMIN_VALUE"',
"pass_authorization",
"max_request_body_bytes",
"StatusCode::PAYLOAD_TOO_LARGE",
"max_response_header_bytes",
"stderr_log_bytes")
# File-system confinement and .htaccess loading must not follow attacker-controlled symlinks.
need(web, "libc::openat", "libc::O_NOFOLLOW", "secure filesystem confinement")
need(htaccess,
"read_htaccess_nofollow",
"libc::O_NOFOLLOW",
"fs::symlink_metadata",
".htaccess scan escaped the Web Service document root",
"must not be a symlink",
"MAX_HTACCESS_FILES",
"MAX_HTACCESS_TOTAL_BYTES")
need(web, "htaccess_configuration_file_cannot_be_a_symlink")
# PHP source must fail closed instead of becoming a static download when FPM is unavailable.
need(web,
"php_source_extensions_are_not_served_without_php_fpm",
"php_fpm_failure_returns_gateway_error_instead_of_php_source",
"fastcgi_never_forwards_proxy_header_as_http_proxy",
"fastcgi_response_cannot_control_downstream_framing_or_hop_headers",
"fastcgi_response_rejects_informational_status_codes",
"php_custom_fastcgi_params_cannot_override_core_or_php_admin_values",
"php_request_body_limit_returns_413_before_fastcgi_connect")
# Admin plane: exact anonymous allowlist + cookie/CSRF/origin/browser marker + CSP/frame denial.
need(admin,
"fn anonymous_allowed",
'single_header(headers, "origin")',
'single_header(headers, "x-rustproxy-request") != Some("1")',
'single_header(headers, "x-csrf-token")',
'"auth.csrf"',
"frame-ancestors 'none'",
"object-src 'none'",
"anonymous_operational_paths_fail_closed_before_extractors",
"duplicate_csrf_or_marker_is_rejected_and_session_survives")
need(auth,
"HttpOnly; SameSite=Strict",
'if secure { "; Secure" } else { "" }',
"Argon2id",
"(12..=128).contains(&value.len())")
# Web serving/admin code must not execute shell commands as part of request/config handling.
for path, text in {
"src/web_server/*.rs": web,
"src/web_server/htaccess.rs": htaccess,
"src/admin/mod.rs": admin,
}.items():
if "Command::new" in text or "std::process::Command" in text:
raise AssertionError(f"unexpected process execution primitive in {path}")
print("PASS: production security source invariants (paths, FastCGI, htaccess, admin auth/CSRF, no shell execution) are present.")
@@ -1,78 +0,0 @@
#!/usr/bin/env python3
"""Offline minimum-version gates for network-facing RustSec advisories reviewed 2026-10-04.
This is deliberately not a replacement for `cargo audit`: it prevents known-fixed
packages from being accidentally downgraded when the lockfile changes.
"""
from __future__ import annotations
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[2]
LOCK = (ROOT / "Cargo.lock").read_text(encoding="utf-8")
def versions() -> dict[str, list[str]]:
found: dict[str, list[str]] = {}
for block in LOCK.split("[[package]]")[1:]:
match = re.search(r'\n?name = "([^"]+)"\nversion = "([^"]+)"', block)
if match:
found.setdefault(match.group(1), []).append(match.group(2))
return found
def triplet(value: str) -> tuple[int, int, int]:
match = re.match(r"^(\d+)\.(\d+)\.(\d+)", value)
if not match:
raise AssertionError(f"unsupported version syntax: {value}")
return tuple(map(int, match.groups()))
packages = versions()
minimums = {
# RUSTSEC-2026-0285
"rustls": (0, 23, 45),
# RUSTSEC-2026-0185 and RUSTSEC-2026-0037
"quinn-proto": (0, 11, 15),
# RUSTSEC-2026-0104 (also covers 0049/0098/0099)
"rustls-webpki": (0, 103, 13),
# RUSTSEC-2025-0009
"ring": (0, 17, 12),
# RUSTSEC-2024-0363
"sqlx": (0, 8, 1),
# RUSTSEC-2026-0258
"h2": (0, 4, 16),
# RUSTSEC-2026-0007
"bytes": (1, 11, 1),
# RUSTSEC-2026-0009
"time": (0, 3, 47),
}
for package, minimum in minimums.items():
locked = packages.get(package)
if not locked:
raise AssertionError(f"expected security-critical dependency {package} in Cargo.lock")
for value in locked:
if triplet(value) < minimum:
raise AssertionError(f"{package} {value} is below reviewed patched minimum {'.'.join(map(str, minimum))}")
# 2026 malicious-crate campaign snapshot reviewed against RustSec on 2026-10-04.
# Keep the exact arrayref version gate because earlier arrayref releases are legitimate.
for value in packages.get("arrayref", []):
if value == "0.3.10":
raise AssertionError("Cargo.lock contains compromised arrayref 0.3.10")
malicious_packages = {
"greentic-setup", "greentic-setup-dev", "append-only-vec", "proc-macro1",
"internment", "proc-macro-en", "onering", "logflux", "exploration",
"sui-execution-cut", "mysten-metrics", "microsoftsystem64",
"pretty-changelog-logger", "safe-agent-rs", "logtrace", "jfrog_quotes",
"tree-sitter-pkl", "custom-req-on-workers", "sophosfirewall-python",
"statsrelay-protobuf", "time_calibrator", "tracings", "tracing_checks",
"rpc-check", "tracing-check", "clob-sdk", "polymarkets-rs-clob-client",
"polymarkets-client-sdk", "polymarket-client-sdks", "finch-rst",
}
present = sorted(malicious_packages.intersection(packages))
if present:
raise AssertionError(f"Cargo.lock contains RustSec-listed malicious package(s): {', '.join(present)}")
print("PASS: Cargo.lock stays above reviewed RustSec minimums and excludes the reviewed 2026 malicious-crate set (snapshot 2026-10-04).")
@@ -1,71 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
const read = path => fs.readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8');
const json = path => JSON.parse(read(path));
const manifest = read('Cargo.toml');
const lib = read('src/lib.rs');
const model = read('src/config/model.rs');
const vhost = read('src/vhost/model.rs');
const forward = read('src/proxy/forward_entry.rs');
const loggingApi = read('src/admin/logging_api.rs');
const ua = read('src/user_agent.rs');
const stored = read('src/admin/file_logs.rs') + read('src/admin/file_logs_query.rs') + read('src/admin/file_log_search.rs');
const html = read('src/admin/web/index.html');
const core = read('src/admin/web/app/core.js');
const editor = read('src/admin/web/vhosts/editor.js');
const traffic = read('src/admin/web/traffic-panel.js');
const fileLogs = read('src/admin/web/file-logs.js');
const openapi = json('src/admin/openapi.json');
const en = json('langs/en_US.json').translations;
const pl = json('langs/pl_pl.json').translations;
assert.match(manifest, /version = "0\.32\.6"/);
assert.match(lib, /pub mod user_agent;/);
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/);
assert.match(loggingApi, /"user-agent-blocking"/);
assert.match(ua, /MAX_PATTERNS: usize = 128/);
assert.match(ua, /candidate\.contains\(pattern\)/);
assert.match(ua, /pattern\.contains\('\*'\)/);
const denyPos = forward.indexOf('denied by User-Agent policy');
const redirectPos = forward.indexOf('let redirect = snapshot.redirect_on');
assert.ok(denyPos > 0 && redirectPos > denyPos, 'UA policy must run before redirect/origin processing');
assert.match(html, /id="global-ua-block-form"/);
assert.match(core, /saveSection\("user-agent-blocking"/);
assert.match(editor, /"ua-block-patterns"/);
assert.match(traffic, /traffic-copy-value/);
assert.match(traffic, /navigator\.clipboard\?\.writeText/);
assert.match(html, /id="file-log-consolidate-vhosts"/);
assert.match(html, /id="file-log-aggregate-form"/);
assert.match(stored, /group_id/);
assert.match(stored, /consolidate_vhosts/);
assert.match(stored, /aggregate_field/);
assert.match(stored, /FILE_LOG_MAX_AGGREGATE_VALUES: usize = 4096/);
assert.match(fileLogs, /member_count/);
assert.match(fileLogs, /rp-file-logs-consolidate-vhosts/);
assert.match(fileLogs, /localStorage\.getItem\(CONSOLIDATION_STORAGE_KEY\)/);
assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
assert.match(fileLogs, /aggregate_min_count/);
assert.match(fileLogs, /file-log-aggregate-copy/);
assert.equal(openapi.info.version, '0.32.7');
assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking);
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
const params = openapi.paths['/api/file-logs/query'].get.parameters.map(item => item.name);
for (const name of ['consolidate_vhosts', 'aggregate_field', 'aggregate_min_count', 'aggregate_limit']) assert.ok(params.includes(name), name);
const sections = openapi.paths['/api/config/{section}'].put.parameters.find(item => item.name === 'section').schema.enum;
assert.ok(sections.includes('user-agent-blocking'));
for (const pack of [en, pl]) {
for (const key of ['globalUaBlockingTitle', 'vhUaBlockEnabled', 'fileLogsConsolidateVhosts', 'fileLogsAdvancedParser', 'copied', 'copyValue']) {
assert.equal(typeof pack[key], 'string', key);
assert.ok(pack[key].length > 0, key);
}
}
console.log('PASS: 0.31.7 UA blocking, copy controls, repeated-value parser, vhost log consolidation, OpenAPI and locales.');
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env python3
"""Compiler-independent source-size and test-isolation contracts for non-Rust modules."""
from __future__ import annotations
from pathlib import Path
import os
import re
import shutil
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
MAX_LINES = 500
def lines(path: Path) -> int:
return len(path.read_text(encoding="utf-8").splitlines())
# Browser implementation modules are bounded. QA harnesses under tools/checks are checked separately.
for path in sorted((ROOT / "src/admin/web").rglob("*.js")):
assert lines(path) <= MAX_LINES, f"{path.relative_to(ROOT)} has {lines(path)} lines (max {MAX_LINES})"
# Docker Agent implementation and Go tests are separate files and all remain bounded.
for path in sorted((ROOT / "docker-agent").glob("*.go")):
assert lines(path) <= MAX_LINES, f"{path.relative_to(ROOT)} has {lines(path)} lines (max {MAX_LINES})"
if not path.name.endswith("_test.go"):
text = path.read_text(encoding="utf-8")
assert not re.search(r"(?m)^func\s+Test[A-Z]", text), f"Go test remains in implementation file: {path.relative_to(ROOT)}"
# Application-coupled Python entry points are split from external QA/dev tooling.
for directory in ("scripts/runtime", "scripts/build", "scripts/deploy", "scripts/migration"):
for path in sorted((ROOT / directory).glob("*.py")):
assert lines(path) <= MAX_LINES, f"{path.relative_to(ROOT)} has {lines(path)} lines (max {MAX_LINES})"
for old in (ROOT / "src/admin/web/app.js", ROOT / "src/admin/web/vhosts.js"):
assert not old.exists(), f"oversized browser monolith returned: {old.relative_to(ROOT)}"
for required in (
"src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js",
"src/admin/web/vhosts/core.js", "src/admin/web/vhosts/protocol.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js",
"docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go",
"docker-agent/certificate.go", "docker-agent/server.go",
"scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py",
"scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py",
):
assert (ROOT / required).is_file(), f"missing split source module: {required}"
# When Node is available, parse every standalone browser module and both assembled public bundles.
node = shutil.which("node")
if node:
env = dict(os.environ, TERM=os.environ.get("TERM", "xterm"))
standalone = list((ROOT / "src/admin/web").rglob("*.js"))
for path in sorted(standalone):
subprocess.run([node, "--check", str(path)], check=True, env=env, stdout=subprocess.DEVNULL)
from web_assets import read_app_js, read_vhosts_js
browser_markup = (ROOT / "src/admin/web/index.html").read_text(encoding="utf-8")
assert browser_markup.count('<script defer="" src="/assets/') == 1, "one private JS bundle prevents module race"
assert '/assets/app___RUSTPROXY_ASSET_ID__.js' in browser_markup
with tempfile.TemporaryDirectory(prefix="rustproxy-js-contract-") as directory:
temporary = Path(directory)
for name, content in (("app.js", read_app_js(ROOT)), ("vhosts.js", read_vhosts_js(ROOT))):
target = temporary / name
target.write_text(content, encoding="utf-8")
subprocess.run([node, "--check", str(target)], check=True, env=env, stdout=subprocess.DEVNULL)
# The public vhosts asset wraps three individually valid source modules in one private IIFE.
# Every source part is parsed above; this additional check keeps the assembled browser asset valid.
print(f"PASS: browser, Docker Agent and application-coupled Python modules are <= {MAX_LINES} lines, tooling is separated, tests are isolated, and browser source boundaries parse cleanly.")
-47
View File
@@ -1,47 +0,0 @@
#!/usr/bin/env python3
"""Verify removed history is optional, but live embedded documents remain required."""
from __future__ import annotations
from pathlib import Path
import shutil
import sys
from tempfile import TemporaryDirectory
from preflight import check
ROOT = Path(__file__).resolve().parents[2]
# A compact 0.31.7 source tree may omit historical version-labelled documents.
# It must still pass the same preflight used by every ./dev.sh command.
assert check(ROOT) == []
with TemporaryDirectory(prefix="rustproxy-pruned-source-") as tmp:
stripped = Path(tmp) / "RustProxy"
shutil.copytree(
ROOT, stripped,
ignore=shutil.ignore_patterns(".git", "target", "data", ".upgrade-backup", "__pycache__"),
)
# This functional document is in the Reader registry, but intentionally not
# in the legacy hand-maintained required-file list. It must still be caught.
reader_doc = stripped / "docs/REFERENCES.md"
assert reader_doc.is_file(), "Missing test fixture: docs/REFERENCES.md"
reader_doc.unlink()
try:
check(stripped)
except ValueError as error:
assert "Missing embedded Reader document: docs/REFERENCES.md" in str(error), error
else:
raise AssertionError("Preflight accepted missing compile-time embedded document")
shutil.copy2(ROOT / "docs/REFERENCES.md", reader_doc)
required_module = stripped / "src/cache/mod.rs"
required_module.unlink()
try:
check(stripped)
except ValueError as error:
assert "missing src/cache/mod.rs" in str(error), error
else:
raise AssertionError("Preflight accepted missing runtime source")
print("PASS: pruned historical documents are optional; embedded Reader files and runtime sources are mandatory.")
-103
View File
@@ -1,103 +0,0 @@
// Verify the real opt-in Stream Dashboard component against a lightweight DOM.
// No server, browser layout engine or Rust executable is exercised here.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const src = fs.readFileSync('src/admin/web/app/stream-dashboard.js', 'utf8');
const preferencesSource = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
const ids = ['stream_overview', 'stream_traffic', 'stream_sessions', 'stream_backend_health'];
assert(!/\b(setInterval|setTimeout|fetch|XMLHttpRequest)\s*\(/.test(src), 'widgets must not create timers/fetches');
const cards = new Map(ids.map(id => [id, { hidden: true }]));
const elements = new Map();
for (const name of ['overview', 'overview-detail', 'sessions', 'sessions-detail', 'health', 'health-detail', 'traffic', 'traffic-detail', 'rx', 'tx', 'rx-rate', 'tx-rate']) {
elements.set(`stream-dashboard-${name}`, { textContent: '-', attrs: new Map(), setAttribute(k, v) { this.attrs.set(k, v); } });
}
const preferences = { dashboard_hidden_widgets: [...ids] };
const window = { rustProxyPreferences: { current: () => preferences } };
let clock = 1000;
const scriptContext = { window, document: { querySelector(selector) {
const match = selector.match(/^\[data-dashboard-widget="([^\"]+)"\]$/);
return match ? cards.get(match[1]) || null : null;
} }, $: name => elements.get(name), ADMIN_APPEARANCE_DEFAULTS: { dashboard_hidden_widgets: ids },
envelope: { config: { streams: [
{ id: 'tcp', enabled: true, protocol: 'tcp' },
{ id: 'udp', enabled: true, protocol: 'udp' }
] } }, latestStatus: null, currentPanel: 'overview',
performance: { now: () => clock }, t: key => key, fmt: n => `${n}`, bytes: n => `${n} B`, Math,
};
vm.runInNewContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8') + '\n' + src, scriptContext);
const widget = window.rustProxyStreamDashboard;
const status = { streams: { active: [
{ id: 'tcp', enabled: true, protocol: 'tcp' },
{ id: 'udp', enabled: true, protocol: 'udp' }
], metrics: {
tcp: { active: 3, bytes_in: 500, bytes_out: 1000, backends: [
{ id: 'a', enabled: true, drain: false, status: 'healthy' },
{ id: 'b', enabled: true, drain: false, status: 'unhealthy' }
] },
udp: { active: 2, bytes_in: 100, bytes_out: 200, backends: [
{ id: 'a', enabled: true, drain: false, status: 'unverified' }
] }
} } };
scriptContext.latestStatus = status;
widget.render(status);
assert(ids.every(id => cards.get(id).hidden), 'new widgets are hidden by default');
assert.equal(elements.get('stream-dashboard-traffic').textContent, '-', 'hidden chart must not sample');
preferences.dashboard_hidden_widgets = [];
widget.render(status);
assert(ids.every(id => !cards.get(id).hidden));
assert.equal(elements.get('stream-dashboard-sessions').textContent, '5');
assert.equal(elements.get('stream-dashboard-overview').textContent, '2 / 2');
assert.equal(elements.get('stream-dashboard-health').textContent, '1 / 1');
clock += 1000;
status.streams.metrics.tcp.bytes_in += 1024;
status.streams.metrics.tcp.bytes_out += 512;
widget.render(status);
assert.equal(elements.get('stream-dashboard-rx-rate').textContent, '1024 B/s');
assert.equal(elements.get('stream-dashboard-tx-rate').textContent, '512 B/s');
assert(elements.get('stream-dashboard-rx').attrs.get('points')?.includes(','));
preferences.dashboard_hidden_widgets = ['stream_traffic'];
widget.syncVisibility();
assert(cards.get('stream_traffic').hidden);
const previous = elements.get('stream-dashboard-rx').attrs.get('points');
clock += 1000;
widget.render(status);
assert.equal(elements.get('stream-dashboard-rx').attrs.get('points'), previous, 'hidden chart must not update');
// Explicitly enabled cards remain visible even without active/configured streams.
// The user should see a neutral state, not a widget that ignores Appearance.
preferences.dashboard_hidden_widgets = [];
scriptContext.envelope.config.streams = [];
scriptContext.latestStatus = { streams: { active: [], metrics: {} } };
widget.render(scriptContext.latestStatus);
assert(ids.every(id => !cards.get(id).hidden), 'appearance must control Stream card visibility even with no listeners');
for (const name of ['overview', 'sessions', 'health', 'traffic']) {
assert.equal(elements.get(`stream-dashboard-${name}`).textContent, '-');
if (name !== 'traffic') assert.equal(elements.get(`stream-dashboard-${name}-detail`).textContent, 'streamWidgetUnavailable');
}
assert.equal(elements.get('stream-dashboard-rx-rate').textContent, '-');
assert.equal(elements.get('stream-dashboard-tx-rate').textContent, '-');
assert.equal(elements.get('stream-dashboard-rx').attrs.get('points'), '0,55 300,55', 'empty status must clear the graph');
// A desired but not yet running Stream must still make the overview useful.
scriptContext.envelope.config.streams = [{ id: 'pending', enabled: true, protocol: 'tcp' }];
preferences.dashboard_hidden_widgets = [];
widget.render({ streams: { active: [], metrics: {} } });
assert.equal(elements.get('stream-dashboard-overview').textContent, '0 / 1');
assert(!cards.get('stream_overview').hidden, 'restart-pending listener does not suppress the card');
preferences.dashboard_hidden_widgets = [...ids];
widget.render(scriptContext.latestStatus);
assert(ids.every(id => cards.get(id).hidden), 'only the Appearance switch hides opt-in cards');
widget.reset();
// Reuse original Appearance normalization logic to protect 0.31.x saved layouts.
const registryPrelude = `const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${fs.readFileSync("src/admin/web/dashboard-widgets.json", "utf8")});`;
const definitions = preferencesSource.slice(preferencesSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), preferencesSource.indexOf('const CHART_STYLE_KEYS'));
const normalize = preferencesSource.slice(preferencesSource.indexOf('function normalizeDashboardWidgets'), preferencesSource.indexOf('function browserLanguage'));
const sandbox = {};
vm.runInNewContext(`${registryPrelude}\n${definitions}\n${normalize}\nthis.normalizeDashboardWidgets = normalizeDashboardWidgets; this.normalizeHiddenDashboardWidgets = normalizeHiddenDashboardWidgets`, sandbox);
const oldOrder = ['requests', 'errors'];
const migratedOrder = [...sandbox.normalizeDashboardWidgets(oldOrder)];
const migratedHidden = [...sandbox.normalizeHiddenDashboardWidgets([], oldOrder)];
assert.deepEqual(migratedOrder.slice(0, 2), oldOrder);
assert(ids.every(id => migratedOrder.includes(id) && migratedHidden.includes(id)), 'legacy order must add cards as hidden');
console.log('PASS: opt-in Stream dashboard uses shared data with no polling, handles user visibility/empty-status states, and migrates old Appearance layouts.');
@@ -1,156 +0,0 @@
// Regression for persisted Appearance preferences and Stream widgets.
// Uses the actual JS functions with a minimal DOM, no network or timers.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const prefsSource = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
const streamSource = fs.readFileSync('src/admin/web/app/stream-dashboard.js', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
const ids = ['stream_overview', 'stream_traffic', 'stream_sessions', 'stream_backend_health'];
assert(html.includes('id="panel-overview"'));
for (const id of ids) assert(html.includes(`data-dashboard-widget="${id}"`));
assert.doesNotMatch(css, /#panel-overview \.stats-grid \.stat:is\(:hover, :focus-visible, :focus-within\)/,
'Overview details must never become hover/focus popovers');
assert.match(css, /#panel-overview \.stats-grid \.stat-chart\s*>\s*\.stat-chart-legend/,
'in-card chart legends must be visible, including on touch devices');
assert.match(css, /#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);\s*\}/,
'desktop Overview must retain the original six cards per row');
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(4, minmax\(0, 1fr\)\);\s*\}/,
'do not replace the original six-column Overview with a four-column layout');
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*>\s*\.stat\s*\{[^}]*\bmin-height\s*:/s,
'0.32.0 fixed height produced empty tiles; let inline metric legends size the row');
assert.match(html, /id="stream-dashboard-traffic-detail"/);
assert.match(html, /id="stream-dashboard-rx-rate"/);
assert.match(html, /id="stream-dashboard-tx-rate"/);
assert.doesNotMatch(html.slice(html.indexOf('id="panel-overview"'), html.indexOf('id="panel-vhosts"')), /<article[^>]*tabindex="0"/,
'metric cards must not introduce redundant keyboard stops');
const cardIds = [...html.matchAll(/<article class="stat[^"]*" data-dashboard-widget="([^"]+)"/g)].map(m => m[1]);
assert.equal(cardIds.filter(id => !id.startsWith('backend_') && !id.startsWith('extra_')).length, 16, 'original Overview cards must be retained');
assert.equal(cardIds.length, 48, '30 existing metric/Stream/backend tiles and 18 optional small charts must be registered');
const cards = new Map(cardIds.map(id => [id, { dataset: { dashboardWidget: id }, hidden: id.startsWith('stream_') }]));
const grid = { querySelectorAll: () => [...cards.values()], append(node) { assert(cards.has(node.dataset.dashboardWidget)); } };
const elements = new Map();
for (const name of ['overview', 'overview-detail', 'traffic', 'traffic-detail', 'sessions', 'sessions-detail', 'health', 'health-detail', 'rx', 'tx', 'rx-rate', 'tx-rate']) {
elements.set('stream-dashboard-' + name, { textContent: '-', attrs: {}, setAttribute(k, v) { this.attrs[k] = v; } });
}
const status = { streams: {
active: [{ id: 'alpha', protocol: 'tcp', enabled: true }],
desired: [{ id: 'alpha', protocol: 'tcp', enabled: true }],
metrics: { alpha: { active: 5, bytes_in: 1024, bytes_out: 2048, backends: [{ id: 'a', enabled: true, status: 'healthy' }] } }
} };
const window = { rustProxyRefresh: { update() {} } };
const context = {
window, latestStatus: status, envelope: { config: { streams: status.streams.desired } },
currentPanel: 'overview', authenticated: true, languagePacks: { size: 0 },
$: id => elements.get(id), document: { querySelector(selector) {
if (selector === '#panel-overview .stats-grid') return grid;
const match = selector.match(/^\[data-dashboard-widget="([^"]+)"\]$/);
return match ? cards.get(match[1]) : null;
} }, performance: { now: () => 1000 }, t: key => key,
fmt: v => String(v), bytes: v => v + ' B', Math,
normalizeRefreshPreferences: v => v || {}, renderRefreshPreferences() {},
applyAdminTheme: v => v, applyAdminLanguage: v => v, applyAdminUiScale: v => v,
applyAdminTimeFormat: v => v, applyAdminChartStyle: v => v,
applyAdminButtonStyle: v => v, applyAdminCornerStyle: v => v, applyAdminTableDensity: v => v,
applyAdminDashboardDensity: v => v, refreshAppearanceUi() {},
};
vm.createContext(context);
const registryPrelude = `const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${fs.readFileSync("src/admin/web/dashboard-widgets.json", "utf8")});`;
const constants = prefsSource.slice(prefsSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), prefsSource.indexOf('const CHART_STYLE_KEYS'));
const normalize = prefsSource.slice(prefsSource.indexOf('function normalizeDashboardWidgets'), prefsSource.indexOf('function browserLanguage'));
const applyLayout = prefsSource.slice(prefsSource.indexOf('function applyDashboardLayout'), prefsSource.indexOf('function dashboardWidgetLabel'));
const applyPreferences = prefsSource.slice(prefsSource.indexOf('function applyAdminPreferences'), prefsSource.indexOf('try { applyAdminTheme('));
vm.runInContext(`${registryPrelude}\n${constants}\n${normalize}\nlet adminPreferences = null;\n${applyLayout}\n${applyPreferences}\nthis.getPrefs = () => adminPreferences;\nthis.applyAdminPreferencesTest = applyAdminPreferences;`, context);
window.rustProxyPreferences = { current: () => context.getPrefs() };
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), context);
vm.runInContext(streamSource, context);
// First load (or page reload): defaults hide the new widgets.
const initial = { dashboard_widgets: cardIds, dashboard_hidden_widgets: ids };
context.applyAdminPreferencesTest(initial);
assert(ids.every(id => cards.get(id).hidden), 'opt-in cards must start hidden');
// Server responds to a previously saved opt-in; at this point the old in-memory
// prefs are still hidden. The new layout must win before adminPreferences is set.
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
assert(ids.every(id => !cards.get(id).hidden), 'server-saved opt-in must reveal cards immediately');
assert.equal(elements.get('stream-dashboard-sessions').textContent, '5', 'no need to wait for polling');
assert.equal(elements.get('stream-dashboard-overview').textContent, '1 / 1');
assert.equal(elements.get('stream-dashboard-traffic').textContent, '-', 'cached repaint is not a traffic sample');
// Hide one widget and restore it, all within the same session. Normal cards stay put.
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: ['stream_traffic'] });
assert(cards.get('stream_traffic').hidden);
assert(!cards.get('requests').hidden);
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
assert(!cards.get('stream_traffic').hidden);
// A disabled/absent Stream must not override an explicit Appearance opt-in.
context.envelope.config.streams = [];
context.latestStatus = { streams: { active: [], desired: [], metrics: {} } };
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
assert(ids.every(id => !cards.get(id).hidden), 'show requested cards even with no configured Stream');
assert.equal(elements.get('stream-dashboard-overview').textContent, '-');
assert.equal(elements.get('stream-dashboard-overview-detail').textContent, 'streamWidgetUnavailable');
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: ids });
assert(ids.every(id => cards.get(id).hidden), 'hiding cards must still work');
assert(!cards.get('requests').hidden, 'legacy widgets must retain their visibility');
// Native title tooltips do not participate in layout or create horizontal scroll.
assert.doesNotMatch(css, /dashboard-widget-(?:description|setting-label-block)/, 'obsolete custom tooltip styles must be removed');
assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\s*\{[^}]*overflow-x: auto;/s,
'Appearance must use a compact horizontal ribbon instead of long half-width columns');
assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list > \.dashboard-widget-setting\s*\{[^}]*min-width: 226px;/s,
'Appearance ribbon must retain readable labels and right-aligned reorder buttons');
assert.doesNotMatch(prefsSource, /role", "tooltip"|aria-describedby|dashboard-widget-description/,
'old popover DOM and associations must be removed');
const widgetList = { children: [], replaceChildren(...children) { this.children = children; } };
const makeElement = (tag, text, className) => ({
tag, textContent: text || '', className, dataset: {}, children: [], attributes: {},
classList: { toggle() {} }, setAttribute(name, value) { this.attributes[name] = value; },
append(...children) { this.children.push(...children); }, addEventListener() {},
});
for (const card of cards.values()) card.querySelector = () => ({ textContent: card.dataset.dashboardWidget });
elements.set('dashboard-widget-list', widgetList);
context.el = makeElement;
context.CSS = { escape: value => value };
vm.runInContext(prefsSource.slice(prefsSource.indexOf('function dashboardWidgetLabel'),
prefsSource.indexOf('function refreshChartStylePalette')), context);
context.renderDashboardWidgetSettings();
assert.equal(widgetList.children.length, 62, 'all cards and full-width panels retain settings controls');
for (const id of ids) {
const row = widgetList.children.find(item => item.dataset.dashboardWidgetSetting === id);
const button = row.children[0];
const key = ({ stream_overview: 'streamWidgetOverviewDescription',
stream_traffic: 'streamWidgetTrafficDescription', stream_sessions: 'streamWidgetSessionsDescription',
stream_backend_health: 'streamWidgetHealthDescription' })[id];
assert.equal(row.title, key, `${id} must have a localized browser tooltip`);
assert.equal(button.attributes['aria-description'], key, 'the tooltip must remain accessible');
assert.equal(button.children.length, 2, 'Appearance shows only the visibility indicator and label');
assert.equal(row.children.length, 2, 'Appearance keeps badge metadata in the visual editor, not in each setting');
assert.equal(row.children[1].className, 'dashboard-widget-setting-actions', 'move actions remain on the right');
}
assert.equal(widgetList.children.filter(row => row.title).length, 50,
'Stream, backend, performance and full-panel entries have translated descriptions');
// Editor-specific regressions: avoid the redundant enable hint and label the
// two distinct inheritance layers instead of saying "Inherit" everywhere.
const editor = fs.readFileSync('src/admin/web/streams.js', 'utf8');
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
assert(editor.includes('enabledLabel?.querySelector(".vh-enable-hint")?.remove()'));
const vhostEditor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
assert.match(vhostEditor, /window\.rustProxyEnableControl\(enabledControl, "vhEnableHint"\)/);
assert.match(vhostEditor, /enabledLabel\?\.querySelector\("\.vh-enable-hint"\)\?\.remove\(\)/, 'vhost editor must not show the redundant active hint');
assert.match(vhostEditor, /enabledLabel\?\.classList\.add\("vh-enable-no-hint"\)/);
assert(editor.includes('["inherit", "streamMetricsDefaultOff"]'));
assert(editor.includes('["inherit", "streamMetricsFromStream"]'));
for (const locale of [pl, en]) {
for (const key of ['streamMetricsDefaultOff', 'streamMetricsFromStream', 'streamMetricsHelp', 'streamBackendDetailMetrics'])
assert(locale[key] && locale[key].length > 5, `missing ${key}`);
}
assert.equal(pl.streamMetricsFromStream, 'Z ustawień Stream');
assert.equal(pl.streamMetricsDefaultOff, 'Domyślnie wyłączone');
console.log('PASS: Stream visibility, inline Overview legends, scroll-safe native Appearance tooltips and Vhost enable label.');
-127
View File
@@ -1,127 +0,0 @@
#!/usr/bin/env node
// Simulated status and tab DOM. No Rust process or visual browser is run here.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
const js = fs.readFileSync('src/admin/web/settings.js', 'utf8');
const core = fs.readFileSync('src/admin/web/app/core.js', 'utf8');
const api = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
const has = (haystack, needle) => assert.ok(haystack.includes(needle), `Missing ${needle}`);
// The two mini charts share the exact H1/H2/H3 palette as their SVG lines.
for (const version of ['h1', 'h2', 'h3']) {
assert.match(css, new RegExp(`\\.stat-chart\\s+\\.stat-chart-legend\\s+\\.legend-${version}\\s+i\\s*\\{\\s*background:\\s*var\\(--traffic-${version}\\);`));
assert.match(css, new RegExp(`\\.proto-${version}-line\\s*\\{[^}]*stroke:\\s*var\\(--traffic-${version}\\);`));
has(html, `id="http-protocol-${version}"`);
has(html, `id="ws-protocol-${version}"`);
}
assert.equal((html.match(/class="stat-chart-legend"/g) || []).length, 2);
const about = html.split('<dialog aria-labelledby="about-title"')[1].split('</dialog>')[0];
for (const forbidden of ['about-acme-mode', 'about-python-runtime', 'about-python-details', 'runtimeAcmeMode']) {
assert.ok(!about.includes(forbidden), `ACME details left in About: ${forbidden}`);
}
assert.ok(!core.includes('renderAboutRuntime('), 'Obsolete About runtime renderer must be removed');
for (const marker of ['aboutAuthor', 'aboutLicense', 'aboutRepository', 'aboutPackagesRepository']) has(about, marker);
has(html, 'data-settings-tab="system-info"');
has(html, 'id="settings-page-system-info"');
const tab = html.split('id="settings-page-system-info"')[1].split('id="settings-page-certificate-api"')[0];
for (const field of ['runtime-operating-system', 'runtime-kernel-version', 'runtime-architecture',
'runtime-logical-cpus', 'runtime-system-memory', 'runtime-process-memory',
'runtime-acme-mode', 'runtime-python', 'runtime-pip', 'runtime-certbot',
'runtime-acme-directory', 'runtime-data-directory', 'runtime-database-path']) has(tab, `id="${field}"`);
assert.ok(!tab.includes('<form'), 'The System information page must be read-only');
const admin = html.split('id="settings-page-administration"')[1].split('id="settings-page-system-info"')[0];
assert.ok(!admin.includes('runtime-information-card'), 'Runtime data must be relocated, not duplicated');
// Execute the real status-to-settings rendering code against a synthetic status payload.
const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || {};'), js.indexOf(' const policy = envelope.config.tls?', js.indexOf(' const runtime = latestStatus?.runtime || {};')));
const elements = new Map();
const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); };
const status = {
version: '0.32.1', uptime_seconds: 4201,
runtime: {
platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64',
statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3',
certificate_runtime: { status: 'ready', mode: 'managed', message: 'acme.runtime_ready',
python: true, pip: true, certbot: true, python_version: '3.13.5', pip_version: '25.2',
certbot_version: '5.8.0', managed_directory: '/var/lib/rustproxy/acme-python' }
},
system: { logical_cpus: 8, cpu_percent: 17.2, memory_total_bytes: 16_384, memory_used_bytes: 8_192,
process_rss_bytes: 2_048, system_uptime_seconds: 100, process_threads: 9, open_fds: 67 }
};
const context = {
latestStatus: status, $: dollar, locale: () => 'en-US',
t: key => ({ runtimeAvailable: 'Available', runtimeUnavailable: 'Unavailable',
certificateRuntimeManaged: 'App-managed Python', 'acme.runtime_ready': 'Ready', unavailable: 'N/A' })[key] || key,
bytes: value => `${value} B`, uptime: value => `${value}s`,
};
vm.runInNewContext(code, context);
assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13');
assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0');
assert.equal(dollar('runtime-version').textContent, '0.32.1');
assert.equal(dollar('runtime-logical-cpus').textContent, '8');
assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%');
assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5');
assert.equal(dollar('runtime-pip').textContent, 'Available · 25.2');
assert.equal(dollar('runtime-certbot').textContent, 'Available · 5.8.0');
assert.equal(dollar('runtime-acme-directory').textContent, '/var/lib/rustproxy/acme-python');
// A working ACME runtime can have no trustworthy version metadata on some OSes.
elements.clear();
vm.runInNewContext(code, { ...context,
latestStatus: { ...status, runtime: { ...status.runtime, certificate_runtime: {
...status.runtime.certificate_runtime, python_version: null, pip_version: null, certbot_version: null
} } }
});
for (const field of ['runtime-python', 'runtime-pip', 'runtime-certbot']) {
assert.equal(dollar(field).textContent, 'Available');
}
// Status fields remain safely unavailable rather than displaying fake zeros.
elements.clear();
vm.runInNewContext(code, { ...context,
latestStatus: { runtime: { certificate_runtime: { status: 'unavailable', mode: 'none',
message: 'acme.runtime_python_missing', python: false, pip: false, certbot: false } }, system: {} }
});
for (const field of ['runtime-cpu-usage', 'runtime-logical-cpus', 'runtime-system-memory', 'runtime-python']) {
assert.ok(!/NaN|0 B/.test(dollar(field).textContent));
}
// Exercise real tab switching, deep-linking and search code without a browser.
const entry = js.slice(js.indexOf(' const buttons ='), js.indexOf(' const cacheTabs ='));
const buttons = ['administration', 'system-info', 'certificate-api'].map(name => ({
dataset: {settingsTab:name}, textContent:name, hidden:false, tabIndex:-1,
listeners:{}, setAttribute(name,value){this[name]=value;}, addEventListener(name,fn){this.listeners[name]=fn;}, focus(){} }));
const pages = buttons.map(b => ({dataset:{settingsPage:b.dataset.settingsTab}, hidden:true,textContent:b.dataset.settingsTab}));
const search={value:'',listeners:{},addEventListener(name,fn){this.listeners[name]=fn;}};
const searchStatus={textContent:''};
const loc = new URL('https://localhost/settings?tab=system-info');
let lastUrl='';
vm.runInNewContext(`(() => { ${entry} return true; })()`, {
document: {querySelectorAll: sel => sel === '[data-settings-tab]' ? buttons : pages,
querySelector: sel => pages.find(p => sel.includes(p.dataset.settingsPage))},
$: id => id === 'settings-search' ? search : searchStatus,
location: loc, history: {state:null,replaceState:(_state,_unused,url) => {lastUrl=url;}},
CSS:{escape:x=>x}, URL, URLSearchParams, t:x=>x,
});
buttons[1].listeners.click();
assert.equal(pages[1].hidden,false);
assert.equal(pages[0].hidden,true);
assert.equal(buttons[1]['aria-selected'],'true');
assert.equal(lastUrl,''); // Already at canonical deep link; no unnecessary history rewrite.
buttons[0].listeners.click();
assert.equal(pages[1].hidden,true);
assert.equal(lastUrl,'/settings?tab=administration');
const runtimeSchema = api.components.schemas.RuntimeInformation;
for (const key of ['operating_system','kernel_version','architecture']) assert.ok(runtimeSchema.properties[key]);
for (const key of ['python_version','pip_version','certbot_version']) {
assert.ok(runtimeSchema.properties.certificate_runtime.properties[key]);
assert.equal(runtimeSchema.properties.certificate_runtime.properties[key].nullable, true);
}
console.log('PASS: 0.31.9 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
@@ -1,41 +0,0 @@
#!/usr/bin/env python3
"""Offline source guard for subprocess-free system information; not a Rust runtime test."""
from pathlib import Path
import tomllib
from rust_source import rust_source
ROOT = Path(__file__).resolve().parents[2]
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
for dependency in ('sysinfo', 'metrics-process'):
assert dependency in manifest['dependencies'], dependency
sysinfo_features = manifest['dependencies']['sysinfo']['features']
assert {'network', 'system'} <= set(sysinfo_features), (
'sysinfo::System and sysinfo::Networks require both system and network features'
)
assert any('systemstat' in target.get('dependencies', {}) for target in manifest.get('target', {}).values())
status = rust_source(ROOT, 'src/admin/status.rs')
assert 'sysinfo::System::long_os_version()' in status
assert 'sysinfo::System::kernel_version()' in status
assert 'std::sync::OnceLock' in status
for forbidden in ('Command::', 'std::process::Command', '/etc/os-release', '/proc/sys/kernel/osrelease'):
assert forbidden not in status, f'Status OS identification cannot use {forbidden}'
metadata = (ROOT / 'src/acme/runtime_versions.rs').read_text()
for marker in ('pyvenv.cfg', '.dist-info', 'METADATA', 'MAX_VERSION_METADATA_BYTES', 'MAX_PACKAGES_PER_SITE', 'fn runtime_versions('):
assert marker in metadata, marker
for forbidden in ('Command::', '.spawn(', 'version_token(', '"--version"', "'-c'", '.await'):
assert forbidden not in metadata, f'Diagnostic versions must not execute: {forbidden}'
runtime = (ROOT / 'src/acme/runtime_env.rs').read_text()
assert 'include!("runtime_versions.rs")' in runtime
assert 'runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed)))' in runtime
assert 'python_version: versions.python' in runtime and 'certbot_version: versions.certbot' in runtime
assert 'path.with_extension("exe")' in runtime, 'Windows executable discovery must include .exe'
regressions = (ROOT / 'src/acme/tests.rs').read_text()
for marker in ('managed_python_pip_and_certbot_versions_use_metadata_only',
'absent_or_unverified_versions_are_not_invented',
'conflicting_site_distributions_do_not_return_stale_versions'):
assert marker in regressions, marker
print('PASS: shared Rust libraries, portable OS detection, no diagnostic subprocesses, bounded metadata and source regressions.')
-147
View File
@@ -1,147 +0,0 @@
#!/usr/bin/env python3
"""Offline source contracts for RustProxy 0.32.7 upstream protocols and card registry.
These assertions supplement, rather than replace, Rust ALPN, H2C and websocket
runtime tests. No code execution against a RustProxy server is implied.
"""
import json
import re
from pathlib import Path
root=Path(__file__).resolve().parents[2]
read=lambda name:(root/name).read_text(encoding='utf-8')
registry=json.loads(read('src/admin/web/dashboard-widgets.json'))
ids=[widget['id'] for widget in registry]
assert len(ids)==len(set(ids)) and len(ids)>=16
assert all(re.fullmatch(r'[a-z0-9_]+',id) for id in ids)
html=read('src/admin/web/index.html')
html_ids=re.findall(r'<article\b[^>]*\bdata-dashboard-widget="([a-z0-9_]+)"',html)
assert len(html_ids)==len(set(html_ids)), 'duplicate Overview widget element'
assert set(ids)==set(html_ids), f'Registry diverges from rendered Overview cards: {set(ids)^set(html_ids)}'
assert ids==html_ids, 'registry should follow the default displayed card order'
for language in ['en_US','pl_pl']:
translations=json.loads(read(f'langs/{language}.json'))['translations']
assert 'Auto' in translations['vhProtocolAuto']
assert 'H2' in translations['vhProtocolHttp2']
assert 'H2' in translations['vhProtocolH2Strict']
assert all(key in translations for key in (
'vhProtocolCompatibilityWarning', 'vhProtocolAutoHttpsDetails', 'vhProtocolH2HttpDetails',
'vhProtocolLegacyHttp2HttpsDetails', 'vhProtocolLegacyHttp2HttpDetails',
'backendWidgetModeCompat', 'backendWidgetModeStrict',
'backendWidgetRequests', 'backendWidgetCapacity'))
for widget in registry:
assert widget['label_key'] in translations, (language,widget['label_key'])
if widget['description_key'] is not None:
assert widget['description_key'] in translations
for widget in registry:
assert widget['element_selector']==f'[data-dashboard-widget="{widget["id"]}"]'
assert widget['data_source'] in ('status_metrics','stream_status','backend_status','system_status','cache_status')
assert widget['no_data']=='neutral_dash'
assert isinstance(widget['default_hidden'],bool)
assert widget['category'] in ('overview','streams','backends','performance','panels')
assert {v['id'] for v in registry if v['default_hidden']}=={
'stream_overview','stream_traffic','stream_sessions','stream_backend_health',
'backend_health','backend_activity','backend_errors','backend_latency',
'backend_traffic','backend_protocols','backend_requests','backend_capacity',
'backend_http5xx','backend_timeouts','backend_network','backend_retries','backend_queue','backend_rotations',
'extra_process_cpu','extra_process_rss','extra_process_fds','extra_open_connections',
'extra_websocket_active','extra_tls_error_rate','extra_request_error_rate',
'extra_cache_hit_rate','extra_cache_memory','extra_h2_share','extra_response_latency',
'extra_backend_unhealthy',
'extra_total_rps', 'extra_active_exchanges', 'extra_h1_share', 'extra_h3_share',
'extra_cache_miss_rate', 'extra_backend_active',
'wide_protocol_rates', 'wide_cpu', 'wide_memory', 'wide_cache_activity',
'wide_connections', 'wide_backend_health', 'wide_latency'
}
backend=read('src/config/model.rs')
client=read('src/upstream/client.rs')
# Existing saved `http2` is NOT strict on HTTPS: only new `h2` is.
assert re.search(r'enum UpstreamProtocol\s*\{\s*#\[default\]\s*Http1,',backend)
for name in ['Http2,', 'Auto,', 'H2,']:
assert name in backend
assert 'is_https && backend.protocol == UpstreamProtocol::Http2' in client
assert 'let protocol = if websocket {' in client and 'UpstreamProtocol::Http1' in client
assert 'if !is_https && protocol == UpstreamProtocol::Auto' in client
assert 'UpstreamProtocol::Http1 => builder.enable_http1().wrap_connector(http)' in client
assert re.search(r'UpstreamProtocol::Http2 \| UpstreamProtocol::H2 => \{?\s*builder\.enable_http2\(\)\.wrap_connector\(http\)',client)
assert 'UpstreamProtocol::Auto => builder.enable_http1().enable_http2().wrap_connector(http)' in client
assert re.search(r'\.http2_only\(matches!\(\s*protocol,\s*UpstreamProtocol::Http2 \| UpstreamProtocol::H2\s*\)\s*\)',client)
assert 'backend.origin.starts_with("http://")' in read('src/proxy/types.rs')
assert 'UpstreamProtocol::Http2 | crate::config::UpstreamProtocol::H2' in read('src/proxy/forward/upstream.rs')
assert '.retry_canceled_requests(false)' in client
assert '.pool_idle_timeout(' in client and '.pool_max_idle_per_host(' in client
assert 'generation_requests' in client and 'rotating' in client
assert 'include!("protocol_tests.rs");' in read('src/upstream/tests.rs')
protocol_tests=read('src/upstream/protocol_tests.rs')
for test in ['https_auto_chooses_h2_by_alpn_without_replaying_request',
'https_auto_negotiates_http1_before_sending_to_h1_only_peer',
'saved_legacy_https_http2_keeps_h1_fallback',
'saved_legacy_https_http2_still_prefers_h2',
'explicit_https_h2_requires_h2_and_refuses_h1_only_peer',
'explicit_https_h2_selects_h2_via_alpn',
'explicit_https_http1_does_not_offer_h2_to_h2_capable_peer',
'websocket_isolated_client_stays_on_http1_even_with_h2_backend',
'cleartext_auto_does_not_attempt_unsafe_h2c_probe',
'default_protocol_and_serialization_are_backward_compatible']:
assert f'fn {test}' in protocol_tests, test
assert 'fn explicit_h2c_backend_uses_http2_prior_knowledge' in read('src/upstream/tests.rs')
assert 'fn upstream_request_version(' in read('src/proxy/types.rs')
assert 'fn legacy_https_http2_version_is_h1_safe_and_strict_h2_is_required' in read('src/proxy/tests/authority_tests.rs')
server=read('src/admin/preferences_columns.rs')
api=read('src/admin/preferences.rs')
router=read('src/admin/router.rs')
js=read('src/admin/web/app/preferences.js')
assert 'include_str!("web/dashboard-widgets.json")' in server
assert 'include_str!("web/dashboard-widgets.json")' in router
assert 'const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(' in router
assert 'ADMIN_DASHBOARD_WIDGET_REGISTRY.map(widget => widget.id)' in js
assert 'ADMIN_DASHBOARD_WIDGET_REGISTRY.filter(widget => widget.default_hidden)' in js
assert 'ADMIN_DASHBOARD_BY_ID.get(id)?.description_key' in js
assert 'reconcile_dashboard_widgets(&mut preferences);' in api
assert 'prefs.dashboard_widgets.retain(' in api and 'prefs.dashboard_hidden_widgets.retain(' in api
assert 'fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids' in read('src/admin/preferences_tests.rs')
assert 'include!("preferences_tests.rs");' in read('src/admin/tests.rs')
assert 'item.insert("protocol".into(), json!(m.definition.protocol));' in read('src/balance/pool_view.rs')
assert 'include_str!("web/app/backend-dashboard.js")' in router
assert 'window.rustProxyBackendDashboard?.render(s)' in read('src/admin/web/app/core.js')
assert 'window.rustProxyBackendDashboard?.syncVisibility' in js
assert 'window.rustProxyBackendDashboard?.reset()' in read('src/admin/web/app/core.js')
assert 'vhProtocolCompatibilityWarning' in read('src/admin/web/vhosts/protocol.js')
assert '["h2", "vhProtocolH2Strict"]' in read('src/admin/web/vhosts/core.js')
assert 'backendWidgetRequestsTotal' in read('src/admin/web/app/backend-dashboard.js')
assert 'backendWidgetCapacityLimits' in read('src/admin/web/app/backend-dashboard.js')
assert 'include_str!("web/app/dashboard-layout-editor.js")' in router
assert 'include_str!("web/app/dashboard-inline-layout.js")' in router
assert all(widget.get('display_type') in {'value','value_chart','chart','panel','table'} for widget in registry)
assert 'dashboardWidgetTypeBadge' in js
assert 'overview-layout-unlock' in html and 'overview-layout-type-filter' in html
assert 'dashboard_widgets: [...draft.order]' in read('src/admin/web/app/dashboard-layout-editor.js')
assert 'dashboard_hidden_widgets: [...draft.hidden]' in read('src/admin/web/app/dashboard-layout-editor.js')
assert 'overview-layout-dialog' in html and 'overview-layout-open' in html
assert 'tls_errors_total' in read('src/admin/web/app/backend-dashboard.js')
assert len(registry) == 62
assert len([w for w in registry if w['category'] == 'panels']) == 14
assert 'include_str!("web/app/overview-extra-metrics.js")' in router
assert 'include_str!("web/app/overview-wide-metrics.js")' in router
assert 'include_str!("web/app/overview-masonry.js")' in router
assert 'window.rustProxyExtraOverview?.render(s)' in read('src/admin/web/app/core.js')
assert all(f'id="{id}"' in html for id in ('dashboard-overview-grid','overview-layout-default','overview-layout-reset','overview-reset-confirm'))
openapi = json.loads(read('src/admin/openapi.json'))
assert openapi['components']['schemas']['VhostBackend']['properties']['protocol']['enum'] == ['http1', 'http2', 'auto', 'h2']
prefs_schema = openapi['components']['schemas']['AdminPreferences']['properties']
for field in ('dashboard_widgets', 'dashboard_hidden_widgets'):
schema = prefs_schema[field]
assert schema['maxItems'] == len(registry), field
assert schema['items']['enum'] == ids, field
expected = ids if field == 'dashboard_widgets' else [widget['id'] for widget in registry if widget['default_hidden']]
assert schema['default'] == expected, field
assert '"version": "0.32.7"' in read('src/admin/openapi.json')
assert 'version = "0.32.7"' in read('Cargo.toml')
assert 'version = "0.32.7"' in read('Cargo.lock')
print(f'PASS: 0.32.7 upstream H1/Auto/compatible-HTTP2/strict-H2 source contracts and {len(ids)} registered Overview Cards')
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env python3
"""Keep executable release identity derived from the Cargo package version."""
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from scripts.version import project_version
root = Path(__file__).resolve().parents[2]
version = project_version(root)
for name in ('src/main/bootstrap.rs','src/main/tasks.rs','src/web_server/fastcgi/protocol.rs','src/web_server/response/file.rs','dev.sh','scripts/deploy/upgrade.py','make_zip.py'):
text = (root/name).read_text(encoding='utf-8')
assert f'RustProxy {version}' not in text and f'RustProxy/{version}' not in text, f'hardcoded runtime version: {name}'
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/web_server/fastcgi/protocol.rs').read_text()
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/web_server/response/file.rs').read_text()
assert '__RUSTPROXY_ASSET_ID__' in (root/'src/admin/web/index.html').read_text()
assert 'OnceLock<PanelContent>' in (root/'src/admin/public.rs').read_text()
assert 'PanelContent::new' in (root/'src/admin/public.rs').read_text()
assert 'preferred_encoding' in (root/'src/admin/panel_content.rs').read_text()
assert 'include_str!("web/index.html")' in (root/'src/admin/public.rs').read_text()
assert 'super::asset_build_id()' in (root/'src/admin/public.rs').read_text()
assert 'admin_index.html' not in (root/'build.rs').read_text()
assert 'project_version' in (root/'make_zip.py').read_text()
print(f'PASS: executable version comes from Cargo.toml ({version})')
@@ -1,95 +0,0 @@
#!/usr/bin/env node
// 0.29.7 origin error-page interception form roundtrip and input validation.
// Browser DOM is simulated; this is NOT a Rust/protocol integration test.
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync('src/admin/web/management.js', 'utf8');
const start = source.indexOf(' function vhostEditor(');
const end = source.indexOf(' let global = null;', start);
if (start === -1 || end <= start) throw new Error('Missing current vhost pages form');
const codes = [400,401,403,404,408,413,429,500,502,503,504];
const check = (valid, message) => { if (!valid) throw new Error(message); };
class Element {
constructor(tag='div', text='') {
this.tag = tag;
this.textContent = text;
this.value = '';
this.checked = false;
this.disabled = false;
this.children = [];
this.listeners = new Map();
}
append(...nodes) { this.children.push(...nodes); }
addEventListener(name, callback) { this.listeners.set(name, callback); }
dispatch(name) { this.listeners.get(name)?.(); }
}
const el = (tag, text) => new Element(tag, text);
const label = (tag, key) => new Element(tag, key);
const pageEditor = (parent, label, initial) => { parent.append(el('div', label)); return {read:()=>initial||null}; };
const errorsEditor = (parent, initial) => { parent.append(el('div')); return {read:()=>initial||{}}; };
const context = {
pageEditor, errorsEditor, el, label, codes,
document: { createElement: tag => el(tag) },
t: key => key,
};
vm.createContext(context);
vm.runInContext(`${source.slice(start,end)}\nthis.makeEditor = vhostEditor;`,context);
const build = values => {
const parent = el('div');
const editor = context.makeEditor(parent, values);
const section = parent.children[2];
check(section.tag === 'section', 'Missing origin interception section');
const input = section.children[0].children[0];
const statuses = section.children[1].children[1];
return {editor, input, statuses, section};
};
const prior = build({});
check(!prior.input.checked && prior.statuses.disabled, 'Interception must default off');
check(JSON.stringify(prior.editor.read().intercept_statuses) === '[]', 'Old config must remain unchanged');
prior.input.checked = true; prior.input.dispatch('change');
check(!prior.statuses.disabled, 'Status list must enable with checkbox');
check(JSON.stringify(prior.editor.read().intercept_statuses) === '[404]', 'Default status must be HTTP 404');
prior.statuses.value = '404, 500, 503';
check(JSON.stringify(prior.editor.read().intercept_statuses) === '[404,500,503]', 'Explicit statuses lost');
for (const invalid of ['302', '404,404', '404.0', '0x194', 'abc', '', '200', '600']) {
prior.statuses.value = invalid;
let rejected = false;
try { prior.editor.read(); } catch { rejected = true; }
check(rejected, `Invalid interception status should be rejected: ${invalid}`);
}
prior.input.checked = false; prior.input.dispatch('change');
check(prior.statuses.disabled && prior.editor.read().intercept_statuses.length===0,'Disabling interception must clear saved statuses');
const saved = build({intercept_statuses:[401,404],errors:{404:{kind:'html',status:null,html:'local'}}});
check(saved.input.checked && !saved.statuses.disabled && saved.statuses.value==='401, 404','Existing interception settings not restored');
check(saved.editor.read().errors['404'].html==='local', 'Custom error page lost');
const rust = fs.readFileSync('src/proxy/error_fallback.rs','utf8');
check(rust.includes('if route.internal {\n return response;'), 'Internal fallback response is not protected from interception');
const web = fs.readFileSync('src/proxy/forward/web_source.rs','utf8');
const proxy = fs.readFileSync('src/proxy/forward/response.rs','utf8');
check(web.includes('let mut response = intercept_route_origin_error(response, route, &snapshot);'), 'Web Server origin interception not in response path');
const prepared = fs.readFileSync('src/proxy/forward/success_prepare.rs','utf8');
check(proxy.includes('include!("success_prepare.rs")'), 'Reverse Proxy response preparation missing');
check(!proxy.includes('let response = intercept_route_origin_error(response, route, &snapshot);'), 'Do not convert Incoming body before HTTP/2 WebSocket setup');
check(prepared.includes('response = intercept_route_origin_error(response, route, &snapshot);'), 'Origin error interception missing after body adaptation');
const marker = fs.readFileSync('src/proxy/error_fallback.rs','utf8');
check(marker.includes('response.extensions_mut().insert(crate::pages::OriginProvidedResponse)'), 'Origin responses must be marked for the outer observer');
const observer = fs.readFileSync('src/proxy/upstream_helpers.rs','utf8');
check(/fn should_replace_error_page\(/.test(observer) && /get::<crate::pages::OriginProvidedResponse>\(\)\s*\.is_none\(\)/.test(observer) && /if should_replace_error_page\(&response, minimal_welcome\)/.test(observer), 'Outer response observer must not unconditionally replace origin 404/5xx');
const pages = fs.readFileSync('src/pages.rs','utf8');
check(pages.includes('body_only.status = None;'), 'Application error interception must preserve the upstream HTTP code even when page overrides status');
const interceptPosition = prepared.indexOf('response = intercept_route_origin_error(response, route, &snapshot);');
check(interceptPosition > prepared.indexOf('Response::from_parts(parts, Body::new(body))'), 'Origin interception must operate on Response<Body>');
check(interceptPosition < prepared.indexOf('response.extensions_mut().insert(ResponseRulesApplied)'), 'Intercepted origin response must receive final response metadata');
check(prepared.includes('websocket::wrap_h2_response_body(body, session, exchange)'), 'HTTP/2 WebSocket tunnel body must remain compatible with Incoming');
const reappliedRules = prepared.indexOf('apply_response_rules(', interceptPosition);
const errorNoStore = prepared.indexOf('HeaderValue::from_static("no-store")', interceptPosition);
check(reappliedRules > interceptPosition && errorNoStore > reappliedRules, 'Intercepted pages must reapply header policy and then force no-store');
const healthLoop = fs.readFileSync('src/balance/lease_runtime.rs','utf8');
check(healthLoop.includes('checks.spawn(probe(Arc::clone(member), health.clone()))'), 'Health probe must take an owned Arc<Member>');
const html = fs.readFileSync('src/admin/web/index.html','utf8');
check(html.includes('id="php-fpm-profiles-help"') && html.includes('id="php-help-dialog"'), 'Shared PHP-FPM profiles lack help modal');
for (const key of ['phpDocsScope','phpDocsValuesText','phpDocsAdminText','phpHintPathInfo']) {
check(html.includes(`data-i18n="${key}"`), `PHP help does not explain ${key}`);
}
console.log('PASS: application error interception form default/roundtrip/validation, internal fallback protection, PHP help access.');
@@ -1,99 +0,0 @@
#!/usr/bin/env python3
"""Render real Vhost cards with mock config/status to verify badge placement/color.
Headless Chromium fixture only: does not verify authenticated Rust runtime/ALPN.
"""
from pathlib import Path
import shutil
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
source = (WEB / 'vhosts/render.js').read_text()
start = source.index('const VHOST_PROTOCOL_FRESH_MS = ')
end = source.index('function backendTrafficRate(', start)
renderer = source[start:end]
css = (WEB / 'style.css').read_text()
html = (f'<!doctype html><html data-theme="ultra_dark" data-corner-style="rounded">'
f'<head><meta name="viewport" content="width=device-width,initial-scale=1">'
f'<style>{css}</style></head><body><section id="panel-vhosts">'
f'<div id="backend-diagnostics-card"></div><input id="vhost-search">'
f'<div id="vhost-list"></div><span id="vhost-editing"></span>'
f'</section></body></html>')
stubs = r'''
const $ = id => document.getElementById(id);
const t = key => key;
const locale = () => 'en-US';
const el = (tag, value, cls) => {
const node=document.createElement(tag);
if (value !== undefined) node.textContent=value;
if (cls) node.className=cls;
return node;
};
const text = (tag, key, cls) => el(tag, t(key), cls);
const action = (name, fn) => {
const btn=el('button', name, 'quiet');
btn.addEventListener('click', fn);
return btn;
};
const button = (name, fn, cls) => {
const btn=action(name, fn);
if (cls) btn.className=cls;
return btn;
};
const initialized=true;
let editing=null, latestStatus=null;
const vhostListPrefs={sort:'id',descending:false};
const envelope={config:{listeners:{endpoints:[]},vhosts:[
{id:'proxy-test',enabled:true,frontend:{hosts:['proxy.example'],listener_ids:[]},
source:{type:'reverse_proxy'},force_ssl:true,websockets:true,
backends:[{id:'origin1',protocol:'auto'}, {id:'origin2',protocol:'http1'}]},
{id:'static-test',enabled:true,frontend:{hosts:['static.example'],listener_ids:[]},
source:{type:'web_server',web_service_id:'public'},
backends:[{id:'unused',protocol:'auto'}]},
]}};
'''
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
for width in (393, 1024):
page = browser.new_page(viewport={'width': width, 'height': 844})
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=stubs + renderer + '\nrender();')
initial = page.evaluate('''() => {
const card=document.querySelector('[data-vhost-card="proxy-test"]');
const badge=card.querySelector('.vhost-backend-protocol-badge');
const group=card.querySelector('.vhost-policy-pills');
const staticCard=document.querySelector('[data-vhost-card="static-test"]');
return {badges:group.querySelectorAll('.vhost-backend-protocol-badge').length,
otherPills:group.querySelectorAll('.pill').length,
parent:badge.parentElement===group,
lastChild:card.lastElementChild===group,
separateRows:card.querySelectorAll('.vhost-backend-protocols').length,
staticBadges:staticCard.querySelectorAll('.vhost-backend-protocol-badge').length,
initialText:badge.textContent,
scroll:document.documentElement.scrollWidth,screen:innerWidth};
}''')
assert initial['badges'] == 2 and initial['otherPills'] == 2, initial
assert initial['parent'] and initial['lastChild'], initial
assert initial['separateRows'] == 0 and initial['staticBadges'] == 0, initial
assert initial['initialText'] == 'origin1 · AUTO → ?', initial
assert initial['scroll'] <= initial['screen'] + 2, initial
appearance = page.evaluate('''() => {
const badge=document.querySelector('[data-upstream-protocol-backend="origin1"]');
const expected=token=>{const n=document.createElement('span');n.style.color=`var(${token})`;
document.body.append(n);const c=getComputedStyle(n).color;n.remove();return c;};
const unknown=getComputedStyle(badge).color;
renderUpstreamProtocolBadges({backend_pools:[{id:'proxy-test',members:[
{id:'origin1',upstream_protocol_observed:'h2',upstream_protocol_last_seen_ms:Date.now()-1000},
{id:'origin2',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:Date.now()-1000}
]}]});
const h2={text:badge.textContent,observed:badge.dataset.observed,
color:getComputedStyle(badge).color,expected:expected('--green')};
return {unknown, expectedUnknown:expected('--muted'), h2};
}''')
assert appearance['unknown'] == appearance['expectedUnknown'], (width, appearance)
assert appearance['h2']['color'] == appearance['h2']['expected'], (width, appearance)
assert appearance['h2']['observed'] == 'true' and appearance['h2']['text'] == 'origin1 · AUTO → H2', appearance
print(f'PASS: {width}px Vhost protocol badges share policy group and preserve green/neutral states')
page.close()
browser.close()
-103
View File
@@ -1,103 +0,0 @@
#!/usr/bin/env node
// Exercise the real vhost WWW-routing editor code with a small DOM stand-in.
// This is a browser-UI state test, not a Rust request-routing test.
import fs from 'node:fs';
import vm from 'node:vm';
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
const first = editor.indexOf('const wwwPanel = el("section", undefined, "vh-wide vh-www-panel");');
const last = editor.indexOf('field(g, "forward-client-ip",', first);
if (first === -1 || last === -1) throw new Error('WWW editor component missing');
const source = editor.slice(first, last);
if (editor.indexOf('readHosts = hostTagEditor(g, value.frontend?.hosts') < editor.indexOf('g = group("vhFrontends")')) {
throw new Error('Host chips must be in the same Frontends tab as WWW routing');
}
class ClassList {
constructor() { this.names = new Set(); }
add(name) { this.names.add(name); }
toggle(name, active) { if (active) this.names.add(name); else this.names.delete(name); }
contains(name) { return this.names.has(name); }
}
class Element {
constructor(tag = 'div', text = '', css = '') {
this.tag = tag;
this.textContent = text || '';
this.classList = new ClassList();
if (css) for (const name of css.split(' ')) this.classList.add(name);
this.children = [];
this.hidden = false;
this.checked = false;
this.value = '';
this.attrs = {};
this.listeners = new Map();
}
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
prepend(...children) { for (const child of [...children].reverse()) { child.parent = this; this.children.unshift(child); } }
replaceChildren(...children) { this.children = []; this.append(...children); }
closest(tag) { let node = this.parent; while (node && node.tag !== tag) node = node.parent; return node; }
setAttribute(key, value) { this.attrs[key] = String(value); }
addEventListener(event, listener) { this.listeners.set(event, listener); }
dispatch(event) { this.listeners.get(event)?.(); }
}
const el = (tag, content, css) => new Element(tag, content, css);
const t = key => key === 'vhWwwSharedCount' ? '{count} names / one vhost' : key;
const text = (tag, key, css) => el(tag, t(key), css);
const g = new Element();
const hosts = new Element('textarea');
hosts.value = 'suba.local\nsubb.local\n*.local';
const read = name => { if (name !== 'hosts') throw new Error(name); return hosts; };
const lines = raw => raw.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
let changes = 0;
const changed = () => { changes++; };
const value = { www_redirect: false, www_redirect_target: 'subb.local' };
const document = { createElement: tag => new Element(tag) };
const field = (parent, name, key, initial, type) => {
const label = new Element('label');
const input = new Element('input');
input.name = name;
input.type = type;
input.checked = Boolean(initial);
label.append(input, text('span', key));
parent.append(label);
return input;
};
let refreshCanonical = () => {};
const context = { el, text, field, g, t, value, read, lines, changed, document, refreshCanonical };
const component = vm.runInNewContext(`(() => { ${source}\nreturn { wwwPanel, wwwRedirect, wwwTarget, wwwChoices, wwwDetails, wwwInactive, wwwOther, wwwNoHosts, wwwHostCount, syncCanonical }; })()`, context);
const check = (condition, message) => { if (!condition) throw new Error(message); };
const choice = (baseIndex, directionIndex) => component.wwwChoices.children[baseIndex].children[1].children[directionIndex];
const route = (baseIndex, directionIndex) => choice(baseIndex, directionIndex).children[1].children[1].textContent;
check(!component.wwwDetails.hidden && !component.wwwInactive.hidden, 'Direction preview should be visible even when redirect is disabled');
check(component.wwwTarget.value === 'subb.local', 'Saved canonical domain was lost');
check(component.wwwChoices.children.length === 2, 'Wildcard must not become a canonical pair');
check(component.wwwHostCount.textContent === '3 names / one vhost', 'Shared host count not updated');
check(route(0, 0) === 'www.suba.local → suba.local', 'Incorrect www -> non-www preview');
check(route(0, 1) === 'suba.local → www.suba.local', 'Incorrect non-www -> www preview');
check(route(1, 0) === 'www.subb.local → subb.local', 'Incorrect second domain preview');
check(choice(1, 0).classList.contains('is-selected'), 'Saved target not visibly selected');
component.wwwRedirect.checked = true;
component.wwwRedirect.dispatch('change');
check(!component.wwwDetails.hidden && component.wwwInactive.hidden, 'Switch did not reveal WWW routing choices');
check(component.wwwRedirect.attrs['aria-expanded'] === 'true', 'Toggle accessibility state not updated');
check(component.wwwOther.children.some(child => child.textContent === 'suba.local'), 'Non-canonical alias must remain available');
check(component.wwwOther.children.some(child => child.textContent === '*.local'), 'Wildcard alias must remain available');
choice(0, 1).children[0].checked = true;
choice(0, 1).children[0].dispatch('change');
check(component.wwwTarget.value === 'www.suba.local', 'WWW destination selected without a separately declared www host was lost');
check(choice(0, 1).classList.contains('is-selected'), 'Selected direction not styled');
check(changes === 2, 'Toggle and radio change did not mark editor dirty');
hosts.value = 'subb.local\nwww.subb.local\n*.local';
context.refreshCanonical();
check(component.wwwTarget.value === 'subb.local', 'Deleting selected domain did not choose a valid fallback');
check(component.wwwChoices.children.length === 1, 'Duplicate base and www domains should be one pair');
check(route(0, 1) === 'subb.local → www.subb.local', 'Wrong preview for canonical www host');
choice(0, 1).children[0].checked = true;
choice(0, 1).children[0].dispatch('change');
check(component.wwwTarget.value === 'www.subb.local', 'Explicit WWW target not serialized');
check(component.wwwOther.children.every(child => child.textContent !== 'www.subb.local'), 'Redirected companion must not appear as a non-redirected alias');
hosts.value = '*.local';
context.refreshCanonical();
check(component.wwwNoHosts.hidden === false && component.wwwTarget.value === '', 'Wildcard-only configurations need a useful empty state');
check(component.wwwRedirect.disabled && !component.wwwRedirect.checked, 'Wildcard-only configuration must disable redirect');
console.log('PASS: WWW-routing UI two-way choices, implicit www target, shared aliases, dynamic host edits, wildcard exclusion and empty state.');
-33
View File
@@ -1,33 +0,0 @@
from pathlib import Path
APP_PARTS = ("core.js", "session-resume.js", "refresh.js", "rate-sampling.js", "preferences.js", "dashboard-layout-editor.js", "dashboard-inline-layout.js", "stream-dashboard.js", "backend-dashboard.js", "config-history.js", "runtime.js", "settings.js")
PANEL_PARTS = (
"management.js", "drafts.js", "integrations.js", "vhosts.js",
"web-server-profiles.js", "web-server.js", "docker.js", "streams.js",
"redirects.js", "host404.js", "traffic-panel.js", "cache-panel.js",
"transform-panel.js", "acme.js", "settings.js", "certificate-api.js", "file-logs.js",
)
VHOST_PARTS = ("core.js", "protocol.js", "health.js", "rates.js", "editor.js", "render.js")
def _read(root: Path, folder: str, parts: tuple[str, ...]) -> str:
return "\n".join((root / "src/admin/web" / folder / part).read_text(encoding="utf-8") for part in parts)
def read_vhosts_js(root: Path) -> str:
body = _read(root, "vhosts", VHOST_PARTS)
return "(() => {\n" + body + "\n})();"
def read_app_js(root: Path) -> str:
# Mirror the Rust include_str! assembly including the server-validated
# widget registry, so QA parses the same core used by the real panel.
parts = []
for part in APP_PARTS:
if part == "preferences.js":
registry = (root / "src/admin/web/dashboard-widgets.json").read_text(encoding="utf-8")
parts.append(f"const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze({registry});")
parts.append((root / "src/admin/web/app" / part).read_text(encoding="utf-8"))
primary = "\n".join(parts)
panel = "\n".join(
read_vhosts_js(root) if name == "vhosts.js" else (root / "src/admin/web" / name).read_text(encoding="utf-8")
for name in PANEL_PARTS
)
return primary + "\n" + panel
@@ -1,31 +0,0 @@
#!/usr/bin/env python3
"""Rust-independent structural checks; the Rust tests require Cargo."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
source = (ROOT / 'src/proxy/forward/web_source.rs').read_text()
helper = (ROOT / 'src/proxy/web_cache_revalidation.rs').read_text()
tests = (ROOT / 'src/proxy/web_cache_revalidation/tests.rs').read_text()
assert 'mod web_cache_revalidation;' in (ROOT / 'src/proxy/mod.rs').read_text()
assert 'state.cache.revalidation_tiered(ticket).await' in source
assert '!cached.reusable_for_request(&request_headers)' in source
assert 'web_origin_request_headers(' in source
assert 'revalidation_sent && response.status() == StatusCode::NOT_MODIFIED' in source
assert 'finish_web_revalidation(' in source
assert 'state.cache.discard(ticket)' in source
assert 'cache.publish_revalidated(' in helper
assert 'cache.ticket_current(ticket)' in helper
assert 'cache.discard(ticket)' in helper
assert 'origin_safe' in helper
assert 'cached.apply_validators(&mut headers)' in helper
assert 'headers.remove("if-none-match")' in helper
assert 'headers.remove("if-modified-since")' in helper
for name in (
'browser_refresh_304_preserves_web_server_hit_counter',
'browser_conditional_refresh_returns_304_without_resetting_hits',
'mismatched_browser_validator_cannot_validate_different_cached_object',
'range_and_head_requests_use_validated_cached_body',
'changed_local_file_is_not_falsely_revalidated',
'304_new_private_policy_discards_cached_representation',
):
assert f'async fn {name}()' in tests
print('PASS: RustProxy 0.32.2 Web Server revalidation wiring and Rust regression contracts (static only)')
-219
View File
@@ -1,219 +0,0 @@
#!/usr/bin/env python3
"""Rust-independent 0.25 Web Server architecture/security contract checks."""
from pathlib import Path
import json
from rust_source import rust_source, rust_tree
from web_assets import read_app_js, read_vhosts_js
ROOT = Path(__file__).resolve().parents[2]
def need(text: str, *markers: str) -> None:
for marker in markers:
assert marker in text, marker
config = rust_source(ROOT, "src/config.rs")
vhost = rust_source(ROOT, "src/vhost.rs")
web_dir = ROOT / "src/web_server"
assert not (ROOT / "src/web_server.rs").exists(), "Web Server monolith must not coexist with src/web_server/mod.rs"
for module in ["mod.rs", "config.rs", "filesystem.rs", "security.rs", "response.rs", "request.rs", "routing.rs", "fastcgi.rs", "serve.rs", "htaccess.rs", "tests.rs"]:
assert (web_dir / module).is_file(), module
web = rust_tree(ROOT, "src/web_server")
proxy = rust_source(ROOT, "src/proxy/mod.rs")
admin = rust_source(ROOT, "src/admin/mod.rs")
ui = read_vhosts_js(ROOT)
web_ui = (ROOT / "src/admin/web/web-server.js").read_text() + "\n" + (ROOT / "src/admin/web/web-server-profiles.js").read_text()
app_ui = read_app_js(ROOT)
html = (ROOT / "src/admin/web/index.html").read_text()
settings_ui = (ROOT / "src/admin/web/settings.js").read_text()
logging = rust_source(ROOT, "src/logging.rs")
metrics = rust_source(ROOT, "src/metrics.rs")
acl = rust_source(ROOT, "src/acl.rs")
openapi = json.loads((ROOT / "src/admin/openapi.json").read_text())
need(config,
"pub web_services: Vec<crate::web_server::Service>",
"pub enum CompiledSource",
"CompiledSource::WebServer",
"unknown Web Service",
"web_server_main_source_needs_no_backend_pool",
"web_server_and_reverse_proxy_can_share_one_vhost",
"one_web_service_can_be_reused_by_multiple_vhosts",
"missing_source_fields_remain_reverse_proxy_for_old_configs")
need(vhost,
"pub enum Source",
"WebServer { web_service_id: String }",
"pub source: Source",
"vhost requires at least one backend member for Reverse Proxy sources",
"Web Server location cannot pin a reverse-proxy backend")
# PATH_INFO must be dispatched before `try_files` rewrites a valid
# Roundcube /static.php/skins/... request into the index.php controller.
serve = (web_dir / "serve.rs").read_text()
assert serve.index("if let Ok(components) = path_components(&rewritten_path)") < serve.index("match resolve_try_files(")
assert "hardening_denied_path(service, &script_path)" in serve
assert "htaccess::denied(service.htaccess.as_ref(), &script_path)" in serve
need(web,
"roundcube_static_php_path_info_runs_before_front_controller_try_files",
"without_php_path_info_try_files_keeps_legacy_front_controller_fallback")
need(web,
"pub struct Service",
"pub document_root: PathBuf",
"pub index: Vec<String>",
"pub directory_listing: bool",
"pub mime_types: BTreeMap<String, String>",
"pub default_mime_type: String",
"pub cache_control: Option<String>",
"pub cache_rules: Vec<CacheRule>",
"pub response_headers: BTreeMap<String, String>",
"pub try_files: Vec<String>",
"pub rewrites: Vec<RewriteRule>",
"pub precompressed: Precompressed",
"pub download_extensions: Vec<String>",
"pub symlink_policy: SymlinkPolicy",
"pub php: Php",
"pub htaccess: HtaccessConfig",
"pub hardening: Hardening",
"hardening_blocks_common_probe_paths_and_trace",
"hardening_strips_fingerprint_headers_after_source_rules",
"HTTP_PROXY is a well-known CGI/FastCGI injection vector",
'if name.as_str() == "proxy"',
'"content-length"',
'"proxy-authenticate"',
'"proxy-authorization"',
"mod htaccess",
"htaccess::apply_rewrites",
"htaccess::protected_component",
"htaccess_wordpress_style_front_controller_and_protected_files_work",
"htaccess_directory_index_mime_headers_expires_and_nested_scope_work",
"htaccess_strict_mode_rejects_unsupported_directives",
"htaccess_security_sensitive_unsupported_directives_fail_closed",
"php_source_extensions_are_not_served_without_php_fpm",
"htaccess_php_addhandler_requires_php_fpm",
"FastCgiEndpoint",
"FCGI_BEGIN_REQUEST",
"try_acquire_owned",
"CONTENT_DISPOSITION",
"CONTENT_ENCODING",
"libc::openat",
"libc::O_NOFOLLOW",
"ReaderStream::new(file.take(body_length))",
"IF_NONE_MATCH", "IF_MODIFIED_SINCE", "IF_RANGE", "RANGE",
"StatusCode::NOT_MODIFIED", "StatusCode::PARTIAL_CONTENT", "StatusCode::RANGE_NOT_SATISFIABLE",
"listing_escapes_html_and_encodes_urls",
"root_directory_listing_is_repeatable_across_refreshes",
"index_precedes_listing_and_missing_index_falls_back_to_listing_per_directory",
"mime_overrides_default_mime_and_cache_control_are_applied_to_files",
'HeaderValue::from_static("no-store")',
"builtin_error_response(StatusCode::FORBIDDEN, &method)",
"empty_index_can_go_directly_to_directory_listing",
"env!(\"CARGO_PKG_VERSION\")",
"traversal_and_symlink_escape_are_not_served",
"try_files_rewrites_cache_rules_headers_and_downloads_work",
"precompressed_sidecars_keep_mime_validators_and_range_semantics",
"php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure",
"php_fpm_executes_configured_directory_index",
"php_fpm_failure_returns_gateway_error_instead_of_php_source",
"inside_root_symlink_policy_allows_only_targets_beneath_root",
"secure filesystem confinement currently requires a Unix platform")
need(proxy,
"if let crate::config::CompiledSource::WebServer(service) = &route.source",
"let request_method = request.method().clone()",
"crate::web_server::serve(",
"let request_headers = request.headers().clone()",
"response.extensions_mut().insert(StaticSourceResponse)",
"execute_error_fallback(",
"crate::pages::replace_error",
"state.cache.wrap_with_fill(",
"crate::transform::apply(",
"apply_response_rules(",
"finish_headers(",
"crate::compression::encode(")
assert "crate::web_server::serve(service, &request" not in proxy
basic_identity_pos = proxy.index("basic_identity =")
identity_pos = proxy.index("let identity =")
web_source_pos = proxy.index("CompiledSource::WebServer(service)", basic_identity_pos)
assert basic_identity_pos < web_source_pos
assert identity_pos < web_source_pos
assert web_source_pos < proxy.index("let (backend, backend_lease, affinity_cookie)")
web_source = rust_source(ROOT, "src/proxy/forward/web_source.rs")
# The 0.32.2 local-304 path also applies response rules, before the normal
# 200-origin processing. Enforce order using the latter's distinct call site.
normal_origin_rules = "apply_response_rules(response.headers_mut(),"
assert web_source.index(normal_origin_rules) > web_source.index("let origin_headers =")
assert web_source.index("crate::transform::apply(") > web_source.index(normal_origin_rules)
need(acl, "ambiguous encoded path character", '"/%2e%2e/secret"', '"/a%2fb"', '"/a%5cb"', '"/bad%"')
need(admin, '"/web-server"', '"web-services"', '"vhost_metrics"')
need((ROOT / "src/admin/panel_assets.rs").read_text(), '"/assets/web-server.js"')
need(html, 'data-panel="web-server"', 'id="panel-web-server"', 'id="web-service-dialog"',
'id="web-service-tabs"', 'data-web-service-page="general"', 'data-web-service-page="routing"',
'data-web-service-page="mime"', 'data-web-service-page="cache"', 'data-web-service-page="delivery"',
'data-web-service-page="php"', 'data-web-service-page="security"', 'data-web-service-page="htaccess"',
'id="web-service-htaccess-enabled"', 'id="web-service-htaccess-help"', 'id="htaccess-help-dialog"', 'id="web-service-try-files"',
'id="web-service-cache-rules"', 'id="web-service-precompressed"', 'id="web-service-compression"', 'id="web-service-compression-zstd"', 'id="web-service-compression-br"', 'id="web-service-compression-gzip"', 'id="web-service-compression-deflate"', 'id="web-service-php-enabled"',
'id="web-service-php-pass-authorization"', 'id="web-service-php-profile"',
'id="web-service-php-profiles"', 'id="php-fpm-profiles-dialog"',
'id="web-service-php-body-limit"',
'id="web-service-php-header-limit"', 'id="web-service-php-stderr-limit"',
'id="web-service-php-custom-params"', 'id="web-service-php-values"', 'id="php-help-dialog"',
'id="web-service-symlink-policy"', 'id="web-service-preset"', 'id="web-service-preset-apply"',
'id="web-service-hardening-attacks"', 'id="web-service-hardening-headers"', 'data-logging-settings-tab="outputs"',
'data-logging-settings-tab="format"', 'data-logging-settings-tab="errors"')
need(settings_ui, "selectLoggingTab", 'data-logging-settings-page', 'selectLoggingTab("outputs")')
need(web_ui, 'saveSection("web-services"', "directory_listing", "web_service_id", "mime_types", "default_mime_type",
"cache_control", "cache_rules", "response_headers", "try_files", "rewrites", "precompressed", "compression",
"download_extensions", "symlink_policy", "php", "htaccess", "hardening", "presetConfig", "phpFrontController", "staticCacheRules",
"pass_authorization", "max_request_body_bytes", "max_response_header_bytes", "stderr_log_bytes", "custom_params", "php_values",
"syncHtaccess", "htaccess-help-dialog", "php-help-dialog", "parseFastCgiParams", "parsePhpValues", "validatePhpCustomData", "activateTab", "initTabs", "presetChanged",
'button = el("button", t(key), "quiet")', 'button.dataset.tab = name', 'button.setAttribute("role", "tab")', "vhost-detail",
'el("span", t(key), "small-label")', "if (envelope) render();")
need(config, "pub php_fpm_profiles: Vec<crate::web_server::PhpFpmProfile>", "duplicate PHP-FPM profile id", "unknown PHP-FPM profile")
need(web, "pub struct PhpFpmProfile", "pub php_profile_id: Option<String>", "fastcgi_always_sets_http_host_for_http2_http3_wordpress_requests")
need(proxy, "empty_for_cache(&mut request)", 'request.headers_mut().insert(HOST, original_host.clone())')
need(web_ui, "phpProfileLocal", 'saveSection("php-fpm-profiles"', "function profileEditor(")
need(logging, '"route"', '"cache_effective_enabled"', '"transform_effective_enabled"', 'value["cache_enabled"]', 'value["transform_enabled"]')
need(metrics, "pub struct VhostMetricsView", "ewma_duration_us", "pub fn view(&self) -> VhostMetricsView")
assert 'item.append(text("span"' not in web_ui
need(app_ui, 'function showStartupFailure(error)', '$("login").hidden = true', 'const info = await api("/api/auth/session")')
assert '$("login").hidden = false; document.documentElement.classList.remove("app-loading")' not in app_ui
need(ui, 'type: "web_server"', "web_service_id", "sourceReverseProxy", "vhBackendRequired", "backend-diagnostics-card", "status.vhost_metrics", "vhostTrafficRate")
assert "WebService" in openapi.get("components", {}).get("schemas", {})
assert "PhpFpmProfile" in openapi["components"]["schemas"]
assert "php_fpm_profiles" in openapi["components"]["schemas"]["Config"]["properties"]
web_service_schema = openapi["components"]["schemas"]["WebService"]
assert "php_profile_id" in web_service_schema["properties"]
assert web_service_schema["properties"]["index"].get("minItems", 0) == 0
assert web_service_schema["properties"]["index"]["maxItems"] == 16
assert web_service_schema["properties"]["mime_types"]["maxProperties"] == 64
assert web_service_schema["properties"]["default_mime_type"]["default"] == "application/octet-stream"
assert web_service_schema["properties"]["cache_control"]["nullable"] is True
assert web_service_schema["properties"]["compression"]["$ref"].endswith("/CompressionOverrides")
compression_override_schema = openapi["components"]["schemas"]["CompressionOverrides"]
for prop in ["enabled", "zstd", "br", "gzip", "deflate"]:
assert prop in compression_override_schema["properties"], prop
for prop in ["cache_rules", "response_headers", "try_files", "rewrites", "precompressed", "compression", "download_extensions", "symlink_policy", "php", "htaccess", "hardening"]:
assert prop in web_service_schema["properties"], prop
assert web_service_schema["properties"]["symlink_policy"]["default"] == "deny"
assert web_service_schema["properties"]["php"]["properties"]["enabled"]["default"] is False
php_schema = web_service_schema["properties"]["php"]["properties"]
assert php_schema["pass_authorization"]["default"] is True
assert php_schema["max_request_body_bytes"]["default"] == 0
assert php_schema["max_response_header_bytes"]["default"] == 65536
assert php_schema["stderr_log_bytes"]["default"] == 4096
assert php_schema["custom_params"]["maxProperties"] == 64
assert php_schema["php_values"]["maxProperties"] == 64
assert web_service_schema["properties"]["htaccess"]["properties"]["enabled"]["default"] is False
assert web_service_schema["properties"]["htaccess"]["properties"]["filename"]["default"] == ".htaccess"
assert web_service_schema["properties"]["hardening"]["properties"]["block_common_attacks"]["default"] is False
assert web_service_schema["properties"]["hardening"]["properties"]["strip_unnecessary_headers"]["default"] is False
htaccess_source = rust_source(ROOT, "src/web_server/htaccess.rs")
need(htaccess_source, "MAX_HTACCESS_FILES", "MAX_HTACCESS_TOTAL_BYTES", "RewriteEngine", "RewriteCond", "RewriteRule", "DirectoryIndex", "AddType", "AddHandler", "ExpiresByType", "Require all granted/denied", "general Files/FilesMatch semantics are not supported", "security/handler directive", "apply_response", "apply_rewrites")
assert "Source" in openapi.get("components", {}).get("schemas", {})
assert "client_ips" in openapi["components"]["schemas"]["TrafficCounters"]["properties"]
assert (ROOT / "docs/WEB_SERVER.md").is_file()
assert (ROOT / "docs/PHP_FPM.md").is_file()
assert (ROOT / "docs/TODO.md").is_file()
docs_html = (ROOT / "src/admin/web/docs.html").read_text()
docs_js = (ROOT / "src/admin/web/docs.js").read_text()
need(docs_html, "docs-tab-reader", "docs-reader-content", "docs-tab-api")
need(docs_js, 'get("/docs/content.json")', "renderMarkdown", "selectDocument")
print("PASS: 0.25 Web Server separation, pipeline, filesystem, HTTP, UI/API and regression contracts are present.")
-259
View File
@@ -1,259 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import vm from "node:vm";
class ClassList {
constructor() { this.values = new Set(); }
toggle(name, active) { active ? this.values.add(name) : this.values.delete(name); }
}
class Element {
constructor(id = "") {
this.id = id;
this.value = "";
this.checked = false;
this.disabled = false;
this.hidden = false;
this.open = false;
this.dataset = {};
this.children = [];
this.listeners = new Map();
this.classList = new ClassList();
this.attributes = new Map();
this.tabIndex = 0;
this.textContent = "";
this.type = "";
this.readOnly = false;
this.elements = [];
}
addEventListener(name, callback) { this.listeners.set(name, callback); }
dispatch(name) { return this.listeners.get(name)?.({ target: this, preventDefault() {} }); }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
append(...items) { this.children.push(...items); }
replaceChildren(...items) { this.children = [...items]; }
showModal() { this.open = true; }
close() { this.open = false; }
reset() {}
checkValidity() { return true; }
}
const ids = [
"htaccess-help-close","htaccess-help-dialog","php-help-close","php-help-dialog","web-service-cache-control","web-service-cache-rules",
"web-service-close","web-service-default-mime","web-service-dialog","web-service-download-extensions",
"web-service-editing","web-service-form","web-service-hardening-attacks","web-service-hardening-headers",
"web-service-htaccess-enabled","web-service-htaccess-filename","web-service-htaccess-help","web-service-htaccess-recursive",
"web-service-htaccess-strict","web-service-id","web-service-index","web-service-list","web-service-listing",
"web-service-mime-types","web-service-name","web-service-new","web-service-php-concurrency",
"web-service-php-connect-timeout","web-service-php-enabled","web-service-php-endpoint","web-service-php-extensions",
"web-service-php-request-timeout","web-service-php-pass-authorization","web-service-php-path-info","web-service-php-body-limit","web-service-php-header-limit",
"web-service-php-stderr-limit","web-service-php-custom-params","web-service-php-values","web-service-php-admin-values","web-service-php-help",
"web-service-compression","web-service-compression-options","web-service-compression-zstd","web-service-compression-br","web-service-compression-gzip","web-service-compression-deflate","web-service-precompressed","web-service-precompressed-br","web-service-precompressed-gzip",
"web-service-preset","web-service-preset-apply","web-service-preset-summary","web-service-reset",
"web-service-response-headers","web-service-rewrites","web-service-root","web-service-search",
"web-service-symlink-policy","web-service-tabs","web-service-try-files",
"web-service-php-profile", "web-service-php-profiles", "web-service-php-profiles-open", "php-fpm-profiles-dialog",
"php-fpm-profiles-list", "php-fpm-profiles-close", "php-fpm-profile-add", "php-fpm-profiles-help"
];
const elements = new Map(ids.map(id => [id, new Element(id)]));
const pages = ["general","routing","htaccess","mime","cache","delivery","php","security"].map(name => {
const page = new Element(); page.dataset.webServicePage = name; return page;
});
elements.get("web-service-php-endpoint").value = "unix:/run/php/php-fpm.sock";
elements.get("web-service-php-connect-timeout").value = "1000";
elements.get("web-service-php-request-timeout").value = "30000";
elements.get("web-service-php-concurrency").value = "64";
elements.get("web-service-php-pass-authorization").checked = true;
elements.get("web-service-php-path-info").checked = false;
elements.get("web-service-php-body-limit").value = "0";
elements.get("web-service-php-header-limit").value = "65536";
elements.get("web-service-php-stderr-limit").value = "4096";
elements.get("web-service-compression").value = "";
for (const name of ["zstd", "br", "gzip", "deflate"]) elements.get(`web-service-compression-${name}`).value = "";
const document = {
body: new Element("body"),
createElement: tag => { const node = new Element(); node.type = tag; return node; },
querySelectorAll(selector) {
if (selector === "#web-service-tabs button") return elements.get("web-service-tabs").children;
if (selector === "[data-web-service-page]") return pages;
return [];
}
};
let savedSection = null;
const context = {
console,
structuredClone,
document,
window: {},
envelope: null,
busy: false,
$: id => {
const value = elements.get(id);
if (!value) throw new Error(`missing fake element ${id}`);
return value;
},
t: key => key,
el: (_tag, text = "", className = "") => {
const node = new Element();
node.textContent = text ?? "";
if (className) node.classList.values.add(className);
return node;
},
toast: () => {},
saveSection: async (name, value) => { savedSection = { name, value: structuredClone(value) }; return true; },
appConfirm: async () => true,
};
vm.createContext(context);
vm.runInContext(fs.readFileSync("src/admin/web/drafts.js", "utf8"), context, { filename: "drafts.js" });
vm.runInContext(fs.readFileSync("src/admin/web/web-server-profiles.js", "utf8"), context, { filename: "web-server-profiles.js" });
vm.runInContext(fs.readFileSync("src/admin/web/web-server.js", "utf8"), context, { filename: "web-server.js" });
// Explicitly disabling scoped dynamic compression must hide and disable codec
// inheritance controls without erasing their saved selections.
const compressionMode = elements.get("web-service-compression");
const compressionOptions = elements.get("web-service-compression-options");
const compressionZstd = elements.get("web-service-compression-zstd");
compressionZstd.value = "off";
compressionMode.value = "off";
compressionMode.dispatch("change");
if (!compressionOptions.hidden) throw new Error("Disabled Web Service compression did not hide codec overrides");
for (const name of ["zstd", "br", "gzip", "deflate"]) {
if (!elements.get(`web-service-compression-${name}`).disabled) throw new Error(`Disabled Web Service compression left ${name} override active`);
}
compressionMode.value = "on";
compressionMode.dispatch("change");
if (compressionOptions.hidden) throw new Error("Re-enabled Web Service compression did not restore codec overrides");
if (compressionZstd.value !== "off") throw new Error("Compression visibility toggle erased the saved codec selection");
const preset = elements.get("web-service-preset");
preset.value = "wordpress";
preset.dispatch("change");
const expectedIndex = "index.php\nindex.html\nindex.htm";
const expectedTry = "$uri\n$uri/\n/index.php?$query_string";
if (elements.get("web-service-index").value !== expectedIndex) throw new Error("WordPress preset did not populate index files");
if (elements.get("web-service-try-files").value !== expectedTry) throw new Error("WordPress preset did not populate try_files");
if (!elements.get("web-service-php-enabled").checked) throw new Error("WordPress preset did not enable PHP-FPM");
if (!elements.get("web-service-precompressed").checked) throw new Error("WordPress preset did not enable precompressed sidecars");
if (!elements.get("web-service-cache-rules").value.includes("ext:css = public, max-age=604800")) throw new Error("WordPress preset did not populate cache rules");
preset.value = "downloads";
preset.dispatch("change");
if (elements.get("web-service-index").value !== "") throw new Error("Downloads preset should clear index files");
if (!elements.get("web-service-listing").checked) throw new Error("Downloads preset should enable directory listing");
if (elements.get("web-service-php-enabled").checked) throw new Error("Downloads preset should disable PHP-FPM");
if (elements.get("web-service-cache-control").value !== "public, max-age=3600") throw new Error("Downloads preset cache policy missing");
// The save path must serialize the values materialized by preset selection, not
// the previous/custom values that were present when the dialog opened.
context.envelope = { config: { web_services: [] } };
elements.get("web-service-id").value = "web1";
elements.get("web-service-name").value = "web1";
elements.get("web-service-root").value = "/srv/web1";
elements.get("web-service-php-pass-authorization").checked = false;
elements.get("web-service-php-path-info").checked = true;
elements.get("web-service-php-body-limit").value = "1048576";
elements.get("web-service-php-header-limit").value = "131072";
elements.get("web-service-php-stderr-limit").value = "8192";
elements.get("web-service-php-custom-params").value = "APP_ENV = production";
elements.get("web-service-php-values").value = "memory_limit = 256M";
preset.value = "wordpress";
preset.dispatch("change");
elements.get("web-service-compression").value = "on";
elements.get("web-service-compression-gzip").value = "off";
await elements.get("web-service-form").dispatch("submit");
if (savedSection?.name !== "web-services" || savedSection.value.length !== 1) throw new Error("WordPress preset save did not persist a Web Service");
const saved = savedSection.value[0];
if (saved.index.join("\n") !== expectedIndex) throw new Error("Saved WordPress preset lost index files");
if (saved.try_files.join("\n") !== expectedTry) throw new Error("Saved WordPress preset lost try_files");
if (!saved.php.enabled) throw new Error("Saved WordPress preset lost PHP-FPM enablement");
if (!saved.precompressed.enabled) throw new Error("Saved WordPress preset lost precompressed enablement");
if (saved.compression?.enabled !== true || saved.compression?.gzip !== false) throw new Error("Saved WordPress preset lost compression/codec override");
if (saved.php.pass_authorization !== false) throw new Error("Saved WordPress preset lost Authorization forwarding choice");
if (saved.php.path_info !== true) throw new Error("Saved WordPress preset lost PATH_INFO choice");
if (saved.php.max_request_body_bytes !== 1048576 || saved.php.max_response_header_bytes !== 131072 || saved.php.stderr_log_bytes !== 8192) throw new Error("Saved WordPress preset lost advanced FastCGI limits");
if (saved.php.custom_params.APP_ENV !== "production") throw new Error("Saved WordPress preset lost custom FastCGI parameters");
if (saved.php.php_values.memory_limit !== "256M") throw new Error("Saved WordPress preset lost PHP_VALUE settings");
if (!saved.cache_rules.some(rule => rule.matcher === "ext:css" && rule.cache_control.includes("604800"))) throw new Error("Saved WordPress preset lost cache rules");
console.log("PASS: Web Service preset selection materializes and persists CMS/download settings.");
// The shared editor lists profiles, saves them under the new revisioned
// section, and Web Services can attach without losing local PHP settings.
context.envelope.config.php_fpm_profiles = [{
id: "wp-pool", name: "WordPress pool", php: {
enabled: true, path_info: true, pass_authorization: true,
extensions: ["php"], endpoint: "tcp:127.0.0.1:9000",
connect_timeout_ms: 1000, request_timeout_ms: 30000,
max_concurrent_requests: 64, max_request_body_bytes: 0,
max_response_header_bytes: 65536, stderr_log_bytes: 4096,
custom_params: {}, php_values: {}, php_admin_values: {}
}
}];
// Both entry points must share the existing modal; the toolbar shortcut
// belongs next to New Web Service rather than inside the service editor.
const html = fs.readFileSync("src/admin/web/index.html", "utf8");
const profileDialog = html.split('id="php-fpm-profiles-dialog"')[1]?.split('</dialog>')[0] ?? '';
const listAt = profileDialog.indexOf('id="php-fpm-profiles-list"');
const addAt = profileDialog.indexOf('id="php-fpm-profile-add"');
const dividerAt = profileDialog.indexOf('class="php-profile-help-divider"');
const helpAt = profileDialog.indexOf('id="php-fpm-profiles-help"');
if (!(listAt >= 0 && listAt < addAt && addAt < dividerAt && dividerAt < helpAt)) {
throw new Error("PHP-FPM syntax/safety help must be at the bottom of the profile modal, separated by a rule");
}
const webPanel = html.split('id="panel-web-server"')[1]?.split("</section>")[0] ?? "";
const toolbar = webPanel.match(/<div class="vhost-list-tools">([\s\S]*?)<\/div>/)?.[1] ?? "";
const shortcutAt = toolbar.indexOf('id="web-service-php-profiles-open"');
const newAt = toolbar.indexOf('id="web-service-new"');
if (shortcutAt < 0 || newAt < 0 || shortcutAt > newAt) {
throw new Error("PHP-FPM profiles shortcut must be adjacent to New Web Service in the toolbar");
}
elements.get("web-service-php-profiles-open").dispatch("click");
if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("Toolbar PHP-FPM profile shortcut did not open the modal");
elements.get("php-fpm-profiles-close").dispatch("click");
if (elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM profile modal failed to close");
elements.get("web-service-php-profiles").dispatch("click");
if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM profile modal was not opened");
const profileDetail = elements.get("php-fpm-profiles-list").children[0];
if (!profileDetail || profileDetail.children[0].textContent !== "WordPress pool") throw new Error("Shared profile list missing");
const profileForm = profileDetail.children[1];
const phpRows = profileForm.children.filter(node => node.classList.values.has("php-switch-row vh-wide"));
if (phpRows.length !== 3) throw new Error("PHP-FPM / PATH_INFO / Authorization switches do not have separate aligned rows");
for (const row of phpRows) {
if (row.children.length !== 2 || row.children[0].children[0].type !== "checkbox" ||
row.children[1].children[0].textContent !== "i" || row.children[1].children[1].children.length < 1) {
throw new Error("Profile PHP toggle lacks its checkbox label or explanation popover");
}
}
if (!phpRows[0].children[0].children[0].checked || !phpRows[1].children[0].children[0].checked) {
throw new Error("PHP execution and PATH_INFO saved values were not restored");
}
phpRows[1].children[0].children[0].checked = false;
if (!phpRows[0].children[0].children[0].checked) {
throw new Error("Changing PATH_INFO also disabled PHP execution");
}
phpRows[1].children[0].children[0].checked = true;
elements.get("php-fpm-profiles-help").dispatch("click");
if (!elements.get("php-help-dialog").open) throw new Error("PHP profile help button did not open syntax documentation");
elements.get("php-help-close").dispatch("click");
if (elements.get("php-help-dialog").open) throw new Error("PHP syntax help dialog did not close");
profileForm.children[1].children[1].value = "Updated WordPress pool";
await profileForm.dispatch("submit");
if (savedSection?.name !== "php-fpm-profiles" || savedSection.value[0].name !== "Updated WordPress pool") {
throw new Error("Shared profile edit did not save to the profile configuration section");
}
const profileSelector = elements.get("web-service-php-profile");
const localEndpoint = elements.get("web-service-php-endpoint").value;
profileSelector.value = "wp-pool";
profileSelector.dispatch("change");
if (!elements.get("web-service-php-endpoint").disabled) throw new Error("Shared profile left local PHP controls editable");
elements.get("web-service-id").value = "wp-shared";
elements.get("web-service-name").value = "WordPress shared";
await elements.get("web-service-form").dispatch("submit");
if (savedSection?.name !== "web-services" || savedSection.value[0].php_profile_id !== "wp-pool") {
throw new Error("Web Service did not persist selected shared PHP-FPM profile");
}
if (savedSection.value[0].php.endpoint !== localEndpoint) {
throw new Error("Attaching a shared FPM profile changed local settings");
}
console.log("PASS: Toolbar and editor PHP-FPM shortcuts open the shared modal; profile edits and Web Service attachment work.");
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
// Exercise the actual domain-choice sync function from the vhost editor with
// DOM stand-ins. This does not replace browser or Rust runtime acceptance.
const source = fs.readFileSync("src/admin/web/vhosts/editor.js", "utf8");
const start = source.search(/^\s*const syncCanonical = \(\) => \{/m);
const end = source.indexOf("refreshCanonical = syncCanonical;", start);
assert.ok(start >= 0 && end > start, "WWW selection logic must remain discoverable");
const selectedLogic = source.slice(start, end);
class Element {
constructor(tag = "", body = "", className = "") {
this.tag = tag;
this.textContent = body ?? "";
this.className = className;
this.children = [];
this.listeners = {};
this.checked = false;
this.disabled = false;
this.hidden = false;
this.value = "";
this.attrs = new Map();
this.classes = new Set();
this.classList = {
add: name => this.classes.add(name),
toggle: (name, active) => active ? this.classes.add(name) : this.classes.delete(name),
};
}
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = [...children]; }
setAttribute(key, value) { this.attrs.set(key, String(value)); }
addEventListener(event, callback) { this.listeners[event] = callback; }
fire(event) { this.listeners[event]?.(); }
}
function editor(hosts, previous = "", checked = false) {
const dom = Object.fromEntries([
"wwwTarget", "wwwChoices", "wwwOther", "wwwNoHosts", "wwwDetails", "wwwInactive", "wwwRedirect", "wwwPanel", "wwwHostCount"
].map(id => [id, new Element()]));
dom.wwwRedirect.checked = checked;
let changes = 0;
const bindings = {
...dom,
document: { createElement: tag => new Element(tag) },
el: (tag, content, className) => new Element(tag, content, className),
text: (_tag, key) => new Element("span", key),
t: key => key === "vhWwwSharedCount" ? "{count} names / one vhost" : key,
read: key => { assert.equal(key, "hosts"); return { value: hosts.join("\n") }; },
lines: value => value.split(/[\n,]+/).filter(Boolean),
changed: () => { changes++; },
};
const code = `let selectedCanonical = ${JSON.stringify(previous)};\n${selectedLogic}\nthis.run = () => syncCanonical();\nthis.getSelected = () => selectedCanonical;`;
const runtime = vm.createContext(bindings);
vm.runInContext(code, runtime, { filename: "editor-www-sync.js" });
runtime.run();
return { runtime, dom, countChanges: () => changes };
}
const result = editor(["suba.local", "subb.local"], "www.subb.local", false);
assert.equal(result.dom.wwwDetails.hidden, false, "destination cards must be visible while redirect is disabled");
assert.equal(result.dom.wwwTarget.value, "www.subb.local", "www target must survive editor re-opening");
assert.equal(result.dom.wwwChoices.children.length, 2, "unrelated domains must remain distinct groups");
assert.equal(result.dom.wwwHostCount.textContent, "2 names / one vhost");
const aChoices = result.dom.wwwChoices.children[0].children[1].children;
assert.equal(aChoices.length, 2, "each domain must offer both www and non-www targets");
assert.equal(aChoices[0].children[0].value, "suba.local");
assert.equal(aChoices[1].children[0].value, "www.suba.local");
assert.ok(aChoices[1].children[1].children[1].textContent.includes("suba.local"), "preview must show direction");
// Select www destination for a different host, without editing hostname chips.
aChoices[1].children[0].checked = true;
aChoices[1].children[0].fire("change");
assert.equal(result.dom.wwwTarget.value, "www.suba.local");
assert.equal(result.countChanges(), 1);
assert.ok(result.dom.wwwOther.children.some(child => child.textContent === "subb.local"), "other alias remains in vhost");
result.dom.wwwRedirect.checked = true;
result.runtime.run();
assert.equal(result.dom.wwwInactive.hidden, true);
assert.ok(result.dom.wwwPanel.classes.has("is-enabled"));
const empty = editor(["*.example.test"], "", true);
assert.equal(empty.dom.wwwRedirect.disabled, true, "wildcard-only vhost cannot enable WWW canonicalization");
assert.equal(empty.dom.wwwRedirect.checked, false);
const canonical = editor(["www.example.test"], "example.test", true);
assert.equal(canonical.dom.wwwTarget.value, "example.test", "bare target may be implicit when only www host is configured");
assert.equal(canonical.dom.wwwChoices.children.length, 1);
console.log("PASS: WWW destination cards, both directions, disabled preview, live update, aliases and wildcard guard.");