feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,16 @@
|
||||
import fs from 'node:fs';import assert from 'node:assert/strict';import {chromium,expect} from '@playwright/test';
|
||||
const privatePath=process.env.MMAPP_ACME_FIXTURE||'tmp/acme250-private.json',p=JSON.parse(fs.readFileSync(privatePath)),login=JSON.parse(fs.readFileSync('tmp/portal195-private.json'));
|
||||
assert(/^mmt250-[a-f0-9]{8}\.pngs\.top$/.test(p.fqdn));assert(!p.jobId,'Never submit an unknown or existing issue job again');
|
||||
const report={date:'2026-10-14',block:'12.5.250',fqdn:p.fqdn,certName:p.certName,serverId:p.serverId,realPortalLogin:true,noMocks:true,issuePosts:0,pageErrors:[]};
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
try{
|
||||
const page=await browser.newPage({viewport:{width:1440,height:1000}});page.on('pageerror',e=>report.pageErrors.push(e.name));page.on('request',r=>{if(new URL(r.url()).pathname==='/api/certificates/issue'&&r.method()==='POST'){const b=r.postDataJSON();assert.equal(String(b.serverId),p.serverId);assert.equal(b.certName,p.certName);assert.deepEqual(b.domainNames,[p.fqdn]);p.jobId=b.requestId;assert(p.jobId);report.issuePosts++;report.jobId=p.jobId;fs.writeFileSync(privatePath,JSON.stringify(p))}});
|
||||
await page.goto('http://localhost:3000/certificates?server=2');await page.getByLabel('Email',{exact:true}).fill(login.email);await page.getByLabel('Пароль',{exact:true}).fill(login.password);await page.getByRole('button',{name:'Войти',exact:true}).click();await expect(page.getByRole('navigation',{name:'Путь страницы'})).toContainText('Сертификаты',{timeout:30000});
|
||||
const settings=await page.evaluate(async({token,zoneId})=>{const headers={Authorization:'Bearer '+localStorage.getItem('mmapp_token'),'Content-Type':'application/json'};const before=await fetch('/api/certificates/acme-settings',{headers});if(!before.ok)throw Error('Settings read failed');const old=await before.json();if(old.tokenConfigured)throw Error('Existing CF token must not be overwritten');const test=await fetch('/api/certificates/acme-settings/test',{method:'POST',headers,body:JSON.stringify({cloudflareApiToken:token})});const tested=await test.json();if(!test.ok||tested.ok!==true)return{testStatus:test.status,testOk:false,message:tested.message};const put=await fetch('/api/certificates/acme-settings',{method:'PUT',headers,body:JSON.stringify({directoryUrl:'https://acme-v02.api.letsencrypt.org/directory',defaultZoneId:zoneId,cloudflareApiToken:token})});const configured=await put.json();if(!put.ok||!configured.tokenConfigured)throw Error('Settings save failed');return{testStatus:test.status,testOk:true,saveStatus:put.status,tokenConfigured:configured.tokenConfigured,directoryUrl:configured.directoryUrl}}, {token:p.token,zoneId:p.zoneId});
|
||||
report.settings=settings;assert.equal(settings.testOk,true);
|
||||
await page.getByRole('button',{name:'Выпустить сертификат',exact:true}).click();const dialog=page.getByRole('dialog',{name:'Выпуск сертификата'});await dialog.getByRole('combobox',{name:'Сервер выпуска'}).selectOption(p.serverId);await dialog.getByRole('textbox',{name:'Имя сертификата',exact:true}).fill(p.certName);await dialog.getByRole('button',{name:'Далее',exact:true}).click();await dialog.getByRole('textbox',{name:'Основной домен',exact:true}).fill(p.fqdn);await dialog.getByRole('button',{name:'Далее',exact:true}).click();await dialog.getByRole('button',{name:'Далее',exact:true}).click();await expect(dialog).toContainText(p.fqdn);
|
||||
const response=page.waitForResponse(r=>new URL(r.url()).pathname==='/api/certificates/issue'&&r.request().method()==='POST');await dialog.getByRole('button',{name:'Выпустить',exact:true}).click();const accepted=await response;assert.equal(accepted.status(),200);assert.equal((await accepted.json()).jobId,p.jobId);report.accepted=true;fs.writeFileSync('docs/ux-ui/acceptance/acme250-live-ui.json',JSON.stringify(report,null,2)+'\n');console.log('One ACME issue accepted for '+p.fqdn);
|
||||
await expect.poll(()=>page.evaluate(async(jobId)=>{const r=await fetch('/api/certificates/issue/'+jobId,{headers:{Authorization:'Bearer '+localStorage.getItem('mmapp_token')}});if(!r.ok)return'unknown';return(await r.json()).status},p.jobId),{timeout:180000,intervals:[2000]}).toMatch(/^(done|failed)$/);
|
||||
report.job=await page.evaluate(async(jobId)=>{const r=await fetch('/api/certificates/issue/'+jobId,{headers:{Authorization:'Bearer '+localStorage.getItem('mmapp_token')}});const b=await r.json();return{id:b.id,status:b.status,step:b.step,error:b.error}},p.jobId);assert.equal(report.issuePosts,1);assert.equal(report.job.status,'done');await expect(dialog.getByRole('status')).toContainText('Завершена',{timeout:15000});assert.deepEqual(report.pageErrors,[]);report.uiTerminalConfirmed=true;
|
||||
fs.writeFileSync('docs/ux-ui/acceptance/acme250-live-ui.json',JSON.stringify(report,null,2)+'\n');console.log('ACME UI job done; one issue POST, JS errors0');
|
||||
}catch(e){report.failure={name:e.name};fs.writeFileSync('docs/ux-ui/acceptance/acme250-live-ui.json',JSON.stringify(report,null,2)+'\n');console.error('ACME test incomplete: '+e.name);process.exitCode=1}finally{await browser.close()}
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import tls from 'node:tls';
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const fingerprint = value => {
|
||||
assert.equal(typeof value, 'string', 'Fingerprint is required');
|
||||
const normalized = value.replaceAll(':', '').toLowerCase();
|
||||
assert(/^[a-f0-9]{64}$/.test(normalized), 'Expected SHA-256 fingerprint');
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export function compareRenewal(before, after) {
|
||||
for (const sample of [before, after]) {
|
||||
assert(/^mmt253-[a-f0-9]{8}\.pngs\.top$/.test(sample.fqdn), 'Only the prepared exact test domain is permitted');
|
||||
assert.equal(sample.remoteAddress, '104.165.244.7');
|
||||
assert.equal(sample.authorized, true);
|
||||
assert.equal(sample.hostnameMatched, true);
|
||||
assert.equal(sample.nativePrivateKey, true);
|
||||
assert.equal(sample.nativeName, sample.certName);
|
||||
assert.equal(sample.certName, sample.fqdn.replace('.pngs.top', ''));
|
||||
assert.equal(fingerprint(sample.nativeFingerprint256), fingerprint(sample.fingerprint256), 'Native leaf must match served TLS');
|
||||
assert(Number.isFinite(Date.parse(sample.validUntil)), 'Expiry is required');
|
||||
assert(Number.isFinite(Date.parse(sample.sampledAt)), 'Sample time is required');
|
||||
assert(Date.parse(sample.validUntil) > Date.parse(sample.sampledAt), 'Certificate is expired');
|
||||
}
|
||||
assert.equal(after.fqdn, before.fqdn);
|
||||
assert.equal(after.certName, before.certName);
|
||||
assert.notEqual(fingerprint(after.fingerprint256), fingerprint(before.fingerprint256), 'Old certificate is still being served');
|
||||
assert(Date.parse(after.sampledAt) >= Date.parse(before.sampledAt));
|
||||
assert(Date.parse(after.validUntil) > Date.parse(after.sampledAt), 'New certificate is expired');
|
||||
// A second issuance on the same day need not extend expiry by a full day.
|
||||
return { fingerprintChanged: true, nativeAndTlsMatch: true, trustedHostnameMatch: true };
|
||||
}
|
||||
|
||||
async function sample(fixture) {
|
||||
assert(/^mmt253-[a-f0-9]{8}\.pngs\.top$/.test(fixture.fqdn));
|
||||
assert.equal(fixture.certName, fixture.fqdn.replace('.pngs.top', ''));
|
||||
assert.equal(fixture.nativePrivateKey, true);
|
||||
fingerprint(fixture.nativeFingerprint256);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = tls.connect({ host: '104.165.244.7', port: 443, servername: fixture.fqdn, rejectUnauthorized: true });
|
||||
socket.setTimeout(15000, () => socket.destroy(new Error('TLS acceptance timeout')));
|
||||
socket.once('error', reject);
|
||||
socket.once('secureConnect', () => {
|
||||
try {
|
||||
assert.equal(socket.authorized, true);
|
||||
const cert = new X509Certificate(socket.getPeerCertificate().raw);
|
||||
assert(cert.checkHost(fixture.fqdn), 'Hostname mismatch');
|
||||
assert(Date.parse(cert.validTo) > Date.now(), 'Certificate is expired');
|
||||
assert.equal(fingerprint(cert.fingerprint256), fingerprint(fixture.nativeFingerprint256), 'Native leaf does not match TLS');
|
||||
resolve({ sampledAt: new Date().toISOString(), fqdn: fixture.fqdn, certName: fixture.certName,
|
||||
remoteAddress: socket.remoteAddress, authorized: socket.authorized, hostnameMatched: true,
|
||||
protocol: socket.getProtocol(), fingerprint256: cert.fingerprint256,
|
||||
validFrom: cert.validFrom, validUntil: cert.validTo,
|
||||
nativeName: fixture.certName, nativePrivateKey: true, nativeFingerprint256: fixture.nativeFingerprint256 });
|
||||
} catch (error) { reject(error); } finally { socket.end(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const [mode, input, output] = process.argv.slice(2);
|
||||
assert(input && output && ['sample', 'compare'].includes(mode), 'Usage: sample native-summary.json output.json | compare samples.json output.json');
|
||||
assert(!fs.existsSync(output), 'Never overwrite acceptance evidence');
|
||||
const data = JSON.parse(fs.readFileSync(input, 'utf8'));
|
||||
const result = mode === 'sample' ? await sample(data) : compareRenewal(data.before, data.after);
|
||||
fs.writeFileSync(output, JSON.stringify(result, null, 2) + '\n', { flag: 'wx' });
|
||||
console.log(mode === 'sample' ? 'Trusted TLS/native fingerprint sample saved' : 'Renewal fingerprint comparison passed');
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { compareRenewal } from './acme-renew-tls.mjs';
|
||||
const before = { fqdn: 'mmt253-a1b2c3d4.pngs.top', certName: 'mmt253-a1b2c3d4', nativeName: 'mmt253-a1b2c3d4',
|
||||
remoteAddress: '104.165.244.7', authorized: true, hostnameMatched: true, nativePrivateKey: true,
|
||||
fingerprint256: 'a'.repeat(64), nativeFingerprint256: 'a'.repeat(64), sampledAt: '2026-10-14T12:00:00Z', validUntil: '2027-01-12T12:00:00Z' };
|
||||
const after = { ...before, fingerprint256: 'b'.repeat(64), nativeFingerprint256: 'b'.repeat(64), sampledAt: '2026-10-14T12:01:00Z' };
|
||||
assert.equal(compareRenewal(before, after).fingerprintChanged, true);
|
||||
for (const patch of [{ fingerprint256: before.fingerprint256, nativeFingerprint256: before.nativeFingerprint256 },
|
||||
{ nativeFingerprint256: before.nativeFingerprint256 }, { authorized: false }, { hostnameMatched: false },
|
||||
{ nativePrivateKey: false }, { nativeName: 'another' }, { fqdn: 'other.pngs.top' },
|
||||
{ remoteAddress: '194.226.121.150' }, { validUntil: 'bad' }, { validUntil: '2026-10-13T12:00:00Z' },
|
||||
{ sampledAt: '2026-10-14T11:00:00Z' }]) assert.throws(() => compareRenewal(before, { ...after, ...patch }));
|
||||
console.log('Renewal acceptance comparison: changed leaf passes; 11 false-success cases rejected. No network or CA calls.');
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own211.invalid';process.env.AUTH_PORTAL_URL='https://own211.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const psk='own211-'+randomBytes(24).toString('base64url'),baseline=false,cases=[];let app,serverId,nativeWrites=0,nativeCalls=0
|
||||
const native=createServer((req,res)=>{nativeCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');res.end(JSON.stringify(req.url.endsWith('/interface/gre')?[{'.id':'*1',name:'own211-encrypted','local-address':'192.0.2.1','remote-address':'192.0.2.2','ipsec-secret':psk,mtu:'1476',keepalive:'10s,3',disabled:'false',running:'true','allow-fast-path':'false'},{'.id':'*2',name:'own211-plain','remote-address':'192.0.2.3','ipsec-secret':'',disabled:'false',running:'true'}]:[]))});await new Promise(r=>native.listen(0,'127.0.0.1',r))
|
||||
const report={date:'2026-10-13',block:'12.5.211',database,baseline,cases,scope:'Production compiled canonical auth/RBAC using own restricted PostgreSQL, local native HTTP fixture and synthetic JWT/PSK',passed:false}
|
||||
try{await initDatabase();serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own211-native','127.0.0.1',$1,false,true,'fixture','fixture') RETURNING id",[native.address().port])).rows[0].id);app=await buildApp({logger:false,startScheduler:false});await app.ready()
|
||||
for(const path of ['/api/servers','/api/%73ervers','/%61pi/servers','/%61%70%69/servers','/api/gre/tunnels','/%61pi/gre/tunnels'])for(const method of ['GET','HEAD']){const before=nativeCalls;const r=await app.inject({method,url:path});assert.equal(r.statusCode,401);assert.equal(nativeCalls,before);cases.push({path,method,role:'anonymous',status:401})}
|
||||
for(const path of ['/api/gre/tunnels','/api/%67re/tunnels','/%61pi/gre/tunnels'])for(const role of ['dashboard','reader','writer']){const permissions=role==='dashboard'?['mm:dashboard:read']:role==='reader'?['mm:network:read']:['mm:network:write'],token=app.jwt.sign({sub:'own211-'+role,apps:['mm'],permissions,iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});const r=await app.inject({method:'GET',url:path+'?serverId='+serverId,headers:{authorization:'Bearer '+token}});assert.equal(r.statusCode,role==='dashboard'?403:200);if(role!=='dashboard')assert.equal(r.json().tunnels.find(t=>t.name==='own211-encrypted').ipsec.secret,role==='writer'?psk:'');cases.push({path,method:'GET',role,status:r.statusCode,privateMaterialStored:false});if(role!=='writer'){const patch=await app.inject({method:'PATCH',url:path,headers:{authorization:'Bearer '+token},payload:{serverId:String(serverId),rosId:'*1',comment:'not applied'}});assert.equal(patch.statusCode,403);cases.push({path,method:'PATCH',role,status:403})}}
|
||||
for(const path of ['/api/auth/config','/%61pi/auth/config']){assert.equal((await app.inject({url:path})).statusCode,200);cases.push({path,role:'anonymous',status:200,publicConfig:true})}
|
||||
assert.equal(nativeWrites,0);report.nativeWrites=0;report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre211-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/auth211-production-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}
|
||||
if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,3 @@
|
||||
import assert from'node:assert/strict';import fs from'node:fs';import{createRequire}from'node:module';import{createServer}from'node:http';
|
||||
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool,db}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{servers}=await import('/app/backend/dist/db/schema.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');let nativeRequests=0;const native=createServer((_req,res)=>{nativeRequests++;res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));const[server]=await db.insert(servers).values({name:'owned-availability237',host:'127.0.0.1',port:native.address().port,useSsl:false,username:'fixture',password:'fixture',enabled:true,type:'exit-node'}).returning();fs.mkdirSync('storage/backups',{recursive:true});fs.writeFileSync('storage/backups/available237.rsc','# harmless own fixture\n');fs.mkdirSync('storage/backups/directory237.rsc');const fixtures=[['available237','available237.rsc','local',null],['missing237','missing237.rsc','local',null],['both237','both237.rsc','both','own-test/both237.rsc'],['remote237','remote237.rsc','s3','own-test/remote237.rsc'],['directory237','directory237.rsc','local',null]];for(const[id,filename,storage,key]of fixtures)await pool.query('INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES($1,$2,$3,$4,1,$5,$6,$7,now())',[id,server.id,'fixture',filename,'manual',storage,key]);const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const report={date:'2026-10-14',block:'12.5.237',passed:false,checks:[],externalS3Contacts:0,chrContacts:0};try{let r=await app.inject({method:'GET',url:'/api/backups'});assert.equal(r.statusCode,200);const statuses=Object.fromEntries(r.json().map(r=>[r.id,r.localFileStatus]));assert.deepEqual(statuses,{available237:'available',missing237:'missing',both237:'missing',remote237:'not-stored',directory237:'unknown'});report.checks.push({listStates:statuses});r=await app.inject({method:'GET',url:'/api/backups/available237/download'});assert.equal(r.statusCode,200);assert.equal(r.body,'# harmless own fixture\n');report.checks.push({availableDownload:200});r=await app.inject({method:'GET',url:'/api/backups/missing237/download'});assert.equal(r.statusCode,404);report.checks.push({missingDownload:404});r=await app.inject({method:'POST',url:'/api/backups/missing237/restore'});assert.equal(r.statusCode,400);assert.equal(r.json().outcome,'not-applied');assert.match(r.json().error,/Локальный файл/);assert.equal(nativeRequests,0);report.checks.push({missingRestore:400,outcome:'not-applied',nativeRequests:0});r=await app.inject({method:'GET',url:'/api/backups'});assert.equal(r.json().length,5);report.checks.push({failurePreservesCatalog:true});fs.writeFileSync('storage/backups/missing237.rsc','# recreated fixture\n');r=await app.inject({method:'DELETE',url:'/api/backups/missing237?recordOnly=true'});assert.equal(r.statusCode,409);assert(fs.existsSync('storage/backups/missing237.rsc'));assert.equal((await pool.query("SELECT count(*)::int AS n FROM backup_entries WHERE id='missing237'")).rows[0].n,1);report.checks.push({recreatedFilePreserved:409});fs.unlinkSync('storage/backups/missing237.rsc');r=await app.inject({method:'DELETE',url:'/api/backups/missing237?recordOnly=true'});assert.equal(r.statusCode,204);assert.equal(r.headers['x-backup-delete-mode'],'record-only');report.checks.push({missingRecordDeletion:204,mode:'record-only'});report.passed=true}catch(e){report.failure={name:e.name}}finally{await databaseBackgroundWork.drain();await app.close();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/checks/backup-availability237.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
|
||||
@@ -0,0 +1,3 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs';import{randomUUID}from'node:crypto';import{createServer}from'node:http';import{createRequire}from'node:module';
|
||||
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool,db}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{servers}=await import('/app/backend/dist/db/schema.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');let nativeRequests=0;const native=createServer((_req,res)=>{nativeRequests++;res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));const[server]=await db.insert(servers).values({name:'owned-legacy234',host:'127.0.0.1',port:native.address().port,useSsl:false,username:'fixture',password:'fixture',enabled:true,type:'exit-node'}).returning();const filename='legacy234-'+randomUUID()+'.rsc',file='storage/backups/'+filename;fs.mkdirSync('storage/backups',{recursive:true});await pool.query("INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,created_at) VALUES('owned-legacy234',$1,'fixture',$2,1,'manual','local',now())",[server.id,filename]);const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const report={date:'2026-10-14',block:'12.5.234',checks:[],passed:false,chrContacts:0,externalS3Contacts:0};try{for(const prefix of['','\uFEFF\r\n ']){const content=prefix+'# synthetic export generated by MikrotikManager\n/system identity\nset name=legacy-fixture\n';fs.writeFileSync(file,content);const download=await app.inject({method:'GET',url:'/api/backups/owned-legacy234/download'});assert.equal(download.statusCode,200);assert.equal(download.body,content);const restore=await app.inject({method:'POST',url:'/api/backups/owned-legacy234/restore'});assert.equal(restore.statusCode,400);assert.equal(restore.json().outcome,'not-applied');assert.match(restore.json().error,/частичным экспортом/);assert.equal(fs.readFileSync(file,'utf8'),content);assert.equal(nativeRequests,0);assert.equal((await pool.query("SELECT count(*)::int AS n FROM backup_entries WHERE id='owned-legacy234'")).rows[0].n,1);report.checks.push({bomAndWhitespace:Boolean(prefix),downloadStatus:200,restoreStatus:400,outcome:'not-applied',fileAndCatalogPreserved:true,nativeRequests:0})}report.passed=true}catch(e){report.failure={name:e.name}}finally{await databaseBackgroundWork.drain();const deleted=await app.inject({method:'DELETE',url:'/api/backups/owned-legacy234'});assert.equal(deleted.statusCode,204);assert(!fs.existsSync(file));assert.equal((await pool.query('SELECT count(*)::int AS n FROM backup_entries')).rows[0].n,0);report.cleanup={catalogRows:0,localFiles:0};await app.close();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/checks/legacy-restore234.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
|
||||
@@ -0,0 +1,5 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs';import{createRequire}from'node:module';
|
||||
const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{pool,closePool}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');const baseline=process.env.MMAPP_ORIGIN_BASELINE==='true',report={date:'2026-10-14',block:'12.5.233',baseline,checks:[],externalS3Contacts:0,chrContacts:0,passed:false};const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();const config={provider:'s3',s3Endpoint:'https://s3.invalid',s3Region:'test-region',s3Bucket:'origin-bucket',s3Prefix:'owned-test',s3AccessKeyId:'fixture',s3SecretAccessKey:'fixture',s3ForcePathStyle:false,keepLocalCopy:true};async function api(method,path,payload,status=200){const r=await app.inject({method,url:'/api'+path,...(payload?{payload}:{})});assert.equal(r.statusCode,status,method+' '+path);return r}
|
||||
async function insert(){await pool.query("INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES('owned-origin',NULL,'fixture','fixture.rsc',1,'manual','s3','owned-test/fixture.rsc',now())")}
|
||||
try{await api('PUT','/backups/storage',config);await insert();for(const patch of[{s3Bucket:'other-bucket'},{s3Endpoint:'https://other.invalid'},{s3Region:'other'},{s3ForcePathStyle:true},{provider:'local'}]){const before=(await api('GET','/backups/storage')).json();await api('PUT','/backups/storage',patch,baseline?200:409);const after=(await api('GET','/backups/storage')).json();if(!baseline)assert.deepEqual(after,before);else await api('PUT','/backups/storage',config);report.checks.push({change:Object.keys(patch)[0],status:baseline?200:409,settingsUnchanged:!baseline})}for(const patch of[{s3Prefix:'new-prefix'},{s3SecretAccessKey:'rotated-fixture'},{keepLocalCopy:false}]){await api('PUT','/backups/storage',patch);report.checks.push({allowed:Object.keys(patch)[0],status:200})}await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'");await api('PUT','/backups/storage',{s3Bucket:'empty-catalog-bucket'});report.checks.push({emptyCatalogChangeAllowed:true});if(!baseline){const{withBackupStorageOperation}=await import('/app/backend/dist/services/backup-storage-operation.js');let release,started;const active=new Promise(r=>started=r),wait=new Promise(r=>release=r);const creator=withBackupStorageOperation(async()=>{started();await wait;await insert()});await active;let settled=false;const update=api('PUT','/backups/storage',{s3Bucket:'racing-bucket'},409).then(()=>settled=true);await new Promise(r=>setTimeout(r,20));await update;assert.equal(settled,true);release();await creator;assert.equal((await api('GET','/backups/storage')).json().s3Bucket,'empty-catalog-bucket');report.checks.push({inFlightUpdateRejectedBeforeCatalogPublication:true});const{readBackupContent,getBackupById}=await import('/app/backend/dist/services/backup-service.js');const stale=await getBackupById('owned-origin');let releaseDelete;const deletion=withBackupStorageOperation(async()=>{await new Promise(r=>releaseDelete=r);await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'")});const download=readBackupContent(stale);const rejected=assert.rejects(download,/Бэкап не найден/);await Promise.resolve();releaseDelete();await deletion;await rejected;report.checks.push({staleDownloadRejectsBeforeS3AfterDeletion:true})}report.passed=true}catch(e){report.failure={name:e.name}}finally{await pool.query("DELETE FROM backup_entries WHERE id='owned-origin'");await databaseBackgroundWork.drain();await app.close();await closePool();fs.writeFileSync('/checks/storage-origin233.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));if(!report.passed)process.exitCode=1}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own216.invalid';process.env.AUTH_PORTAL_URL='https://own216.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js'),{fetchBgpSessionsForAlerts}=await import('/app/backend/dist/services/bgp-peers-live.js'),{collectGreBgpSnapshotOnce}=await import('/app/backend/dist/services/gre-bgp-snapshot-collector.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
let phase='populated',app,nativeCalls=0,nativeWrites=0,disabledCalls=0;const cases=[]
|
||||
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');if(phase==='failed'||(phase==='partial'&&label==='second')){res.statusCode=401;res.end(JSON.stringify({error:401,message:'own216 fixture denied'}));return}const rows=phase==='populated'?[{'.id':'*1',name:'own216-peer',state:'established'}]:[];res.end(JSON.stringify(rows))})
|
||||
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
|
||||
const baseline=process.env.MMAPP_BASELINE==='true';const report={date:'2026-10-14',block:'12.5.216',baseline,database,cases,scope:'Compiled production routes/auth, isolated PostgreSQL and RouterOS HTTP fixture; real CHR unchanged',passed:false}
|
||||
try{
|
||||
await initDatabase();const ids=[];for(let i=0;i<natives.length;i++)ids.push(Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4) RETURNING id",['own216-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])).rows[0].id))
|
||||
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own216-reader',apps:['mm'],permissions:['mm:bgp:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token}
|
||||
const wrongToken=app.jwt.sign({sub:'own216-other-reader',apps:['mm'],permissions:['mm:network:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});for(const [name,h,status] of [['anonymous',{},401],['wrong permission',{authorization:'Bearer '+wrongToken},403]]){const before=nativeCalls,r=await app.inject({method:'GET',url:'/api/bgp/sessions',headers:h});assert.equal(r.statusCode,status);assert.equal(nativeCalls,before);cases.push({phase:name,endpoint:'bgp/sessions',status,nativeCalls:0})}
|
||||
for(phase of ['populated','empty','partial','failed','disabled']){
|
||||
if(phase==='disabled')await pool.query('UPDATE servers SET enabled=false')
|
||||
const hasFailure=['partial','failed'].includes(phase),expected=phase==='partial'?[ids[1]]:phase==='failed'?ids.slice(0,2):[]
|
||||
for(const endpoint of ['bgp/sessions','bgp/catalog']){
|
||||
const before=nativeCalls,r=await app.inject({method:'GET',url:'/api/'+endpoint,headers}),body=r.json(),isCatalog=endpoint==='bgp/catalog'
|
||||
assert.equal(r.statusCode,!baseline&&hasFailure&&!isCatalog?503:200)
|
||||
if(isCatalog||(!baseline&&hasFailure)){assert.deepEqual(body.failures.map(f=>f.serverId),expected);if(isCatalog)assert.equal(body.sessions.length,phase==='populated'?2:0)}else{assert(Array.isArray(body));assert.equal(body.length,phase==='populated'?2:0)}
|
||||
if(phase==='disabled')assert.equal(nativeCalls,before)
|
||||
cases.push({phase,endpoint,status:r.statusCode,failures:body.failures?.length??0,nativeCalls:nativeCalls-before})
|
||||
}
|
||||
const snapshot=await collectGreBgpSnapshotOnce({fetchGre:async()=>[{targetLabel:'own216-GRE-control',status:'up'}],fetchBgp:fetchBgpSessionsForAlerts})
|
||||
assert.equal(snapshot.greWritten,1);assert.equal(snapshot.bgpWritten,phase==='populated'?2:0);assert.equal(snapshot.errors.length,!baseline&&hasFailure?1:0)
|
||||
if(snapshot.errors.length)assert.match(snapshot.errors[0],/BGP: Не опрошены устройства BGP/)
|
||||
cases.push({phase,endpoint:'compiled snapshot collector',bgpWritten:snapshot.bgpWritten,greWritten:1,sourceErrors:snapshot.errors.length})
|
||||
}
|
||||
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/bgp216-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/bgp216-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Separate output: never replace an in-flight or accepted poll-memory tree. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdir, lstat } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
assert(process.argv.length===2||process.argv.length===3,'Supply at most one new output name')
|
||||
const outputName=process.argv[2]??'dist-installation-memory'
|
||||
assert(/^dist-installation-memory(?:-[a-z0-9-]{3,64})?$/.test(outputName),'Invalid compiled output name')
|
||||
const backend=resolve(dirname(fileURLToPath(import.meta.url)),'../..'), output=resolve(backend,outputName)
|
||||
const existing=await lstat(output).catch(error=>{if(error.code!=='ENOENT')throw error})
|
||||
assert(!existing,'Use a new output directory; no implicit cleanup or concurrent rebuild')
|
||||
await mkdir(output)
|
||||
const configPath=resolve(backend,'tsconfig.json'),config=ts.readConfigFile(configPath,ts.sys.readFile)
|
||||
assert(!config.error)
|
||||
const parsed=ts.parseJsonConfigFileContent(config.config,ts.sys,backend,undefined,configPath)
|
||||
const options={...parsed.options,rootDir:resolve(backend,'src'),outDir:output,noEmit:false,noEmitOnError:true,declaration:false,declarationMap:false,sourceMap:false,incremental:false,composite:false,tsBuildInfoFile:undefined}
|
||||
const roots=['src/services/installation-co-load.owned.test.ts','src/services/traffic-flow-collector-worker.ts'].map(file=>resolve(backend,file))
|
||||
const program=ts.createProgram({rootNames:roots,options}),diagnostics=ts.getPreEmitDiagnostics(program)
|
||||
if(diagnostics.length)throw Error(ts.formatDiagnosticsWithColorAndContext(diagnostics,{getCurrentDirectory:()=>backend,getCanonicalFileName:file=>file,getNewLine:()=> '\n'}))
|
||||
assert(!program.emit().emitSkipped)
|
||||
console.log(JSON.stringify({output,rootEntries:roots.length,actualWorkerIncluded:true,productionModulesSubstituted:false}))
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Emit one owned memory entry plus its actual production import tree. The
|
||||
* measured child runs plain node, not the TypeScript compiler or tsx. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { lstat, rm } from 'node:fs/promises'
|
||||
import { dirname, basename, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
const backend = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const output = resolve(backend, 'dist-mcp-memory')
|
||||
assert.equal(dirname(output), backend)
|
||||
assert.equal(basename(output), 'dist-mcp-memory')
|
||||
const existing = await lstat(output).catch(error => { if (error.code !== 'ENOENT') throw error })
|
||||
assert(!existing?.isSymbolicLink(), 'Refuse to clean a redirected output directory')
|
||||
if (existing) await rm(output, { recursive: true })
|
||||
const configPath = resolve(backend, 'tsconfig.json')
|
||||
const config = ts.readConfigFile(configPath, ts.sys.readFile)
|
||||
const format = diagnostics => ts.formatDiagnosticsWithColorAndContext(diagnostics, {
|
||||
getCurrentDirectory: () => backend, getCanonicalFileName: name => name, getNewLine: () => '\n',
|
||||
})
|
||||
if (config.error) throw Error(format([config.error]))
|
||||
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, backend, undefined, configPath)
|
||||
if (parsed.errors.length) throw Error(format(parsed.errors))
|
||||
const entry = resolve(backend, 'src/services/server-poll-memory.owned.test.ts')
|
||||
const options = { ...parsed.options, rootDir: resolve(backend, 'src'), outDir: output,
|
||||
noEmit: false, noEmitOnError: true, declaration: false, declarationMap: false, sourceMap: false,
|
||||
incremental: false, composite: false, tsBuildInfoFile: undefined }
|
||||
// Deliberately override tsconfig include/exclude with this single test root.
|
||||
// TypeScript still follows all normal imports; no module/path/IO substitutes.
|
||||
const program = ts.createProgram({ rootNames: [entry], options })
|
||||
const diagnostics = ts.getPreEmitDiagnostics(program)
|
||||
if (diagnostics.length) throw Error(format(diagnostics))
|
||||
const emitted = program.emit()
|
||||
if (emitted.emitSkipped || emitted.diagnostics.length) throw Error(format(emitted.diagnostics) || 'Memory entry emit skipped')
|
||||
console.log(JSON.stringify({ compiledPollMemoryEntry: resolve(output, 'services/server-poll-memory.owned.test.js'),
|
||||
outputDirectory: output, rootEntries: 1, noEmitOnError: true, measuredChildUsesTsx: false }))
|
||||
@@ -0,0 +1,169 @@
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, realpath, lstat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
export const SOURCES = Object.freeze({
|
||||
'194.226.121.150': Object.freeze({ serverId: 1, port: 19081 }),
|
||||
'104.165.244.7': Object.freeze({ serverId: 2, port: 19082 }),
|
||||
});
|
||||
export const PATHS = Object.freeze([
|
||||
'/system/resource', '/routing/route', '/routing/nexthop', '/interface',
|
||||
'/routing/table', '/ip/vrf', '/routing/nexthop', '/routing/route', '/system/resource',
|
||||
]);
|
||||
const LIMITS = Object.freeze({ response: 1048576, bytes: 8388608, requests: 256, rows: 1000, totalRows: 10000, duration: 120000 });
|
||||
const sha = value => createHash('sha256').update(value).digest('hex');
|
||||
const deny = code => { throw new Error(code); };
|
||||
|
||||
export function configuration(args) {
|
||||
const options = {};
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
if (!['--source', '--transport', '--output', '--owned-tunnel-attestation'].includes(args[i]) || !args[i + 1] || options[args[i]] !== undefined) deny('invalid-options');
|
||||
options[args[i]] = args[i + 1];
|
||||
}
|
||||
const source = options['--source'], transport = options['--transport'];
|
||||
if (!Object.hasOwn(SOURCES, source) || !['https', 'ssh-loopback'].includes(transport) || !options['--output']) deny('invalid-source-or-transport');
|
||||
const attestation = options['--owned-tunnel-attestation'];
|
||||
if (transport === 'ssh-loopback' && attestation !== 'verified-known-host-161.104.46.222-owned-forward') deny('owned-tunnel-attestation-required');
|
||||
if (transport === 'https' && attestation !== undefined) deny('irrelevant-tunnel-attestation');
|
||||
return { source, serverId: SOURCES[source].serverId, transport, output: options['--output'],
|
||||
endpoint: transport === 'https' ? `https://${source}:443` : `http://127.0.0.1:${SOURCES[source].port}`,
|
||||
tunnelAttestation: attestation ?? null };
|
||||
}
|
||||
|
||||
export async function privateDirectory(output, root = ROOT) {
|
||||
// Reject symlinks/junctions at every existing component, including tmp itself.
|
||||
const base = path.resolve(root, 'tmp'), target = path.resolve(root, output);
|
||||
const relative = path.relative(base, target);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) deny('output-must-be-inside-tmp');
|
||||
await mkdir(base, { recursive: true, mode: 0o700 });
|
||||
if ((await lstat(base)).isSymbolicLink() || await realpath(base) !== base) deny('output-symlink-refused');
|
||||
let current = base;
|
||||
for (const part of relative.split(path.sep)) {
|
||||
current = path.join(current, part);
|
||||
try { await mkdir(current, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
||||
const stat = await lstat(current);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(current) !== current) deny('output-symlink-refused');
|
||||
}
|
||||
// Exclusive claim prevents mixing runs and refuses existing captures.
|
||||
await writeFile(path.join(target, 'capture.claim'), 'chr-recursive-observation-v1\n', { flag: 'wx', mode: 0o600 });
|
||||
return target;
|
||||
}
|
||||
|
||||
export function decodeBody(body) {
|
||||
let text, payload;
|
||||
try { text = new TextDecoder('utf-8', { fatal: true }).decode(body); payload = JSON.parse(text); } catch { deny('invalid-utf8-or-json'); }
|
||||
const rows = Array.isArray(payload) ? payload : payload && typeof payload === 'object' ? [payload] : deny('invalid-row-shape');
|
||||
if (rows.length > LIMITS.rows || rows.some(row => !row || typeof row !== 'object' || Array.isArray(row))) deny('row-limit-or-shape');
|
||||
return { rows };
|
||||
}
|
||||
|
||||
export function readResponse(endpoint, resource, authorization, budget) {
|
||||
const allowed = Object.entries(SOURCES).flatMap(([host, item]) => [`https://${host}:443`, `http://127.0.0.1:${item.port}`]);
|
||||
if (!allowed.includes(endpoint)) deny('non-whitelisted-endpoint');
|
||||
if (!PATHS.includes(resource) || budget.bytes >= LIMITS.bytes || budget.rows >= LIMITS.totalRows || ++budget.requests > LIMITS.requests || Date.now() >= budget.deadline) deny('request-budget');
|
||||
const url = new URL('/rest' + resource, endpoint);
|
||||
return new Promise((resolve, reject) => {
|
||||
const fail = (code, status) => reject(Object.assign(new Error(code), Number.isInteger(status) ? { httpStatus: status } : {}));
|
||||
const req = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
|
||||
method: 'GET', agent: false, rejectUnauthorized: true, maxHeaderSize: 16384,
|
||||
headers: { Authorization: authorization, Accept: 'application/json', 'Accept-Encoding': 'identity' },
|
||||
}, res => {
|
||||
const chunks = []; let size = 0;
|
||||
res.on('data', chunk => {
|
||||
size += chunk.length; budget.bytes += chunk.length;
|
||||
if (size > LIMITS.response || budget.bytes > LIMITS.bytes) { req.destroy(); fail('byte-limit'); return; }
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('aborted', () => fail('response-aborted'));
|
||||
res.on('error', () => fail('response-failed'));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
// Raw failed HTTP responses are excluded: error bodies can reflect secrets.
|
||||
if (res.statusCode !== 200) return fail('http-status-refused', res.statusCode);
|
||||
const type = res.headers['content-type'];
|
||||
if (typeof type !== 'string' || !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(type) || res.headers['content-encoding'] && res.headers['content-encoding'] !== 'identity') return fail('response-encoding-refused');
|
||||
try {
|
||||
const { rows } = decodeBody(body);
|
||||
budget.rows += rows.length;
|
||||
if (budget.rows > LIMITS.totalRows || Date.now() >= budget.deadline) return fail('response-budget');
|
||||
resolve({ body, rows, status: res.statusCode, contentType: type });
|
||||
} catch { fail('invalid-response'); }
|
||||
});
|
||||
});
|
||||
const timer = setTimeout(() => { req.destroy(); fail('absolute-deadline'); }, Math.max(1, budget.deadline - Date.now()));
|
||||
req.on('error', () => fail('transport-failed'));
|
||||
req.on('close', () => clearTimeout(timer));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function acquire(config, credentials) {
|
||||
if (typeof credentials?.username !== 'string' || !credentials.username || credentials.username.length > 256 || /[:\r\n]/.test(credentials.username) || typeof credentials.password !== 'string' || !credentials.password || credentials.password.length > 4096 || /[\r\n]/.test(credentials.password)) deny('invalid-private-credentials');
|
||||
// Validate caller configuration again; exported API cannot supply a custom host.
|
||||
const fixed = configuration(['--source', config.source, '--transport', config.transport, '--output', config.output,
|
||||
...(config.tunnelAttestation ? ['--owned-tunnel-attestation', config.tunnelAttestation] : [])]);
|
||||
const directory = await privateDirectory(fixed.output);
|
||||
const budget = { requests: 0, bytes: 0, rows: 0, deadline: Date.now() + LIMITS.duration };
|
||||
const authorization = 'Basic ' + Buffer.from(credentials.username + ':' + credentials.password).toString('base64');
|
||||
const report = { format: 'chr-recursive-raw-acquisition-v1', source: fixed.source, serverId: fixed.serverId,
|
||||
transport: fixed.transport, tunnelAttestation: fixed.tunnelAttestation, startedAt: new Date().toISOString(),
|
||||
semanticProof: false, complete: false, records: [], limits: LIMITS };
|
||||
let version, attemptedResource;
|
||||
try {
|
||||
for (const [index, resource] of PATHS.entries()) {
|
||||
attemptedResource = resource;
|
||||
const startedAt = new Date().toISOString();
|
||||
const response = await readResponse(fixed.endpoint, resource, authorization, budget);
|
||||
// Never persist a body reflecting the private credential or Basic value.
|
||||
if ([credentials.password, authorization].some(secret => response.body.includes(Buffer.from(secret)))) deny('credential-reflection-refused');
|
||||
if (resource === '/system/resource') {
|
||||
const current = response.rows.length === 1 && response.rows[0].version;
|
||||
if (typeof current !== 'string' || !/^7\.2[0-6](?:\.\d{1,3})?(?: \((?:stable|long-term)\))?$/.test(current) || version && version !== current) deny('unsupported-or-drifting-version');
|
||||
version = current;
|
||||
}
|
||||
const file = `${String(index + 1).padStart(2, '0')}-${resource.slice(1).replaceAll('/', '-')}.json`;
|
||||
await writeFile(path.join(directory, file), response.body, { flag: 'wx', mode: 0o600 });
|
||||
report.records.push({ file, resource, startedAt, finishedAt: new Date().toISOString(), version,
|
||||
status: response.status, contentType: response.contentType, bytes: response.body.length, rows: response.rows.length, rawSha256: sha(response.body) });
|
||||
}
|
||||
report.complete = true;
|
||||
} catch (error) {
|
||||
report.failure = 'acquisition-incomplete';
|
||||
report.failedResource = attemptedResource;
|
||||
if (Number.isInteger(error.httpStatus) && error.httpStatus >= 100 && error.httpStatus <= 599) report.failedHttpStatus = error.httpStatus;
|
||||
}
|
||||
report.finishedAt = new Date().toISOString();
|
||||
report.usage = { requests: budget.requests, bytes: budget.bytes, rows: budget.rows };
|
||||
await writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
||||
return report;
|
||||
}
|
||||
|
||||
async function privateStdin() {
|
||||
// Piped stdin is private (no terminal echo). TTY raw mode hides all input.
|
||||
const input = process.stdin, chunks = []; let size = 0;
|
||||
if (input.isTTY) { process.stderr.write('Private credentials JSON (hidden), then Enter: '); input.setRawMode(true); }
|
||||
try {
|
||||
for await (const chunk of input) {
|
||||
size += chunk.length;
|
||||
if (size > 8192) deny('private-input-limit');
|
||||
if (input.isTTY && chunk.includes(3)) deny('private-input-cancelled');
|
||||
chunks.push(chunk);
|
||||
if (input.isTTY && (chunk.includes(13) || chunk.includes(10))) break;
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8').trim());
|
||||
} finally { if (input.isTTY) { input.setRawMode(false); process.stderr.write('\n'); } }
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
const config = configuration(process.argv.slice(2));
|
||||
const report = await acquire(config, await privateStdin());
|
||||
// Stdout contains metadata only; no native bodies, credentials or errors.
|
||||
process.stdout.write(JSON.stringify({ complete: report.complete, semanticProof: false, source: report.source, records: report.records.length }) + '\n');
|
||||
if (!report.complete) process.exitCode = 1;
|
||||
} catch { process.stderr.write('Read-only acquisition refused or incomplete.\n'); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { createServer } from 'node:http';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { configuration, decodeBody, readResponse, acquire, privateDirectory, PATHS } from './chr-recursive-observation.mjs';
|
||||
|
||||
test('source/transport allowlist refuses arbitrary hosts, plaintext external, missing tunnel attestation and option injection', () => {
|
||||
const args = ['--source', '194.226.121.150', '--transport', 'https', '--output', 'tmp/acquisition-test'];
|
||||
assert.equal(configuration(args).endpoint, 'https://194.226.121.150:443');
|
||||
for (const changes of [args.map(x => x === '194.226.121.150' ? 'example.org' : x), args.map(x => x === 'https' ? 'http' : x),
|
||||
args.map(x => x === 'https' ? 'ssh-loopback' : x), [...args, '--password', 'forbidden'], [...args, '--source', '104.165.244.7']]) assert.throws(() => configuration(changes));
|
||||
const tunnel = configuration([...args.map(x => x === 'https' ? 'ssh-loopback' : x), '--owned-tunnel-attestation', 'verified-known-host-161.104.46.222-owned-forward']);
|
||||
assert.equal(tunnel.endpoint, 'http://127.0.0.1:19081');
|
||||
});
|
||||
|
||||
test('full own fixture saves nine exact immutable captures and metadata; reflected errors never enter reports', async () => {
|
||||
const output = fileURLToPath(new URL('../../../tmp/chr-recursive-own-fixture-' + randomUUID(), import.meta.url)), seen = [];
|
||||
let reflect = false;
|
||||
const server = createServer((req, res) => {
|
||||
seen.push(req.url); res.setHeader('Content-Type', 'application/json');
|
||||
if (reflect) { res.statusCode = 401; res.end('{"message":"own-fixture-secret-only"}'); return; }
|
||||
res.end(req.url === '/rest/system/resource' ? '[{"version":"7.26"}]\n' : '[{".id":"*1"}]\n');
|
||||
});
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(19081, '127.0.0.1', resolve); });
|
||||
const makeConfig = destination => configuration(['--source', '194.226.121.150', '--transport', 'ssh-loopback', '--output', destination,
|
||||
'--owned-tunnel-attestation', 'verified-known-host-161.104.46.222-owned-forward']);
|
||||
try {
|
||||
const report = await acquire(makeConfig(output), { username: 'own-fixture', password: 'own-fixture-secret-only' });
|
||||
assert.equal(report.complete, true); assert.equal(report.semanticProof, false);
|
||||
assert.deepEqual(seen, PATHS.map(resource => '/rest' + resource));
|
||||
for (const record of report.records) {
|
||||
const body = await readFile(output + '/' + record.file);
|
||||
assert.equal(createHash('sha256').update(body).digest('hex'), record.rawSha256);
|
||||
assert.equal(body.length, record.bytes); assert.equal(record.version, '7.26');
|
||||
}
|
||||
await assert.rejects(acquire(makeConfig(output), { username: 'own-fixture', password: 'own-fixture-secret-only' }));
|
||||
assert.equal(seen.length, 9);
|
||||
await assert.rejects(privateDirectory('../outside-tmp'));
|
||||
reflect = true;
|
||||
const failedOutput = output + '-failed';
|
||||
const failed = await acquire(makeConfig(failedOutput), { username: 'own-fixture', password: 'own-fixture-secret-only' });
|
||||
assert.equal(failed.complete, false); assert.equal(failed.records.length, 0);
|
||||
assert.equal(failed.failedResource, '/system/resource'); assert.equal(failed.failedHttpStatus, 401);
|
||||
assert.deepEqual((await readdir(failedOutput)).sort(), ['capture.claim', 'report.json']);
|
||||
assert(!String(await readFile(failedOutput + '/report.json')).includes('own-fixture-secret-only'));
|
||||
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
|
||||
test('exact UTF8 and row bounds fail closed', () => {
|
||||
assert.equal(decodeBody(Buffer.from('[{"version":"7.26"}]')).rows.length, 1);
|
||||
for (const body of [Buffer.from([0xff]), Buffer.from('null'), Buffer.from('[[]]'), Buffer.from(JSON.stringify(Array.from({ length: 1001 }, () => ({}))))]) assert.throws(() => decodeBody(body));
|
||||
});
|
||||
|
||||
test('own loopback fixture observes exact GET paths, refuses redirect/error/encoding/bytes, no retry or follow', async () => {
|
||||
let mode = 'normal', requests = 0;
|
||||
const server = createServer((req, res) => {
|
||||
requests++;
|
||||
assert.equal(req.method, 'GET'); assert.equal(req.url, '/rest/system/resource');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (mode === 'redirect') { res.statusCode = 302; res.setHeader('Location', 'http://example.invalid'); res.end('[]'); }
|
||||
else if (mode === 'error') { res.statusCode = 401; res.end('{"error":"reflected-private-input"}'); }
|
||||
else if (mode === 'encoding') { res.setHeader('Content-Encoding', 'gzip'); res.end('[]'); }
|
||||
else if (mode === 'oversized') res.end(' '.repeat(1048577));
|
||||
else res.end('[{"version":"7.26"}]\n');
|
||||
});
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(19081, '127.0.0.1', resolve); });
|
||||
const budget = () => ({ requests: 0, bytes: 0, rows: 0, deadline: Date.now() + 5000 });
|
||||
try {
|
||||
const first = await readResponse('http://127.0.0.1:19081', PATHS[0], 'Basic own-fixture', budget());
|
||||
assert.equal(first.body.toString(), '[{"version":"7.26"}]\n');
|
||||
for (mode of ['redirect', 'error', 'encoding', 'oversized']) await assert.rejects(readResponse('http://127.0.0.1:19081', PATHS[0], 'Basic own-fixture', budget()));
|
||||
assert.equal(requests, 5);
|
||||
assert.throws(() => readResponse('http://194.226.121.150', PATHS[0], '', budget()));
|
||||
assert.throws(() => readResponse('http://127.0.0.1:19081', '/system/reboot', '', budget()));
|
||||
assert.throws(() => readResponse('http://127.0.0.1:19081', PATHS[0], '', { ...budget(), requests: 256 }));
|
||||
assert.throws(() => readResponse('http://127.0.0.1:19081', PATHS[0], '', { ...budget(), deadline: 0 }));
|
||||
assert.equal(requests, 5);
|
||||
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs';import path from 'node:path';import{pathToFileURL}from'node:url';import{createHash}from'node:crypto';
|
||||
const vantage=process.env.MMAPP_TEST_VANTAGE;assert(['workstation','debian'].includes(vantage));const output=process.env.MMAPP_TEST_REPORT;assert(output);const hosts=['194.226.121.150','104.165.244.7'],root=path.resolve('backend/dist'),{MikrotikClient}=await import(pathToFileURL(root+'/services/mikrotik.js'));let rows;
|
||||
if(vantage==='debian'){const{pool}=await import(pathToFileURL(root+'/db/index.js'));try{rows=(await pool.query("SELECT host,port,use_ssl,enabled,username,password FROM servers WHERE host IN ('194.226.121.150','104.165.244.7') ORDER BY host")).rows}finally{await pool.end()}}else{assert(process.env.MMAPP_TEST_CHR_USER&&process.env.MMAPP_TEST_CHR_PASSWORD);rows=hosts.map(host=>({host,port:80,use_ssl:false,enabled:true,username:process.env.MMAPP_TEST_CHR_USER,password:process.env.MMAPP_TEST_CHR_PASSWORD}))}assert.equal(rows.length,2);assert.deepEqual(rows.map(r=>r.host).sort(),[...hosts].sort());assert(rows.every(r=>r.enabled&&r.port===80&&!r.use_ssl));
|
||||
const cycles=Number(process.env.MMAPP_REST_CYCLES??8),periodMs=Number(process.env.MMAPP_REST_PERIOD_MS??12000),maxMs=Number(process.env.MMAPP_REST_MAX_MS??960000);assert(Number.isInteger(cycles)&&cycles>=1&&cycles<=32);assert(Number.isInteger(periodMs)&&periodMs>=1000&&periodMs<=60000);assert(Number.isInteger(maxMs)&&maxMs>=10000&&maxMs<=960000);const deadline=Date.now()+maxMs;const report={date:'2026-10-14',block:process.env.MMAPP_REST_BLOCK??'12.5.236',vantage,cycles,periodMs,maxMs,credentialSource:vantage==='debian'?'stored-enabled-server-rows':'explicit-owned-test-env',startedAt:new Date().toISOString(),requests:[],diagnostics:[],configurationWrites:0,passwordChanges:0,rootCauseKnown:false};const sleep=ms=>new Promise(r=>setTimeout(r,ms));const clients=new Map(rows.map(r=>[r.host,new MikrotikClient({host:r.host,port:80,useSsl:false,verifySsl:false,username:r.username,password:r.password})]));
|
||||
function errorMetadata(e,body){let obj;try{obj=JSON.parse(body)}catch{}const detail=String(obj?.detail??'');return{errorName:e?.name??null,errorBodySha256:body?createHash('sha256').update(body).digest('hex'):null,errorKeys:obj&&typeof obj==='object'?Object.keys(obj).sort():[],detailCategory:/not enough permissions/i.test(detail)?'insufficient-policy':/not allowed/i.test(detail)?'not-allowed':/session closed/i.test(detail)?'session-closed':detail?'unclassified':'absent'}}
|
||||
async function read(row,endpoint,mode,cycle,kind='sample'){const begin=Date.now(),record={host:row.host,path:endpoint,mode,cycle,kind,startedAt:new Date().toISOString()};let data;try{if(mode==='compiled-client'){data=await clients.get(row.host).get(endpoint,6000);record.status=200}else{const authorization='Basic '+Buffer.from(row.username+':'+row.password).toString('base64');const r=await fetch('http://'+row.host+'/rest'+endpoint,{headers:{authorization,Connection:'close'},signal:AbortSignal.timeout(6000)}),body=await r.text();record.status=r.status;record.basicChallenge=/Basic/i.test(r.headers.get('www-authenticate')??'');if(r.ok)data=JSON.parse(body);else Object.assign(record,errorMetadata(null,body))}}catch(e){record.status=e.statusCode??null;Object.assign(record,errorMetadata(e,e.body??''))}record.durationMs=Date.now()-begin;report.requests.push(record);await sleep(125);return data}
|
||||
async function diagnostic(row,phase){const userRows=await read(row,'/user','compiled-client',0,'diagnostic'),users=Array.isArray(userRows)?userRows.filter(u=>u.name===row.username):[];const groups=await read(row,'/user/group','compiled-client',0,'diagnostic'),group=Array.isArray(groups)?groups.find(g=>g.name===users[0]?.group):null;const active=await read(row,'/user/active','compiled-client',0,'diagnostic');const apiSessions=Array.isArray(active)?active.filter(s=>s.name===row.username&&s.via==='api').map(s=>createHash('sha256').update(String(s['.id'])+'|'+String(s.when)).digest('hex')).sort():null;const services=await read(row,'/ip/service','compiled-client',0,'diagnostic'),resource=await read(row,'/system/resource','compiled-client',0,'diagnostic'),logs=await read(row,'/log','compiled-client',0,'diagnostic');const auth=Array.isArray(logs)?logs.filter(l=>/login failure/i.test(String(l.message))):[];const events=auth.map(l=>{const message=String(l.message);return{time:l.time??null,via:message.match(/via (\S+)/)?.[1]??null,fromTestDebian:/from 161\.104\.46\.222\b/.test(message),forConfiguredUser:message.includes('for user '+row.username+' ')}});const www=Array.isArray(services)?services.filter(s=>s.name==='www'):[];report.diagnostics.push({host:row.host,phase,apiSessionFingerprints:apiSessions,retainedRestSessions:Array.isArray(active)?active.filter(s=>s.via==='rest-api').length:null,userPresent:users.length===1,userDisabled:users[0]?.disabled??null,userAddressRestriction:users[0]?.address??null,hasApiPolicy:group?String(group.policy).split(',').includes('api'):null,hasRestApiPolicy:group?String(group.policy).split(',').includes('rest-api'):null,wwwEnabled:www.some(s=>s.disabled==='false'),wwwMaxSessions:www.find(s=>s['max-sessions'])?.['max-sessions']??null,wwwDynamicRows:www.filter(s=>s.dynamic==='true').length,resource:resource?{version:resource.version,uptime:resource.uptime,cpuLoad:resource['cpu-load'],freeMemory:resource['free-memory']}:null,retainedAuthFailureCount:auth.length,recentFailures:events.slice(-15),retainedLogIsNotCurrentRate:true})}
|
||||
function save(){const samples=report.requests.filter(r=>r.kind==='sample');report.completedAt=new Date().toISOString();report.summary={sampleRequests:samples.length,failedSamples:samples.filter(r=>r.status!==200).length,allRequests:report.requests.length,failures:report.requests.filter(r=>r.status!==200).map(r=>({host:r.host,path:r.path,mode:r.mode,status:r.status,kind:r.kind,startedAt:r.startedAt}))};fs.writeFileSync(output,JSON.stringify(report,null,2)+'\n')}
|
||||
try{for(const row of rows)await diagnostic(row,'before');const started=Date.now(),paths=['/system/resource','/interface/gre','/routing/bgp/session','/routing/ospf/neighbor','/interface/wireguard/peers','/interface/vxlan'];for(let cycle=1;cycle<=cycles&&Date.now()<deadline;cycle++){for(const row of rows)for(const endpoint of paths){const modes=cycle%2?['compiled-client','fetch-close']:['fetch-close','compiled-client'];for(const mode of modes){const previous=report.requests.length;await read(row,endpoint,mode,cycle);if(report.requests[previous].status!==200){await read(row,'/system/resource','fetch-close',cycle,'failure-control');await diagnostic(row,'failure-'+cycle)}}}save();console.log(JSON.stringify({vantage,cycle,samples:report.summary.sampleRequests,failed:report.summary.failedSamples}));if(cycle<cycles)await sleep(Math.max(0,Math.min(deadline,started+cycle*periodMs)-Date.now()))}for(const row of rows)await diagnostic(row,'after')}finally{save();console.log(JSON.stringify({vantage,summary:report.summary,rootCauseKnown:false}))}
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
|
||||
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? '').split(',')
|
||||
assert.deepEqual(hosts, ['194.226.121.150', '104.165.244.7'], 'Explicit owned lab hosts required')
|
||||
const { MMAPP_TEST_CHR_USER: username, MMAPP_TEST_CHR_PASSWORD: password } = process.env
|
||||
assert(username && password, 'Explicit test credentials required')
|
||||
const vantage = process.env.MMAPP_TEST_VANTAGE
|
||||
assert(['workstation', 'debian'].includes(vantage), 'Explicit vantage required')
|
||||
const output = process.env.MMAPP_TEST_REPORT
|
||||
assert(output, 'Explicit report path required')
|
||||
const authorization = 'Basic ' + Buffer.from(username + ':' + password).toString('base64')
|
||||
const report = { block: '12.5.225', date: '2026-10-14', vantage, startedAt: new Date().toISOString(), requests: [], diagnostics: [], configurationWrites: 0, passwordChanges: 0, rootCauseKnown: false }
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
async function read(host, path) {
|
||||
const startedAt = new Date().toISOString(), start = Date.now()
|
||||
try {
|
||||
const response = await fetch(`http://${host}/rest${path}`, { headers: { authorization, Connection: 'close' }, signal: AbortSignal.timeout(8000) })
|
||||
const body = await response.text()
|
||||
report.requests.push({ host, path, startedAt, durationMs: Date.now() - start, status: response.status, headers: Object.fromEntries(['date', 'server', 'www-authenticate', 'connection'].map(key => [key, response.headers.get(key)])) })
|
||||
if (!response.ok) return null
|
||||
try { return JSON.parse(body) } catch { return null }
|
||||
} catch (error) {
|
||||
report.requests.push({ host, path, startedAt, durationMs: Date.now() - start, status: null, errorName: error.name })
|
||||
return null
|
||||
} finally { await sleep(300) }
|
||||
}
|
||||
|
||||
for (let cycle = 1; cycle <= 3; cycle++) {
|
||||
for (const host of hosts) for (const path of ['/system/resource', '/interface/gre']) await read(host, path)
|
||||
if (cycle < 3) await sleep(5000)
|
||||
}
|
||||
for (const host of hosts) {
|
||||
const clock = await read(host, '/system/clock')
|
||||
const services = await read(host, '/ip/service')
|
||||
const logs = await read(host, '/log')
|
||||
const failures = Array.isArray(logs) ? logs.filter(row => /login failure/i.test(String(row.message))) : null
|
||||
const sources = {}
|
||||
const events = failures?.map(row => {
|
||||
const message = String(row.message), source = message.match(/from ([\d.]+)/)?.[1] ?? 'unparsed'
|
||||
const origin = source === '161.104.46.222' ? 'test-debian' : 'other-or-unidentified'
|
||||
sources[origin] = (sources[origin] ?? 0) + 1
|
||||
return { time: row.time ?? null, origin, via: message.match(/via (\w+)/)?.[1] ?? null }
|
||||
})
|
||||
report.diagnostics.push({ host, clock: clock ? { date: clock.date, time: clock.time, gmtOffset: clock['gmt-offset'] } : null, wwwServices: Array.isArray(services) ? services.filter(row => row.name === 'www').map(row => ({ disabled: row.disabled, maxSessions: row['max-sessions'] ?? null, dynamic: row.dynamic ?? null })) : null, retainedAuthFailures: failures?.length ?? null, sources, recentFailures: events?.slice(-20) ?? null, retainedLogIsNotCurrentRate: true })
|
||||
}
|
||||
report.completedAt = new Date().toISOString()
|
||||
report.summary = hosts.map(host => { const rows = report.requests.filter(row => row.host === host); return { host, requests: rows.length, ok: rows.filter(row => row.status === 200).length, unauthorized: rows.filter(row => row.status === 401).length, serverErrors: rows.filter(row => row.status >= 500).length, transportErrors: rows.filter(row => row.status === null).length } })
|
||||
fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n')
|
||||
console.log(JSON.stringify({ vantage, summary: report.summary, configurationWrites: 0 }))
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
const hosts=(process.env.MMAPP_TEST_CHR_HOSTS??'').split(',');assert.deepEqual(hosts,['194.226.121.150','104.165.244.7'],'Explicit owned lab hosts required')
|
||||
const username=process.env.MMAPP_TEST_CHR_USER,password=process.env.MMAPP_TEST_CHR_PASSWORD;assert(username&&password,'Explicit test credentials required')
|
||||
const authorization='Basic '+Buffer.from(username+':'+password).toString('base64'),paths=['/system/resource','/interface/gre','/routing/bgp/session','/routing/ospf/neighbor'],cases=[],start=Date.now(),deadline=start+360000,samples=20,periodMs=10000
|
||||
const sleep=ms=>new Promise(r=>setTimeout(r,ms)),output='docs/ux-ui/acceptance/chr224-rest-observation-2026-10-14.json'
|
||||
function save(completed){const failed=cases.filter(c=>c.status!==200);fs.writeFileSync(output,JSON.stringify({date:'2026-10-14',block:'12.5.224',startedAt:new Date(start).toISOString(),completedAt:new Date().toISOString(),plannedSamples:samples,completedSamples:completed,periodMs,requests:cases.length,failedRequests:failed.length,cases,configurationWrites:0,passwordChanges:0,scope:'Low-rate direct native REST with Connection close; short window, no burst/load/physical WAN/SLA/root-cause proof'},null,2)+'\n')}
|
||||
for(let sample=1;sample<=samples;sample++){
|
||||
if(Date.now()>deadline)break
|
||||
for(const host of hosts)for(const path of paths){const began=Date.now();try{const r=await fetch('http://'+host+'/rest'+path,{headers:{authorization,Connection:'close'},signal:AbortSignal.timeout(8000)});await r.arrayBuffer();cases.push({sample,host,path,status:r.status,durationMs:Date.now()-began})}catch(e){cases.push({sample,host,path,status:null,errorName:e.name,durationMs:Date.now()-began})}await sleep(150)}
|
||||
save(sample);console.log(JSON.stringify({completedSamples:sample,requests:cases.length,failedRequests:cases.filter(c=>c.status!==200).length}));if(sample<samples)await sleep(Math.max(0,start+sample*periodMs-Date.now()))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Real Portal login; credentials stay in the caller's ignored private fixture.
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium, expect } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
const phase = process.argv[2];
|
||||
assert(['guard', 'remove', 'repeat'].includes(phase));
|
||||
const login = JSON.parse(fs.readFileSync(process.env.MMAPP_PORTAL_FIXTURE || 'tmp/portal195-private.json'));
|
||||
const own = JSON.parse(fs.readFileSync(process.env.MMAPP_CONTAINER_FIXTURE || 'tmp/state248-public.json'));
|
||||
const reportPath = 'docs/ux-ui/acceptance/containers248-live-ui.json';
|
||||
const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath)) : { date: '2026-10-14', block: '12.5.248', realPortalLogin: true, noMocks: true, steps: [], pageErrors: [] };
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.on('pageerror', error => report.pageErrors.push(error.message));
|
||||
await page.goto((process.env.MMAPP_TEST_URL || 'http://localhost:3000') + '/containers');
|
||||
await page.getByLabel('Email', { exact: true }).fill(login.email);
|
||||
await page.getByLabel('Пароль', { exact: true }).fill(login.password);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Путь страницы' })).toContainText('Контейнеры', { timeout: 30000 });
|
||||
const endpoint = `/api/servers/${own.serverId}/containers/remove`;
|
||||
const direct = () => page.evaluate(async ({ endpoint, rosId }) => {
|
||||
const r = await fetch(endpoint, { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('mmapp_token'), 'Content-Type': 'application/json' }, body: JSON.stringify({ rosId }) });
|
||||
const body = await r.json();
|
||||
return { status: r.status, ok: body.ok === true, error: body.error };
|
||||
}, { endpoint, rosId: own.rosId });
|
||||
if (phase === 'repeat') {
|
||||
const result = await direct(); assert.equal(result.status, 409); assert.equal(result.ok, false); assert.match(result.error, /уже отсутствует/);
|
||||
report.steps.push({ phase, status: result.status, absentConflict: true });
|
||||
} else {
|
||||
await page.getByRole('button', { name: 'Действия контейнера ' + own.name, exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'Удалить', exact: true }).click();
|
||||
const dialog = page.getByRole('alertdialog');
|
||||
await expect(dialog).toContainText(own.name);
|
||||
if (phase === 'guard') {
|
||||
report.layout = [];
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await expect(dialog).toContainText('после подтверждённой остановки');
|
||||
await expect(dialog.getByRole('button', { name: 'Удалить', exact: true })).toBeDisabled();
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth > innerWidth), { timeout: 5000 }).toBe(false);
|
||||
const layout = await page.evaluate(() => ({ viewport: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
|
||||
(report.layout ??= []).push(layout);
|
||||
if (layout.scrollWidth > layout.viewport) {
|
||||
report.overflow = await page.evaluate(() => Array.from(document.querySelectorAll('*')).map(el => ({tag:el.tagName,slot:el.getAttribute('data-slot'),left:el.getBoundingClientRect().left,right:el.getBoundingClientRect().right,width:el.getBoundingClientRect().width,position:getComputedStyle(el).position})).filter(el=>el.right>innerWidth+1).slice(0,20));
|
||||
await page.screenshot({ path: `tmp/containers248-guard-${width}.png`, fullPage: true });
|
||||
}
|
||||
assert.equal(layout.scrollWidth > layout.viewport, false);
|
||||
}
|
||||
const result = await direct(); assert.equal(result.status, 409); assert.equal(result.ok, false);
|
||||
report.steps.push({ phase, viewports: [1440, 390], uiDeleteDisabled: true, backendConflict: result.status });
|
||||
} else {
|
||||
await expect(dialog).toContainText('остановленный контейнер');
|
||||
const response = page.waitForResponse(r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST');
|
||||
await dialog.getByRole('button', { name: 'Удалить', exact: true }).click();
|
||||
const r = await response; assert.equal(r.request().postDataJSON().rosId, own.rosId); assert.equal(r.status(), 200); assert.equal((await r.json()).ok, true);
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Действия контейнера ' + own.name, exact: true })).toHaveCount(0);
|
||||
report.steps.push({ phase, status: 200, receiptOk: true, targetMatched: true, rowAbsent: true });
|
||||
}
|
||||
}
|
||||
assert.deepEqual(report.pageErrors, []);
|
||||
delete report.overflow;
|
||||
if (report.failure?.phase === phase) { (report.harnessFailures ??= []).push(report.failure); delete report.failure; }
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n');
|
||||
console.log('Live container removal: ' + phase + ' passed');
|
||||
} catch (error) {
|
||||
report.failure = { phase, name: error.name, ...(error.name === 'AssertionError' ? { actual: error.actual, expected: error.expected } : {}) }; fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); throw error;
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error('Live container removal failed: ' + error.name); process.exitCode = 1; });
|
||||
@@ -0,0 +1,97 @@
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
const { pool } = await import('/app/backend/dist/db/index.js');
|
||||
const { MikrotikClient } = await import('/app/backend/dist/services/mikrotik.js');
|
||||
const rows = (await pool.query("SELECT * FROM servers WHERE host='104.165.244.7' AND enabled=true")).rows; assert.equal(rows.length, 1);
|
||||
const server = rows[0], client = new MikrotikClient({ host: server.host, port: 80, useSsl: false, verifySsl: false, username: server.username, password: server.password });
|
||||
const controlRows = (await pool.query("SELECT * FROM servers WHERE host='194.226.121.150' AND enabled=true")).rows; assert.equal(controlRows.length, 1);
|
||||
const control = new MikrotikClient({ host: controlRows[0].host, port: 80, useSsl: false, verifySsl: false, username: controlRows[0].username, password: controlRows[0].password });
|
||||
const statePath = '/checks/state247-private.json', phase = process.argv[2];
|
||||
const save = state => fs.writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 });
|
||||
const sha = value => createHash('sha256').update(value).digest('hex');
|
||||
const normalizedExport = value => value.split(/\r?\n/).filter(line => !line.startsWith('#') && line.trim()).join('\n');
|
||||
// RouterOS may list available, uninstalled extra packages after reboot; they have no installed version.
|
||||
const packages = rows => rows.filter(p => typeof p.version === 'string' && p.version.trim()).map(p => ({ name: p.name, version: p.version, disabled: p.disabled ?? 'false' })).sort((a,b) => a.name.localeCompare(b.name));
|
||||
const ownContainers = async state => (await client.get('/container', 5000)).filter(c => c.interface === state.veth && c.comment === state.comment);
|
||||
const nativeStatus = row => row.status ?? (row.running === 'true' && row.stopped !== 'true' ? 'running' : row.stopped === 'true' && row.running !== 'true' ? 'stopped' : 'unknown');
|
||||
async function poll(read, accept, maxMs = 150000) {
|
||||
const until = Date.now() + maxMs; let last;
|
||||
while (Date.now() < until) { try { last = await read(); if (accept(last)) return last; } catch {} await new Promise(r => setTimeout(r, 1500)); }
|
||||
throw Error('Native state not confirmed within polling budget');
|
||||
}
|
||||
async function reboot(state, key) {
|
||||
assert(!state[key], 'Never repeat a possibly applied reboot');
|
||||
state[key] = { requested: true, acknowledgement: 'unknown' }; save(state);
|
||||
try { await client.post('/system/reboot', {}, 5000); state[key].acknowledgement = 'confirmed'; }
|
||||
catch (e) { state[key].errorName = e.name; state[key].status = e.statusCode ?? null; }
|
||||
save(state); console.log(JSON.stringify({ phase, rebootRequestedOnce: true, acknowledgement: state[key].acknowledgement }));
|
||||
}
|
||||
try {
|
||||
if (phase === 'init') {
|
||||
assert(!fs.existsSync(statePath));
|
||||
const uuid = randomUUID().slice(0,8), state = { serverId: Number(server.id), targetHost: server.host, veth: 'mmc247-' + uuid, root: 'mmc247-' + uuid + '-root', comment: 'mmc247-' + uuid, imageFile: 'mmc247-busybox.tar', packageFile: 'container-7.24.5.npk', packageSize: 1163409, imageSize: 2226176, beforePackages: packages(await client.get('/system/package')), beforeFiles: await client.get('/file'), beforeExport: normalizedExport(await client.exportConfigScript()), stages: [] };
|
||||
assert(!state.beforePackages.some(p => p.name === 'container')); assert(state.beforePackages.every(p => p.version === '7.24.5'));
|
||||
assert(!state.beforeFiles.some(f => f.name === state.imageFile || f.name === state.packageFile || f.name.startsWith(state.root)));
|
||||
assert(!(await client.get('/interface')).some(i => i.name === state.veth)); await control.get('/system/resource', 5000);
|
||||
state.beforeExportSha256 = sha(state.beforeExport); save(state); console.log('Private native baseline captured; control CHR reachable');
|
||||
} else {
|
||||
const state = JSON.parse(fs.readFileSync(statePath)); assert(/^mmc247-[a-f0-9]{8}$/.test(state.veth)); assert.equal(state.root, state.veth + '-root');
|
||||
if (phase === 'reboot-install') {
|
||||
const files = await client.get('/file');
|
||||
assert(files.some(f => f.name === state.packageFile && Number(f.size) === state.packageSize));
|
||||
assert(files.some(f => f.name === state.imageFile && Number(f.size) === state.imageSize));
|
||||
await control.get('/system/resource', 5000); await reboot(state, 'installReboot');
|
||||
} else if (phase === 'installed') {
|
||||
const p = await poll(() => client.get('/system/package', 4000), p => p.some(p => p.name === 'container' && p.version === '7.24.5' && p.disabled === 'false'));
|
||||
assert.equal((await client.get('/container')).length, 0); await control.get('/system/resource', 5000);
|
||||
state.afterInstallPackages = packages(p); state.stages.push({ phase, packageInstalled: true, version: '7.24.5', controlReachable: true }); save(state); console.log('Container package installed on7.24.5; native endpoint200/control reachable');
|
||||
} else if (phase === 'create') {
|
||||
assert(state.afterInstallPackages); assert.equal((await ownContainers(state)).length, 0);
|
||||
await client.put('/interface/veth', { name: state.veth, address: '198.18.247.2/30', gateway: '198.18.247.1', comment: state.comment });
|
||||
await client.put('/container', { file: state.imageFile, interface: state.veth, 'root-dir': state.root, cmd: '/bin/sleep 600', 'start-on-boot': 'false', comment: state.comment });
|
||||
const containers = await poll(() => ownContainers(state), rows => rows.length === 1 && nativeStatus(rows[0]) === 'stopped'); assert.equal(containers.length, 1);
|
||||
state.containerId = containers[0]['.id']; state.stages.push({ phase, initialStatus: 'stopped' }); save(state);
|
||||
fs.writeFileSync('/checks/state247-public.json', JSON.stringify({ serverId: state.serverId, rosId: state.containerId, interface: state.veth, comment: state.comment, root: state.root })); console.log('Own container extracted/stopped; public identity saved');
|
||||
} else if (phase === 'adopt-created') {
|
||||
const containers = await ownContainers(state); assert.equal(containers.length, 1); assert.equal(nativeStatus(containers[0]), 'stopped');
|
||||
state.containerId = containers[0]['.id']; state.stages.push({ phase: 'create', initialStatus: 'stopped', observedNativeStoppedFlag: containers[0].stopped }); save(state);
|
||||
fs.writeFileSync('/checks/state247-public.json', JSON.stringify({ serverId: state.serverId, rosId: state.containerId, name: containers[0].name, interface: state.veth, comment: state.comment, root: state.root })); console.log('Existing own extracted container adopted read-only');
|
||||
} else if (phase === 'running' || phase === 'stopped') {
|
||||
const containers = await poll(() => ownContainers(state), rows => rows.length === 1 && nativeStatus(rows[0]) === phase, 30000);
|
||||
assert.equal(containers[0]['.id'], state.containerId); state.stages.push({ phase, nativeConfirmed: true }); save(state); console.log('Native status confirmed: ' + phase);
|
||||
} else if (phase === 'cleanup-network') {
|
||||
const containers = await ownContainers(state); assert(containers.length <= 1);
|
||||
if (containers.length) { if (nativeStatus(containers[0]) !== 'stopped') { await client.post('/container/stop', { '.id': containers[0]['.id'] }); await poll(() => ownContainers(state), rows => rows.length === 1 && nativeStatus(rows[0]) === 'stopped', 30000); } await client.delete('/container/' + encodeURIComponent(containers[0]['.id'])); }
|
||||
const veth = (await client.get('/interface/veth')).filter(i => i.name === state.veth); assert(veth.length <= 1);
|
||||
for (const i of veth) { assert.equal(i.comment, state.comment); await client.delete('/interface/veth/' + encodeURIComponent(i['.id'])); }
|
||||
const files = await client.get('/file');
|
||||
const ownFile = f => { const name = String(f.name).replace(/^\/+/, ''); return name === state.imageFile || name === state.packageFile || name === state.root || name.startsWith(state.root + '/'); };
|
||||
assert(!state.beforeFiles.some(ownFile), 'Never remove an original file');
|
||||
const own = files.filter(ownFile).sort((a,b) => b.name.length - a.name.length);
|
||||
assert(own.every(f => !f.name.split('/').includes('..')));
|
||||
for (const f of own) { const current = (await client.get('/file')).find(x => x.name === f.name); if (current) await client.removeFile(current['.id']); }
|
||||
assert.equal((await ownContainers(state)).length, 0); assert(!(await client.get('/interface/veth')).some(i => i.name === state.veth));
|
||||
assert(!(await client.get('/file')).some(ownFile));
|
||||
state.stages.push({ phase, ownObjectsAbsent: true }); save(state); console.log('Own container/veth/image/root removed');
|
||||
} else if (phase === 'uninstall') {
|
||||
assert(state.stages.some(s => s.phase === 'cleanup-network'));
|
||||
const p = (await client.get('/system/package')).filter(p => p.name === 'container'); assert.equal(p.length, 1); assert.equal(p[0].version, '7.24.5');
|
||||
await client.post('/system/package/uninstall', { '.id': p[0]['.id'] });
|
||||
const scheduled = (await client.get('/system/package')).find(p => p.name === 'container'); assert(['uninstall','scheduled for uninstall'].includes(scheduled.scheduled));
|
||||
await reboot(state, 'uninstallReboot');
|
||||
} else if (phase === 'reboot-uninstall') {
|
||||
const scheduled = (await client.get('/system/package')).find(p => p.name === 'container'); assert(scheduled && ['uninstall','scheduled for uninstall'].includes(scheduled.scheduled));
|
||||
await reboot(state, 'uninstallReboot');
|
||||
} else if (phase === 'final') {
|
||||
const p = await poll(() => client.get('/system/package', 4000), p => !packages(p).some(p => p.name === 'container'));
|
||||
assert.deepEqual(packages(p), state.beforePackages);
|
||||
// Automatic timezone discovery can settle after package removal/reboot.
|
||||
// Wait for equality without excluding settings or logging the private export.
|
||||
await poll(async () => normalizedExport(await client.exportConfigScript()), value => value === state.beforeExport);
|
||||
await control.get('/system/resource', 5000);
|
||||
const report = { date: '2026-10-14', block: '12.5.247', targetHost: server.host, controlHost: '194.226.121.150', stages: state.stages, installReboot: state.installReboot, uninstallReboot: state.uninstallReboot, originalPackagesMatched: true, originalNormalizedExportSha256: state.beforeExportSha256, originalNormalizedExportMatched: true, controlReachable: true, passwordChanges: 0, deviceModeChanges: 0, firmwareVersionChanged: false, passed: true };
|
||||
fs.writeFileSync('/checks/containers247-native.json', JSON.stringify(report, null, 2) + '\n'); fs.unlinkSync(statePath); console.log(JSON.stringify(report));
|
||||
} else throw Error('Unsupported phase');
|
||||
}
|
||||
} finally { await pool.end(); }
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
const { pool } = await import('/app/backend/dist/db/index.js');
|
||||
const { MikrotikClient } = await import('/app/backend/dist/services/mikrotik.js');
|
||||
const { getAcmeSettingsPublic } = await import('/app/backend/dist/services/certificates-service.js');
|
||||
const rows = (await pool.query("SELECT * FROM servers WHERE host IN ('194.226.121.150','104.165.244.7') ORDER BY host")).rows;
|
||||
assert.equal(rows.length, 2); assert(rows.every(s => s.enabled && s.port === 80 && !s.use_ssl));
|
||||
const report = { date: '2026-10-14', block: '12.5.243', scope: 'Read-only availability, not container lifecycle or ACME issuance', hosts: [], requests: [], routerosWrites: 0, databaseWrites: 0, dnsWrites: 0, passwordChanges: 0 };
|
||||
async function read(client, host, endpoint) {
|
||||
try { const result = await client.get(endpoint, 8000); report.requests.push({ host, path: endpoint, status: 200 }); return result; }
|
||||
catch (e) { report.requests.push({ host, path: endpoint, status: e.statusCode ?? null, errorName: e.name, errorBodySha256: e.body ? createHash('sha256').update(e.body).digest('hex') : null }); return undefined; }
|
||||
}
|
||||
try {
|
||||
for (const server of rows) {
|
||||
const client = new MikrotikClient({ host: server.host, port: 80, useSsl: false, verifySsl: false, username: server.username, password: server.password });
|
||||
const packages = await read(client, server.host, '/system/package');
|
||||
const resource = await read(client, server.host, '/system/resource');
|
||||
const mode = await read(client, server.host, '/system/device-mode');
|
||||
const disks = await read(client, server.host, '/disk');
|
||||
const containers = await read(client, server.host, '/container');
|
||||
const m = Array.isArray(mode) ? mode[0] : mode;
|
||||
report.hosts.push({ host: server.host, packages: Array.isArray(packages) ? packages.map(p => ({ name: p.name, version: p.version, disabled: p.disabled })) : null, resource: resource ? { version: resource.version, architecture: resource['architecture-name'], boardName: resource['board-name'], freeHddSpace: resource['free-hdd-space'], totalHddSpace: resource['total-hdd-space'], freeMemory: resource['free-memory'] } : null, deviceMode: m ? { mode: m.mode ?? null, container: m.container ?? null, flagged: m.flagged ?? null } : null, disks: Array.isArray(disks) ? disks.length : null, containers: Array.isArray(containers) ? { count: containers.length, statuses: containers.map(c => c.status ?? null) } : null });
|
||||
}
|
||||
const acme = await getAcmeSettingsPublic();
|
||||
report.acme = { tokenConfigured: acme.tokenConfigured, defaultZoneConfigured: Boolean(acme.defaultZoneId), directoryHost: null, directoryStatus: null };
|
||||
const directory = new URL(acme.directoryUrl);
|
||||
assert(!directory.username && !directory.password);
|
||||
report.acme.directoryHost = directory.hostname;
|
||||
if (directory.protocol === 'https:' && ['acme-v02.api.letsencrypt.org','acme-staging-v02.api.letsencrypt.org'].includes(directory.hostname) && directory.pathname === '/directory') {
|
||||
const r = await fetch(directory, { signal: AbortSignal.timeout(8000) }); report.acme.directoryStatus = r.status;
|
||||
if (r.ok) { const data = await r.json(); report.acme.directoryKeys = Object.keys(data).sort(); }
|
||||
}
|
||||
} finally { await pool.end(); fs.writeFileSync('/checks/external-jobs243-availability.json', JSON.stringify(report, null, 2) + '\n'); console.log(JSON.stringify(report)); }
|
||||
@@ -0,0 +1,25 @@
|
||||
import fs from 'node:fs'
|
||||
import assert from 'node:assert/strict'
|
||||
import {execFileSync} from 'node:child_process'
|
||||
import pg from 'pg'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,root='/app/backend/acceptance'
|
||||
assert(/^mm_accept_166_\d+$/.test(database))
|
||||
const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
const client=new pg.Client({connectionString:url.toString()});await client.connect()
|
||||
assert.equal((await client.query('SELECT current_database() AS name')).rows[0].name,database)
|
||||
const role=(await client.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]
|
||||
assert(Object.values(role).every(value=>value===false));await client.end()
|
||||
const pkg=JSON.parse(fs.readFileSync('/app/backend/package.json'))
|
||||
const profiles=pkg.scripts['test:all'].split(/\s*&&\s*/).map(c=>{const m=/^npm run (test:[\w-]+)$/.exec(c);assert(m);return m[1]})
|
||||
const report={date:'2026-10-14',block:'12.5.222',scope:'full backend profile list on owned isolated PostgreSQL; no production DSN',database,profiles:[],passed:false}
|
||||
for(const profile of profiles){
|
||||
const started=Date.now();let output='',passed=true,errorName
|
||||
try{output=execFileSync('npm',['--prefix','backend','run',profile],{cwd:'/app',timeout:240_000,maxBuffer:16*1024**2,encoding:'utf8',env:{...process.env,NODE_ENV:'test',GEOIP_DIR:'/tmp/'+database+'/geoip',DATABASE_PATH:'/tmp/'+database+'/legacy.db'},stdio:['ignore','pipe','pipe']})}
|
||||
catch(e){passed=false;errorName=e.name;output=String(e.stdout??'')+'\n'+String(e.stderr??'')}
|
||||
fs.writeFileSync(root+'/'+profile.replace(':','-')+'-private.log',output,{mode:0o600})
|
||||
const skippedLines=output.split('\n').filter(s=>/\bskip(?:ped)?\b/i.test(s));const unavailableSql=skippedLines.filter(s=>/PostgreSQL|owned|own SQL|explicit owned|database|MMAPP_OWN/i.test(s)&&!s.includes('skip/release/recovery passed'));
|
||||
report.profiles.push({profile,passed,durationMs:Date.now()-started,errorName,skippedLineCount:skippedLines.length,unavailableSqlBranches:unavailableSql.map(line=>line.trim())})
|
||||
fs.writeFileSync(root+'/owned222-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report.profiles.at(-1)))
|
||||
}
|
||||
report.passed=report.profiles.every(p=>p.passed);fs.writeFileSync(root+'/owned222-result.json',JSON.stringify(report,null,2))
|
||||
if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
const database = process.env.MMAPP_OWN_SQL_DB;
|
||||
assert(/^mm_accept_166_\d+$/.test(database));
|
||||
const url = new URL(process.env.DATABASE_URL); assert.equal(url.pathname, '/' + database); assert.equal(url.username, database);
|
||||
assert.equal(process.env.GEOIP_DIR, '/tmp/' + database + '/geoip');
|
||||
fs.mkdirSync('/tmp/' + database, { recursive: true }); process.chdir('/tmp/' + database);
|
||||
const { pool, closePool } = await import('/app/backend/dist/db/index.js');
|
||||
const { initDatabase } = await import('/app/backend/dist/db/bootstrap.js');
|
||||
const role = (await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];
|
||||
assert(Object.values(role).every(v => v === false)); await initDatabase();
|
||||
const { collectGeoipUpdateOnce, setGeoipFetchForTests, resetGeoipUpdateForTests } = await import('/app/backend/dist/services/geoip-update-collector.js');
|
||||
const { getGeoipSettings, updateGeoipSettings } = await import('/app/backend/dist/services/geoip-settings.js');
|
||||
const { geoipReadersStatus, lookupGeoip, GEOIP_COUNTRY_FILE, GEOIP_ASN_FILE } = await import('/app/backend/dist/services/traffic-flow-geoip.js');
|
||||
const { databaseBackgroundWork } = await import('/app/backend/dist/services/database-background-work.js');
|
||||
const report = { date: '2026-10-14', block: '12.5.242', passed: false, scope: 'Compiled GeoIP collector + owned PostgreSQL/files + actual public mirror; controlled negative bodies separately marked', mirrorRequests: [], checks: [], chrContacts: 0, mainDatabaseWrites: 0 };
|
||||
const file = name => path.join(process.env.GEOIP_DIR, name);
|
||||
const sha = name => createHash('sha256').update(fs.readFileSync(file(name))).digest('hex');
|
||||
const save = () => fs.writeFileSync('/checks/geoip-mirror242.json', JSON.stringify(report, null, 2) + '\n');
|
||||
const check = (name, detail = {}) => { report.checks.push({ name, ...detail }); save(); };
|
||||
const realFetch = globalThis.fetch.bind(globalThis);
|
||||
async function observedFetch(url, options) {
|
||||
const started = Date.now(), response = await realFetch(url, options);
|
||||
report.mirrorRequests.push({ file: new URL(String(url)).pathname.split('/').at(-1), conditional: Boolean(options?.headers?.['If-None-Match']), status: response.status, headersMs: Date.now() - started }); save(); return response;
|
||||
}
|
||||
try {
|
||||
await updateGeoipSettings({ enabled: false }); setGeoipFetchForTests(observedFetch);
|
||||
let snapshot = await collectGeoipUpdateOnce({ force: true });
|
||||
assert(!snapshot.fatalError); assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.downloaded, 2);
|
||||
assert(geoipReadersStatus().countryLoaded && geoipReadersStatus().asnLoaded);
|
||||
const initial = { country: sha(GEOIP_COUNTRY_FILE), asn: sha(GEOIP_ASN_FILE) };
|
||||
const lookup = lookupGeoip('8.8.8.8'); assert.equal(lookup?.country, 'US'); assert.equal(lookup?.asn, 15169);
|
||||
check('actual-two-downloads-valid-mmdb-and-lookup', { bytes: snapshot.bytes, hashes: initial, settings: await getGeoipSettings(), lookup: { country: lookup.country, asn: lookup.asn } });
|
||||
const first = collectGeoipUpdateOnce({ force: true });
|
||||
await assert.rejects(collectGeoipUpdateOnce({ force: true }), e => e.statusCode === 409);
|
||||
assert.equal((await collectGeoipUpdateOnce()).skipped, true);
|
||||
snapshot = await first; assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.skippedUnchanged, 2); assert.equal(snapshot.downloaded, 0);
|
||||
check('actual-conditional304-and-concurrent409', { skippedUnchanged: 2, hashesUnchanged: sha(GEOIP_COUNTRY_FILE) === initial.country && sha(GEOIP_ASN_FILE) === initial.asn });
|
||||
fs.unlinkSync(file(GEOIP_COUNTRY_FILE));
|
||||
const beforeMissing = report.mirrorRequests.length; snapshot = await collectGeoipUpdateOnce({ force: true });
|
||||
assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.downloaded, 1); assert.equal(snapshot.skippedUnchanged, 1);
|
||||
assert(report.mirrorRequests.slice(beforeMissing).some(r => r.file === GEOIP_COUNTRY_FILE && !r.conditional && r.status === 200));
|
||||
assert.equal(sha(GEOIP_COUNTRY_FILE), initial.country);
|
||||
check('missing-country-refetched-without-conditional-header');
|
||||
fs.writeFileSync(file(GEOIP_COUNTRY_FILE), 'Own corrupt GeoIP fixture');
|
||||
snapshot = await collectGeoipUpdateOnce({ force: true }); assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.downloaded, 1); assert.equal(sha(GEOIP_COUNTRY_FILE), initial.country);
|
||||
check('corrupt-local-country-refetched-and-validated');
|
||||
const lastSuccess = (await getGeoipSettings()).lastSuccessAt;
|
||||
setGeoipFetchForTests(async (url, options) => String(url).endsWith(GEOIP_COUNTRY_FILE) ? new Response('Own invalid MMDB body', { status: 200 }) : observedFetch(url, options));
|
||||
snapshot = await collectGeoipUpdateOnce({ force: true }); assert.equal(snapshot.errors.length, 1); assert.equal(snapshot.downloaded, 0); assert.equal(snapshot.skippedUnchanged, 1);
|
||||
assert.equal(sha(GEOIP_COUNTRY_FILE), initial.country); assert.equal((await getGeoipSettings()).lastSuccessAt, lastSuccess);
|
||||
assert((await getGeoipSettings()).lastError); assert(!fs.existsSync(file(GEOIP_COUNTRY_FILE) + '.tmp'));
|
||||
check('controlled-invalid-body-preserves-file-and-last-success', { negativeCountryResponse: 'controlled-200-invalid-MMDB', realAsnStatus: 304 });
|
||||
fs.unlinkSync(file(GEOIP_COUNTRY_FILE));
|
||||
setGeoipFetchForTests(async (url, options) => String(url).endsWith(GEOIP_COUNTRY_FILE) ? new Response(null, { status: 304 }) : observedFetch(url, options));
|
||||
snapshot = await collectGeoipUpdateOnce({ force: true }); assert.equal(snapshot.errors.length, 1); assert.equal(snapshot.skippedUnchanged, 1); assert(snapshot.errors[0].includes('304 без проверенной локальной базы'));
|
||||
assert.equal((await getGeoipSettings()).lastSuccessAt, lastSuccess);
|
||||
check('controlled-uncached304-is-error-with-last-success-retained');
|
||||
setGeoipFetchForTests(observedFetch); snapshot = await collectGeoipUpdateOnce({ force: true }); assert.deepEqual(snapshot.errors, []); assert.equal(snapshot.downloaded, 1);
|
||||
assert.equal(sha(GEOIP_COUNTRY_FILE), initial.country); assert.equal(sha(GEOIP_ASN_FILE), initial.asn);
|
||||
const beforeSkip = report.mirrorRequests.length; assert.equal((await collectGeoipUpdateOnce()).skipped, true); assert.equal(report.mirrorRequests.length, beforeSkip);
|
||||
check('actual-recovery-and-disabled-background-no-fetch'); report.passed = true;
|
||||
} catch (e) { report.failure = { name: e.name }; process.exitCode = 1; }
|
||||
finally { resetGeoipUpdateForTests(); await databaseBackgroundWork.drain(); await closePool(); save(); console.log(JSON.stringify(report)); }
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own212.invalid';process.env.AUTH_PORTAL_URL='https://own212.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
|
||||
const rows=[],writes=[],cases=[];let app,serverId,nativeCalls=0;
|
||||
const native=createServer(async(req,res)=>{nativeCalls++;let text='';for await(const chunk of req)text+=chunk;const body=text?JSON.parse(text):{};res.setHeader('content-type','application/json');if(req.method==='GET'){res.end(JSON.stringify(req.url.endsWith('/interface/gre')?rows:[]));return}writes.push({method:req.method,path:req.url,body});if(req.method==='PUT'){const row={...body,'.id':'*'+(rows.length+1),running:'false'};rows.push(row);res.end(JSON.stringify(row));return}if(req.method==='PATCH'){const id=decodeURIComponent(req.url.split('/').pop()),row=rows.find(r=>r['.id']===id);assert(row);Object.assign(row,body);res.end(JSON.stringify(row));return}res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));
|
||||
const report={date:'2026-10-13',block:'12.5.212',database,cases,scope:'Production compiled GRE create/patch using isolated PostgreSQL and native HTTP fixture',passed:false};
|
||||
try{await initDatabase();serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own212-native','127.0.0.1',$1,false,true,'fixture','fixture') RETURNING id",[native.address().port])).rows[0].id);app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own212-writer',apps:['mm'],permissions:['mm:network:write'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token};
|
||||
for(const field of ['keepaliveInterval','keepaliveRetries'])for(const value of [0,-1,1.5,4294967296]){const before=nativeCalls;const r=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name:'invalid',remoteAddress:'192.0.2.2',[field]:value}});assert.equal(r.statusCode,400);assert.equal(nativeCalls,before);cases.push({field,value,status:400,nativeCalls:0})}
|
||||
for(const params of [{},{keepaliveInterval:60},{keepaliveRetries:3},{keepaliveInterval:90,keepaliveRetries:7}]){const name='own212-'+rows.length;const r=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name,remoteAddress:'192.0.2.2',enabled:false,...params}});assert.equal(r.statusCode,201);const expected=(params.keepaliveInterval??10)+'s,'+(params.keepaliveRetries??10);assert.equal(rows.at(-1).keepalive,expected);cases.push({operation:'create',params,keepalive:expected,status:201})}
|
||||
rows[0].keepalive='1m,3';const patch=await app.inject({method:'PATCH',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),rosId:rows[0]['.id'],comment:'own212 updated'}});assert.equal(patch.statusCode,200);assert.equal(rows[0].keepalive,'1m,3');assert.equal(rows[0].comment,'own212 updated');cases.push({operation:'patch-other-field',nativeKeepalivePreserved:'1m,3',status:200});const read=await app.inject({method:'GET',url:'/api/gre/tunnels?serverId='+serverId,headers});assert.equal(read.statusCode,200);assert.equal(read.json().tunnels.find(t=>t.name===rows[0].name).keepaliveInterval,60);cases.push({operation:'read-minutes',seconds:60,status:200});const encrypted=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name:'own212-psk',remoteAddress:'192.0.2.2',enabled:false,ipsecSecret:'own212-synthetic-psk'}});assert.equal(encrypted.statusCode,201);const secured=rows.find(r=>r.name==='own212-psk');assert.equal(secured['allow-fast-path'],'no');cases.push({operation:'psk-create-default-fastpath',status:201,fastPath:'no'});let before=nativeCalls;const conflict=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name:'conflict',remoteAddress:'192.0.2.2',ipsecSecret:'own212-synthetic-psk',allowFastPath:true}});assert.equal(conflict.statusCode,400);assert.equal(nativeCalls,before);cases.push({operation:'explicit-psk-fastpath-conflict',status:400,nativeCalls:0});before=writes.length;const invalidPatch=await app.inject({method:'PATCH',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),rosId:secured['.id'],allowFastPath:true}});assert.equal(invalidPatch.statusCode,400);assert.equal(writes.length,before);assert.equal(secured['allow-fast-path'],'no');cases.push({operation:'existing-psk-fastpath-conflict',status:400,nativeWrites:0});const upgrade=await app.inject({method:'PATCH',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),rosId:rows[1]['.id'],ipsecSecret:'own212-synthetic-psk'}});assert.equal(upgrade.statusCode,200);assert.equal(rows[1]['allow-fast-path'],'no');cases.push({operation:'enable-psk-with-omitted-fastpath',status:200,fastPath:'no'});report.passed=true;report.nativeWrites=writes.length;
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre212-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/gre212-keepalive-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1;
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{collectGreBgpSnapshotOnce}=await import('/app/backend/dist/services/gre-bgp-snapshot-collector.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const baseline=process.env.MMAPP_BASELINE==='true',cases=[];let phase='populated',nativeCalls=0,nativeWrites=0,disabledCalls=0
|
||||
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');const gre=req.url==='/rest/interface/gre',failed=(gre&&phase==='gre-failed')||(label==='second'&&(gre&&['gre-partial','both-partial'].includes(phase)||!gre&&['bgp-partial','both-partial'].includes(phase)));if(failed){res.statusCode=401;res.end(JSON.stringify({error:401,message:'own219 denied'}));return}const row=gre?{'.id':'*1',name:'own219-gre',running:'true',disabled:'false'}:{'.id':'*1',name:'own219-peer',established:'true','remote.as':'65219','local.as':'65220'};const rows=phase==='empty'?[]:gre&&label==='second'&&phase==='malformed'?[{unknown:'fixture'}]:gre&&label==='second'&&phase==='duplicate'?[row,row]:[row];res.end(JSON.stringify(rows))})
|
||||
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
|
||||
const report={date:'2026-10-14',block:'12.5.219',baseline,database,cases,scope:'Actual compiled GRE/BGP sources and snapshot collector, owned SQL/native HTTP fixture; no API auth claim',passed:false}
|
||||
try{
|
||||
await initDatabase();for(let i=0;i<natives.length;i++)await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4)",['own219-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])
|
||||
for(phase of ['populated','empty','gre-partial','gre-failed','bgp-partial','both-partial','malformed','duplicate','disabled','recovery']){
|
||||
if(phase==='disabled')await pool.query('UPDATE servers SET enabled=false');if(phase==='recovery')await pool.query("UPDATE servers SET enabled=true WHERE name IN ('own219-0','own219-1')")
|
||||
const before=nativeCalls,snapshot=await collectGreBgpSnapshotOnce(),greFail=['gre-partial','gre-failed','both-partial','malformed','duplicate'].includes(phase),bgpFail=['bgp-partial','both-partial'].includes(phase),empty=['empty','disabled'].includes(phase),greCount=empty?0:greFail?(baseline?phase==='gre-failed'?0:phase==='malformed'?2:phase==='duplicate'?3:1:0):2,bgpCount=empty||bgpFail?0:2,errorCount=Number(bgpFail)+Number(greFail&&!baseline)
|
||||
assert.equal(snapshot.greWritten,greCount);assert.equal(snapshot.bgpWritten,bgpCount);assert.equal(snapshot.errors.length,errorCount);assert.equal(snapshot.fatalError,undefined)
|
||||
if(greFail&&!baseline)assert(snapshot.errors.some(e=>e.startsWith('GRE: Не опрошены устройства GRE:')))
|
||||
if(bgpFail)assert(snapshot.errors.some(e=>e.startsWith('BGP: Не опрошены устройства BGP:')))
|
||||
if(phase==='disabled')assert.equal(nativeCalls,before)
|
||||
cases.push({phase,greWritten:snapshot.greWritten,bgpWritten:snapshot.bgpWritten,sourceErrors:snapshot.errors.length,nativeCalls:nativeCalls-before})
|
||||
}
|
||||
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre219-private.log',String(e.stack??e),{mode:0o600})}finally{for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/gre219-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own210.invalid';process.env.AUTH_PORTAL_URL='https://own210.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
|
||||
const rows=[],writes=[],cases=[];let app,serverId,nativeCalls=0;
|
||||
const native=createServer(async(req,res)=>{nativeCalls++;let text='';for await(const chunk of req)text+=chunk;const body=text?JSON.parse(text):{};res.setHeader('content-type','application/json');if(req.method==='GET'){res.end(JSON.stringify(req.url.endsWith('/interface/gre')?rows:[]));return}writes.push({method:req.method,path:req.url,body});if(req.method==='PUT'){const row={...body,'.id':'*'+(rows.length+1),running:'false'};rows.push(row);res.end(JSON.stringify(row));return}if(req.method==='PATCH'){const id=decodeURIComponent(req.url.split('/').pop()),row=rows.find(r=>r['.id']===id);assert(row);Object.assign(row,body);res.end(JSON.stringify(row));return}res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));
|
||||
const report={date:'2026-10-13',block:'12.5.210',database,cases,scope:'Production compiled GRE create/patch using isolated PostgreSQL and native HTTP fixture',passed:false};
|
||||
try{await initDatabase();serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own210-native','127.0.0.1',$1,false,true,'fixture','fixture') RETURNING id",[native.address().port])).rows[0].id);app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own210-writer',apps:['mm'],permissions:['mm:network:write'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token};
|
||||
for(const field of ['keepaliveInterval','keepaliveRetries'])for(const value of [0,-1,1.5,4294967296]){const before=nativeCalls;const r=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name:'invalid',remoteAddress:'192.0.2.2',[field]:value}});assert.equal(r.statusCode,400);assert.equal(nativeCalls,before);cases.push({field,value,status:400,nativeCalls:0})}
|
||||
for(const params of [{},{keepaliveInterval:60},{keepaliveRetries:3},{keepaliveInterval:90,keepaliveRetries:7}]){const name='own210-'+rows.length;const r=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name,remoteAddress:'192.0.2.2',enabled:false,...params}});assert.equal(r.statusCode,201);const expected=(params.keepaliveInterval??10)+'s,'+(params.keepaliveRetries??10);assert.equal(rows.at(-1).keepalive,expected);cases.push({operation:'create',params,keepalive:expected,status:201})}
|
||||
rows[0].keepalive='1m,3';const patch=await app.inject({method:'PATCH',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),rosId:rows[0]['.id'],comment:'own210 updated'}});assert.equal(patch.statusCode,200);assert.equal(rows[0].keepalive,'1m,3');assert.equal(rows[0].comment,'own210 updated');cases.push({operation:'patch-other-field',nativeKeepalivePreserved:'1m,3',status:200});const read=await app.inject({method:'GET',url:'/api/gre/tunnels?serverId='+serverId,headers});assert.equal(read.statusCode,200);assert.equal(read.json().tunnels.find(t=>t.name===rows[0].name).keepaliveInterval,60);cases.push({operation:'read-minutes',seconds:60,status:200});report.passed=true;report.nativeWrites=writes.length;
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre210-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/gre210-keepalive-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1;
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own221.invalid';process.env.AUTH_PORTAL_URL='https://own221.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{collectGreBgpSnapshotOnce}=await import('/app/backend/dist/services/gre-bgp-snapshot-collector.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const baseline=process.env.MMAPP_BASELINE==='true',cases=[];let app,nativeCalls=0,nativeWrites=0,disabledCalls=0,variant='valid'
|
||||
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');const gre=req.url==='/rest/interface/gre',bgp=req.url==='/rest/routing/bgp/session',normal={'.id':'*A',name:'own221-GRE «тест» / peer',disabled:'false',running:'true'},kinds={valid:[normal],empty:[],'missing-id':[{name:normal.name,disabled:'false',running:'true'}],'missing-name':[{'.id':'*A',disabled:'false',running:'true'}],'blank-id':[{...normal,'.id':''}],'blank-name':[{...normal,name:' '}],'whitespace-id':[{...normal,'.id':' *A'}],'number-field':[{...normal,mtu:1400}],'duplicate-id':[normal,{...normal,name:'other-GRE'}],'duplicate-name':[normal,{...normal,'.id':'*B'}],'trimmed-name-collision':[normal,{...normal,'.id':'*B',name:' '+normal.name+' '}]},rows=gre?(label==='second'?kinds[variant]:[normal]):bgp?[{'.id':'*1',name:'own221-peer',established:'true','remote.as':'65221','local.as':'65222'}]:[];res.end(JSON.stringify(rows))})
|
||||
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
|
||||
const report={date:'2026-10-14',block:'12.5.221',baseline,database,cases,scope:'Actual compiled Native GRE identity and actual API/config/snapshot paths, owned SQL/native HTTP fixture; signed reader/authenticated GRE API',passed:false}
|
||||
try{
|
||||
await initDatabase();const ids=[];for(let i=0;i<natives.length;i++)ids.push(Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4) RETURNING id",['own221-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])).rows[0].id))
|
||||
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own221-reader',apps:['mm'],permissions:['mm:network:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token};
|
||||
for(variant of ['valid','empty','missing-id','missing-name','blank-id','blank-name','whitespace-id','number-field','duplicate-id','duplicate-name','trimmed-name-collision']){
|
||||
const bad=!['valid','empty'].includes(variant),oldSourceAccepts=['valid','empty','whitespace-id','duplicate-name','trimmed-name-collision'].includes(variant),sourceUnknown=baseline?!oldSourceAccepts:bad,oldCount=['duplicate-id','duplicate-name','trimmed-name-collision'].includes(variant)?3:variant==='empty'?1:2,snapshot=await collectGreBgpSnapshotOnce()
|
||||
assert.equal(snapshot.greWritten,sourceUnknown?0:oldCount);assert.equal(snapshot.bgpWritten,2);assert.equal(snapshot.errors.length,sourceUnknown?1:0);assert.equal(snapshot.fatalError,undefined)
|
||||
cases.push({phase:variant,endpoint:'compiled collector',greWritten:snapshot.greWritten,bgpWritten:2,sourceErrors:snapshot.errors.length})
|
||||
const before=Number((await pool.query("SELECT count(*) AS count FROM config_revisions WHERE server_id=$1 AND section='gre'",[ids[1]])).rows[0].count),api=await app.inject({method:'GET',url:'/api/gre/tunnels',headers}),body=api.json();assert.equal(api.statusCode,200);assert.equal(body.failures.length,bad&&!baseline?1:0);assert.equal(body.tunnels.length,bad&&!baseline?1:oldCount);const after=Number((await pool.query("SELECT count(*) AS count FROM config_revisions WHERE server_id=$1 AND section='gre'",[ids[1]])).rows[0].count);if(bad&&!baseline)assert.equal(after,before)
|
||||
cases.push({phase:variant,endpoint:'signed GRE catalog',status:200,tunnels:body.tunnels.length,failures:body.failures.length,observedRevisionsAdded:after-before})
|
||||
}
|
||||
|
||||
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre221-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/gre221-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own220.invalid';process.env.AUTH_PORTAL_URL='https://own220.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{collectGreBgpSnapshotOnce}=await import('/app/backend/dist/services/gre-bgp-snapshot-collector.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const baseline=process.env.MMAPP_BASELINE==='true',cases=[];let app,nativeCalls=0,nativeWrites=0,disabledCalls=0,flags={running:'true',disabled:'false'}
|
||||
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');const gre=req.url==='/rest/interface/gre',bgp=req.url==='/rest/routing/bgp/session',row=gre?{'.id':'*1',name:'own220-gre',...(label==='second'?flags:{running:'true',disabled:'false'})}:{'.id':'*1',name:'own220-peer',established:'true','remote.as':'65220','local.as':'65221'};res.end(JSON.stringify(gre||bgp?[row]:[]))})
|
||||
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
|
||||
const report={date:'2026-10-14',block:'12.5.220',baseline,database,cases,scope:'Actual compiled GRE operational flags and actual snapshot collector, owned SQL/native HTTP fixture; signed reader/authenticated GRE API',passed:false}
|
||||
try{
|
||||
await initDatabase();for(let i=0;i<natives.length;i++)await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4)",['own220-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])
|
||||
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own220-reader',apps:['mm'],permissions:['mm:network:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token};
|
||||
const flagCases=[{name:'up',flags:{running:'true',disabled:'false'}},{name:'degraded',flags:{running:'false',disabled:'false'}},{name:'down',flags:{running:'false',disabled:'true'}},{name:'yes-up',flags:{running:'yes',disabled:'no'}},{name:'yes-degraded',flags:{running:'no',disabled:'no'}},{name:'yes-down',flags:{running:'no',disabled:'yes'}},{name:'missing-disabled',flags:{running:'true'}},{name:'missing-running',flags:{disabled:'false'}},{name:'blank-disabled',flags:{running:'true',disabled:''}},{name:'invalid-running',flags:{running:'junk',disabled:'false'}},{name:'invalid-disabled',flags:{running:'true',disabled:'junk'}},{name:'both-missing',flags:{}},{name:'disabled-missing-running',flags:{disabled:'true'}},{name:'disabled-invalid-running',flags:{disabled:'true',running:'junk'}},{name:'recovery',flags:{running:'true',disabled:'false'}}]
|
||||
for(const item of flagCases){
|
||||
flags=item.flags;const off=['true','yes'].includes(flags.disabled??''),on=['false','no'].includes(flags.disabled??''),runningKnown=['true','yes','false','no'].includes(flags.running??''),unknown=!off&&!(on&&runningKnown),before=nativeCalls,snapshot=await collectGreBgpSnapshotOnce()
|
||||
assert.equal(snapshot.greWritten,unknown&&!baseline?0:2);assert.equal(snapshot.bgpWritten,2);assert.equal(snapshot.errors.length,unknown&&!baseline?1:0);assert.equal(snapshot.fatalError,undefined)
|
||||
if(unknown&&!baseline){assert.match(snapshot.errors[0],/Не подтверждено состояние GRE/)}else{const row=snapshot.greTunnels.find(row=>row.targetLabel.endsWith('own220-1'));assert(row);const expected=baseline?flags.disabled==='true'?'down':flags.running==='true'?'up':'degraded':off?'down':['true','yes'].includes(flags.running??'')?'up':'degraded';assert.equal(row.status,expected)}
|
||||
const api=await app.inject({method:'GET',url:'/api/gre/tunnels',headers}),body=api.json();assert.equal(api.statusCode,200);assert.equal(body.failures.length,unknown&&!baseline?1:0);assert.equal(body.tunnels.length,unknown&&!baseline?1:2);cases.push({phase:item.name,endpoint:'GRE authenticated catalog',status:200,tunnels:body.tunnels.length,failures:body.failures.length});
|
||||
cases.push({phase:item.name,flags,greWritten:snapshot.greWritten,bgpWritten:2,sourceErrors:snapshot.errors.length,secondStatus:snapshot.greTunnels.find(row=>row.targetLabel.endsWith('own220-1'))?.status??null,nativeCalls:nativeCalls-before})
|
||||
}
|
||||
|
||||
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre220-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/gre220-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own209.invalid';process.env.AUTH_PORTAL_URL='https://own209.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const psk='own209-'+randomBytes(24).toString('base64url'),baseline=process.env.MMAPP_GRE_SECRET_EXPECT==='leak',cases=[];let app,serverId,nativeWrites=0
|
||||
const native=createServer((req,res)=>{if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');res.end(JSON.stringify(req.url.endsWith('/interface/gre')?[{'.id':'*1',name:'own209-encrypted','local-address':'192.0.2.1','remote-address':'192.0.2.2','ipsec-secret':psk,mtu:'1476',keepalive:'10s,3',disabled:'false',running:'true','allow-fast-path':'false'},{'.id':'*2',name:'own209-plain','remote-address':'192.0.2.3','ipsec-secret':'',disabled:'false',running:'true'}]:[]))});await new Promise(r=>native.listen(0,'127.0.0.1',r))
|
||||
const report={date:'2026-10-13',block:'12.5.209',database,baseline,cases,scope:'Production compiled GRE/Filters GET and auth with own restricted PostgreSQL, local native HTTP fixture, synthetic nonempty PSK and isolated signed JWT',passed:false}
|
||||
try{await initDatabase();serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own209-native','127.0.0.1',$1,false,true,'fixture','fixture') RETURNING id",[native.address().port])).rows[0].id);app=await buildApp({logger:false,startScheduler:false});await app.ready()
|
||||
for(const role of ['reader','filter-writer','network-writer','network-admin','is-admin-only']){const permissions=['mm:network:read','mm:filters:read',...(role==='filter-writer'?['mm:filters:write']:role==='network-writer'?['mm:network:write']:role==='network-admin'?['mm:network:admin']:[])],token=app.jwt.sign({sub:'own209-'+role,apps:['mm'],permissions,is_admin:role==='is-admin-only',iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});for(const path of ['/api/gre/tunnels','/api/filters/gre-tunnels']){const r=await app.inject({method:'GET',url:path+'?serverId='+serverId,headers:{authorization:'Bearer '+token}});assert.equal(r.statusCode,200);const rows=r.json().tunnels;assert.equal(rows.length,2);const encrypted=rows.find(t=>t.name==='own209-encrypted'),plain=rows.find(t=>t.name==='own209-plain');assert.equal(plain.ipsec,null);const direct=path==='/api/gre/tunnels',privileged=role==='network-writer'||role==='network-admin';if(direct){assert(encrypted.ipsec);assert.equal(encrypted.ipsec.secret,baseline||privileged?psk:'')}else if(baseline)assert.equal(encrypted.ipsec,null);else{assert(encrypted.ipsec);assert.equal(encrypted.ipsec.secret,'')}const exposed=r.body.includes(psk);assert.equal(exposed,direct&&(baseline||privileged));cases.push({role,path,status:200,pskExposed:exposed,configuredStatePresent:!!encrypted.ipsec,plainStateNull:true})}}
|
||||
for(const path of ['/api/gre/tunnels','/api/filters/gre-tunnels']){assert.equal((await app.inject({method:'GET',url:path})).statusCode,401);cases.push({role:'unauthenticated',path,status:401})}
|
||||
const revisions=(await pool.query("SELECT payload FROM config_revisions WHERE server_id=$1 AND section='gre'",[serverId])).rows;assert(revisions.length>0);assert(revisions.some(r=>JSON.stringify(r.payload).includes(psk)));assert.equal(nativeWrites,0);report.internalSnapshotRetainsPsk=true;report.nativeWrites=0;report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre209-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/gre209-'+(baseline?'baseline':'fixed')+'-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}
|
||||
if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,155 @@
|
||||
/** Arithmetic preflight only. No Docker/host access, changes or workload admission.
|
||||
* Roles are operator-supplied labels, not authenticated service identities.
|
||||
* Even a fitting budget cannot prove runtime <=2GB or permission to change limits. */
|
||||
const MiB = 1024 * 1024
|
||||
const roles = new Set(['backend', 'postgres', 'frontend', 'portal', 'proxy', 'agent', 'other'])
|
||||
const singleton = new Set(['backend', 'postgres', 'frontend', 'portal'])
|
||||
const integer = value => Number.isSafeInteger(value) && value >= 0
|
||||
const exact = (value, fields) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& Object.keys(value).sort().join('|') === [...fields].sort().join('|')
|
||||
const id = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
|
||||
|
||||
export function evaluateInstallationMemoryBudget(input) {
|
||||
if (!exact(input, ['hostMemTotalBytes', 'osReserveBytes', 'observedContainerIds', 'containers'])
|
||||
|| !integer(input.hostMemTotalBytes) || !integer(input.osReserveBytes)
|
||||
|| !Array.isArray(input.observedContainerIds) || !Array.isArray(input.containers)
|
||||
|| input.observedContainerIds.length > 128 || input.containers.length > 128) throw Error('Invalid bounded installation inventory')
|
||||
if (input.observedContainerIds.some(value => !id(value)) || new Set(input.observedContainerIds).size !== input.observedContainerIds.length) throw Error('Invalid observed container set')
|
||||
const seen = new Set(), counts = new Map(), reasons = new Set()
|
||||
let configuredBytes = 0
|
||||
for (const row of input.containers) {
|
||||
if (!exact(row, ['id', 'role', 'configuredLimitBytes']) || !id(row.id) || !roles.has(row.role)
|
||||
|| !integer(row.configuredLimitBytes) || seen.has(row.id)) throw Error('Invalid container budget')
|
||||
seen.add(row.id); counts.set(row.role, (counts.get(row.role) ?? 0) + 1)
|
||||
if (row.configuredLimitBytes === 0) reasons.add('UNBOUNDED_CONTAINER')
|
||||
if (row.configuredLimitBytes > input.hostMemTotalBytes) reasons.add('CONTAINER_LIMIT_EXCEEDS_HOST')
|
||||
if (!Number.isSafeInteger(configuredBytes + row.configuredLimitBytes)) throw Error('Container budget overflow')
|
||||
configuredBytes += row.configuredLimitBytes
|
||||
}
|
||||
if (seen.size !== input.observedContainerIds.length || input.observedContainerIds.some(value => !seen.has(value))) reasons.add('INCOMPLETE_HOST_CONTAINER_INVENTORY')
|
||||
for (const role of ['backend', 'postgres', 'frontend']) if (!counts.has(role)) reasons.add('REQUIRED_SERVICE_MISSING')
|
||||
for (const role of singleton) if ((counts.get(role) ?? 0) > 1) reasons.add('DUPLICATE_APPLICATION_STACK')
|
||||
if (input.hostMemTotalBytes === 0 || input.hostMemTotalBytes > 2 * 1024 * MiB) reasons.add('HOST_OUTSIDE_2GIB_TARGET')
|
||||
if (input.osReserveBytes < 512 * MiB) reasons.add('OS_RESERVE_BELOW_CANDIDATE_FLOOR')
|
||||
if (!Number.isSafeInteger(configuredBytes + input.osReserveBytes)) throw Error('Installation budget overflow')
|
||||
const plannedBytes = configuredBytes + input.osReserveBytes
|
||||
if (plannedBytes > input.hostMemTotalBytes) reasons.add('INSTALLATION_BUDGET_EXCEEDS_HOST')
|
||||
return Object.freeze({ scope: 'arithmetic-preflight-only', budgetFits: reasons.size === 0,
|
||||
wholeInstallation2GbAccepted: false, runtimeAcceptanceClaimed: false, changesAuthorized: false,
|
||||
configuredContainerBytes: configuredBytes, osReserveBytes: input.osReserveBytes,
|
||||
plannedBytes, remainingBytes: input.hostMemTotalBytes - plannedBytes,
|
||||
observedContainers: input.observedContainerIds.length, reasons: Object.freeze([...reasons].sort()) })
|
||||
}
|
||||
|
||||
/** Project the whitelist-only read-only inventory. Never substitute idle RSS or
|
||||
* Docker's reported host ceiling for HostConfig.Memory. Labels/freshness are
|
||||
* not authenticated here; this result must not be used as mutation authority. */
|
||||
export function evaluateObservedInstallationBudget(inventory, roleById, osReserveBytes = 512 * MiB) {
|
||||
if (!inventory || inventory.readOnly !== true || inventory.containerSetStable !== true
|
||||
|| inventory.environmentRead !== false || inventory.serviceChanges !== false || inventory.newFixtureStarted !== false
|
||||
|| !Array.isArray(inventory.containers) || inventory.containers.length > 128
|
||||
|| !roleById || typeof roleById !== 'object' || Array.isArray(roleById)) throw Error('Invalid read-only installation inventory')
|
||||
const containers = inventory.containers.map(row => {
|
||||
if (!row || !id(row.id) || row.running !== true || !integer(row.memoryLimitBytes)
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(row.imageId) || !Number.isFinite(Date.parse(row.startedAt))) throw Error('Incomplete configured-limit observation')
|
||||
const label = Object.getOwnPropertyDescriptor(roleById, row.id)
|
||||
if (!label || typeof label.value !== 'string' || !roles.has(label.value)) throw Error('Explicit container role missing')
|
||||
return { id: row.id, role: label.value, configuredLimitBytes: row.memoryLimitBytes }
|
||||
})
|
||||
const observedContainerIds = containers.map(row => row.id)
|
||||
if (Object.keys(roleById).length !== observedContainerIds.length || Object.keys(roleById).some(key => !observedContainerIds.includes(key))) throw Error('Foreign or incomplete role inventory')
|
||||
const budget = evaluateInstallationMemoryBudget({ hostMemTotalBytes: inventory.hostMemTotalBytes, osReserveBytes, observedContainerIds, containers })
|
||||
return Object.freeze({ ...budget, configuredLimitsSource: 'observed-HostConfig.Memory', roleLabelsAuthenticated: false, inventoryFreshnessProven: false })
|
||||
}
|
||||
|
||||
/** NEW pure raw-text bridge for measure-memory schema2/3. This is NOT the
|
||||
* separate GET-only inventory contract consumed by evaluateObserved above.
|
||||
* No file/container/socket access or mutation authority. Parse raw text only:
|
||||
* precision lost by a caller's previous JSON.parse cannot be recovered. */
|
||||
export function evaluateMeasuredInstallationBudget(rawText, roleById, osReserveBytes = 512 * MiB) {
|
||||
if (typeof rawText !== 'string') throw Error('Raw measurement JSON text required; preparsed precision cannot be recovered')
|
||||
if (Buffer.byteLength(rawText, 'utf8') > 32 * 1024 * 1024) throw Error('Measurement text bound exceeded')
|
||||
// A narrow numeric lexical gate BEFORE JSON.parse, not a JSON parser. The
|
||||
// Python producer emits only integer JSON numbers (PSI averages are strings).
|
||||
// Reject decimal/exponent/unsafe integers even where JSON.parse would round
|
||||
// a fractional lexeme into a seemingly safe integer. JSON.parse checks syntax.
|
||||
const number = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y
|
||||
let quoted = false, escaped = false
|
||||
for (let index = 0; index < rawText.length; index++) {
|
||||
const char = rawText[index]
|
||||
if (quoted) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === '"') quoted = false; continue }
|
||||
if (char === '"') { quoted = true; continue }
|
||||
if (char === '-' || char >= '0' && char <= '9') {
|
||||
number.lastIndex = index; const token = number.exec(rawText)?.[0]
|
||||
if (!token || token === '-0' || !/^-?(?:0|[1-9][0-9]*)$/.test(token) || token.replace('-', '').length > 16) throw Error('Unsafe or noninteger measurement JSON number')
|
||||
const value = BigInt(token)
|
||||
if (value < -9007199254740991n || value > 9007199254740991n) throw Error('Unsafe measurement JSON integer')
|
||||
index = number.lastIndex - 1
|
||||
}
|
||||
}
|
||||
const data = JSON.parse(rawText)
|
||||
if (!data || ![2, 3].includes(data.schemaVersion) || data.readOnlyDockerAndProc !== true || data.serviceChanges !== 0
|
||||
|| data.summary?.acceptancePassed !== false || !Array.isArray(data.observations) || !data.observations.length || data.observations.length > 300
|
||||
|| !roleById || typeof roleById !== 'object' || Array.isArray(roleById)) throw Error('Unsupported or incomplete memory measurement')
|
||||
const nonnegativeSafe = value => Number.isSafeInteger(value) && value >= 0
|
||||
const limit = value => value === 'max' || nonnegativeSafe(value) ? value : null
|
||||
const swap = row => row.configuredMemorySwapStatus === 'available' && Number.isSafeInteger(row.configuredMemorySwapBytes) && row.configuredMemorySwapBytes >= -1 ? row.configuredMemorySwapBytes : null
|
||||
const cpu = value => value && ['nr_periods', 'nr_throttled', 'throttled_usec'].every(key => nonnegativeSafe(value[key]))
|
||||
? Object.fromEntries(['nr_periods', 'nr_throttled', 'throttled_usec'].map(key => [key, value[key]])) : null
|
||||
const pressure = host => {
|
||||
if (host.memoryPressure?.status !== 'available') return null
|
||||
const result = {}
|
||||
for (const key of ['some', 'full']) {
|
||||
const value = host.memoryPressure.metrics?.[key]
|
||||
if (!value || !nonnegativeSafe(value.totalMicros)) return null
|
||||
for (const avg of ['avg10Percent', 'avg60Percent', 'avg300Percent']) {
|
||||
if (typeof value[avg] !== 'string' || !/^[0-9]{1,3}\.[0-9]{2}$/.test(value[avg]) || Number(value[avg]) > 100) return null
|
||||
}
|
||||
result[key] = Object.fromEntries(['avg10Percent', 'avg60Percent', 'avg300Percent', 'totalMicros'].map(name => [name, value[name]]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
let baseline, hostMemory, priorElapsed = -1, complete = data.schemaVersion === 3
|
||||
const extendedObservations = []
|
||||
for (const observation of data.observations) {
|
||||
if (!observation?.host || !nonnegativeSafe(observation.elapsedMs) || observation.elapsedMs < priorElapsed || observation.elapsedMs > 360000
|
||||
|| !nonnegativeSafe(observation.host.MemTotalBytes) || !observation.host.MemTotalBytes
|
||||
|| !Array.isArray(observation.containers) || !observation.containers.length || observation.containers.length > 16) throw Error('Incomplete bounded measurement sample')
|
||||
priorElapsed = observation.elapsedMs
|
||||
hostMemory ??= observation.host.MemTotalBytes
|
||||
if (hostMemory !== observation.host.MemTotalBytes) throw Error('Measurement host memory changed')
|
||||
const ids = new Set(), identities = [], extended = []
|
||||
for (const row of observation.containers) {
|
||||
if (!row || !id(row.id) || ids.has(row.id) || !/^sha256:[a-f0-9]{64}$/.test(row.imageId) || (typeof row.startedAt !== 'string' || !Number.isFinite(Date.parse(row.startedAt)))
|
||||
|| row.running !== true || row.runningAfter !== true || row.identityStableDuringSample !== true || row.oomKilled !== false || row.oomKilledAfter !== false
|
||||
|| !nonnegativeSafe(row.restartCount) || !Number.isSafeInteger(row.pid) || row.pid < 1 || row.pid > 2147483647 || !nonnegativeSafe(row.configuredLimitBytes)) throw Error('Unavailable measurement container identity or configured cap')
|
||||
ids.add(row.id); identities.push([row.id, row.imageId, row.startedAt, row.pid, row.restartCount, row.configuredLimitBytes, data.schemaVersion === 3 ? swap(row) : null])
|
||||
if (data.schemaVersion === 3) {
|
||||
const metrics = row.cgroupV2?.metrics, memoryMax = limit(metrics?.['memory.max']), swapMax = limit(metrics?.['memory.swap.max'])
|
||||
const cpuThrottling = cpu(metrics?.['cpu.stat']), configuredMemorySwapBytes = swap(row)
|
||||
const available = row.cgroupV2?.status === 'available' && row.resourceLimitsStableDuringSample === true
|
||||
&& memoryMax !== null && swapMax !== null && cpuThrottling !== null && configuredMemorySwapBytes !== null
|
||||
complete &&= available
|
||||
extended.push({id: row.id, memoryMax, swapMax, configuredMemorySwapBytes, cpuThrottling, available})
|
||||
} else extended.push({id: row.id, memoryMax: null, swapMax: null, configuredMemorySwapBytes: null, cpuThrottling: null, available: false})
|
||||
}
|
||||
identities.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
const encoded = JSON.stringify(identities)
|
||||
baseline ??= encoded
|
||||
if (encoded !== baseline) throw Error('Measurement container set, identity or memory cap changed')
|
||||
const memoryPressure = data.schemaVersion === 3 ? pressure(observation.host) : null
|
||||
complete &&= memoryPressure !== null
|
||||
extendedObservations.push({elapsedMs: observation.elapsedMs, memoryPressure, containers: extended})
|
||||
}
|
||||
const captured = JSON.parse(baseline)
|
||||
if (Object.keys(roleById).length !== captured.length || Object.keys(roleById).some(key => !captured.some(row => row[0] === key))) throw Error('Incomplete or foreign measured role inventory')
|
||||
const containers = captured.map(([identifier, _image, _start, _pid, _restart, configuredLimitBytes]) => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(roleById, identifier)
|
||||
if (!descriptor || typeof descriptor.value !== 'string' || !roles.has(descriptor.value)) throw Error('Explicit measured container role missing')
|
||||
return {id: identifier, role: descriptor.value, configuredLimitBytes}
|
||||
})
|
||||
const budget = evaluateInstallationMemoryBudget({hostMemTotalBytes: hostMemory, osReserveBytes, observedContainerIds: containers.map(row => row.id), containers})
|
||||
return Object.freeze({...budget, measurementSchemaVersion: data.schemaVersion, measurementSamples: data.observations.length,
|
||||
extendedMetricsComplete: complete, extendedObservations: Object.freeze(extendedObservations),
|
||||
roleLabelsAuthenticated: false, inventoryFreshnessProven: false, hostContainerInventoryComplete: false, measurementSelectedContainersOnly: true, noSwapAccepted: false, sourceGenerationAuthenticated: false})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { evaluateInstallationMemoryBudget as evaluate } from './installation-memory-budget.mjs'
|
||||
const MiB = 1024 * 1024
|
||||
function fixture() {
|
||||
const containers = [['backend',512],['postgres',384],['frontend',160],['portal',192]].map(([role, limit], i) => ({ id: String(i+1).repeat(64), role, configuredLimitBytes: limit * MiB }))
|
||||
return { hostMemTotalBytes: 2021212 * 1024, osReserveBytes: 512 * MiB, observedContainerIds: containers.map(row => row.id), containers }
|
||||
}
|
||||
test('complete four-service candidate fits arithmetic, never runtime acceptance or change permission', () => {
|
||||
const input = fixture(), before = structuredClone(input), result = evaluate(input)
|
||||
assert.equal(result.budgetFits,true); assert.equal(result.plannedBytes,1760 * MiB)
|
||||
assert.equal(result.wholeInstallation2GbAccepted,false); assert.equal(result.runtimeAcceptanceClaimed,false); assert.equal(result.changesAuthorized,false)
|
||||
assert(Object.isFrozen(result)); assert(Object.isFrozen(result.reasons)); assert.deepEqual(input,before)
|
||||
})
|
||||
test('coexisting baseline and owned app/PG cannot represent one installation', () => {
|
||||
const input = fixture()
|
||||
for (const [i,role,limit] of [[5,'backend',320],[6,'postgres',192]]) { input.containers.push({id:String(i).repeat(64),role,configuredLimitBytes:limit*MiB}); input.observedContainerIds.push(String(i).repeat(64)) }
|
||||
const result = evaluate(input)
|
||||
assert.equal(result.budgetFits,false); assert(result.reasons.includes('DUPLICATE_APPLICATION_STACK')); assert(result.reasons.includes('INSTALLATION_BUDGET_EXCEEDS_HOST'))
|
||||
})
|
||||
test('unknown host containers, missing and foreign inventory entries refuse', () => {
|
||||
for (const mode of ['omitted','foreign','missing-service']) {
|
||||
const input = fixture()
|
||||
if (mode === 'omitted') input.observedContainerIds.push('a'.repeat(64))
|
||||
if (mode === 'foreign') input.containers[0].id='b'.repeat(64)
|
||||
if (mode === 'missing-service') { input.containers.splice(0,1); input.observedContainerIds.splice(0,1) }
|
||||
const result = evaluate(input); assert.equal(result.budgetFits,false)
|
||||
assert(result.reasons.includes(mode === 'missing-service'?'REQUIRED_SERVICE_MISSING':'INCOMPLETE_HOST_CONTAINER_INVENTORY'))
|
||||
}
|
||||
})
|
||||
test('unlimited cap is distinct from observed idle RSS or Docker host limit display', () => {
|
||||
const input = fixture(); input.containers[0].configuredLimitBytes=0
|
||||
assert(evaluate(input).reasons.includes('UNBOUNDED_CONTAINER'))
|
||||
input.containers[0].configuredLimitBytes=3*1024*MiB
|
||||
assert(evaluate(input).reasons.includes('CONTAINER_LIMIT_EXCEEDS_HOST'))
|
||||
})
|
||||
test('extra service, reserve and physical host all count toward the same budget', () => {
|
||||
const input = fixture(); input.containers.push({id:'a'.repeat(64),role:'other',configuredLimitBytes:256*MiB}); input.observedContainerIds.push('a'.repeat(64))
|
||||
assert(evaluate(input).reasons.includes('INSTALLATION_BUDGET_EXCEEDS_HOST'))
|
||||
const low = fixture(); low.osReserveBytes=128*MiB; assert(evaluate(low).reasons.includes('OS_RESERVE_BELOW_CANDIDATE_FLOOR'))
|
||||
const large = fixture(); large.hostMemTotalBytes=4*1024*MiB; assert(evaluate(large).reasons.includes('HOST_OUTSIDE_2GIB_TARGET'))
|
||||
})
|
||||
test('malformed, duplicate, fractional, oversized or unsafe inventory rejects without I/O', () => {
|
||||
const mutations = [input=>{input.inject='unknown'},input=>{input.containers[0].role='invented'},input=>{input.containers[0].configuredLimitBytes=-1},input=>{input.containers[0].configuredLimitBytes=0.5},input=>{input.containers[0].id='../foreign'},input=>{input.containers[1].id=input.containers[0].id},input=>{input.observedContainerIds.push(input.observedContainerIds[0])},input=>{input.containers[0].configuredLimitBytes=Number.MAX_SAFE_INTEGER},input=>{input.observedContainerIds=Array(129).fill('a'.repeat(64))}]
|
||||
for (const mutate of mutations) { const input=fixture();mutate(input);assert.throws(()=>evaluate(input)) }
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {test} from 'node:test'
|
||||
import {evaluateMeasuredInstallationBudget as evaluate,evaluateInstallationMemoryBudget as legacy} from './installation-memory-budget.mjs'
|
||||
const MiB=1024*1024
|
||||
function fixture(version=3){
|
||||
const roles=['backend','postgres','frontend','portal'],caps=[512,384,160,192]
|
||||
const containers=roles.map((role,i)=>({id:String(i+1).repeat(64),imageId:'sha256:'+'a'.repeat(64),startedAt:'2026-11-02T00:00:00Z',pid:i+1,restartCount:0,running:true,runningAfter:true,identityStableDuringSample:true,oomKilled:false,oomKilledAfter:false,configuredLimitBytes:caps[i]*MiB,reportedLimitBytes:2*1024*MiB,
|
||||
...(version===3?{configuredMemorySwapBytes:caps[i]*MiB,configuredMemorySwapStatus:'available',resourceLimitsStableDuringSample:true,cgroupV2:{status:'available',metrics:{'memory.max':caps[i]*MiB,'memory.swap.max':0,'cpu.stat':{nr_periods:10,nr_throttled:2,throttled_usec:1234,notWhitelisted:999}}}}:{cgroupV2:{status:'available',metrics:{'memory.current':1,'memory.peak':2}}})}))
|
||||
const p={avg10Percent:'0.00',avg60Percent:'0.01',avg300Percent:'1.25',totalMicros:123456}
|
||||
const host={MemTotalBytes:2069721088,MemAvailableBytes:1000000000,pswpin:0,pswpout:0,oom_kill:0,...(version===3?{memoryPressure:{status:'available',metrics:{some:p,full:{...p,totalMicros:0}}}}:{})}
|
||||
const data={schemaVersion:version,readOnlyDockerAndProc:true,serviceChanges:0,summary:{acceptancePassed:false},observations:[{elapsedMs:0,host,containers},{elapsedMs:1000,host:structuredClone(host),containers:structuredClone(containers)}]}
|
||||
return {data,roleById:Object.fromEntries(containers.map((c,i)=>[c.id,roles[i]])),raw:()=>JSON.stringify(data)}
|
||||
}
|
||||
test('real schema2 producer contract remains arithmetic-only; missing extended measurements stay unknown',()=>{const f=fixture(2),r=evaluate(f.raw(),f.roleById);assert.equal(r.budgetFits,true);assert.equal(r.plannedBytes,1760*MiB);assert.equal(r.measurementSchemaVersion,2);assert.equal(r.extendedMetricsComplete,false);assert.equal(r.extendedObservations[0].memoryPressure,null);assert.equal(r.extendedObservations[0].containers[0].memoryMax,null);assert.equal(r.wholeInstallation2GbAccepted,false)})
|
||||
test('schema3 projects only fixed new fields and never converts data to runtime/no-swap authority',()=>{const f=fixture(),r=evaluate(f.raw(),f.roleById);assert.equal(r.extendedMetricsComplete,true);assert.equal(r.measurementSamples,2);assert.equal(r.extendedObservations[0].containers[0].cpuThrottling.throttled_usec,1234);assert.equal(r.extendedObservations[0].memoryPressure.some.avg300Percent,'1.25');assert(!JSON.stringify(r).includes('notWhitelisted'));assert.equal(r.runtimeAcceptanceClaimed,false);assert.equal(r.changesAuthorized,false);assert.equal(r.noSwapAccepted,false);assert.equal(r.inventoryFreshnessProven,false);assert.equal(r.hostContainerInventoryComplete,false);assert.equal(r.measurementSelectedContainersOnly,true)})
|
||||
test('numeric unlimited max/zero/-1/daemon-default preserve their different observed meanings',()=>{const f=fixture();for(const o of f.data.observations){const c=o.containers[0];c.cgroupV2.metrics['memory.max']='max';c.cgroupV2.metrics['memory.swap.max']='max';c.configuredMemorySwapBytes=-1}let r=evaluate(f.raw(),f.roleById);assert.equal(r.extendedObservations[0].containers[0].memoryMax,'max');assert.equal(r.extendedObservations[0].containers[0].configuredMemorySwapBytes,-1);assert.equal(r.noSwapAccepted,false);for(const o of f.data.observations)o.containers[0].configuredMemorySwapBytes=0;r=evaluate(f.raw(),f.roleById);assert.equal(r.extendedObservations[0].containers[0].configuredMemorySwapBytes,0)})
|
||||
test('missing schema3 fields, partial cgroup and invalid PSI are unknown rather than zero',()=>{for(const mutate of [o=>{delete o.containers[0].configuredMemorySwapBytes},o=>{o.containers[0].cgroupV2.status='partial'},o=>{o.containers[0].cgroupV2.metrics['memory.max']=null},o=>{delete o.containers[0].cgroupV2.metrics['cpu.stat'].nr_periods},o=>{o.host.memoryPressure.metrics.some.avg10Percent='100.01'},o=>{o.host.memoryPressure.status='unavailable'}]){const f=fixture();for(const o of f.data.observations)mutate(o);const r=evaluate(f.raw(),f.roleById);assert.equal(r.extendedMetricsComplete,false);assert.equal(r.wholeInstallation2GbAccepted,false)}})
|
||||
test('unsafe uint64 raw lexemes fail before JSON.parse; no rounded Number is recovered',()=>{for(const token of ['9007199254740992','9007199254740993','18446744073709551615','1.0000000000000001','9007199254740991.1','1e0','-0']){const f=fixture(),raw=f.raw().replace('123456',token);assert.throws(()=>evaluate(raw,f.roleById),/Unsafe|noninteger/);assert.throws(()=>evaluate(JSON.parse(raw),f.roleById),/Raw measurement JSON text required/)} })
|
||||
test('safe integer boundary is exact and large number-like text inside strings is not parsed as a counter',()=>{const f=fixture();for(const o of f.data.observations)o.host.memoryPressure.metrics.some.totalMicros=Number.MAX_SAFE_INTEGER;f.data.ignored='9007199254740993 1.0000000000000001 \\" escaped';const r=evaluate(f.raw(),f.roleById);assert.equal(r.extendedObservations[0].memoryPressure.some.totalMicros,Number.MAX_SAFE_INTEGER);assert(!JSON.stringify(r).includes('ignored'))})
|
||||
test('malformed JSON, unsupported schemas or claimed producer acceptance refuse',()=>{const f=fixture();assert.throws(()=>evaluate('{bad',f.roleById));for(const mutate of [d=>{d.schemaVersion=1},d=>{d.schemaVersion=4},d=>{d.serviceChanges=1},d=>{d.readOnlyDockerAndProc=false},d=>{d.summary.acceptancePassed=true},d=>{d.observations=[]}]){const x=fixture();mutate(x.data);assert.throws(()=>evaluate(x.raw(),x.roleById))}})
|
||||
test('full IDs, complete stable container set, OOM/running/process/image/caps stay mandatory',()=>{for(const mutate of [d=>{d.observations[1].containers.pop()},d=>{d.observations[1].containers.push(d.observations[1].containers[0])},d=>{d.observations[1].containers[0].pid++},d=>{d.observations[1].containers[0].runningAfter=false},d=>{d.observations[1].containers[0].oomKilledAfter=true},d=>{d.observations[1].containers[0].configuredLimitBytes++},d=>{d.observations[1].containers[0].configuredMemorySwapBytes++},d=>{d.observations[1].host.MemTotalBytes++},d=>{d.observations[1].containers[0].startedAt=123}]){const f=fixture();mutate(f.data);assert.throws(()=>evaluate(f.raw(),f.roleById))}})
|
||||
test('role labels remain exact own DATA descriptors, never an inherited/accessor authority',()=>{for(const kind of ['getter','inherited','missing','foreign']){const f=fixture(),key=f.data.observations[0].containers[0].id;if(kind==='getter')Object.defineProperty(f.roleById,key,{enumerable:true,get(){assert.fail('Getter executed')}});if(kind==='inherited'){const x=Object.create({[key]:'backend'});for(const [id,role]of Object.entries(f.roleById))if(id!==key)x[id]=role;f.roleById=x}if(kind==='missing')delete f.roleById[key];if(kind==='foreign')f.roleById['f'.repeat(64)]='other';assert.throws(()=>evaluate(f.raw(),f.roleById))}})
|
||||
test('legacy arithmetic export keeps soft metadata and no-runtime semantics',()=>{const f=fixture(),input={hostMemTotalBytes:2069721088,osReserveBytes:512*MiB,observedContainerIds:Object.keys(f.roleById),containers:f.data.observations[0].containers.map(c=>({id:c.id,role:f.roleById[c.id],configuredLimitBytes:c.configuredLimitBytes}))};assert.equal(legacy(input).budgetFits,true);assert.equal(legacy(input).changesAuthorized,false)})
|
||||
import {readFileSync} from 'node:fs'
|
||||
test('actual base/candidate Python producers interoperate through synthetic raw wire files',()=>{for(const version of [2,3]){const raw=readFileSync(new URL('./schema'+version+'.main.synthetic.json',import.meta.url),'utf8');const r=evaluate(raw,{['a'.repeat(64)]:'backend'});assert.equal(r.measurementSchemaVersion,version);assert.equal(r.extendedMetricsComplete,version===3);assert.equal(r.budgetFits,false);assert.equal(r.wholeInstallation2GbAccepted,false);if(version===3){assert.equal(r.extendedObservations[0].containers[0].configuredMemorySwapBytes,1024);assert.equal(r.extendedObservations[0].memoryPressure.some.totalMicros,123456)}}})
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { evaluateObservedInstallationBudget as evaluate } from './installation-memory-budget.mjs'
|
||||
const MiB=1024*1024
|
||||
function fixture() {
|
||||
const containers=['backend','postgres','frontend','portal'].map((role,index)=>({id:String(index+1).repeat(64),role,running:true,imageId:'sha256:'+'a'.repeat(64),startedAt:'2026-10-28T12:00:00Z',memoryLimitBytes:0,reportedLimitBytes:2*1024*MiB}))
|
||||
const roleById=Object.fromEntries(containers.map(row=>[row.id,row.role]))
|
||||
return {inventory:{readOnly:true,containerSetStable:true,environmentRead:false,serviceChanges:false,newFixtureStarted:false,hostMemTotalBytes:2021212*1024,containers},roleById}
|
||||
}
|
||||
test('observed zero configured caps stay unbounded despite positive Docker reported host limits',()=>{
|
||||
const f=fixture(), result=evaluate(f.inventory,f.roleById)
|
||||
assert.equal(result.budgetFits,false);assert(result.reasons.includes('UNBOUNDED_CONTAINER'));assert.equal(result.configuredContainerBytes,0)
|
||||
assert.equal(result.wholeInstallation2GbAccepted,false);assert.equal(result.roleLabelsAuthenticated,false);assert.equal(result.inventoryFreshnessProven,false)
|
||||
})
|
||||
test('complete explicit configured caps can fit arithmetic without becoming runtime acceptance',()=>{
|
||||
const f=fixture();[512,384,160,192].forEach((limit,index)=>{f.inventory.containers[index].memoryLimitBytes=limit*MiB})
|
||||
const result=evaluate(f.inventory,f.roleById);assert(result.budgetFits);assert.equal(result.plannedBytes,1760*MiB);assert.equal(result.changesAuthorized,false)
|
||||
})
|
||||
test('uncertain snapshots, started fixtures and missing limit/identity fields refuse',()=>{
|
||||
for(const mutate of [f=>{f.inventory.containerSetStable=false},f=>{f.inventory.serviceChanges=true},f=>{f.inventory.newFixtureStarted=true},f=>{delete f.inventory.containers[0].memoryLimitBytes},f=>{f.inventory.containers[0].running=false},f=>{f.inventory.containers[0].imageId='unknown'},f=>{f.inventory.containers[0].startedAt='invalid'}]){const f=fixture();mutate(f);assert.throws(()=>evaluate(f.inventory,f.roleById))}
|
||||
})
|
||||
test('missing, foreign, inherited and accessor role labels cannot silently classify a service',()=>{
|
||||
for(const mode of ['missing','foreign','inherited','accessor']){
|
||||
const f=fixture(),key=f.inventory.containers[0].id
|
||||
if(mode==='missing')delete f.roleById[key]
|
||||
if(mode==='foreign')f.roleById['f'.repeat(64)]='other'
|
||||
if(mode==='inherited'){const labels=Object.create({[key]:'backend'});for(const [id,role]of Object.entries(f.roleById))if(id!==key)labels[id]=role;f.roleById=labels}
|
||||
if(mode==='accessor')Object.defineProperty(f.roleById,key,{enumerable:true,get(){assert.fail('Role getter must not execute')}})
|
||||
assert.throws(()=>evaluate(f.inventory,f.roleById))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own203.invalid';process.env.AUTH_PORTAL_URL='https://own203.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const secrets={s3:'own203-'+randomBytes(16).toString('hex'),evo:'own203-'+randomBytes(16).toString('hex'),telegram:'12345:'+randomBytes(24).toString('base64url')},cases=[];let app,requests=0;const originalFetch=globalThis.fetch;globalThis.fetch=async()=>{requests++;throw Error('External fetch forbidden in owned secret DTO test')}
|
||||
const report={date:'2026-10-13',block:'12.5.203',database,scope:'Actual compiled buildApp/auth/GET handlers and own restricted PostgreSQL with nonempty synthetic credentials; isolated signed test JWT, not real Portal login',cases,passed:false}
|
||||
try{
|
||||
await initDatabase()
|
||||
await pool.query("INSERT INTO backup_storage_settings(id,provider,s3_endpoint,s3_access_key_id,s3_secret_access_key) VALUES(1,'local','https://s3.own.invalid','OWN203_PUBLIC_ACCESS_ID',$1) ON CONFLICT(id) DO UPDATE SET s3_secret_access_key=$1",[secrets.s3])
|
||||
await pool.query("INSERT INTO evobgp_settings(id,base_url,api_key,enabled) VALUES(1,'https://evo.own.invalid',$1,false) ON CONFLICT(id) DO UPDATE SET api_key=$1",[secrets.evo])
|
||||
await pool.query("INSERT INTO alert_telegram_settings(id,bot_token,chat_id) VALUES(1,$1,'0') ON CONFLICT(id) DO UPDATE SET bot_token=$1",[secrets.telegram])
|
||||
app=await buildApp({logger:false,startScheduler:false});await app.ready()
|
||||
for(const role of ['reader','admin']){const permissions=['mm:backups:read','mm:alerts:read',...(role==='admin'?['mm:settings:admin']:[])];const jwt=app.jwt.sign({sub:'own203-'+role,apps:['mm'],permissions,iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});for(const path of ['/api/backups/storage','/api/evobgp/settings','/api/alerts']){const r=await app.inject({method:'GET',url:path,headers:{authorization:'Bearer '+jwt}});assert.equal(r.statusCode,role==='reader'&&path.includes('evobgp')?403:200);for(const secret of Object.values(secrets))assert(!r.body.includes(secret));if(r.statusCode===200){const dto=r.json();if(path.includes('storage')||path.includes('evobgp'))assert.equal(dto.secretConfigured,true);else assert.equal(dto.telegram.tokenConfigured,true)}cases.push({role,path,status:r.statusCode,syntheticSecretExposed:false,configuredFlag:r.statusCode===200});}}
|
||||
for(const path of ['/api/backups/storage','/api/evobgp/settings','/api/alerts']){assert.equal((await app.inject({method:'GET',url:path})).statusCode,401);cases.push({role:'unauthenticated',path,status:401})}
|
||||
assert.equal((await pool.query('SELECT s3_secret_access_key FROM backup_storage_settings WHERE id=1')).rows[0].s3_secret_access_key,secrets.s3);assert.equal((await pool.query('SELECT api_key FROM evobgp_settings WHERE id=1')).rows[0].api_key,secrets.evo);assert.equal((await pool.query('SELECT bot_token FROM alert_telegram_settings WHERE id=1')).rows[0].bot_token,secrets.telegram);assert.equal(requests,0);report.externalRequests=0;report.storedSyntheticSecretsUnchanged=true;report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/secret203-private.log',String(e.stack??e),{mode:0o600})}
|
||||
finally{globalThis.fetch=originalFetch;if(app)await app.close();await closePool();fs.writeFileSync('/app/backend/acceptance/secret203-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}
|
||||
if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Synthetic-only: no Docker/socket, real /proc/cgroup reads, process enumeration or main()."""
|
||||
import copy
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
AREA = Path(__file__).parent
|
||||
spec = importlib.util.spec_from_file_location('memory_measurement_extended', AREA / 'measure-memory.py')
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
MEMINFO = 'MemTotal: 2048 kB\nMemAvailable: 1024 kB\nSwapTotal: 4096 kB\nSwapFree: 3072 kB\n'
|
||||
VMSTAT = 'pswpin 20\npswpout 30\noom_kill 0\n'
|
||||
PSI = 'some avg10=0.00 avg60=1.25 avg300=100.00 total=123456\nfull avg10=0.00 avg60=0.01 avg300=1.00 total=0\n'
|
||||
ID = 'a' * 64
|
||||
CG = '/sys/fs/cgroup/system.slice/docker-' + ID + '.scope'
|
||||
|
||||
class Synthetic(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.reads = []
|
||||
self.gets = []
|
||||
self.before = {'Id': ID, 'Image': 'sha256:' + 'b' * 64, 'RestartCount': 0,
|
||||
'State': {'StartedAt': '2026-11-02T00:00:00Z', 'Pid': 123, 'Running': True, 'OOMKilled': False},
|
||||
'HostConfig': {'Memory': 512, 'MemorySwap': 1024}, 'Config': {'Env': ['SECRET=never-retain']}}
|
||||
self.stats = {'id': ID, 'memory_stats': {'usage': 100, 'limit': 2048, 'stats': {'inactive_file': 30}}}
|
||||
self.files = {'/proc/meminfo': MEMINFO, '/proc/vmstat': VMSTAT, '/proc/pressure/memory': PSI,
|
||||
'/proc/self/mountinfo': '1 0 0:1 / /sys/fs/cgroup rw - cgroup2 cgroup rw\n',
|
||||
'/proc/123/cgroup': '0::/system.slice/docker-' + ID + '.scope\n',
|
||||
'/proc/123/stat': '123 (owned fixture) S ' + ' '.join(['0'] * 18 + ['987'] + ['0'] * 8),
|
||||
CG + '/memory.current': '100\n', CG + '/memory.peak': '200\n',
|
||||
CG + '/memory.events': 'low 0\nhigh 0\nmax 0\noom 0\noom_kill 0\n',
|
||||
CG + '/memory.swap.current': '0\n', CG + '/memory.swap.peak': '0\n',
|
||||
CG + '/memory.swap.events': 'high 0\nmax 0\nfail 0\n',
|
||||
CG + '/memory.max': '512\n', CG + '/memory.swap.max': '512\n',
|
||||
CG + '/cpu.stat': 'usage_usec 50\nuser_usec 30\nsystem_usec 20\nnr_periods 10\nnr_throttled 2\nthrottled_usec 1234\nnr_bursts 0\n'}
|
||||
def deny(*_args, **_kwargs):
|
||||
raise AssertionError('Real external/OS I/O is forbidden by synthetic test')
|
||||
# Module-local OS proxy avoids changing unittest/Python's filesystem operations.
|
||||
fake_os = SimpleNamespace(**vars(m.os))
|
||||
fake_os.path = SimpleNamespace(**vars(m.os.path))
|
||||
fake_os.sep = '/' # synthetic Linux path contract, not this Windows host
|
||||
fake_os.open = deny
|
||||
fake_os.stat = deny
|
||||
fake_os.path.realpath = deny
|
||||
self.enterContext(patch.object(m, 'os', fake_os))
|
||||
self.enterContext(patch.object(m, 'read_bounded', side_effect=deny))
|
||||
self.enterContext(patch.object(m, 'docker_get', side_effect=deny))
|
||||
self.enterContext(patch.object(m.socket, 'socket', side_effect=deny))
|
||||
|
||||
def read(self, name, expected_device=None, deadline=None):
|
||||
self.reads.append(name)
|
||||
if name not in self.files:
|
||||
raise FileNotFoundError('synthetic missing')
|
||||
value = self.files[name]
|
||||
if isinstance(value, BaseException):
|
||||
raise value
|
||||
return value
|
||||
|
||||
def observe(self, after=None, realpath=None, statter=None):
|
||||
after = copy.deepcopy(self.before) if after is None else after
|
||||
def get(name, deadline=None):
|
||||
self.gets.append(name)
|
||||
if self.gets == ['/containers/' + ID + '/json']:
|
||||
return copy.deepcopy(self.before)
|
||||
if self.gets == ['/containers/' + ID + '/json', '/containers/' + ID + '/stats?stream=false&one-shot=true']:
|
||||
return copy.deepcopy(self.stats)
|
||||
if self.gets == ['/containers/' + ID + '/json', '/containers/' + ID + '/stats?stream=false&one-shot=true', '/containers/' + ID + '/json']:
|
||||
return copy.deepcopy(after)
|
||||
raise AssertionError('Unexpected Docker path or extra request')
|
||||
def statter_default(name):
|
||||
allowed = {'/sys/fs/cgroup': (8, 1), CG: (8, 2), '/proc/123/root': (8, 3)}
|
||||
if name not in allowed:
|
||||
raise AssertionError('Unexpected stat path')
|
||||
dev, ino = allowed[name]
|
||||
return SimpleNamespace(st_dev=dev, st_ino=ino)
|
||||
with patch.object(m, 'read_bounded', side_effect=self.read), patch.object(m, 'docker_get', side_effect=get), \
|
||||
patch.object(m.os.path, 'realpath', side_effect=realpath or (lambda p: p)), \
|
||||
patch.object(m.os, 'stat', side_effect=statter or statter_default):
|
||||
return m.collect_container(ID)
|
||||
|
||||
def test_limits_max_zero_finite_are_not_missing_or_host_ram(self):
|
||||
for value, expected in [('max\n', 'max'), ('max', 'max'), ('0\n', 0), ('512\n', 512), (str(2**64-1), 2**64-1)]:
|
||||
self.assertEqual(m.parse_limit(value), expected)
|
||||
for bad in ('MAX', '-1', ' 0', '00', '1.0', str(2**64), 'max\n\n'):
|
||||
with self.assertRaises(ValueError): m.parse_limit(bad)
|
||||
|
||||
def test_cpu_throttling_units_required_fields_and_output_whitelist(self):
|
||||
parsed = m.parse_cpu_throttling(self.files[CG + '/cpu.stat'])
|
||||
self.assertEqual(parsed, {'nr_periods':10,'nr_throttled':2,'throttled_usec':1234})
|
||||
for bad in ('nr_periods 1\nnr_throttled 0\n', 'nr_periods 1\nnr_periods 2\nnr_throttled 0\nthrottled_usec 0\n', 'nr_periods -1\nnr_throttled 0\nthrottled_usec 0\n'):
|
||||
with self.assertRaises(ValueError): m.parse_cpu_throttling(bad)
|
||||
|
||||
def test_pressure_fixed_groups_percent_and_microseconds(self):
|
||||
parsed = m.parse_memory_pressure(PSI)
|
||||
self.assertEqual(parsed['some'], {'avg10Percent':'0.00','avg60Percent':'1.25','avg300Percent':'100.00','totalMicros':123456})
|
||||
self.assertEqual(parsed['full']['totalMicros'], 0)
|
||||
for bad in (PSI.replace('100.00','100.01'), PSI.replace('1.25','nan'), PSI.replace('total=0','total=-1'), PSI.splitlines()[0], PSI.replace('full','some'), PSI+'extra\n', PSI.replace('total=0','total='+str(2**64))):
|
||||
with self.assertRaises(ValueError): m.parse_memory_pressure(bad)
|
||||
|
||||
def test_docker_memoryswap_raw_minus1_zero_finite_and_missing_differ(self):
|
||||
for raw in (-1,0,512,2**63-1):
|
||||
item = copy.deepcopy(self.before); item['HostConfig']['MemorySwap'] = raw
|
||||
self.assertEqual(m.configured_memory_swap(item), raw)
|
||||
for bad in (None,False,True,'0',-2,2**63):
|
||||
item = copy.deepcopy(self.before); item['HostConfig']['MemorySwap'] = bad
|
||||
self.assertIsNone(m.configured_memory_swap(item))
|
||||
for host in ({},None,[]):
|
||||
self.assertIsNone(m.configured_memory_swap({'HostConfig':host}))
|
||||
|
||||
def test_real_collection_paths_are_only_fixed_host_and_retained_cgroup(self):
|
||||
row = self.observe()
|
||||
self.assertTrue(row['identityStableDuringSample'])
|
||||
self.assertTrue(row['resourceLimitsStableDuringSample'])
|
||||
self.assertEqual(row['configuredMemorySwapBytes'], 1024)
|
||||
self.assertEqual(row['cgroupV2']['status'], 'available')
|
||||
self.assertEqual(row['cgroupV2']['metrics']['memory.max'], 512)
|
||||
self.assertEqual(row['cgroupV2']['metrics']['cpu.stat']['throttled_usec'], 1234)
|
||||
self.assertTrue(set(self.reads).issubset(set(self.files)))
|
||||
self.assertNotIn('SECRET', str(row)); self.assertNotIn('Env', str(row))
|
||||
self.assertEqual(len(self.gets), 3)
|
||||
|
||||
def test_unlimited_limits_do_not_become_numeric_zero(self):
|
||||
self.files[CG+'/memory.max']='max\n'; self.files[CG+'/memory.swap.max']='max\n'
|
||||
row=self.observe();self.assertEqual(row['cgroupV2']['metrics']['memory.max'],'max');self.assertEqual(row['cgroupV2']['metrics']['memory.swap.max'],'max')
|
||||
|
||||
def test_each_new_cgroup_source_missing_or_malformed_is_unknown(self):
|
||||
for name in ('memory.max','memory.swap.max','cpu.stat'):
|
||||
for value in (None,'not-a-counter\n'):
|
||||
with self.subTest(name=name,value=value):
|
||||
self.setUp_fixture_only()
|
||||
if value is None: self.files.pop(CG+'/'+name)
|
||||
else: self.files[CG+'/'+name]=value
|
||||
row=self.observe();self.assertEqual(row['cgroupV2']['status'],'partial');self.assertIsNone(row['cgroupV2']['metrics'][name]);self.assertIn(name,row['cgroupV2']['unavailable'])
|
||||
def setUp_fixture_only(self):
|
||||
# Recreate dictionary, not guards/patches; no real I/O.
|
||||
self.files.update({CG+'/memory.max':'512\n',CG+'/memory.swap.max':'512\n',CG+'/cpu.stat':'nr_periods 10\nnr_throttled 2\nthrottled_usec 1234\n'});self.gets=[]
|
||||
|
||||
def test_missing_or_changed_swap_configuration_cannot_authenticate_limits(self):
|
||||
after=copy.deepcopy(self.before);after['HostConfig']['MemorySwap']=2048
|
||||
row=self.observe(after);self.assertFalse(row['resourceLimitsStableDuringSample']);self.assertEqual(row['cgroupV2']['status'],'partial');self.assertIsNone(row['cgroupV2']['metrics']['memory.swap.max'])
|
||||
self.gets=[];self.before['HostConfig'].pop('MemorySwap');after=copy.deepcopy(self.before)
|
||||
row=self.observe(after);self.assertIsNone(row['configuredMemorySwapBytes']);self.assertEqual(row['configuredMemorySwapStatus'],'unavailable');self.assertIsNone(row['resourceLimitsStableDuringSample']);self.assertEqual(row['cgroupV2']['status'],'partial')
|
||||
|
||||
def test_memory_limit_change_cannot_reuse_stable_container_identity_as_limit_proof(self):
|
||||
after=copy.deepcopy(self.before);after['HostConfig']['Memory']=1024
|
||||
row=self.observe(after)
|
||||
self.assertTrue(row['identityStableDuringSample'])
|
||||
self.assertFalse(row['resourceLimitsStableDuringSample'])
|
||||
for key in ('memory.max','memory.swap.max'):
|
||||
self.assertIsNone(row['cgroupV2']['metrics'][key])
|
||||
self.assertEqual(row['cgroupV2']['unavailable'][key],'container-resource-limits-changed-or-unavailable')
|
||||
|
||||
def test_legacy_projection_fields_keep_cache_limit_and_rss_distinctions(self):
|
||||
item=copy.deepcopy(self.before);item['HostConfig'].pop('MemorySwap');item['HostConfig']['Memory']=0
|
||||
row=m.project_container(item,self.stats,ID)
|
||||
self.assertEqual(row['usageIncludingCacheBytes'],100)
|
||||
self.assertEqual(row['usageMinusInactiveFileBytes'],70)
|
||||
self.assertEqual(row['configuredLimitBytes'],0)
|
||||
self.assertEqual(row['reportedLimitBytes'],2048)
|
||||
self.assertEqual(row['configuredMemorySwapStatus'],'unavailable')
|
||||
self.assertIsNone(row['configuredMemorySwapBytes'])
|
||||
|
||||
def test_identity_drift_symlink_and_pid_generation_still_refuse(self):
|
||||
after=copy.deepcopy(self.before);after['State']['Pid']=456
|
||||
row=self.observe(after);self.assertFalse(row['identityStableDuringSample']);self.assertIsNone(row['resourceLimitsStableDuringSample']);self.assertEqual(row['cgroupV2']['status'],'unavailable')
|
||||
self.gets=[];row=self.observe(realpath=lambda p:'/other' if p.endswith('memory.swap.max') else p);self.assertEqual(row['cgroupV2']['status'],'partial');self.assertIsNone(row['cgroupV2']['metrics']['memory.swap.max'])
|
||||
self.gets=[]
|
||||
with patch.object(m,'process_generation',side_effect=[(1,8,3),(2,8,3)]):
|
||||
self.assertEqual(self.observe()['cgroupV2']['status'],'unavailable')
|
||||
|
||||
def test_host_collection_whitelists_one_psi_file_and_missing_is_explicit(self):
|
||||
with patch.object(m,'read_bounded',side_effect=self.read):
|
||||
host=m.collect_host();self.assertEqual(host['memoryPressure']['status'],'available');self.assertEqual(host['MemTotalBytes'],2048*1024)
|
||||
self.assertEqual(self.reads,['/proc/meminfo','/proc/vmstat','/proc/pressure/memory'])
|
||||
self.files.pop('/proc/pressure/memory');host=m.collect_host();self.assertEqual(host['memoryPressure']['status'],'unavailable');self.assertIsNone(host['memoryPressure']['metrics'])
|
||||
|
||||
def test_deadline_never_becomes_partial_or_success(self):
|
||||
self.files[CG+'/cpu.stat']=m.ObservationDeadlineExceeded('fixture-deadline')
|
||||
with self.assertRaises(m.ObservationDeadlineExceeded): self.observe()
|
||||
with patch.object(m,'read_bounded',side_effect=[MEMINFO,VMSTAT,m.ObservationDeadlineExceeded('fixture-deadline')]):
|
||||
with self.assertRaises(m.ObservationDeadlineExceeded): m.collect_host()
|
||||
|
||||
def test_summary_exposes_unknown_and_never_authorizes_runtime(self):
|
||||
row=self.observe()
|
||||
with patch.object(m,'read_bounded',side_effect=self.read):host=m.collect_host()
|
||||
sample={'elapsedMs':0,'host':host,'containers':[row]}
|
||||
out=m.summarize([sample]);self.assertTrue(out['allResourceLimitsStableDuringSample']);self.assertTrue(out['allHostMemoryPressureAvailable']);self.assertFalse(out['acceptancePassed'])
|
||||
row['resourceLimitsStableDuringSample']=None;host['memoryPressure']['status']='unavailable'
|
||||
out=m.summarize([sample]);self.assertFalse(out['allResourceLimitsStableDuringSample']);self.assertFalse(out['allHostMemoryPressureAvailable']);self.assertFalse(out['acceptancePassed'])
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -0,0 +1,459 @@
|
||||
"""Bounded Linux/Docker memory observation; GET only, no service changes.
|
||||
|
||||
Run alongside the named acceptance workload. Output is evidence, never an
|
||||
automatic declaration that a 2 GB deployment has passed its load acceptance.
|
||||
Requires Python 3 and read access to /proc and the local Docker socket.
|
||||
"""
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
|
||||
MAX_RESPONSE = 4 * 1024 * 1024
|
||||
MAX_FILE_BYTES = 65536
|
||||
MAX_FILE_LINES = 256
|
||||
CGROUP_ROOT = '/sys/fs/cgroup'
|
||||
CGROUP_FILES = ('memory.current', 'memory.peak', 'memory.events',
|
||||
'memory.swap.current', 'memory.swap.peak', 'memory.swap.events',
|
||||
'memory.max', 'memory.swap.max', 'cpu.stat')
|
||||
|
||||
|
||||
class CgroupUnavailable(ValueError):
|
||||
"""Fixed reason codes only; never include private paths or raw content."""
|
||||
|
||||
|
||||
class ObservationDeadlineExceeded(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def check_deadline(deadline):
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
raise ObservationDeadlineExceeded('Observation time bound exceeded')
|
||||
|
||||
|
||||
def read_bounded(path, expected_device=None, deadline=None):
|
||||
# Fixed production paths only; no CLI path or executable is accepted.
|
||||
check_deadline(deadline)
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0))
|
||||
with os.fdopen(descriptor, 'rb') as handle:
|
||||
identity = os.fstat(handle.fileno())
|
||||
if not stat.S_ISREG(identity.st_mode) or (expected_device is not None and identity.st_dev != expected_device):
|
||||
raise ValueError('Non-regular observation')
|
||||
raw = handle.read(MAX_FILE_BYTES + 1)
|
||||
check_deadline(deadline)
|
||||
if len(raw) > MAX_FILE_BYTES:
|
||||
raise ValueError('Observation file too large')
|
||||
text = raw.decode('ascii')
|
||||
if len(text.splitlines()) > MAX_FILE_LINES:
|
||||
raise ValueError('Too many observation lines')
|
||||
return text
|
||||
|
||||
|
||||
def parse_counter(text):
|
||||
if not re.fullmatch(r'(?:0|[1-9][0-9]{0,19})\n?', text):
|
||||
raise ValueError('Invalid counter')
|
||||
value = int(text)
|
||||
if value > 2 ** 64 - 1:
|
||||
raise ValueError('Counter overflow')
|
||||
return value
|
||||
|
||||
|
||||
def parse_events(text, required):
|
||||
lines = text.splitlines()
|
||||
if not 1 <= len(lines) <= 32:
|
||||
raise ValueError('Invalid event count')
|
||||
result = {}
|
||||
for line in lines:
|
||||
fields = line.split(' ')
|
||||
if len(fields) != 2 or not re.fullmatch(r'[a-z_]{1,64}', fields[0]) or fields[0] in result:
|
||||
raise ValueError('Invalid event')
|
||||
result[fields[0]] = parse_counter(fields[1])
|
||||
if not set(required).issubset(result):
|
||||
raise ValueError('Incomplete events')
|
||||
return result
|
||||
|
||||
|
||||
|
||||
def parse_limit(text):
|
||||
# Preserve unlimited vs zero vs unavailable; never substitute host RAM.
|
||||
return 'max' if text in ('max', 'max\n') else parse_counter(text)
|
||||
|
||||
|
||||
def parse_cpu_throttling(text):
|
||||
required = ('nr_periods', 'nr_throttled', 'throttled_usec')
|
||||
parsed = parse_events(text, required)
|
||||
# Kernel extensions are validated but do not expand the output whitelist.
|
||||
return {key: parsed[key] for key in required}
|
||||
|
||||
|
||||
def parse_memory_pressure(text):
|
||||
rows = text.splitlines()
|
||||
if len(rows) != 2:
|
||||
raise ValueError('Incomplete memory pressure')
|
||||
result = {}
|
||||
for line in rows:
|
||||
match = re.fullmatch(r'(some|full) avg10=([0-9]{1,3}\.[0-9]{2}) avg60=([0-9]{1,3}\.[0-9]{2}) avg300=([0-9]{1,3}\.[0-9]{2}) total=((?:0|[1-9][0-9]{0,19}))', line)
|
||||
if not match or match[1] in result:
|
||||
raise ValueError('Invalid memory pressure')
|
||||
for value in match.group(2, 3, 4):
|
||||
whole, fraction = value.split('.')
|
||||
if int(whole) * 100 + int(fraction) > 10000:
|
||||
raise ValueError('Invalid memory pressure percentage')
|
||||
result[match[1]] = {'avg10Percent': match[2], 'avg60Percent': match[3],
|
||||
'avg300Percent': match[4], 'totalMicros': parse_counter(match[5])}
|
||||
if set(result) != {'some', 'full'}:
|
||||
raise ValueError('Incomplete memory pressure')
|
||||
return result
|
||||
|
||||
|
||||
def configured_memory_swap(inspect):
|
||||
# Docker MemorySwap is TOTAL memory+swap; 0 means daemon default, -1 unlimited.
|
||||
host = inspect.get('HostConfig')
|
||||
value = host.get('MemorySwap') if isinstance(host, dict) else None
|
||||
if type(value) is not int or not -1 <= value <= 2 ** 63 - 1:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def resource_limits_stable(before, after):
|
||||
values = []
|
||||
for item in (before, after):
|
||||
host = item.get('HostConfig')
|
||||
memory = host.get('Memory') if isinstance(host, dict) else None
|
||||
swap = configured_memory_swap(item)
|
||||
if type(memory) is not int or not 0 <= memory <= 2 ** 63 - 1 or swap is None:
|
||||
return None
|
||||
values.append((memory, swap))
|
||||
return values[0] == values[1]
|
||||
|
||||
|
||||
def collect_host(deadline=None):
|
||||
memory = parse_host(read_bounded('/proc/meminfo', deadline=deadline),
|
||||
read_bounded('/proc/vmstat', deadline=deadline))
|
||||
try:
|
||||
pressure = parse_memory_pressure(read_bounded('/proc/pressure/memory', deadline=deadline))
|
||||
memory['memoryPressure'] = {'status': 'available', 'metrics': pressure, 'reason': None}
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
memory['memoryPressure'] = {'status': 'unavailable', 'metrics': None,
|
||||
'reason': 'missing-unreadable-or-malformed'}
|
||||
return memory
|
||||
|
||||
|
||||
def container_identity(inspect, identifier):
|
||||
state = inspect['State']
|
||||
if (inspect.get('Id') != identifier or not re.fullmatch(r'[a-f0-9]{64}', identifier)
|
||||
or type(state.get('Pid')) is not int or not 0 < state['Pid'] <= 2 ** 31 - 1
|
||||
or state.get('Running') is not True or type(state.get('OOMKilled')) is not bool
|
||||
or not isinstance(state.get('StartedAt'), str) or not state['StartedAt']
|
||||
or type(inspect.get('RestartCount')) is not int or inspect['RestartCount'] < 0
|
||||
or not isinstance(inspect.get('Image'), str) or not inspect['Image']):
|
||||
raise ValueError('Unavailable container identity')
|
||||
return (identifier, inspect['Image'], state['StartedAt'], state['Pid'], inspect['RestartCount'], state['OOMKilled'])
|
||||
|
||||
|
||||
def cgroup_location(pid, identifier, deadline=None):
|
||||
check_deadline(deadline)
|
||||
mounts = read_bounded('/proc/self/mountinfo', deadline=deadline).splitlines()
|
||||
# Namespace-relative/subtree mounts are deliberately unsupported: their
|
||||
# root cannot authenticate a host container's complete cgroup membership.
|
||||
matches = [line.split(' - ', 1) for line in mounts if ' - ' in line]
|
||||
if not any(left.split()[3:5] == ['/', CGROUP_ROOT] and right.split()[0] == 'cgroup2'
|
||||
for left, right in matches):
|
||||
raise CgroupUnavailable('unsupported-or-unavailable-host-cgroup2-mount')
|
||||
membership = read_bounded('/proc/' + str(pid) + '/cgroup', deadline=deadline)
|
||||
lines = membership.splitlines()
|
||||
if len(lines) != 1 or not lines[0].startswith('0::/'):
|
||||
raise CgroupUnavailable('unsupported-v1-hybrid-or-malformed-membership')
|
||||
relative = lines[0][3:]
|
||||
parts = relative.split('/')[1:]
|
||||
if (not parts or any(not p or p in ('.', '..') or not re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}', p) for p in parts)
|
||||
or parts[-1] not in (identifier, 'docker-' + identifier + '.scope')):
|
||||
raise ValueError('Unbound cgroup membership')
|
||||
root = os.path.realpath(CGROUP_ROOT)
|
||||
path = os.path.realpath(CGROUP_ROOT + relative)
|
||||
if root != CGROUP_ROOT or path != root + relative or not path.startswith(root + os.sep):
|
||||
raise ValueError('Cgroup path escaped')
|
||||
return path, membership
|
||||
|
||||
|
||||
def process_generation(pid, deadline=None):
|
||||
check_deadline(deadline)
|
||||
raw = read_bounded('/proc/' + str(pid) + '/stat', deadline=deadline)
|
||||
prefix, suffix = raw.rsplit(')', 1)
|
||||
if prefix.split(' ', 1)[0] != str(pid) or ' (' not in prefix:
|
||||
raise ValueError('Invalid process identity')
|
||||
fields = suffix.split()
|
||||
if len(fields) < 20:
|
||||
raise ValueError('Incomplete process identity')
|
||||
start = parse_counter(fields[19])
|
||||
root = os.stat('/proc/' + str(pid) + '/root')
|
||||
return start, root.st_dev, root.st_ino
|
||||
|
||||
|
||||
def unavailable_cgroup(reason):
|
||||
return {'version': 2, 'status': 'unavailable', 'reason': reason,
|
||||
'metrics': {name: None for name in CGROUP_FILES}, 'unavailable': {name: reason for name in CGROUP_FILES}}
|
||||
|
||||
|
||||
def collect_container(identifier, deadline=None):
|
||||
check_deadline(deadline)
|
||||
before = docker_get('/containers/' + identifier + '/json', deadline)
|
||||
stats = docker_get('/containers/' + identifier + '/stats?stream=false&one-shot=true', deadline)
|
||||
check_deadline(deadline)
|
||||
result = project_container(before, stats, identifier)
|
||||
cgroup = unavailable_cgroup('identity-unavailable')
|
||||
try:
|
||||
identity = container_identity(before, identifier)
|
||||
pid = identity[3]
|
||||
generation = process_generation(pid, deadline)
|
||||
path, membership = cgroup_location(pid, identifier, deadline)
|
||||
directory = os.stat(path)
|
||||
if directory.st_dev != os.stat(CGROUP_ROOT).st_dev:
|
||||
raise ValueError('Cgroup filesystem changed')
|
||||
metrics, missing = {}, {}
|
||||
for name in CGROUP_FILES:
|
||||
check_deadline(deadline)
|
||||
try:
|
||||
candidate = path + '/' + name
|
||||
if os.path.realpath(candidate) != candidate:
|
||||
raise ValueError('Symlinked metric')
|
||||
raw = read_bounded(candidate, directory.st_dev, deadline=deadline)
|
||||
check_deadline(deadline)
|
||||
if name == 'memory.events':
|
||||
metrics[name] = parse_events(raw, ('low', 'high', 'max', 'oom', 'oom_kill'))
|
||||
elif name == 'memory.swap.events':
|
||||
metrics[name] = parse_events(raw, ('high', 'max', 'fail'))
|
||||
elif name in ('memory.max', 'memory.swap.max'):
|
||||
metrics[name] = parse_limit(raw)
|
||||
elif name == 'cpu.stat':
|
||||
metrics[name] = parse_cpu_throttling(raw)
|
||||
else:
|
||||
metrics[name] = parse_counter(raw)
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
metrics[name] = None
|
||||
missing[name] = 'missing-unreadable-or-malformed'
|
||||
check_deadline(deadline)
|
||||
after_path, after_membership = cgroup_location(pid, identifier, deadline)
|
||||
after_directory = os.stat(path)
|
||||
if (process_generation(pid, deadline) != generation or after_path != path or after_membership != membership
|
||||
or (directory.st_dev, directory.st_ino) != (after_directory.st_dev, after_directory.st_ino)):
|
||||
cgroup = unavailable_cgroup('process-or-cgroup-changed')
|
||||
else:
|
||||
cgroup = {'version': 2, 'status': 'partial' if missing else 'available',
|
||||
'reason': 'some-metrics-unavailable' if missing else None,
|
||||
'metrics': metrics, 'unavailable': missing,
|
||||
'peakScope': 'cgroup lifetime or since external reset; not an isolated workload peak',
|
||||
'provenance': 'fixed host cgroup2 mount + exact Docker ID membership + process/root generation'}
|
||||
except CgroupUnavailable as error:
|
||||
cgroup = unavailable_cgroup(str(error))
|
||||
except (OSError, ValueError, KeyError, IndexError, UnicodeError):
|
||||
cgroup = unavailable_cgroup('identity-process-or-cgroup-unavailable-or-unsupported')
|
||||
# Even failed cgroup observations are bracketed by a fresh Docker inspect.
|
||||
check_deadline(deadline)
|
||||
after = docker_get('/containers/' + identifier + '/json', deadline)
|
||||
check_deadline(deadline)
|
||||
try:
|
||||
stable = container_identity(before, identifier) == container_identity(after, identifier)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
stable = False
|
||||
if not stable:
|
||||
cgroup = unavailable_cgroup('container-identity-changed-or-unavailable')
|
||||
limits_stable = resource_limits_stable(before, after) if stable else None
|
||||
if stable and limits_stable is not True and cgroup['status'] != 'unavailable':
|
||||
for key in ('memory.max', 'memory.swap.max'):
|
||||
cgroup['metrics'][key] = None
|
||||
cgroup['unavailable'][key] = 'container-resource-limits-changed-or-unavailable'
|
||||
cgroup['status'] = 'partial'
|
||||
cgroup['reason'] = 'some-metrics-unavailable'
|
||||
result.update({'resourceLimitsStableDuringSample': limits_stable,
|
||||
'pid': before['State'].get('Pid'), 'identityStableDuringSample': stable,
|
||||
'runningAfter': after.get('State', {}).get('Running'),
|
||||
'oomKilledAfter': after.get('State', {}).get('OOMKilled'), 'cgroupV2': cgroup})
|
||||
return result
|
||||
|
||||
|
||||
def docker_get(path, deadline=None):
|
||||
deadline = time.monotonic() + 5 if deadline is None else deadline
|
||||
check_deadline(deadline)
|
||||
connection = http.client.HTTPConnection('localhost', timeout=min(5, deadline - time.monotonic()))
|
||||
owned_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
connection.sock = owned_socket
|
||||
# A socket timeout alone permits an endless body that drips bytes. The
|
||||
# watchdog owns precisely this request's socket, including after HTTP's
|
||||
# Connection:close detaches it from HTTPConnection into HTTPResponse.
|
||||
def expire():
|
||||
try:
|
||||
owned_socket.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
owned_socket.close()
|
||||
watchdog = threading.Timer(max(0, deadline - time.monotonic()), expire)
|
||||
watchdog.daemon = True
|
||||
watchdog_started = False
|
||||
response = None
|
||||
try:
|
||||
watchdog.start()
|
||||
watchdog_started = True
|
||||
owned_socket.settimeout(min(5, max(0.001, deadline - time.monotonic())))
|
||||
check_deadline(deadline)
|
||||
owned_socket.connect('/var/run/docker.sock')
|
||||
check_deadline(deadline)
|
||||
connection.request('GET', '/v1.45' + path, headers={'Connection': 'close'})
|
||||
response = connection.getresponse()
|
||||
raw = response.read(MAX_RESPONSE + 1)
|
||||
check_deadline(deadline)
|
||||
if response.status != 200 or len(raw) > MAX_RESPONSE:
|
||||
raise ValueError('Docker observation unavailable')
|
||||
result = json.loads(raw)
|
||||
check_deadline(deadline)
|
||||
return result
|
||||
except Exception:
|
||||
check_deadline(deadline)
|
||||
raise
|
||||
finally:
|
||||
watchdog.cancel()
|
||||
if watchdog_started:
|
||||
watchdog.join()
|
||||
try:
|
||||
if response is not None:
|
||||
response.close()
|
||||
finally:
|
||||
try:
|
||||
connection.close()
|
||||
finally:
|
||||
owned_socket.close()
|
||||
|
||||
|
||||
def nonnegative(value):
|
||||
if type(value) is not int or value < 0:
|
||||
raise ValueError('Invalid numeric observation')
|
||||
return value
|
||||
|
||||
|
||||
def parse_host(meminfo, vmstat):
|
||||
memory = {}
|
||||
for line in meminfo.splitlines():
|
||||
fields = line.split()
|
||||
if fields and fields[0] in ('MemTotal:', 'MemAvailable:', 'SwapTotal:', 'SwapFree:'):
|
||||
if len(fields) != 3 or fields[2] != 'kB':
|
||||
raise ValueError('Invalid meminfo units')
|
||||
memory[fields[0][:-1] + 'Bytes'] = nonnegative(int(fields[1])) * 1024
|
||||
if len(memory) != 4 or memory['MemAvailableBytes'] > memory['MemTotalBytes'] or memory['SwapFreeBytes'] > memory['SwapTotalBytes']:
|
||||
raise ValueError('Incomplete host memory observation')
|
||||
for line in vmstat.splitlines():
|
||||
fields = line.split()
|
||||
if fields and fields[0] in ('pswpin', 'pswpout', 'oom_kill'):
|
||||
memory[fields[0]] = nonnegative(int(fields[1]))
|
||||
return memory
|
||||
|
||||
|
||||
def project_container(inspect, stats, identifier):
|
||||
if inspect.get('Id') != identifier or stats.get('id') != identifier:
|
||||
raise ValueError('Pinned container changed')
|
||||
state = inspect['State']
|
||||
memory = stats['memory_stats']
|
||||
usage = nonnegative(memory['usage'])
|
||||
inactive = memory.get('stats', {}).get('inactive_file')
|
||||
if inactive is not None:
|
||||
inactive = nonnegative(inactive)
|
||||
if inactive > usage:
|
||||
raise ValueError('Invalid cache observation')
|
||||
return {
|
||||
'id': identifier, 'imageId': inspect['Image'],
|
||||
'startedAt': state['StartedAt'], 'running': state['Running'] is True,
|
||||
'oomKilled': state.get('OOMKilled'), 'restartCount': inspect.get('RestartCount'),
|
||||
'usageIncludingCacheBytes': usage, 'inactiveFileBytes': inactive,
|
||||
'usageMinusInactiveFileBytes': usage - inactive if inactive is not None else None,
|
||||
'configuredLimitBytes': nonnegative(inspect['HostConfig']['Memory']),
|
||||
'configuredMemorySwapBytes': configured_memory_swap(inspect),
|
||||
'configuredMemorySwapStatus': 'available' if configured_memory_swap(inspect) is not None else 'unavailable',
|
||||
'reportedLimitBytes': nonnegative(memory['limit']),
|
||||
'swapUsageBytes': memory.get('swap_usage'),
|
||||
}
|
||||
|
||||
|
||||
def summarize(samples):
|
||||
if not samples:
|
||||
raise ValueError('No observations')
|
||||
baseline = {c['id']: (c['imageId'], c['startedAt'], c['restartCount'], c.get('pid'), c.get('oomKilled')) for c in samples[0]['containers']}
|
||||
stable = all({c['id']: (c['imageId'], c['startedAt'], c['restartCount'], c.get('pid'), c.get('oomKilled')) for c in sample['containers']} == baseline
|
||||
and all(c.get('identityStableDuringSample', True) for c in sample['containers']) for sample in samples)
|
||||
counters = {}
|
||||
for field in ('pswpin', 'pswpout', 'oom_kill'):
|
||||
first = samples[0]['host'].get(field)
|
||||
last = samples[-1]['host'].get(field)
|
||||
counters[field + 'Delta'] = last - first if first is not None and last is not None and last >= first else None
|
||||
return {
|
||||
'samples': len(samples), 'elapsedMs': samples[-1]['elapsedMs'],
|
||||
'hostMemTotalBytes': samples[0]['host']['MemTotalBytes'],
|
||||
'minHostAvailableBytes': min(s['host']['MemAvailableBytes'] for s in samples),
|
||||
'maxObservedContainerUsageIncludingCacheBytes': max(sum(c['usageIncludingCacheBytes'] for c in s['containers']) for s in samples),
|
||||
'containerIdentityAndStartStable': stable,
|
||||
'allObservedRunning': all(c['running'] is True and c.get('runningAfter') is True
|
||||
and c.get('identityStableDuringSample') is True for s in samples for c in s['containers']),
|
||||
'anyObservedOomKilled': any(c['oomKilled'] is True or c.get('oomKilledAfter') is True for s in samples for c in s['containers']),
|
||||
'allCgroupV2MetricsAvailable': all(s['containers'] and all(c.get('cgroupV2', {}).get('status') == 'available' for c in s['containers']) for s in samples),
|
||||
**counters,
|
||||
'allResourceLimitsStableDuringSample': all(c.get('resourceLimitsStableDuringSample') is True for s in samples for c in s['containers']) and all(s['containers'] for s in samples),
|
||||
'allHostMemoryPressureAvailable': all(s['host'].get('memoryPressure', {}).get('status') == 'available' for s in samples),
|
||||
'acceptancePassed': False,
|
||||
'scope': 'sampled evidence only; correlate with workload, limits, errors and latency; unsampled peaks are unknown',
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--daemon-id', required=True)
|
||||
parser.add_argument('--container', action='append', required=True)
|
||||
parser.add_argument('--samples', type=int, default=30)
|
||||
parser.add_argument('--interval', type=float, default=1)
|
||||
parser.add_argument('--output', required=True)
|
||||
args = parser.parse_args()
|
||||
if os.name != 'posix' or not hasattr(socket, 'AF_UNIX'):
|
||||
parser.error('Run on the Linux Docker host')
|
||||
if not re.fullmatch(r'[A-Za-z0-9-]{8,128}', args.daemon_id) or not 1 <= len(args.container) <= 16 or len(set(args.container)) != len(args.container) or any(not re.fullmatch(r'[a-f0-9]{64}', c) for c in args.container):
|
||||
parser.error('Pin exact daemon ID and 1..16 distinct full container IDs')
|
||||
if not 1 <= args.samples <= 300 or not 0.2 <= args.interval <= 10 or args.samples * args.interval > 300:
|
||||
parser.error('Observation bound: 300 samples / 300 seconds')
|
||||
started = time.monotonic()
|
||||
deadline = started + 360
|
||||
if docker_get('/info', deadline).get('ID') != args.daemon_id:
|
||||
raise ValueError('Pinned Docker daemon changed')
|
||||
samples = []
|
||||
for index in range(args.samples):
|
||||
check_deadline(deadline)
|
||||
host = collect_host(deadline)
|
||||
containers = []
|
||||
for identifier in args.container:
|
||||
check_deadline(deadline)
|
||||
containers.append(collect_container(identifier, deadline))
|
||||
check_deadline(deadline)
|
||||
samples.append({'elapsedMs': round((time.monotonic() - started) * 1000), 'host': host, 'containers': containers})
|
||||
if index + 1 < args.samples:
|
||||
time.sleep(min(args.interval, max(0, deadline - time.monotonic())))
|
||||
check_deadline(deadline)
|
||||
if docker_get('/info', deadline).get('ID') != args.daemon_id:
|
||||
raise ValueError('Pinned Docker daemon changed')
|
||||
result = {'schemaVersion': 3, 'readOnlyDockerAndProc': True, 'serviceChanges': 0, 'summary': summarize(samples), 'observations': samples}
|
||||
encoded = json.dumps(result, indent=2) + '\n'
|
||||
check_deadline(deadline)
|
||||
# Refuse to replace an existing evidence file. Never dump Docker env/logs.
|
||||
with open(args.output, 'x', encoding='utf-8') as handle:
|
||||
check_deadline(deadline)
|
||||
handle.write(encoded)
|
||||
check_deadline(deadline)
|
||||
print(json.dumps(result['summary']))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(json.dumps({'status': 'observation-refused', 'errorClass': type(error).__name__, 'serviceChanges': 0}))
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,384 @@
|
||||
import copy
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import os
|
||||
import tempfile
|
||||
import io
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('memory_measure', Path(__file__).with_name('measure-memory.py'))
|
||||
measure = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(measure)
|
||||
|
||||
|
||||
class MemoryEvidenceTests(unittest.TestCase):
|
||||
def test_host_units_and_missing_fields(self):
|
||||
raw = 'MemTotal: 2048 kB\nMemAvailable: 1024 kB\nSwapTotal: 4096 kB\nSwapFree: 3072 kB\n'
|
||||
parsed = measure.parse_host(raw, 'pswpin 20\npswpout 30\noom_kill 1\n')
|
||||
self.assertEqual(parsed['MemTotalBytes'], 2048 * 1024)
|
||||
self.assertEqual(parsed['pswpin'], 20)
|
||||
for invalid in (raw.replace('kB', 'MB'), raw.replace('MemAvailable: 1024 kB\n', ''), raw.replace('MemAvailable: 1024', 'MemAvailable: 4096')):
|
||||
with self.assertRaises(ValueError):
|
||||
measure.parse_host(invalid, '')
|
||||
|
||||
def test_cache_and_no_configured_limit_are_distinct(self):
|
||||
item = {'Id': 'a' * 64, 'Image': 'sha256:fixed', 'State': {'StartedAt': 't0', 'Running': True}, 'HostConfig': {'Memory': 0}}
|
||||
stats = {'id': 'a' * 64, 'memory_stats': {'usage': 100, 'limit': 2048, 'stats': {'inactive_file': 30}}}
|
||||
result = measure.project_container(item, stats, 'a' * 64)
|
||||
self.assertEqual(result['usageIncludingCacheBytes'], 100)
|
||||
self.assertEqual(result['usageMinusInactiveFileBytes'], 70)
|
||||
self.assertEqual(result['configuredLimitBytes'], 0)
|
||||
with self.assertRaises(ValueError):
|
||||
measure.project_container(item, stats, 'b' * 64)
|
||||
|
||||
def test_simultaneous_peak_is_not_sum_of_separate_peaks(self):
|
||||
host = {'MemTotalBytes': 2048, 'MemAvailableBytes': 1024, 'pswpin': 3, 'pswpout': 5, 'oom_kill': 1}
|
||||
def container(identifier, usage):
|
||||
return {'id': identifier, 'imageId': 'i', 'startedAt': 't0', 'restartCount': 0, 'running': True, 'oomKilled': False, 'usageIncludingCacheBytes': usage}
|
||||
samples = [{'elapsedMs': 0, 'host': host, 'containers': [container('a', 100), container('b', 20)]},
|
||||
{'elapsedMs': 1000, 'host': {**host, 'pswpin': 4}, 'containers': [container('a', 20), container('b', 100)]}]
|
||||
result = measure.summarize(samples)
|
||||
self.assertEqual(result['maxObservedContainerUsageIncludingCacheBytes'], 120)
|
||||
self.assertEqual(result['pswpinDelta'], 1)
|
||||
self.assertTrue(result['containerIdentityAndStartStable'])
|
||||
self.assertFalse(result['acceptancePassed'])
|
||||
changed = copy.deepcopy(samples)
|
||||
changed[-1]['containers'][0]['startedAt'] = 't1'
|
||||
changed[-1]['containers'][1]['oomKilled'] = True
|
||||
self.assertFalse(measure.summarize(changed)['containerIdentityAndStartStable'])
|
||||
self.assertTrue(measure.summarize(changed)['anyObservedOomKilled'])
|
||||
|
||||
def test_counter_rollback_is_unknown(self):
|
||||
samples = [{'elapsedMs': i, 'host': {'MemTotalBytes': 2, 'MemAvailableBytes': 1, 'pswpin': value}, 'containers': []} for i, value in enumerate([100, 2])]
|
||||
self.assertIsNone(measure.summarize(samples)['pswpinDelta'])
|
||||
|
||||
|
||||
class CgroupEvidenceTests(unittest.TestCase):
|
||||
identifier = 'a' * 64
|
||||
path = '/sys/fs/cgroup/system.slice/docker-' + identifier + '.scope'
|
||||
|
||||
def setUp(self):
|
||||
self.before = {'Id': self.identifier, 'Image': 'sha256:fixed', 'RestartCount': 0,
|
||||
'State': {'StartedAt': 'original-start', 'Pid': 123, 'Running': True, 'OOMKilled': False},
|
||||
'HostConfig': {'Memory': 512, 'MemorySwap': 1024}}
|
||||
self.stats = {'id': self.identifier, 'memory_stats': {'usage': 100, 'limit': 512, 'stats': {'inactive_file': 30}}}
|
||||
self.files = {
|
||||
'/proc/self/mountinfo': '1 0 0:1 / /sys/fs/cgroup rw - cgroup2 cgroup rw\n',
|
||||
'/proc/123/cgroup': '0::/system.slice/docker-' + self.identifier + '.scope\n',
|
||||
'/proc/123/stat': '123 (a name with ) parentheses) S ' + ' '.join(['0'] * 18 + ['987'] + ['0'] * 8),
|
||||
self.path + '/memory.max': '512\n', self.path + '/memory.swap.max': '512\n',
|
||||
self.path + '/cpu.stat': 'nr_periods 10\nnr_throttled 2\nthrottled_usec 1234\n',
|
||||
self.path + '/memory.current': '100\n', self.path + '/memory.peak': '200\n',
|
||||
self.path + '/memory.events': 'low 0\nhigh 1\nmax 2\noom 3\noom_kill 4\n',
|
||||
self.path + '/memory.swap.current': '5\n', self.path + '/memory.swap.peak': '6\n',
|
||||
self.path + '/memory.swap.events': 'high 0\nmax 2\nfail 1\n',
|
||||
}
|
||||
|
||||
def observe(self, after=None, reader=None, resolver=None, statter=None):
|
||||
def read(path, _expected_device=None, deadline=None):
|
||||
if path not in self.files:
|
||||
raise FileNotFoundError()
|
||||
return self.files[path]
|
||||
queries = []
|
||||
def get(path, deadline=None):
|
||||
queries.append(path)
|
||||
if '/stats?' in path:
|
||||
return self.stats
|
||||
return self.before if len(queries) == 1 else (after or self.before)
|
||||
with patch.object(measure, 'docker_get', side_effect=get), \
|
||||
patch.object(measure, 'read_bounded', side_effect=reader or read), \
|
||||
patch.object(measure.os.path, 'realpath', side_effect=resolver or (lambda path: path)), \
|
||||
patch.object(measure.os, 'stat', side_effect=statter or (lambda path: os.stat_result((0, 7, 8, 0, 0, 0, 0, 0, 0, 0)))), \
|
||||
patch.object(measure.os, 'sep', '/'):
|
||||
result = measure.collect_container(self.identifier)
|
||||
self.assertEqual(queries, ['/containers/' + self.identifier + '/json',
|
||||
'/containers/' + self.identifier + '/stats?stream=false&one-shot=true',
|
||||
'/containers/' + self.identifier + '/json'])
|
||||
return result
|
||||
|
||||
def test_exact_identity_metrics_and_docker_cache_are_distinct(self):
|
||||
result = self.observe()
|
||||
self.assertTrue(result['identityStableDuringSample'])
|
||||
self.assertEqual(result['usageMinusInactiveFileBytes'], 70)
|
||||
self.assertEqual(result['cgroupV2']['status'], 'available')
|
||||
self.assertEqual(result['cgroupV2']['metrics']['memory.current'], 100)
|
||||
self.assertEqual(result['cgroupV2']['metrics']['memory.peak'], 200)
|
||||
self.assertEqual(result['cgroupV2']['metrics']['memory.events']['oom_kill'], 4)
|
||||
self.assertEqual(result['cgroupV2']['metrics']['memory.swap.current'], 5)
|
||||
self.assertEqual(result['cgroupV2']['metrics']['memory.swap.peak'], 6)
|
||||
self.assertEqual(result['cgroupV2']['metrics']['memory.swap.events']['fail'], 1)
|
||||
|
||||
def test_optional_swap_peak_missing_is_unknown_not_zero(self):
|
||||
del self.files[self.path + '/memory.swap.peak']
|
||||
result = self.observe()['cgroupV2']
|
||||
self.assertEqual(result['status'], 'partial')
|
||||
self.assertIsNone(result['metrics']['memory.swap.peak'])
|
||||
self.assertEqual(result['metrics']['memory.peak'], 200)
|
||||
self.assertIn('memory.swap.peak', result['unavailable'])
|
||||
|
||||
def test_each_missing_metric_is_explicit_unknown(self):
|
||||
for name in measure.CGROUP_FILES:
|
||||
with self.subTest(name=name):
|
||||
value = self.files.pop(self.path + '/' + name)
|
||||
result = self.observe()['cgroupV2']
|
||||
self.assertIsNone(result['metrics'][name])
|
||||
self.assertEqual(result['status'], 'partial')
|
||||
self.files[self.path + '/' + name] = value
|
||||
|
||||
def test_malformed_metrics_never_coerce_to_zero(self):
|
||||
for value in ('-1\n', '+1\n', ' 1\n', '01\n', '1\n2\n', '18446744073709551616\n', 'max\n'):
|
||||
with self.subTest(value=value):
|
||||
self.files[self.path + '/memory.current'] = value
|
||||
self.assertIsNone(self.observe()['cgroupV2']['metrics']['memory.current'])
|
||||
for value in ('oom 1\noom 2\n', 'low 0\nhigh -1\nmax 0\noom 0\noom_kill 0\n', 'low 0\n', 'low 0\n'):
|
||||
with self.subTest(value=value):
|
||||
self.files[self.path + '/memory.events'] = value
|
||||
self.assertIsNone(self.observe()['cgroupV2']['metrics']['memory.events'])
|
||||
|
||||
def test_docker_identity_drift_invalidates_entire_cgroup_sample(self):
|
||||
for field, value in [('Pid', 124), ('StartedAt', 'new'), ('OOMKilled', True), ('Running', False)]:
|
||||
after = copy.deepcopy(self.before); after['State'][field] = value
|
||||
result = self.observe(after)
|
||||
self.assertFalse(result['identityStableDuringSample'])
|
||||
self.assertTrue(all(value is None for value in result['cgroupV2']['metrics'].values()))
|
||||
for field, value in [('Id', 'b' * 64), ('RestartCount', 1), ('Image', 'sha256:new')]:
|
||||
after = copy.deepcopy(self.before); after[field] = value
|
||||
self.assertEqual(self.observe(after)['cgroupV2']['status'], 'unavailable')
|
||||
|
||||
def test_process_reuse_rootfs_swap_and_membership_move_are_unknown(self):
|
||||
for changed in ('/proc/123/stat', '/proc/123/cgroup'):
|
||||
count = 0
|
||||
def read(path, _expected_device=None, deadline=None):
|
||||
nonlocal count
|
||||
if path == changed:
|
||||
count += 1
|
||||
if count == 2:
|
||||
return self.files[path].replace('987', '988') if path.endswith('/stat') else '0::/docker/' + 'b' * 64 + '\n'
|
||||
return self.files[path]
|
||||
self.assertEqual(self.observe(reader=read)['cgroupV2']['status'], 'unavailable')
|
||||
count = 0
|
||||
def statter(path):
|
||||
nonlocal count
|
||||
if path == '/proc/123/root':
|
||||
count += 1
|
||||
return os.stat_result((0, count, 8, 0, 0, 0, 0, 0, 0, 0))
|
||||
self.assertEqual(self.observe(statter=statter)['cgroupV2']['status'], 'unavailable')
|
||||
|
||||
def test_unsupported_v1_namespace_subtree_and_unbound_membership(self):
|
||||
original = self.files['/proc/123/cgroup']
|
||||
for value in ('2:memory:/docker/' + self.identifier + '\n', '0::/\n', '0::/docker/' + 'b' * 64 + '\n',
|
||||
'0::/docker/' + self.identifier + '/../other\n', original.rstrip() + '/child\n', original + original):
|
||||
self.files['/proc/123/cgroup'] = value
|
||||
self.assertEqual(self.observe()['cgroupV2']['status'], 'unavailable')
|
||||
self.files['/proc/123/cgroup'] = original
|
||||
self.files['/proc/self/mountinfo'] = '1 0 0:1 /subtree /sys/fs/cgroup rw - cgroup2 cgroup rw\n'
|
||||
self.assertEqual(self.observe()['cgroupV2']['status'], 'unavailable')
|
||||
|
||||
def test_symlink_outside_or_to_other_container_is_refused(self):
|
||||
for destination in ('/etc', '/sys/fs/cgroup/docker/' + 'b' * 64):
|
||||
self.assertEqual(self.observe(resolver=lambda path: destination if path == self.path else path)['cgroupV2']['status'], 'unavailable')
|
||||
result = self.observe(resolver=lambda path: '/etc/passwd' if path.endswith('/memory.current') else path)
|
||||
self.assertIsNone(result['cgroupV2']['metrics']['memory.current'])
|
||||
|
||||
def test_cgroup_directory_replacement_or_nested_filesystem_is_unknown(self):
|
||||
for nested in (False, True):
|
||||
calls = 0
|
||||
def statter(path):
|
||||
nonlocal calls
|
||||
if path == self.path:
|
||||
calls += 1
|
||||
inode = 8 if path == self.path and calls > 1 else 7
|
||||
device = 9 if nested and path == self.path else 8
|
||||
return os.stat_result((0, inode, device, 0, 0, 0, 0, 0, 0, 0))
|
||||
result = self.observe(statter=statter)['cgroupV2']
|
||||
self.assertEqual(result['status'], 'unavailable')
|
||||
self.assertTrue(all(value is None for value in result['metrics'].values()))
|
||||
|
||||
def test_pid_reuse_unreadable_process_and_after_oom_stay_unknown(self):
|
||||
after = copy.deepcopy(self.before); after['State']['OOMKilled'] = True
|
||||
result = self.observe(after)
|
||||
host = {'MemTotalBytes': 2048, 'MemAvailableBytes': 1024}
|
||||
summary = measure.summarize([{'elapsedMs': 0, 'host': host, 'containers': [result]}])
|
||||
self.assertTrue(summary['anyObservedOomKilled'])
|
||||
self.assertFalse(summary['containerIdentityAndStartStable'])
|
||||
self.assertFalse(summary['allCgroupV2MetricsAvailable'])
|
||||
self.assertFalse(summary['acceptancePassed'])
|
||||
def read(path, _expected_device=None, deadline=None):
|
||||
if path == '/proc/123/stat':
|
||||
raise PermissionError()
|
||||
return self.files[path]
|
||||
self.assertEqual(self.observe(reader=read)['cgroupV2']['status'], 'unavailable')
|
||||
|
||||
def test_real_temp_files_are_bounded_and_strictly_ascii(self):
|
||||
with tempfile.TemporaryDirectory(dir=Path(__file__).resolve().parent) as directory:
|
||||
path = Path(directory) / 'memory.current'
|
||||
path.write_bytes(b'123\n')
|
||||
self.assertEqual(measure.parse_counter(measure.read_bounded(path)), 123)
|
||||
with self.assertRaises(ValueError):
|
||||
measure.read_bounded(path, path.stat().st_dev + 1)
|
||||
for raw in (b'0' * (measure.MAX_FILE_BYTES + 1), b'0\n' * (measure.MAX_FILE_LINES + 1), b'\xff'):
|
||||
path.write_bytes(raw)
|
||||
with self.assertRaises((ValueError, UnicodeError)):
|
||||
measure.read_bounded(path)
|
||||
|
||||
def test_after_stopped_or_unknown_identity_cannot_summarize_running(self):
|
||||
for value in (False, None):
|
||||
after = copy.deepcopy(self.before); after['State']['Running'] = value
|
||||
result = self.observe(after)
|
||||
self.assertIs(result['runningAfter'], value)
|
||||
summary = measure.summarize([{'elapsedMs': 0, 'host': {'MemTotalBytes': 2048, 'MemAvailableBytes': 1024}, 'containers': [result]}])
|
||||
self.assertFalse(summary['allObservedRunning'])
|
||||
result = self.observe()
|
||||
summary = measure.summarize([{'elapsedMs': 0, 'host': {'MemTotalBytes': 2048, 'MemAvailableBytes': 1024}, 'containers': [result]}])
|
||||
self.assertTrue(summary['allObservedRunning'])
|
||||
|
||||
def test_event_future_counters_retained_but_duplicates_and_overflow_refuse(self):
|
||||
self.assertEqual(measure.parse_events('high 0\nmax 1\nfail 2\nfuture 3\n', ('high', 'max', 'fail'))['future'], 3)
|
||||
for value in ('x 1\nx 2\n', 'x 18446744073709551616\n', ''.join('x_' + str(i) + ' 0\n' for i in range(33))):
|
||||
with self.assertRaises(ValueError):
|
||||
measure.parse_events(value, ())
|
||||
|
||||
|
||||
class DeadlineEvidenceTests(unittest.TestCase):
|
||||
def test_expired_http_never_opens_socket_or_schedules_watchdog(self):
|
||||
with patch.object(measure.time, 'monotonic', return_value=10), \
|
||||
patch.object(measure.socket, 'socket') as own_socket, patch.object(measure.threading, 'Timer') as timer:
|
||||
with self.assertRaises(measure.ObservationDeadlineExceeded):
|
||||
measure.docker_get('/info', 10)
|
||||
own_socket.assert_not_called()
|
||||
timer.assert_not_called()
|
||||
|
||||
def test_expired_container_read_never_starts_io(self):
|
||||
with patch.object(measure.time, 'monotonic', return_value=10), patch.object(measure, 'docker_get') as get:
|
||||
with self.assertRaises(measure.ObservationDeadlineExceeded):
|
||||
measure.collect_container('a' * 64, 10)
|
||||
get.assert_not_called()
|
||||
|
||||
def test_near_cutoff_after_stats_stops_before_proc_or_more_gets(self):
|
||||
clock, queries = [9.9], []
|
||||
def get(path, deadline):
|
||||
self.assertEqual(deadline, 10)
|
||||
queries.append(path)
|
||||
if len(queries) == 2:
|
||||
clock[0] = 10
|
||||
return {}
|
||||
with patch.object(measure.time, 'monotonic', side_effect=lambda: clock[0]), \
|
||||
patch.object(measure, 'docker_get', side_effect=get), patch.object(measure, 'read_bounded') as read:
|
||||
with self.assertRaises(measure.ObservationDeadlineExceeded):
|
||||
measure.collect_container('a' * 64, 10)
|
||||
self.assertEqual(len(queries), 2)
|
||||
read.assert_not_called()
|
||||
|
||||
def test_metric_deadline_is_not_downgraded_to_partial_or_followed_by_io(self):
|
||||
fixture = CgroupEvidenceTests(); fixture.setUp()
|
||||
clock, reads, queries = [9.9], [], []
|
||||
def read(path, _device=None, deadline=None):
|
||||
self.assertEqual(deadline, 10)
|
||||
reads.append(path)
|
||||
if path.endswith('/memory.current'):
|
||||
clock[0] = 10
|
||||
return fixture.files[path]
|
||||
def get(path, deadline):
|
||||
queries.append(path)
|
||||
return fixture.stats if '/stats?' in path else fixture.before
|
||||
with patch.object(measure.time, 'monotonic', side_effect=lambda: clock[0]), \
|
||||
patch.object(measure, 'docker_get', side_effect=get), patch.object(measure, 'read_bounded', side_effect=read), \
|
||||
patch.object(measure.os.path, 'realpath', side_effect=lambda path: path), \
|
||||
patch.object(measure.os, 'stat', return_value=os.stat_result((0, 7, 8, 0, 0, 0, 0, 0, 0, 0))), \
|
||||
patch.object(measure.os, 'sep', '/'):
|
||||
with self.assertRaises(measure.ObservationDeadlineExceeded):
|
||||
measure.collect_container(fixture.identifier, 10)
|
||||
self.assertEqual(reads[-1], fixture.path + '/memory.current')
|
||||
self.assertEqual(len(queries), 2)
|
||||
|
||||
def test_final_daemon_check_crosses_deadline_without_writing_report(self):
|
||||
clock, calls = [0], []
|
||||
def get(path, deadline):
|
||||
self.assertEqual(deadline, 360)
|
||||
calls.append(path)
|
||||
if len(calls) == 2:
|
||||
clock[0] = 361
|
||||
return {'ID': 'owned-daemon'}
|
||||
host = 'MemTotal: 2048 kB\nMemAvailable: 1024 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB\n'
|
||||
args = ['measure-memory.py', '--daemon-id', 'owned-daemon', '--container', 'a' * 64, '--samples', '1', '--output', 'NEVER_CREATED']
|
||||
with patch.object(sys, 'argv', args), patch.object(measure.os, 'name', 'posix'), \
|
||||
patch.object(measure.socket, 'AF_UNIX', 1, create=True), \
|
||||
patch.object(measure.time, 'monotonic', side_effect=lambda: clock[0]), patch.object(measure, 'docker_get', side_effect=get), \
|
||||
patch.object(measure, 'read_bounded', side_effect=[host, '', 'some avg10=0.00 avg60=0.00 avg300=0.00 total=0\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n']), patch.object(measure, 'collect_container', return_value={}), \
|
||||
patch.object(measure, 'summarize', return_value={}), patch('builtins.open') as output:
|
||||
with self.assertRaises(measure.ObservationDeadlineExceeded):
|
||||
measure.main()
|
||||
output.assert_not_called()
|
||||
|
||||
def test_slow_body_absolute_deadline_closes_only_own_socket_and_joins(self):
|
||||
# A real HTTPResponse/BufferedReader and real watchdog timer, with an
|
||||
# in-memory socket transport. No AF_UNIX availability or local network
|
||||
# permissions are needed on Windows. Inactivity timeouts never fire:
|
||||
# the body keeps producing bytes until shutdown interrupts the read.
|
||||
class DripBody(io.RawIOBase):
|
||||
def __init__(self, stopped):
|
||||
self.header = b'HTTP/1.1 200 OK\r\nContent-Length: 1000\r\nConnection: close\r\n\r\n'
|
||||
self.stopped = stopped
|
||||
self.body_reads = 0
|
||||
def readable(self):
|
||||
return True
|
||||
def readinto(self, buffer):
|
||||
if self.header:
|
||||
value, self.header = self.header[:len(buffer)], self.header[len(buffer):]
|
||||
buffer[:len(value)] = value
|
||||
return len(value)
|
||||
if self.stopped.wait(0.01):
|
||||
return 0
|
||||
self.body_reads += 1
|
||||
buffer[0] = 32
|
||||
return 1
|
||||
class SocketTransport:
|
||||
def __init__(self):
|
||||
self.stopped = threading.Event()
|
||||
self.body = DripBody(self.stopped)
|
||||
self.closed = False
|
||||
self.request = b''
|
||||
def connect(self, path):
|
||||
self.path = path
|
||||
def settimeout(self, value):
|
||||
self.timeout = value
|
||||
def sendall(self, value):
|
||||
self.request += value
|
||||
def makefile(self, _mode):
|
||||
return io.BufferedReader(self.body)
|
||||
def shutdown(self, _how):
|
||||
self.stopped.set()
|
||||
def close(self):
|
||||
self.closed = True
|
||||
client, unrelated = SocketTransport(), SocketTransport()
|
||||
started = time.monotonic()
|
||||
original_timer = measure.threading.Timer
|
||||
timers = []
|
||||
def timer(*args, **kwargs):
|
||||
value = original_timer(*args, **kwargs)
|
||||
timers.append(value)
|
||||
return value
|
||||
with patch.object(measure.socket, 'AF_UNIX', 1, create=True), \
|
||||
patch.object(measure.socket, 'socket', return_value=client), \
|
||||
patch.object(measure.threading, 'Timer', side_effect=timer):
|
||||
with self.assertRaises(measure.ObservationDeadlineExceeded):
|
||||
measure.docker_get('/info', started + 0.12)
|
||||
self.assertLess(time.monotonic() - started, 1.5)
|
||||
self.assertTrue(client.closed)
|
||||
self.assertTrue(client.stopped.is_set())
|
||||
self.assertGreater(client.body.body_reads, 0)
|
||||
self.assertFalse(unrelated.closed)
|
||||
self.assertFalse(unrelated.stopped.is_set())
|
||||
self.assertEqual(client.path, '/var/run/docker.sock')
|
||||
self.assertIn(b'GET /v1.45/info ', client.request)
|
||||
self.assertEqual(len(timers), 1)
|
||||
self.assertFalse(timers[0].is_alive(), 'No deadline callback can perform late I/O after return')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own215.invalid';process.env.AUTH_PORTAL_URL='https://own215.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
let phase='populated',app,nativeCalls=0,nativeWrites=0,disabledCalls=0;const cases=[]
|
||||
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');if(phase==='failed'||(phase==='partial'&&label==='second')){res.statusCode=401;res.end(JSON.stringify({error:401,message:'own215 fixture denied'}));return}const rows=phase==='populated'&&req.url==='/rest/routing/ospf/instance'?[{'.id':'*1',name:'own215-ospf',disabled:'false'}]:phase==='populated'&&req.url==='/rest/routing/bfd/session'?[{'.id':'*1','local-address':'192.0.2.1','remote-address':'192.0.2.2',state:'up'}]:[];res.end(JSON.stringify(rows))})
|
||||
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
|
||||
const baseline=process.env.MMAPP_BASELINE==='true';const report={date:'2026-10-14',block:'12.5.215',baseline,database,cases,scope:'Compiled production routes/auth, isolated PostgreSQL and RouterOS HTTP fixture; real CHR unchanged',passed:false}
|
||||
try{
|
||||
await initDatabase();const ids=[];for(let i=0;i<natives.length;i++)ids.push(Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4) RETURNING id",['own215-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])).rows[0].id))
|
||||
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own215-reader',apps:['mm'],permissions:['mm:network:read','mm:dashboard:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token}
|
||||
for(phase of ['populated','empty','partial','failed','disabled']){
|
||||
if(phase==='disabled')await pool.query('UPDATE servers SET enabled=false')
|
||||
for(const endpoint of ['ospf/neighbors','ospf/interfaces','ospf/instances','ospf/all','bfd/sessions']){
|
||||
const before=nativeCalls,r=await app.inject({method:'GET',url:'/api/'+endpoint,headers}),body=r.json(),hasFailure=['partial','failed'].includes(phase)
|
||||
assert.equal(r.statusCode,!baseline&&hasFailure?503:200)
|
||||
if(!baseline&&hasFailure){const expected=phase==='partial'?[ids[1]]:ids.slice(0,2);assert.deepEqual(body.failures.map(f=>f.serverId),expected);assert(body.failures.every(f=>f.error.includes('401')));assert.match(body.error,/OSPF\/BFD/)}
|
||||
else if(endpoint==='ospf/all'){assert.deepEqual(Object.keys(body).sort(),['bfdSessions','instances','interfaces','neighbors','routes']);assert.equal(body.instances.length,phase==='populated'?2:0);assert.equal(body.bfdSessions.length,phase==='populated'?2:0)}
|
||||
else{assert(Array.isArray(body));assert.equal(body.length,phase==='populated'&&['ospf/instances','bfd/sessions'].includes(endpoint)?2:0)}
|
||||
if(phase==='disabled')assert.equal(nativeCalls,before)
|
||||
cases.push({phase,endpoint,status:r.statusCode,failures:body.failures?.length??0,nativeCalls:nativeCalls-before})
|
||||
}
|
||||
}
|
||||
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/ospf215-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/ospf215-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium, expect } from '@playwright/test';
|
||||
|
||||
const output = 'tmp/rest262-soak.json';
|
||||
assert(!fs.existsSync(output), 'Never overwrite acceptance evidence');
|
||||
const credentials = JSON.parse(fs.readFileSync('tmp/portal195-private.json'));
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
let token;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://localhost:3000/certificates');
|
||||
await page.getByLabel('Email', { exact: true }).fill(credentials.email);
|
||||
await page.getByLabel('Пароль', { exact: true }).fill(credentials.password);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Путь страницы' })).toContainText('Сертификаты', { timeout: 30000 });
|
||||
token = await page.evaluate(() => localStorage.getItem('mmapp_token'));
|
||||
assert(token);
|
||||
} finally { await browser.close(); }
|
||||
|
||||
const report = {
|
||||
date: '2026-10-15', block: '12.5.262', realPortalLogin: true, mocks: false,
|
||||
backend: 'ux-certificates258', endpoint: '/api/certificates/state', directBackendTunnel: true,
|
||||
concurrentRequestsPerWave: 12, waveIntervalMs: 30000, plannedWindowMs: 600000,
|
||||
nonAuthApiWrites: 0, nativeConfigurationWrites: 0, waves: [],
|
||||
bounds: ['One backend, two CHR over HTTP REST; not a global cap for external clients or multiple backend instances', 'Concurrent normal collectors and UI acceptance may run; not maximum throughput', 'A finite successful window does not prove all historical 401/500 causes or indefinite stability'],
|
||||
};
|
||||
const start = Date.now();
|
||||
const lastReceipts = new Map();
|
||||
try {
|
||||
for (let wave = 0; wave <= 20; wave++) {
|
||||
const wait = start + wave * report.waveIntervalMs - Date.now();
|
||||
if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait));
|
||||
const began = Date.now();
|
||||
const samples = await Promise.all(Array.from({ length: 12 }, async () => {
|
||||
const requested = Date.now();
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/certificates/state', {
|
||||
headers: { Authorization: 'Bearer ' + token }, signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
const body = await response.json();
|
||||
return { status: response.status, durationMs: Date.now() - requested, servers: body.servers?.map(s => ({ serverId: s.serverId, collectedAt: s.collectedAt, error: s.error !== null })) ?? [] };
|
||||
} catch (error) { return { status: null, durationMs: Date.now() - requested, failure: error.name, servers: [] }; }
|
||||
}));
|
||||
const item = { wave, startedAt: new Date(began).toISOString(), elapsedMs: Date.now() - start, durationMs: Date.now() - began, samples };
|
||||
report.waves.push(item);
|
||||
fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n');
|
||||
assert(samples.every(s => s.status === 200 && s.servers.length === 2 && s.servers.every(row => !row.error) && s.servers.map(row => row.serverId).sort().join(',') === '1,2'), 'Request or device read failed; stopping without automatic replay');
|
||||
for (const id of ['1', '2']) {
|
||||
const receipts = samples.flatMap(s => s.servers.filter(row => row.serverId === id).map(row => Date.parse(row.collectedAt)));
|
||||
assert(receipts.every(Number.isFinite));
|
||||
const earliest = Math.min(...receipts);
|
||||
if (lastReceipts.has(id)) assert(earliest > lastReceipts.get(id), 'A later wave returned an old receipt');
|
||||
lastReceipts.set(id, Math.max(...receipts));
|
||||
}
|
||||
console.log(JSON.stringify({ wave, durationMs: item.durationMs, requests: samples.length, errors: 0 }));
|
||||
}
|
||||
report.completed = true;
|
||||
report.durationMs = Date.now() - start;
|
||||
assert(report.durationMs >= report.plannedWindowMs);
|
||||
report.passed = true;
|
||||
} catch (error) {
|
||||
report.passed = false;
|
||||
report.failure = { name: error.name, message: error.message.slice(0, 200) };
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
token = undefined;
|
||||
fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n');
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
cd /opt/mmapp-test
|
||||
mm233_db="mm_accept_166_$(date +%s%3N)"
|
||||
mm233_pass="$(openssl rand -hex 24)"
|
||||
mm233_role_created=false
|
||||
mm233_db_created=false
|
||||
cleanup233() {
|
||||
docker rm mmapp-s3app233-owned >/dev/null 2>&1 || true
|
||||
if $mm233_db_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP DATABASE \"$mm233_db\" WITH (FORCE)"; fi
|
||||
if $mm233_role_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP ROLE \"$mm233_db\""; fi
|
||||
remaining=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm233_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm233_db')")
|
||||
[ "$remaining" = 0 ]
|
||||
python3 - <<'PY'
|
||||
import json,pathlib
|
||||
p=pathlib.Path('/opt/mmapp-test/artifacts/backup233-checks/storage-origin233.json')
|
||||
if p.exists():
|
||||
r=json.loads(p.read_text());r['ownedDatabaseAndRoleRemoved']=True;p.write_text(json.dumps(r,indent=2)+'\n')
|
||||
PY
|
||||
printf 'OWNED_DATABASE_AND_ROLE_REMOVED\n'
|
||||
unset mm233_pass
|
||||
}
|
||||
trap cleanup233 EXIT
|
||||
[[ "$mm233_db" =~ ^mm_accept_166_[0-9]+$ ]]
|
||||
existing=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm233_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm233_db')")
|
||||
[ "$existing" = 0 ]
|
||||
printf "CREATE ROLE \"%s\" LOGIN PASSWORD '%s' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;\n" "$mm233_db" "$mm233_pass" | docker exec -i mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1
|
||||
mm233_role_created=true
|
||||
docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$mm233_db\" OWNER \"$mm233_db\""
|
||||
mm233_db_created=true
|
||||
docker run --name mmapp-s3app233-owned --network mmapp-test_default --cap-drop ALL --security-opt no-new-privileges -e "MMAPP_ORIGIN_BASELINE=${2:-false}" -e NODE_ENV=test -e "DATABASE_URL=postgres://$mm233_db:$mm233_pass@postgres:5432/$mm233_db" -e "MMAPP_OWN_SQL_DB=$mm233_db" -e "DATABASE_PATH=/tmp/$mm233_db/legacy.db" -e "GEOIP_DIR=/tmp/$mm233_db/geoip" -v /opt/mmapp-test/artifacts/backup233-checks:/checks --entrypoint node "${1:-mmapp-test/backend:ux-backup233}" /checks/backup-storage-origin.mjs
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
cd /opt/mmapp-test
|
||||
mm242_db="mm_accept_166_$(date +%s%3N)"
|
||||
mm242_pass="$(openssl rand -hex 24)"
|
||||
mm242_role_created=false
|
||||
mm242_db_created=false
|
||||
cleanup242() {
|
||||
docker rm mmapp-s3app242-owned >/dev/null 2>&1 || true
|
||||
if $mm242_db_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP DATABASE \"$mm242_db\" WITH (FORCE)"; fi
|
||||
if $mm242_role_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP ROLE \"$mm242_db\""; fi
|
||||
remaining=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm242_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm242_db')")
|
||||
[ "$remaining" = 0 ]
|
||||
python3 - <<'PY'
|
||||
import json,pathlib
|
||||
p=pathlib.Path('/opt/mmapp-test/artifacts/backup242-checks/geoip-mirror242.json')
|
||||
if p.exists():
|
||||
r=json.loads(p.read_text());r['ownedDatabaseAndRoleRemoved']=True;p.write_text(json.dumps(r,indent=2)+'\n')
|
||||
PY
|
||||
printf 'OWNED_DATABASE_AND_ROLE_REMOVED\n'
|
||||
unset mm242_pass
|
||||
}
|
||||
trap cleanup242 EXIT
|
||||
[[ "$mm242_db" =~ ^mm_accept_166_[0-9]+$ ]]
|
||||
existing=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm242_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm242_db')")
|
||||
[ "$existing" = 0 ]
|
||||
printf "CREATE ROLE \"%s\" LOGIN PASSWORD '%s' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;\n" "$mm242_db" "$mm242_pass" | docker exec -i mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1
|
||||
mm242_role_created=true
|
||||
docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$mm242_db\" OWNER \"$mm242_db\""
|
||||
mm242_db_created=true
|
||||
docker run --name mmapp-s3app242-owned --network mmapp-test_default --cap-drop ALL --security-opt no-new-privileges -e "MMAPP_ORIGIN_BASELINE=${2:-false}" -e NODE_ENV=test -e "DATABASE_URL=postgres://$mm242_db:$mm242_pass@postgres:5432/$mm242_db" -e "MMAPP_OWN_SQL_DB=$mm242_db" -e "DATABASE_PATH=/tmp/$mm242_db/legacy.db" -e "GEOIP_DIR=/tmp/$mm242_db/geoip" -v /opt/mmapp-test/artifacts/backup242-checks:/checks --entrypoint node "${1:-mmapp-test/backend:ux-geoip242}" /checks/geoip-mirror.mjs
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Local smoke preparation only: owns a new embedded PG and one compiled child.
|
||||
* Does not represent frontend/portal/Docker/whole-installation acceptance. */
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join,resolve,dirname,basename } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createServer } from 'node:net'
|
||||
import { randomBytes,createHash } from 'node:crypto'
|
||||
import { spawn } from 'node:child_process'
|
||||
const root=resolve(dirname(fileURLToPath(import.meta.url)),'../../..')
|
||||
assert(process.argv.length>=3&&process.argv.length<=5,'Supply a unique evidence name, optional compiled output and delayed-approval scenario')
|
||||
const delayedApproval=process.argv[4]==='delayed-approval'
|
||||
assert(process.argv[4]===undefined||delayedApproval,'Unknown isolated scenario')
|
||||
const name=process.argv[2];assert(/^[a-z0-9][a-z0-9-]{3,99}$/.test(name),'Invalid evidence name')
|
||||
const outputName=process.argv[3]??'dist-installation-memory'
|
||||
assert(/^dist-installation-memory(?:-[a-z0-9-]{3,64})?$/.test(outputName),'Invalid compiled output name')
|
||||
const prefix=resolve(root,'tmp',name), statusPath=prefix+'-status.json'
|
||||
const exclusive=await fs.open(statusPath,'wx');await exclusive.close()
|
||||
const state={scope:'local-backend-coload-only',wholeInstallation2GbAccepted:false,compiledOutputName:outputName,delayedHumanApprovalScenario:delayedApproval,status:'starting',nodeExitCode:null,sourcePinsUnchanged:false,cleanupConfirmed:false,startedAt:new Date().toISOString(),wrapperPid:process.pid}
|
||||
const save=()=>fs.writeFile(statusPath,JSON.stringify(state,null,2)+'\n');await save()
|
||||
const require=createRequire(new URL('../../package.json',import.meta.url)),EmbeddedPostgres=require('embedded-postgres').default
|
||||
const directory=await fs.mkdtemp(join(tmpdir(),'mm-mcp-test-')),server=createServer()
|
||||
await new Promise(done=>server.listen(0,'127.0.0.1',done));const port=server.address().port;await new Promise(done=>server.close(done))
|
||||
const password=randomBytes(24).toString('hex')
|
||||
const postgres=new EmbeddedPostgres({databaseDir:join(directory,'pg'),port,user:'mcp_test',password,persistent:true,createPostgresUser:false,
|
||||
initdbFlags:['--encoding=UTF8'],postgresFlags:['-h','127.0.0.1'],onLog:()=>{},onError:()=>{}})
|
||||
const output=resolve(root,'backend',outputName),entry=resolve(output,'services/installation-co-load.owned.test.js')
|
||||
async function pins() {
|
||||
const rows=[]
|
||||
async function walk(dir){for(const item of await fs.readdir(dir,{withFileTypes:true})){const file=join(dir,item.name);if(item.isDirectory())await walk(file);else rows.push({path:file.slice(root.length+1).replaceAll('\\','/'),sha256:createHash('sha256').update(await fs.readFile(file)).digest('hex')})}}
|
||||
await walk(output);return rows.sort((a,b)=>a.path.localeCompare(b.path))
|
||||
}
|
||||
const before=await pins();await fs.writeFile(prefix+'-pins.json',JSON.stringify({before},null,2)+'\n')
|
||||
let log,timer
|
||||
try {
|
||||
await postgres.initialise();await postgres.start()
|
||||
log=await fs.open(prefix+'.log','wx')
|
||||
const retained=Object.fromEntries(['SystemRoot','WINDIR','TEMP','TMP','LANG','LC_ALL'].filter(key=>process.env[key]).map(key=>[key,process.env[key]]))
|
||||
const child=spawn(process.execPath,['--max-old-space-size=192','--max-semi-space-size=16',entry],{cwd:directory,windowsHide:true,stdio:['ignore',log.fd,log.fd],env:{...retained,
|
||||
NODE_OPTIONS:'',NODE_ENV:'test',PORT:'8000',AUTH_ISSUER:'https://auth.shnt.top',AUTH_PORTAL_URL:'http://localhost:5175',SQLITE_IMPORT_FULL_HISTORY:'false',
|
||||
MM_FLOW_READ_RESOURCE_PROFILE:'constrained',DATABASE_URL:`postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`,DATABASE_PATH:join(directory,'no-legacy.sqlite'),
|
||||
MCP_STATE_DIR:join(directory,'state'),AUTH_REQUIRED:'true',AUTH_JWT_SECRET:randomBytes(32).toString('hex'),MCP_PUBLIC_URL:'http://localhost:8000/mcp',MMAPP_OWN_INSTALLATION_COLOAD:'true',MMAPP_OWN_COLOAD_DELAYED_APPROVAL:String(delayedApproval)}})
|
||||
state.status='running';state.childPid=child.pid;await save()
|
||||
timer=setTimeout(()=>{state.outerTimedOut=true;child.kill()},300000)
|
||||
state.nodeExitCode=await new Promise((done,reject)=>{child.once('error',reject);child.once('exit',code=>done(code??1))})
|
||||
clearTimeout(timer);await log.close();log=undefined
|
||||
const after=await pins();state.sourcePinsUnchanged=JSON.stringify(before)===JSON.stringify(after)
|
||||
await fs.writeFile(prefix+'-pins.json',JSON.stringify({before,after,unchanged:state.sourcePinsUnchanged},null,2)+'\n')
|
||||
process.exitCode=state.nodeExitCode===0&&state.sourcePinsUnchanged?0:1
|
||||
}catch(error){state.failure=error instanceof Error?error.name:'UnknownError';process.exitCode=1}
|
||||
finally {
|
||||
if(timer)clearTimeout(timer);if(log)await log.close()
|
||||
try{
|
||||
if(postgres.process?.exitCode===null)await postgres.stop()
|
||||
const actual=await fs.realpath(directory),parent=await fs.realpath(tmpdir())
|
||||
assert.equal(dirname(actual),parent);assert.match(basename(actual),/^mm-mcp-test-[A-Za-z0-9-]+$/);assert.equal((await fs.lstat(directory)).isSymbolicLink(),false)
|
||||
await fs.rm(actual,{recursive:true,force:true,maxRetries:20,retryDelay:100});state.cleanupConfirmed=true
|
||||
}catch{state.cleanupConfirmed=false;process.exitCode=1}
|
||||
state.status='terminal';state.finishedAt=new Date().toISOString();await save();console.log(JSON.stringify(state))
|
||||
}
|
||||
if(state.cleanupConfirmed)process.exit(process.exitCode??1)
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
cd /opt/mmapp-test
|
||||
mm240_db="mm_accept_166_$(date +%s%3N)"
|
||||
mm240_pass="$(openssl rand -hex 24)"
|
||||
mm240_role_created=false
|
||||
mm240_db_created=false
|
||||
cleanup240() {
|
||||
docker rm mmapp-s3app240-owned >/dev/null 2>&1 || true
|
||||
if $mm240_db_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP DATABASE \"$mm240_db\" WITH (FORCE)"; fi
|
||||
if $mm240_role_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP ROLE \"$mm240_db\""; fi
|
||||
remaining=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm240_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm240_db')")
|
||||
[ "$remaining" = 0 ]
|
||||
python3 - <<'PY'
|
||||
import json,pathlib
|
||||
p=pathlib.Path('/opt/mmapp-test/artifacts/s3-retention240/s3-retention240.json')
|
||||
if p.exists():
|
||||
r=json.loads(p.read_text());r['ownedDatabaseAndRoleRemoved']=True;p.write_text(json.dumps(r,indent=2)+'\n')
|
||||
PY
|
||||
printf 'OWNED_DATABASE_AND_ROLE_REMOVED\n'
|
||||
unset mm240_pass
|
||||
}
|
||||
trap cleanup240 EXIT
|
||||
[[ "$mm240_db" =~ ^mm_accept_166_[0-9]+$ ]]
|
||||
existing=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm240_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm240_db')")
|
||||
[ "$existing" = 0 ]
|
||||
printf "CREATE ROLE \"%s\" LOGIN PASSWORD '%s' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;\n" "$mm240_db" "$mm240_pass" | docker exec -i mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1
|
||||
mm240_role_created=true
|
||||
docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$mm240_db\" OWNER \"$mm240_db\""
|
||||
mm240_db_created=true
|
||||
docker run --name mmapp-s3app240-owned --network mmapp-test_default --cap-drop ALL --security-opt no-new-privileges -e "MMAPP_ORIGIN_BASELINE=${2:-false}" --env-file /opt/mmapp-test/artifacts/s3-retention240/private.env -e NODE_ENV=test -e "DATABASE_URL=postgres://$mm240_db:$mm240_pass@postgres:5432/$mm240_db" -e "MMAPP_OWN_SQL_DB=$mm240_db" -e "DATABASE_PATH=/tmp/$mm240_db/legacy.db" -e "GEOIP_DIR=/tmp/$mm240_db/geoip" -v /opt/mmapp-test/artifacts/s3-retention240:/checks --entrypoint node "${1:-mmapp-test/backend:ux-backups237}" /checks/s3-backup-retention.mjs
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
cd /opt/mmapp-test
|
||||
mm229_db="mm_accept_166_$(date +%s%3N)"
|
||||
mm229_pass="$(openssl rand -hex 24)"
|
||||
mm229_role_created=false
|
||||
mm229_db_created=false
|
||||
cleanup229() {
|
||||
docker rm mmapp-s3app229-owned >/dev/null 2>&1 || true
|
||||
if $mm229_db_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP DATABASE \"$mm229_db\" WITH (FORCE)"; fi
|
||||
if $mm229_role_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP ROLE \"$mm229_db\""; fi
|
||||
remaining=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm229_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm229_db')")
|
||||
[ "$remaining" = 0 ]
|
||||
python3 - <<'PY'
|
||||
import json,pathlib
|
||||
p=pathlib.Path('/opt/mmapp-test/artifacts/s3-229/s3-application229.json')
|
||||
if p.exists():
|
||||
r=json.loads(p.read_text());r['ownedDatabaseAndRoleRemoved']=True;p.write_text(json.dumps(r,indent=2)+'\n')
|
||||
PY
|
||||
printf 'OWNED_DATABASE_AND_ROLE_REMOVED\n'
|
||||
unset mm229_pass
|
||||
}
|
||||
trap cleanup229 EXIT
|
||||
[[ "$mm229_db" =~ ^mm_accept_166_[0-9]+$ ]]
|
||||
existing=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm229_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm229_db')")
|
||||
[ "$existing" = 0 ]
|
||||
printf "CREATE ROLE \"%s\" LOGIN PASSWORD '%s' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;\n" "$mm229_db" "$mm229_pass" | docker exec -i mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1
|
||||
mm229_role_created=true
|
||||
docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$mm229_db\" OWNER \"$mm229_db\""
|
||||
mm229_db_created=true
|
||||
docker run --name mmapp-s3app229-owned --network mmapp-test_default --cap-drop ALL --security-opt no-new-privileges --env-file /opt/mmapp-test/artifacts/s3-228-private.env -e NODE_ENV=test -e "DATABASE_URL=postgres://$mm229_db:$mm229_pass@postgres:5432/$mm229_db" -e "MMAPP_OWN_SQL_DB=$mm229_db" -e "DATABASE_PATH=/tmp/$mm229_db/legacy.db" -e "GEOIP_DIR=/tmp/$mm229_db/geoip" -v /opt/mmapp-test/artifacts/s3-229:/checks --entrypoint node mmapp-test/backend:ux-regression222 /checks/s3-backups-api.mjs
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
cd /opt/mmapp-test
|
||||
mm245_db="mm_accept_166_$(date +%s%3N)"
|
||||
mm245_pass="$(openssl rand -hex 24)"
|
||||
mm245_role_created=false
|
||||
mm245_db_created=false
|
||||
cleanup245() {
|
||||
docker rm mmapp-s3app245-owned >/dev/null 2>&1 || true
|
||||
if $mm245_db_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP DATABASE \"$mm245_db\" WITH (FORCE)"; fi
|
||||
if $mm245_role_created; then docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "DROP ROLE \"$mm245_db\""; fi
|
||||
remaining=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm245_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm245_db')")
|
||||
[ "$remaining" = 0 ]
|
||||
python3 - <<'PY'
|
||||
import json,pathlib
|
||||
p=pathlib.Path('/opt/mmapp-test/artifacts/traffic-flow245/traffic-flow245.json')
|
||||
if p.exists():
|
||||
r=json.loads(p.read_text());r['ownedDatabaseAndRoleRemoved']=True;p.write_text(json.dumps(r,indent=2)+'\n')
|
||||
PY
|
||||
printf 'OWNED_DATABASE_AND_ROLE_REMOVED\n'
|
||||
unset mm245_pass
|
||||
}
|
||||
trap cleanup245 EXIT
|
||||
[[ "$mm245_db" =~ ^mm_accept_166_[0-9]+$ ]]
|
||||
existing=$(docker exec mmapp-test-postgres psql -U mmapp -d mmapp -Atc "SELECT (SELECT count(*) FROM pg_database WHERE datname='$mm245_db')+(SELECT count(*) FROM pg_roles WHERE rolname='$mm245_db')")
|
||||
[ "$existing" = 0 ]
|
||||
printf "CREATE ROLE \"%s\" LOGIN PASSWORD '%s' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;\n" "$mm245_db" "$mm245_pass" | docker exec -i mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1
|
||||
mm245_role_created=true
|
||||
docker exec mmapp-test-postgres psql -U mmapp -d mmapp -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$mm245_db\" OWNER \"$mm245_db\""
|
||||
mm245_db_created=true
|
||||
docker run --name mmapp-s3app245-owned --network mmapp-test_default --cap-drop ALL --security-opt no-new-privileges -e "MMAPP_ORIGIN_BASELINE=${2:-false}" -e NODE_ENV=test -e "DATABASE_URL=postgres://$mm245_db:$mm245_pass@postgres:5432/$mm245_db" -e "MMAPP_OWN_SQL_DB=$mm245_db" -e "DATABASE_PATH=/tmp/$mm245_db/legacy.db" -e "GEOIP_DIR=/tmp/$mm245_db/geoip" -v /opt/mmapp-test/artifacts/traffic-flow245:/checks --entrypoint node "${1:-mmapp-test/backend:ux-geoip242-acceptance}" /checks/traffic-flow-regression.mjs
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
const database = process.env.MMAPP_OWN_SQL_DB;
|
||||
assert(/^mm_accept_166_\d+$/.test(database));
|
||||
const url = new URL(process.env.DATABASE_URL); assert.equal(url.pathname, '/' + database); assert.equal(url.username, database);
|
||||
fs.mkdirSync('/tmp/' + database, { recursive: true }); process.chdir('/tmp/' + database);
|
||||
const require = createRequire('/app/backend/package.json'), { ListObjectVersionsCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { pool, db, closePool } = await import('/app/backend/dist/db/index.js');
|
||||
const { initDatabase } = await import('/app/backend/dist/db/bootstrap.js');
|
||||
const { servers } = await import('/app/backend/dist/db/schema.js');
|
||||
const { databaseBackgroundWork } = await import('/app/backend/dist/services/database-background-work.js');
|
||||
const { createS3ClientFromConfig, s3PutObject, s3GetObject } = await import('/app/backend/dist/services/s3-backup-client.js');
|
||||
const { updateBackupStorageSettings, pruneBackupsForServer, readBackupContent, listBackups } = await import('/app/backend/dist/services/backup-service.js');
|
||||
const role = (await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];
|
||||
assert(Object.values(role).every(v => v === false)); await initDatabase();
|
||||
const prefix = 'mmapp-acceptance/' + randomUUID(), secret = process.env.MMAPP_TEST_S3_SECRET_KEY;
|
||||
const cfg = { endpoint: process.env.MMAPP_TEST_S3_ENDPOINT, region: process.env.MMAPP_TEST_S3_REGION, bucket: process.env.MMAPP_TEST_S3_BUCKET, accessKeyId: process.env.MMAPP_TEST_S3_ACCESS_KEY, secretAccessKey: secret, forcePathStyle: false };
|
||||
assert.equal(cfg.endpoint, 'https://s3.ru-1.storage.selcloud.ru'); assert.equal(cfg.bucket, 'mikrotik-test-s3'); assert(secret);
|
||||
const s3 = createS3ClientFromConfig(cfg), plannedKeys = [], seededIds = [], payloads = new Map();
|
||||
const report = { date: '2026-10-14', block: '12.5.240', scope: 'Owned PostgreSQL + compiled retention service + real Selectel S3; no CHR contacts', prefix, passed: false, checks: [], cleanup: {}, chrContacts: 0, bucketSettingsWrites: 0 };
|
||||
const save = () => fs.writeFileSync('/checks/s3-retention240.json', JSON.stringify(report, null, 2) + '\n');
|
||||
const check = (name, details = {}) => { report.checks.push({ name, ...details }); save(); };
|
||||
const catalog = async () => (await pool.query('SELECT id,storage,s3_key FROM backup_entries ORDER BY id')).rows;
|
||||
async function versions() {
|
||||
let keyMarker, versionMarker; const all = [];
|
||||
for (let n = 0; n < 10; n++) {
|
||||
const r = await s3.send(new ListObjectVersionsCommand({ Bucket: cfg.bucket, Prefix: prefix + '/', KeyMarker: keyMarker, VersionIdMarker: versionMarker }));
|
||||
all.push(...(r.Versions ?? []).map(v => ({ ...v, kind: 'version' })), ...(r.DeleteMarkers ?? []).map(v => ({ ...v, kind: 'delete-marker' })));
|
||||
if (!r.IsTruncated) return all;
|
||||
assert(r.NextKeyMarker || r.NextVersionIdMarker); keyMarker = r.NextKeyMarker; versionMarker = r.NextVersionIdMarker;
|
||||
} throw Error('Own pagination limit');
|
||||
}
|
||||
try {
|
||||
assert.equal((await versions()).length, 0);
|
||||
const hosts = await db.insert(servers).values(['a', 'b', 'c'].map(s => ({ name: 'owned-retention240-' + s, host: '127.0.0.1', port: 9, useSsl: false, username: 'fixture', password: 'fixture', enabled: true, type: 'exit-node' }))).returning();
|
||||
await updateBackupStorageSettings({ provider: 's3', s3Endpoint: cfg.endpoint, s3Region: cfg.region, s3Bucket: cfg.bucket, s3Prefix: prefix, s3AccessKeyId: cfg.accessKeyId, s3SecretAccessKey: secret, s3ForcePathStyle: false, keepLocalCopy: true });
|
||||
fs.mkdirSync('storage/backups', { recursive: true });
|
||||
const fixtures = [['a4',0,4,'both'],['a3',0,3,'s3'],['a2',0,2,'local'],['a1',0,1,'s3'],['b2',1,2,'both'],['b1',1,1,'local'],['b0',1,0,'s3'],['c0',2,0,'local']];
|
||||
for (const [id, hostIndex, seconds, storage] of fixtures) {
|
||||
const filename = id + '-retention240.rsc', bytes = Buffer.from('# Own retention240 ' + id + '\n'), key = storage === 'local' ? null : prefix + '/' + filename;
|
||||
seededIds.push(id); payloads.set(id, bytes);
|
||||
if (key) { plannedKeys.push(key); await s3PutObject(s3, cfg.bucket, key, bytes); assert.deepEqual(await s3GetObject(s3, cfg.bucket, key), bytes); }
|
||||
if (storage !== 's3') fs.writeFileSync('storage/backups/' + filename, bytes);
|
||||
await pool.query('INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,storage,s3_key,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)', [id, hosts[hostIndex].id, hosts[hostIndex].name, filename, bytes.length, id.endsWith('0') ? 'manual' : 'auto', storage, key, new Date(Date.UTC(2026,9,14,0,0,seconds)).toISOString()]);
|
||||
}
|
||||
check('eight-fixtures-three-servers', { remoteObjects: plannedKeys.length, localFiles: fixtures.filter(f => f[3] !== 's3').length });
|
||||
const untouched = (await catalog()).filter(r => !r.id.startsWith('a'));
|
||||
await updateBackupStorageSettings({ s3SecretAccessKey: secret + '-invalid' });
|
||||
let failure;
|
||||
try { await pruneBackupsForServer(String(hosts[0].id), 2); } catch (e) { failure = e; }
|
||||
assert.equal(failure?.name, 'BackupPruneError'); assert.equal(failure.pruned, 1);
|
||||
let remaining = await catalog(); assert.deepEqual(remaining.filter(r => !r.id.startsWith('a')), untouched);
|
||||
assert.deepEqual(remaining.filter(r => r.id.startsWith('a')).map(r => r.id), ['a1','a3','a4']);
|
||||
assert(!fs.existsSync('storage/backups/a2-retention240.rsc'));
|
||||
assert.deepEqual(await s3GetObject(s3, cfg.bucket, prefix + '/a1-retention240.rsc'), payloads.get('a1'));
|
||||
check('partial-failure-acknowledges-one-local-deletion', { pruned: failure.pruned, failedRowRetained: true, remoteObjectRetained: true, otherServersUnchanged: true });
|
||||
await updateBackupStorageSettings({ s3SecretAccessKey: secret });
|
||||
assert.equal(await pruneBackupsForServer(String(hosts[0].id), 2), 1);
|
||||
assert.equal(await pruneBackupsForServer(String(hosts[0].id), 2), 0);
|
||||
assert.deepEqual((await catalog()).filter(r => r.id.startsWith('a')).map(r => r.id), ['a3','a4']);
|
||||
check('retry-retains-latest-two-and-repeat-noop', { retryPruned: 1, repeatPruned: 0 });
|
||||
assert.equal(await pruneBackupsForServer(String(hosts[1].id), 1), 2);
|
||||
assert.deepEqual((await catalog()).map(r => r.id), ['a3','a4','b2','c0']);
|
||||
check('second-server-retains-latest-and-unselected-manual-copy', { pruned: 2, retained: ['a3','a4','b2','c0'] });
|
||||
const copies = await listBackups();
|
||||
for (const copy of copies) assert.deepEqual(await readBackupContent(copy), payloads.get(copy.id));
|
||||
assert.equal(copies.find(r => r.id === 'a3').localFileStatus, 'not-stored');
|
||||
assert(copies.filter(r => r.id !== 'a3').every(r => r.localFileStatus === 'available'));
|
||||
check('retained-content-hashes-and-local-states', { copies: copies.map(r => ({ id: r.id, localFileStatus: r.localFileStatus, sha256: createHash('sha256').update(payloads.get(r.id)).digest('hex') })) });
|
||||
report.passed = true;
|
||||
} catch (e) { report.failure = { name: e.name }; process.exitCode = 1; }
|
||||
finally {
|
||||
try {
|
||||
const existing = await versions(); assert(existing.every(v => plannedKeys.includes(v.Key) && v.VersionId));
|
||||
report.cleanup = { plannedKeys, versionsBefore: existing.filter(v => v.kind === 'version').length, deleteMarkersBefore: existing.filter(v => v.kind === 'delete-marker').length, removed: 0 }; save();
|
||||
for (const v of existing) { await s3.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: v.Key, VersionId: v.VersionId })); report.cleanup.removed++; save(); }
|
||||
assert.equal((await versions()).length, 0); report.cleanup.versionsAndMarkersRemaining = 0;
|
||||
if (seededIds.length) await pool.query('DELETE FROM backup_entries WHERE id=ANY($1::text[])', [seededIds]);
|
||||
report.cleanup.ownCatalogRemaining = Number((await pool.query('SELECT count(*) AS n FROM backup_entries')).rows[0].n); assert.equal(report.cleanup.ownCatalogRemaining, 0);
|
||||
} catch (e) { report.cleanup.failure = { name: e.name }; report.passed = false; process.exitCode = 1; }
|
||||
await databaseBackgroundWork.drain(); await closePool(); s3.destroy(); save(); console.log(JSON.stringify(report));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs';import{randomUUID,createHash}from'node:crypto';import{createServer}from'node:http';import{createRequire}from'node:module';
|
||||
assert.equal(process.env.MMAPP_TEST_S3_OWNED_SCOPE,'yes');assert.equal(process.env.MMAPP_TEST_S3_RUN,'confirmed');assert.equal(process.env.MMAPP_TEST_S3_PATH_STYLE,'false');const database=process.env.MMAPP_OWN_SQL_DB;assert(/^mm_accept_166_\d+$/.test(database));const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database);fs.mkdirSync('/tmp/'+database,{recursive:true});process.chdir('/tmp/'+database);
|
||||
const require=createRequire('/app/backend/package.json'),Fastify=require('fastify'),{validatorCompiler,serializerCompiler}=require('@fastify/type-provider-zod');const{db,pool,closePool}=await import('/app/backend/dist/db/index.js');const{initDatabase}=await import('/app/backend/dist/db/bootstrap.js');const{servers}=await import('/app/backend/dist/db/schema.js');const{eq}=await import('/app/node_modules/drizzle-orm/index.js');
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);const role=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false));await initDatabase();
|
||||
const{createBackupsRoutes}=await import('/app/backend/dist/routes/backups.js');const{databaseBackgroundWork}=await import('/app/backend/dist/services/database-background-work.js');const{s3ListObjects,s3PutObject,s3DeleteObject,createS3ClientFromConfig}=await import('/app/backend/dist/services/s3-backup-client.js');
|
||||
const prefix='mmapp-acceptance/'+randomUUID(),secret=process.env.MMAPP_TEST_S3_SECRET_KEY,config={provider:'s3',s3Endpoint:process.env.MMAPP_TEST_S3_ENDPOINT,s3Region:process.env.MMAPP_TEST_S3_REGION,s3Bucket:process.env.MMAPP_TEST_S3_BUCKET,s3Prefix:prefix,s3AccessKeyId:process.env.MMAPP_TEST_S3_ACCESS_KEY,s3SecretAccessKey:secret,s3ForcePathStyle:false,keepLocalCopy:true};assert(secret&&config.s3Endpoint==='https://s3.ru-1.storage.selcloud.ru'&&config.s3Bucket==='mikrotik-test-s3');const client=createS3ClientFromConfig({endpoint:config.s3Endpoint,region:config.s3Region,bucket:config.s3Bucket,prefix,accessKeyId:config.s3AccessKeyId,secretAccessKey:secret,forcePathStyle:false});
|
||||
const report={date:'2026-10-14',block:'12.5.229',scope:'Compiled backup API + owned PostgreSQL + real Selectel S3; synthetic RouterOS HTTP export fixture, no CHR contacts',prefix,checks:[],passed:false,cleanup:{},nativeChrContacts:0,nativeRestoreCalls:0};const file='/checks/s3-application229.json';const save=()=>fs.writeFileSync(file,JSON.stringify(report,null,2)+'\n');const check=(name,extra={})=>{report.checks.push({name,passed:true,...extra});save()};const sha=b=>createHash('sha256').update(b).digest('hex');let exportCalls=0;const body='# Owned MikroTik Manager S3 application acceptance\n';
|
||||
const fixture=createServer((req,res)=>{if(req.method==='POST'&&req.url==='/rest/console/export'){exportCalls++;res.setHeader('content-type','application/json');res.end(JSON.stringify({ret:body}));}else{res.statusCode=404;res.end('{}')}});await new Promise(r=>fixture.listen(0,'127.0.0.1',r));const names=['s3-positive-'+randomUUID(),'s3-negative-'+randomUUID(),'s3-only-'+randomUUID()];const [server]=await db.insert(servers).values({name:names[0],host:'127.0.0.1',port:fixture.address().port,useSsl:false,username:'fixture',password:'fixture',enabled:true,type:'exit-node'}).returning();const app=Fastify({logger:false});app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(createBackupsRoutes(),{prefix:'/api'});await app.ready();
|
||||
async function api(method,path,payload,status=200){const r=await app.inject({method,url:'/api'+path,...(payload?{payload}:{})});assert.equal(r.statusCode,status,method+' '+path+' HTTP '+r.statusCode);return r}
|
||||
async function job(){const requestId=randomUUID(),before=exportCalls;await api('POST','/backups/create',{serverIds:[server.id],requestId},202);for(let n=0;n<100;n++){const r=(await api('GET','/backups/jobs/'+requestId)).json();if(r.status==='done'){assert.equal(r.completed,1);assert.equal(r.failures.length,0);assert.equal(r.created.length,1);assert.equal(exportCalls,before+1);await api('POST','/backups/create',{serverIds:[server.id],requestId},202);assert.equal(exportCalls,before+1);return r.created[0]}await new Promise(r=>setTimeout(r,100))}throw Error('Job did not complete')}
|
||||
try{
|
||||
assert.equal((await s3ListObjects(client,config.s3Bucket,prefix)).length,0);check('empty-prefix-preflight');
|
||||
let settings=(await api('PUT','/backups/storage',config)).json();assert(settings.secretConfigured&&settings.lastTestAt===null);assert(!JSON.stringify(settings).includes(secret));check('settings-save-without-secret-disclosure');
|
||||
settings=(await api('POST','/backups/storage/test')).json();assert(settings.lastTestAt&&!settings.lastTestError);check('real-headbucket-test-receipt');
|
||||
const both=await job();assert.equal(both.storage,'both');assert(!both.uploadError&&both.s3Key.startsWith(prefix+'/'));check('async-create-both-and-request-id-no-repeat');
|
||||
const downloaded=await api('GET','/backups/'+both.id+'/download');assert.equal(sha(downloaded.rawPayload),sha(Buffer.from(body)));check('download-exact-bytes');
|
||||
await api('PUT','/backups/storage',{s3SecretAccessKey:secret+'-invalid'});const denied=(await api('POST','/backups/storage/test',undefined,400)).json();assert(denied.settings.lastTestError&&denied.settings.secretConfigured);check('denied-credentials-test-preserves-settings');
|
||||
const fallback=await api('GET','/backups/'+both.id+'/download');assert.equal(sha(fallback.rawPayload),sha(Buffer.from(body)));check('download-local-fallback-during-s3-denial');
|
||||
await db.update(servers).set({name:names[1]}).where(eq(servers.id,server.id));const partial=await job();assert.equal(partial.storage,'local');assert(partial.uploadError&&!partial.s3Key);check('upload-denial-keeps-local-backup-and-error');
|
||||
await api('PUT','/backups/storage',{s3SecretAccessKey:secret,keepLocalCopy:false});await db.update(servers).set({name:names[2]}).where(eq(servers.id,server.id));const remote=await job();assert.equal(remote.storage,'s3');assert(!remote.uploadError);assert(!fs.existsSync('storage/backups/'+remote.filename));const remoteBytes=await api('GET','/backups/'+remote.id+'/download');assert.equal(sha(remoteBytes.rawPayload),sha(Buffer.from(body)));check('s3-only-removes-local-copy-and-downloads-remote');
|
||||
const key=prefix+'/seed/probe-sync.rsc';await s3PutObject(client,config.s3Bucket,key,body);const sync=(await api('POST','/backups/storage/sync')).json();assert.equal(sync.imported,1);const again=(await api('POST','/backups/storage/sync')).json();assert.equal(again.imported,0);check('sync-import-and-idempotent-repeat');
|
||||
const catalog=(await api('GET','/backups')).json();assert.equal(catalog.length,4);const imported=catalog.find(r=>r.s3Key===key);assert(imported&&imported.serverId===null);assert.equal((await api('GET','/backups/'+imported.id+'/download')).payload,body);check('catalog-and-imported-object-download');
|
||||
for(const row of catalog)await api('DELETE','/backups/'+row.id,undefined,204);assert.equal((await api('GET','/backups')).json().length,0);assert.equal((await s3ListObjects(client,config.s3Bucket,prefix)).length,0);await api('DELETE','/backups/'+both.id,undefined,404);check('delete-catalog-and-current-remote-objects');report.passed=true;
|
||||
}catch(e){report.failure={name:e.name};}
|
||||
finally{
|
||||
try{await api('PUT','/backups/storage',{s3SecretAccessKey:secret});const rows=(await api('GET','/backups')).json();for(const row of rows){assert(!row.s3Key||row.s3Key.startsWith(prefix+'/'));await api('DELETE','/backups/'+row.id,undefined,204)}const leftovers=await s3ListObjects(client,config.s3Bucket,prefix);for(const row of leftovers){assert(row.key===prefix+'/seed/probe-sync.rsc'||names.some(n=>row.key.startsWith(prefix+'/'+n+'/'+n+'_')),'Unexpected key; do not delete');await s3DeleteObject(client,config.s3Bucket,row.key)}report.cleanup.currentObjectsRemaining=(await s3ListObjects(client,config.s3Bucket,prefix)).length;report.cleanup.catalogRowsRemaining=(await api('GET','/backups')).json().length;report.cleanup.localFilesRemaining=fs.existsSync('storage/backups')?fs.readdirSync('storage/backups').length:0;assert.equal(report.cleanup.currentObjectsRemaining,0);assert.equal(report.cleanup.localFilesRemaining,0)}catch(e){report.cleanup.failure={name:e.name};report.passed=false}
|
||||
await databaseBackgroundWork.drain();await app.close();await new Promise(r=>fixture.close(r));await closePool();client.destroy();report.fixtureExportCalls=exportCalls;report.completedAt=new Date().toISOString();report.bounds=['Native export is HTTP fixture; no real device backup/restore or UI repeated','RBAC not repeated in this standalone injected API','S3 current-object deletion only; retained versions/retention not inspected'];save();console.log(JSON.stringify({passed:report.passed,checks:report.checks.length,cleanup:report.cleanup,fixtureExportCalls:exportCalls,nativeChrContacts:0}));if(!report.passed)process.exitCode=1;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import assert from 'node:assert/strict';import fs from 'node:fs';import{createRequire}from'node:module';import{createS3ClientFromConfig}from'/app/backend/dist/services/s3-backup-client.js';const require=createRequire('/app/backend/package.json'),{ListObjectVersionsCommand,DeleteObjectCommand}=require('@aws-sdk/client-s3');const bucket=process.env.MMAPP_TEST_S3_BUCKET;assert.equal(bucket,'mikrotik-test-s3');const client=createS3ClientFromConfig({endpoint:process.env.MMAPP_TEST_S3_ENDPOINT,region:process.env.MMAPP_TEST_S3_REGION,bucket,prefix:'mmapp-acceptance',accessKeyId:process.env.MMAPP_TEST_S3_ACCESS_KEY,secretAccessKey:process.env.MMAPP_TEST_S3_SECRET_KEY,forcePathStyle:false});const reports=JSON.parse(fs.readFileSync('/checks/owned-prefixes-public.json'));const plans=[],out={at:new Date().toISOString(),bucketConfigurationWrites:0,prefixes:[],passed:false};
|
||||
async function list(prefix){let keyMarker,versionMarker,rows=[];for(let page=0;page<50;page++){const r=await client.send(new ListObjectVersionsCommand({Bucket:bucket,Prefix:prefix+'/',KeyMarker:keyMarker,VersionIdMarker:versionMarker}));rows.push(...(r.Versions??[]).map(v=>({...v,kind:'version'})),...(r.DeleteMarkers??[]).map(v=>({...v,kind:'delete-marker'})));if(!r.IsTruncated)return rows;assert(r.NextKeyMarker||r.NextVersionIdMarker);keyMarker=r.NextKeyMarker;versionMarker=r.NextVersionIdMarker;}throw Error('Version pagination limit')}
|
||||
function isOwn(r,key){if(r.plannedKeys)return r.plannedKeys.includes(key);const relative=key.slice(r.prefix.length+1);return relative==='seed/probe-sync.rsc'||/^(s3-(?:positive|only)-[a-f0-9-]{36})\/\1_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.rsc$/.test(relative)}
|
||||
try{for(const r of reports){assert(/^mmapp-acceptance\/[a-f0-9-]{36}$/.test(r.prefix));const rows=await list(r.prefix);for(const row of rows)assert(row.Key?.startsWith(r.prefix+'/')&&isOwn(r,row.Key)&&typeof row.VersionId==='string'&&row.VersionId.length>0,'Unexpected version: do not delete');plans.push({r,rows});}for(const{r,rows}of plans){let removed=0;for(const row of rows){await client.send(new DeleteObjectCommand({Bucket:bucket,Key:row.Key,VersionId:row.VersionId}));removed++;}const after=await list(r.prefix);assert.equal(after.length,0);out.prefixes.push({prefix:r.prefix,versionsBefore:rows.filter(r=>r.kind==='version').length,deleteMarkersBefore:rows.filter(r=>r.kind==='delete-marker').length,removed,versionsAndMarkersRemaining:0});}out.passed=true}catch(e){out.failure={name:e.name,httpStatus:e.$metadata?.httpStatusCode??null}}finally{client.destroy();fs.writeFileSync('/checks/owned-versions-cleanup-public.json',JSON.stringify(out,null,2)+'\n');console.log(JSON.stringify(out));if(!out.passed)process.exitCode=1}
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
|
||||
// Run in the compiled test backend image. Never upload a device configuration here.
|
||||
async function main() {
|
||||
const endpoint = new URL(process.env.MMAPP_TEST_S3_ENDPOINT ?? '')
|
||||
assert.equal(endpoint.protocol, 'https:', 'Verified HTTPS endpoint required')
|
||||
assert(!endpoint.username && !endpoint.password && !endpoint.search && !endpoint.hash, 'Endpoint must not contain credentials or query parameters')
|
||||
assert.equal(process.env.MMAPP_TEST_S3_OWNED_SCOPE, 'yes', 'Explicit bucket/prefix authorization required')
|
||||
const bucket = process.env.MMAPP_TEST_S3_BUCKET
|
||||
assert(bucket && bucket.trim() === bucket, 'Explicit bucket required')
|
||||
const accessKeyId = process.env.MMAPP_TEST_S3_ACCESS_KEY, secretAccessKey = process.env.MMAPP_TEST_S3_SECRET_KEY
|
||||
assert(accessKeyId && secretAccessKey, 'Explicit test credentials required')
|
||||
const region = process.env.MMAPP_TEST_S3_REGION
|
||||
assert(region, 'Explicit region required')
|
||||
const pathStyle = process.env.MMAPP_TEST_S3_PATH_STYLE
|
||||
assert(['true', 'false'].includes(pathStyle), 'Explicit path-style choice required')
|
||||
const prefix = 'mmapp-acceptance/' + randomUUID()
|
||||
const keys = [prefix + '/probe-1.rsc', prefix + '/probe-2.rsc']
|
||||
if (process.env.MMAPP_TEST_S3_DRY_RUN === 'true') {
|
||||
console.log(JSON.stringify({ dryRun: true, networkRequests: 0, plannedKeys: keys, deviceConfigurationUploads: 0 }))
|
||||
} else {
|
||||
assert.equal(process.env.MMAPP_TEST_S3_RUN, 'confirmed', 'Explicit live execution required')
|
||||
const output = process.env.MMAPP_TEST_REPORT
|
||||
assert(output, 'Explicit report path required')
|
||||
const { createS3ClientFromConfig, s3PutObject, s3GetObject, s3ListObjects, s3DeleteObject } = await import('/app/backend/dist/services/s3-backup-client.js')
|
||||
const client = createS3ClientFromConfig({ endpoint: endpoint.toString(), region, bucket, prefix, accessKeyId, secretAccessKey, forcePathStyle: pathStyle === 'true' })
|
||||
const report = { startedAt: new Date().toISOString(), scope: 'Compiled application S3 client; harmless synthetic bodies; dedicated UUID prefix', prefix, passed: false, checks: [], cleanup: [], deviceConfigurationUploads: 0, bounds: ['No application backup jobs, SQL catalog, native restore or UI acceptance', 'Cleanup covers current objects; retained versions/delete markers/retention policies are not inspected'] }
|
||||
const attempted = []
|
||||
report.plannedKeys = keys
|
||||
report.attemptedKeys = attempted
|
||||
const save = () => fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n')
|
||||
const failure = error => ({ name: /^[\w:.-]+$/.test(error.name) ? error.name : 'Error', httpStatus: error.$metadata?.httpStatusCode ?? null })
|
||||
const sha = bytes => createHash('sha256').update(bytes).digest('hex')
|
||||
save()
|
||||
try {
|
||||
// An empty namespace is checked before recording ownership or attempting a write.
|
||||
assert.equal((await s3ListObjects(client, bucket, prefix)).length, 0, 'Namespace already contains objects; no writes allowed')
|
||||
report.checks.push({ operation: 'empty-prefix-preflight', passed: true }); save()
|
||||
for (const key of keys) {
|
||||
const body = Buffer.from('# MikroTik Manager owned S3 roundtrip probe\n# ' + key + '\n')
|
||||
attempted.push(key); save()
|
||||
await s3PutObject(client, bucket, key, body)
|
||||
const received = await s3GetObject(client, bucket, key)
|
||||
assert.equal(sha(received), sha(body), 'Object content mismatch')
|
||||
report.checks.push({ operation: 'put-get-content', key, bytes: received.length, passed: true }); save()
|
||||
}
|
||||
const listed = (await s3ListObjects(client, bucket, prefix)).map(row => row.key).sort()
|
||||
assert.deepEqual(listed, [...keys].sort(), 'Unexpected listing in owned namespace')
|
||||
report.checks.push({ operation: 'list-exact-owned-keys', passed: true })
|
||||
report.passed = true
|
||||
} catch (error) { report.failure = failure(error) }
|
||||
finally {
|
||||
for (const key of attempted) {
|
||||
try { await s3DeleteObject(client, bucket, key); report.cleanup.push({ key, acknowledged: true }) }
|
||||
catch (error) { report.cleanup.push({ key, acknowledged: false, failure: failure(error) }); report.passed = false }
|
||||
}
|
||||
if (attempted.length) {
|
||||
try {
|
||||
const remaining = (await s3ListObjects(client, bucket, prefix)).filter(row => attempted.includes(row.key)).length
|
||||
report.currentOwnedObjectsRemaining = remaining
|
||||
if (remaining) report.passed = false
|
||||
} catch (error) { report.cleanupReadbackFailure = failure(error); report.passed = false }
|
||||
}
|
||||
report.completedAt = new Date().toISOString(); save(); client.destroy()
|
||||
}
|
||||
console.log(JSON.stringify({ passed: report.passed, checks: report.checks.length, attemptedObjects: attempted.length, currentOwnedObjectsRemaining: report.currentOwnedObjectsRemaining ?? null }))
|
||||
if (!report.passed) process.exitCode = 1
|
||||
}
|
||||
}
|
||||
await main().catch(error => {
|
||||
const name = /^[\w:.-]+$/.test(error.name) ? error.name : 'Error'
|
||||
console.error('S3_ACCEPTANCE_FAILED: ' + name)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
const fs = require('fs'), assert = require('node:assert/strict'), { createHash } = require('crypto');
|
||||
const { chromium, expect } = require('@playwright/test');
|
||||
(async () => {
|
||||
const phase = process.argv[2], portal = JSON.parse(fs.readFileSync('tmp/portal195-private.json'));
|
||||
const state = JSON.parse(fs.readFileSync('tmp/state239-public.json'));
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }), errors = [], receipts = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
page.on('response', r => { if (new URL(r.url()).pathname.startsWith('/api/backups') && r.request().method() !== 'GET') receipts.push({ method: r.request().method(), path: new URL(r.url()).pathname, status: r.status() }); });
|
||||
await page.goto('http://localhost:3000/backups');
|
||||
await page.getByLabel('Email', { exact: true }).fill(portal.email);
|
||||
await page.getByLabel('Пароль', { exact: true }).fill(portal.password);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Резервные копии', exact: true })).toBeVisible({ timeout: 30000 });
|
||||
if (phase === 'configure') {
|
||||
const s = JSON.parse(fs.readFileSync('tmp/s3-ui239-private.json'));
|
||||
const paused = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/backups/schedule', { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + localStorage.getItem('mmapp_token') }, body: JSON.stringify({ enabled: false }) });
|
||||
return { status: r.status, body: await r.json() };
|
||||
});
|
||||
assert.equal(paused.status, 200); assert.equal(paused.body.enabled, false);
|
||||
await page.getByRole('tab', { name: 'Настройки', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'S3', exact: true }).click();
|
||||
for (const [label, value] of Object.entries({ Endpoint: s.endpoint, Region: s.region, Bucket: s.bucket, Prefix: state.prefix, 'Access key': s.accessKey, 'Secret key': s.secretKey })) await page.getByLabel(label, { exact: true }).fill(value);
|
||||
const path = page.getByRole('switch', { name: 'Path-style', exact: true });
|
||||
if (await path.getAttribute('aria-checked') === 'true') await path.click();
|
||||
const saved = page.waitForResponse(r => new URL(r.url()).pathname === '/api/backups/storage' && r.request().method() === 'PUT');
|
||||
await page.getByRole('button', { name: 'Сохранить хранилище', exact: true }).click();
|
||||
assert.equal((await saved).status(), 200);
|
||||
await expect(page.getByLabel('Secret key', { exact: true })).toHaveValue('');
|
||||
const tested = page.waitForResponse(r => new URL(r.url()).pathname === '/api/backups/storage/test' && r.request().method() === 'POST');
|
||||
await page.getByRole('button', { name: 'Проверить', exact: true }).click();
|
||||
assert.equal((await tested).status(), 200);
|
||||
} else {
|
||||
await page.getByRole('button', { name: 'Обновить', exact: true }).click();
|
||||
await expect(page.getByText(state.filename, { exact: true })).toBeVisible();
|
||||
if (phase === 'restore') {
|
||||
const download = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Скачать ' + state.filename, exact: true }).click();
|
||||
const d = await download; assert.equal(d.suggestedFilename(), state.filename);
|
||||
const bytes = fs.readFileSync(await d.path());
|
||||
const expected = Buffer.from(`# Own limited UI restore 239\n/interface bridge add name=${state.bridge} comment=mm-ui239\n`);
|
||||
assert.deepEqual(bytes, expected);
|
||||
await page.getByRole('button', { name: 'Восстановить ' + state.filename, exact: true }).click();
|
||||
const dialog = page.getByRole('alertdialog');
|
||||
await expect(dialog).toContainText(state.filename);
|
||||
const restored = page.waitForResponse(r => new URL(r.url()).pathname === '/api/backups/' + state.id + '/restore' && r.request().method() === 'POST');
|
||||
await dialog.getByRole('button', { name: 'Восстановить', exact: true }).click();
|
||||
const r = await restored; assert.equal(r.status(), 200);
|
||||
const result = await r.json(); assert.equal(result.backupId, state.id); assert.equal(result.serverId, state.serverId);
|
||||
await expect(dialog).toBeHidden();
|
||||
fs.writeFileSync('docs/ux-ui/acceptance/s3-ui239-browser.json', JSON.stringify({ date: '2026-10-14', block: '12.5.239', realPortalLogin: true, noMocks: true, passed: true, downloadBytes: bytes.length, downloadSha256: createHash('sha256').update(bytes).digest('hex'), restoreStatus: 200, restoreReceiptMatched: true, receipts, pageErrors: errors, fullConfigurationApplied: false }, null, 2) + '\n');
|
||||
} else if (phase === 'delete') {
|
||||
await page.getByRole('button', { name: 'Удалить ' + state.filename, exact: true }).click();
|
||||
const deleted = page.waitForResponse(r => new URL(r.url()).pathname === '/api/backups/' + state.id && r.request().method() === 'DELETE');
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Удалить', exact: true }).click();
|
||||
assert.equal((await deleted).status(), 204); await expect(page.getByRole('alertdialog')).toBeHidden();
|
||||
await expect(page.getByText(state.filename, { exact: true })).toHaveCount(0);
|
||||
const p = 'docs/ux-ui/acceptance/s3-ui239-browser.json', report = JSON.parse(fs.readFileSync(p));
|
||||
Object.assign(report, { deleteStatus: 204, ownRowRemovedThroughUi: true, deleteReceipts: receipts }); fs.writeFileSync(p, JSON.stringify(report, null, 2) + '\n');
|
||||
} else throw Error('Unsupported phase');
|
||||
}
|
||||
assert.deepEqual(errors, []); console.log('S3 UI239 ' + phase + ' passed');
|
||||
} finally { await browser.close(); }
|
||||
})().catch(e => { console.error(e.message); process.exitCode = 1; });
|
||||
@@ -0,0 +1,89 @@
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire('/app/backend/package.json');
|
||||
const { ListObjectVersionsCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { pool } = await import('/app/backend/dist/db/index.js');
|
||||
const { MikrotikClient } = await import('/app/backend/dist/services/mikrotik.js');
|
||||
const { createS3ClientFromConfig, s3PutObject, s3GetObject } = await import('/app/backend/dist/services/s3-backup-client.js');
|
||||
const phase = process.argv[2], statePath = '/checks/state239-private.json', publicPath = '/checks/s3-ui239-native.json';
|
||||
const config = { endpoint: process.env.MMAPP_TEST_S3_ENDPOINT, region: process.env.MMAPP_TEST_S3_REGION, bucket: process.env.MMAPP_TEST_S3_BUCKET, accessKeyId: process.env.MMAPP_TEST_S3_ACCESS_KEY, secretAccessKey: process.env.MMAPP_TEST_S3_SECRET_KEY, forcePathStyle: false };
|
||||
assert.equal(config.endpoint, 'https://s3.ru-1.storage.selcloud.ru');
|
||||
assert.equal(config.bucket, 'mikrotik-test-s3'); assert(config.secretAccessKey);
|
||||
const s3 = createS3ClientFromConfig(config);
|
||||
const server = (await pool.query("SELECT * FROM servers WHERE host='194.226.121.150' AND enabled=true")).rows;
|
||||
assert.equal(server.length, 1);
|
||||
const client = new MikrotikClient({ host: server[0].host, port: server[0].port, useSsl: server[0].use_ssl, verifySsl: false, username: server[0].username, password: server[0].password });
|
||||
const rows = async table => (await pool.query(`SELECT to_jsonb(${table}) AS row FROM ${table} ORDER BY id`)).rows.map(r => r.row);
|
||||
const read = endpoint => client.get(endpoint, 10000);
|
||||
const save = state => fs.writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 });
|
||||
async function versions(state) {
|
||||
const result = []; let keyMarker, versionMarker;
|
||||
for (let n = 0; n < 10; n++) {
|
||||
const r = await s3.send(new ListObjectVersionsCommand({ Bucket: config.bucket, Prefix: state.prefix + '/', KeyMarker: keyMarker, VersionIdMarker: versionMarker }));
|
||||
result.push(...(r.Versions ?? []).map(v => ({ ...v, kind: 'version' })), ...(r.DeleteMarkers ?? []).map(v => ({ ...v, kind: 'delete-marker' })));
|
||||
if (!r.IsTruncated) return result;
|
||||
assert(r.NextKeyMarker || r.NextVersionIdMarker); keyMarker = r.NextKeyMarker; versionMarker = r.NextVersionIdMarker;
|
||||
} throw Error('Own version pagination limit');
|
||||
}
|
||||
async function restoreTable(table, original) {
|
||||
assert.equal(original.length, 1); assert.equal(original[0].id, 1);
|
||||
const columns = Object.keys(original[0]).filter(k => k !== 'id');
|
||||
assert(columns.every(k => /^[a-z][a-z0-9_]*$/.test(k)));
|
||||
await pool.query(`UPDATE ${table} SET ${columns.map((k, n) => `"${k}"=$${n + 1}`).join(',')} WHERE id=1`, columns.map(k => {
|
||||
const value = original[0][k]; return value && typeof value === 'object' ? JSON.stringify(value) : value;
|
||||
}));
|
||||
assert.deepEqual(await rows(table), original);
|
||||
}
|
||||
try {
|
||||
if (phase === 'init') {
|
||||
assert(!fs.existsSync(statePath));
|
||||
const uuid = randomUUID(), bridge = 'mm-ui239-' + uuid.slice(0, 12), filename = 'mm-ui239-' + uuid + '.rsc';
|
||||
const state = { id: 'mm-ui239-' + uuid, prefix: 'mmapp-acceptance/' + uuid, bridge, filename, key: 'mmapp-acceptance/' + uuid + '/' + filename, serverId: Number(server[0].id), serverName: server[0].name, storage: await rows('backup_storage_settings'), schedule: await rows('backup_schedule_settings'), catalog: await rows('backup_entries'), bridges: await read('/interface/bridge'), files: await read('/file') };
|
||||
assert.equal(state.storage[0].provider, 'local'); assert(state.catalog.every(r => r.storage === 'local' && !r.s3_key));
|
||||
assert(!state.bridges.some(b => b.name === bridge)); assert(!state.files.some(f => f.name === filename));
|
||||
assert.equal((await versions(state)).length, 0); save(state);
|
||||
fs.writeFileSync('/checks/state239-public.json', JSON.stringify({ id: state.id, prefix: state.prefix, filename, bridge, serverId: state.serverId }));
|
||||
console.log('Own scope initialized; original settings/catalog saved privately');
|
||||
} else {
|
||||
const state = JSON.parse(fs.readFileSync(statePath));
|
||||
assert(/^mm-ui239-[a-f0-9-]+\.rsc$/.test(state.filename)); assert(state.key === state.prefix + '/' + state.filename);
|
||||
if (phase === 'upload') {
|
||||
const storage = (await rows('backup_storage_settings'))[0];
|
||||
assert.equal(storage.provider, 's3'); assert.equal(storage.s3_prefix, state.prefix); assert.equal(storage.s3_bucket, config.bucket);
|
||||
assert.equal((await rows('backup_schedule_settings'))[0].enabled, false);
|
||||
const script = Buffer.from(`# Own limited UI restore 239\n/interface bridge add name=${state.bridge} comment=mm-ui239\n`);
|
||||
assert.equal((await versions(state)).length, 0);
|
||||
await s3PutObject(s3, config.bucket, state.key, script);
|
||||
assert.deepEqual(await s3GetObject(s3, config.bucket, state.key), script);
|
||||
await pool.query('INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,notes,storage,s3_key,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,now())', [state.id, state.serverId, state.serverName, state.filename, script.length, 'manual', 'Own limited UI restore 239: bridge only', 's3', state.key]);
|
||||
state.bytes = script.length; state.sha256 = createHash('sha256').update(script).digest('hex'); save(state);
|
||||
console.log(JSON.stringify({ ownS3ScriptUploaded: true, bytes: state.bytes, catalogRowCreated: true }));
|
||||
} else if (phase === 'verify') {
|
||||
const bridges = await read('/interface/bridge'), own = bridges.filter(b => b.name === state.bridge);
|
||||
assert.equal(own.length, 1); assert.equal(own[0].comment, 'mm-ui239');
|
||||
assert.equal((await read('/interface/bridge/port')).filter(p => p.bridge === state.bridge).length, 0);
|
||||
assert.equal((await read('/ip/address')).filter(p => p.interface === state.bridge).length, 0);
|
||||
assert.deepEqual(bridges.filter(b => b.name !== state.bridge), state.bridges);
|
||||
state.nativeVerified = true; save(state);
|
||||
console.log('Native bridge confirmed; no ports/addresses, original bridges unchanged');
|
||||
} else if (phase === 'cleanup') {
|
||||
const own = (await read('/interface/bridge')).filter(b => b.name === state.bridge);
|
||||
assert(own.length <= 1);
|
||||
if (own.length) { assert.equal(own[0].comment, 'mm-ui239'); await client.delete('/interface/bridge/' + encodeURIComponent(own[0]['.id'])); }
|
||||
const ownFiles = (await read('/file')).filter(f => f.name === state.filename); assert(ownFiles.length <= 1);
|
||||
for (const file of ownFiles) await client.removeFile(file['.id']);
|
||||
await pool.query('DELETE FROM backup_entries WHERE id=$1 AND filename=$2 AND s3_key=$3', [state.id, state.filename, state.key]);
|
||||
const existing = await versions(state); assert(existing.every(v => v.Key === state.key && v.VersionId));
|
||||
for (const v of existing) await s3.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: v.Key, VersionId: v.VersionId }));
|
||||
assert.equal((await versions(state)).length, 0);
|
||||
assert.deepEqual(await rows('backup_entries'), state.catalog);
|
||||
assert.deepEqual(await read('/interface/bridge'), state.bridges);
|
||||
assert(!(await read('/file')).some(f => f.name === state.filename));
|
||||
await restoreTable('backup_storage_settings', state.storage); await restoreTable('backup_schedule_settings', state.schedule);
|
||||
const report = { date: '2026-10-14', block: '12.5.239', host: server[0].host, prefix: state.prefix, limitedScriptBytes: state.bytes ?? null, sha256: state.sha256 ?? null, nativeBridgeVerified: Boolean(state.nativeVerified), bridgeAndUploadedFileRemoved: true, originalBridgesUnchanged: true, originalCatalogAllFieldsMatched: true, originalCatalogRows: state.catalog.length, originalSettingsAllFieldsMatched: true, s3VersionsAndMarkersRemoved: existing.length, s3VersionsAndMarkersRemaining: 0, passwordChanges: 0, fullConfigurationApplied: false };
|
||||
fs.writeFileSync(publicPath, JSON.stringify(report, null, 2) + '\n'); fs.unlinkSync(statePath); console.log(JSON.stringify(report));
|
||||
} else throw Error('Unsupported phase');
|
||||
}
|
||||
} finally { s3.destroy(); await pool.end(); }
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"readOnlyDockerAndProc": true,
|
||||
"serviceChanges": 0,
|
||||
"summary": {
|
||||
"samples": 1,
|
||||
"elapsedMs": 0,
|
||||
"hostMemTotalBytes": 2097152,
|
||||
"minHostAvailableBytes": 1048576,
|
||||
"maxObservedContainerUsageIncludingCacheBytes": 100,
|
||||
"containerIdentityAndStartStable": true,
|
||||
"allObservedRunning": true,
|
||||
"anyObservedOomKilled": false,
|
||||
"allCgroupV2MetricsAvailable": true,
|
||||
"pswpinDelta": 0,
|
||||
"pswpoutDelta": 0,
|
||||
"oom_killDelta": 0,
|
||||
"acceptancePassed": false,
|
||||
"scope": "sampled evidence only; correlate with workload, limits, errors and latency; unsampled peaks are unknown"
|
||||
},
|
||||
"observations": [
|
||||
{
|
||||
"elapsedMs": 0,
|
||||
"host": {
|
||||
"MemTotalBytes": 2097152,
|
||||
"MemAvailableBytes": 1048576,
|
||||
"SwapTotalBytes": 4194304,
|
||||
"SwapFreeBytes": 3145728,
|
||||
"pswpin": 20,
|
||||
"pswpout": 30,
|
||||
"oom_kill": 0
|
||||
},
|
||||
"containers": [
|
||||
{
|
||||
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"imageId": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"startedAt": "2026-11-02T00:00:00Z",
|
||||
"running": true,
|
||||
"oomKilled": false,
|
||||
"restartCount": 0,
|
||||
"usageIncludingCacheBytes": 100,
|
||||
"inactiveFileBytes": 30,
|
||||
"usageMinusInactiveFileBytes": 70,
|
||||
"configuredLimitBytes": 512,
|
||||
"reportedLimitBytes": 2048,
|
||||
"swapUsageBytes": null,
|
||||
"pid": 123,
|
||||
"identityStableDuringSample": true,
|
||||
"runningAfter": true,
|
||||
"oomKilledAfter": false,
|
||||
"cgroupV2": {
|
||||
"version": 2,
|
||||
"status": "available",
|
||||
"reason": null,
|
||||
"metrics": {
|
||||
"memory.current": 100,
|
||||
"memory.peak": 200,
|
||||
"memory.events": {
|
||||
"low": 0,
|
||||
"high": 0,
|
||||
"max": 0,
|
||||
"oom": 0,
|
||||
"oom_kill": 0
|
||||
},
|
||||
"memory.swap.current": 0,
|
||||
"memory.swap.peak": 0,
|
||||
"memory.swap.events": {
|
||||
"high": 0,
|
||||
"max": 0,
|
||||
"fail": 0
|
||||
}
|
||||
},
|
||||
"unavailable": {},
|
||||
"peakScope": "cgroup lifetime or since external reset; not an isolated workload peak",
|
||||
"provenance": "fixed host cgroup2 mount + exact Docker ID membership + process/root generation"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"readOnlyDockerAndProc": true,
|
||||
"serviceChanges": 0,
|
||||
"summary": {
|
||||
"samples": 1,
|
||||
"elapsedMs": 0,
|
||||
"hostMemTotalBytes": 2097152,
|
||||
"minHostAvailableBytes": 1048576,
|
||||
"maxObservedContainerUsageIncludingCacheBytes": 100,
|
||||
"containerIdentityAndStartStable": true,
|
||||
"allObservedRunning": true,
|
||||
"anyObservedOomKilled": false,
|
||||
"allCgroupV2MetricsAvailable": true,
|
||||
"pswpinDelta": 0,
|
||||
"pswpoutDelta": 0,
|
||||
"oom_killDelta": 0,
|
||||
"allResourceLimitsStableDuringSample": true,
|
||||
"allHostMemoryPressureAvailable": true,
|
||||
"acceptancePassed": false,
|
||||
"scope": "sampled evidence only; correlate with workload, limits, errors and latency; unsampled peaks are unknown"
|
||||
},
|
||||
"observations": [
|
||||
{
|
||||
"elapsedMs": 0,
|
||||
"host": {
|
||||
"MemTotalBytes": 2097152,
|
||||
"MemAvailableBytes": 1048576,
|
||||
"SwapTotalBytes": 4194304,
|
||||
"SwapFreeBytes": 3145728,
|
||||
"pswpin": 20,
|
||||
"pswpout": 30,
|
||||
"oom_kill": 0,
|
||||
"memoryPressure": {
|
||||
"status": "available",
|
||||
"metrics": {
|
||||
"some": {
|
||||
"avg10Percent": "0.00",
|
||||
"avg60Percent": "1.25",
|
||||
"avg300Percent": "100.00",
|
||||
"totalMicros": 123456
|
||||
},
|
||||
"full": {
|
||||
"avg10Percent": "0.00",
|
||||
"avg60Percent": "0.01",
|
||||
"avg300Percent": "1.00",
|
||||
"totalMicros": 0
|
||||
}
|
||||
},
|
||||
"reason": null
|
||||
}
|
||||
},
|
||||
"containers": [
|
||||
{
|
||||
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"imageId": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"startedAt": "2026-11-02T00:00:00Z",
|
||||
"running": true,
|
||||
"oomKilled": false,
|
||||
"restartCount": 0,
|
||||
"usageIncludingCacheBytes": 100,
|
||||
"inactiveFileBytes": 30,
|
||||
"usageMinusInactiveFileBytes": 70,
|
||||
"configuredLimitBytes": 512,
|
||||
"configuredMemorySwapBytes": 1024,
|
||||
"configuredMemorySwapStatus": "available",
|
||||
"reportedLimitBytes": 2048,
|
||||
"swapUsageBytes": null,
|
||||
"resourceLimitsStableDuringSample": true,
|
||||
"pid": 123,
|
||||
"identityStableDuringSample": true,
|
||||
"runningAfter": true,
|
||||
"oomKilledAfter": false,
|
||||
"cgroupV2": {
|
||||
"version": 2,
|
||||
"status": "available",
|
||||
"reason": null,
|
||||
"metrics": {
|
||||
"memory.current": 100,
|
||||
"memory.peak": 200,
|
||||
"memory.events": {
|
||||
"low": 0,
|
||||
"high": 0,
|
||||
"max": 0,
|
||||
"oom": 0,
|
||||
"oom_kill": 0
|
||||
},
|
||||
"memory.swap.current": 0,
|
||||
"memory.swap.peak": 0,
|
||||
"memory.swap.events": {
|
||||
"high": 0,
|
||||
"max": 0,
|
||||
"fail": 0
|
||||
},
|
||||
"memory.max": 512,
|
||||
"memory.swap.max": 512,
|
||||
"cpu.stat": {
|
||||
"nr_periods": 10,
|
||||
"nr_throttled": 2,
|
||||
"throttled_usec": 1234
|
||||
}
|
||||
},
|
||||
"unavailable": {},
|
||||
"peakScope": "cgroup lifetime or since external reset; not an isolated workload peak",
|
||||
"provenance": "fixed host cgroup2 mount + exact Docker ID membership + process/root generation"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {spawn} from 'node:child_process'
|
||||
|
||||
// Never part of test:all: full mode sends three messages; resume mode sends one intentional duplicate.
|
||||
const label=process.env.MMAPP_LIVE_TELEGRAM_TEST_LABEL??'197'
|
||||
assert(/^\d{3}$/.test(label))
|
||||
const resume=process.env.MMAPP_LIVE_TELEGRAM_RESUME==='manual-retry-only'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''),'An explicitly owned test database is required')
|
||||
const url=new URL(process.env.DATABASE_URL)
|
||||
assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
assert.equal(process.env.MMAPP_LIVE_TELEGRAM_SEND_ACK,'owned-test-chat')
|
||||
assert.equal(process.env.MMAPP_LIVE_TELEGRAM_ACK_DUPLICATE,'yes')
|
||||
const bot=JSON.parse(fs.readFileSync(process.env.MMAPP_LIVE_TELEGRAM_PRIVATE_FILE))
|
||||
assert.equal(bot.chatId,process.env.MMAPP_LIVE_TELEGRAM_CHAT_ACK)
|
||||
assert(/^\d+:[A-Za-z0-9_-]+$/.test(bot.token))
|
||||
const portal=JSON.parse(fs.readFileSync(process.env.MMAPP_LIVE_PORTAL_PRIVATE_FILE))
|
||||
assert.equal(process.env.AUTH_REQUIRED,'true');assert.equal(process.env.AUTH_JWT_SECRET,portal.jwtSecret)
|
||||
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js')
|
||||
const {initDatabase}=await import('/app/backend/dist/db/bootstrap.js')
|
||||
const {updateTelegramSettings}=await import('/app/backend/dist/services/alerts-service.js')
|
||||
const {enqueueTelegramOutbox,dispatchPendingOutbox,getAlertsOutboxOverview}=await import('/app/backend/dist/services/alert-engine/outbox.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database)
|
||||
const permissions=(await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]
|
||||
assert(Object.values(permissions).every(v=>v===false))
|
||||
const root='/app/backend/acceptance',prefix='own-live'+label+'-'+Date.now(),ownedUsers=[],cases=[]
|
||||
const report={date:'2026-10-13',block:'12.5.'+label,database,resumeOnly:resume,scope:'Production compiled outbox, owned PostgreSQL, actual Portal JWT and real Telegram provider',cases,providerSendCalls:0,requestTrace:[],providerAcceptanceIds:[],ownPortalUsersRemoved:false,passed:false}
|
||||
let app,adminToken,originalFetch=globalThis.fetch
|
||||
const portalUrl='http://portal:8080'
|
||||
async function portalCall(path,method='GET',body,token){
|
||||
const r=await originalFetch(portalUrl+path,{method,headers:{...(body!==undefined?{'content-type':'application/json'}:{}),...(token?{Authorization:'Bearer '+token}:{})},body:body===undefined?undefined:JSON.stringify(body)})
|
||||
const text=await r.text();return {status:r.status,data:text?JSON.parse(text):null}
|
||||
}
|
||||
const payload=(number)=>({text:`TEST${label} №${number} — очередь Telegram. ${number===1?'Проверка dedupe и двух отправителей.':'Проверка unknown и намеренного ручного повтора.'}`,chatId:bot.chatId,
|
||||
history:{id:prefix+'-history'+number,ruleId:null,groupId:null,ruleName:'Own TEST'+label+' №'+number,severity:'info',message:'Контролируемая приёмка очереди Telegram',firedAt:new Date().toISOString()}})
|
||||
async function sender(){
|
||||
const code=`import assert from 'node:assert/strict';import {pool,closePool} from '/app/backend/dist/db/index.js';import {dispatchPendingOutbox} from '/app/backend/dist/services/alert-engine/outbox.js';assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,process.env.MMAPP_OWN_SQL_DB);let calls=0,ids=[];const native=globalThis.fetch;globalThis.fetch=async(...args)=>{const r=await native(...args);if(new URL(String(args[0])).pathname.endsWith('/sendMessage')){calls++;const d=await r.clone().json();if(d.ok)ids.push(d.result.message_id)}return r};try{const result=await dispatchPendingOutbox(1);console.log(JSON.stringify({result,calls,ids}))}finally{await closePool()}`
|
||||
return new Promise((resolve,reject)=>{
|
||||
const child=spawn(process.execPath,['--input-type=module','-e',code],{env:process.env,stdio:['ignore','pipe','pipe']})
|
||||
let output='',error='';child.stdout.on('data',b=>output+=b);child.stderr.on('data',b=>error+=b)
|
||||
child.on('error',reject);child.on('exit',status=>{if(status!==0)return reject(Error('Owned sender failed: '+error));try{resolve(JSON.parse(output.trim()))}catch(e){reject(e)}})
|
||||
})
|
||||
}
|
||||
let suppressReceipt=false
|
||||
try{
|
||||
await initDatabase();assert.equal(Number((await pool.query('SELECT count(*) AS n FROM alert_outbox')).rows[0].n),0)
|
||||
await updateTelegramSettings({token:bot.token,chatId:bot.chatId,messageThreadId:null})
|
||||
const login=await portalCall('/api/v1/auth/login','POST',{email:portal.email,password:portal.password});assert.equal(login.status,200);adminToken=login.data.access_token
|
||||
const readerEmail=prefix+'@mmapp.test'
|
||||
const created=await portalCall('/api/v1/admin/users','POST',{email:readerEmail,password:portal.password,name:'Own TEST'+label+' reader',apps:['mm'],permissions:['mm:alerts:read']},adminToken)
|
||||
assert.equal(created.status,201);ownedUsers.push(created.data.id)
|
||||
fs.writeFileSync(root+'/owned-portal-users-private.json',JSON.stringify(ownedUsers),{mode:0o600})
|
||||
const readerLogin=await portalCall('/api/v1/auth/login','POST',{email:readerEmail,password:portal.password});assert.equal(readerLogin.status,200)
|
||||
const {buildApp}=await import('/app/backend/dist/index.js');app=await buildApp({logger:false,startScheduler:false})
|
||||
const id1=prefix+'-1',id2=prefix+'-2'
|
||||
if(!resume){
|
||||
assert.equal(await enqueueTelegramOutbox({id:id1,dedupeKey:id1,payload:payload(1)}),true)
|
||||
assert.equal(await enqueueTelegramOutbox({id:id1+'-duplicate',dedupeKey:id1,payload:payload(1)}),false)
|
||||
const senders=await Promise.all([sender(),sender()])
|
||||
assert.equal(senders.reduce((n,s)=>n+s.result.sent,0),1);assert.equal(senders.reduce((n,s)=>n+s.calls,0),1)
|
||||
report.providerSendCalls+=1;report.providerAcceptanceIds.push(...senders.flatMap(s=>s.ids))
|
||||
let overview=await getAlertsOutboxOverview();assert.equal(overview.counts.sent,1)
|
||||
assert.equal(Number((await pool.query('SELECT count(*) AS n FROM alert_history WHERE id=$1 AND sent_ok=true',[prefix+'-history1'])).rows[0].n),1)
|
||||
cases.push({name:'dedupe and independent senders',senders:2,queuedRows:1,providerSends:1,sentHistoryRows:1})
|
||||
}
|
||||
let lastSendAt=Date.now()
|
||||
globalThis.fetch=async(...args)=>{
|
||||
const sending=new URL(String(args[0])).pathname.endsWith('/sendMessage')
|
||||
if(sending){await new Promise(resolve=>setTimeout(resolve,Math.max(0,1500-(Date.now()-lastSendAt))));lastSendAt=Date.now();assert(report.providerSendCalls<(resume?1:3),'Live send budget exhausted');report.providerSendCalls++}
|
||||
const trace=sending?{requestNumber:report.providerSendCalls,startedAt:new Date().toISOString()}:null
|
||||
if(trace)report.requestTrace.push(trace)
|
||||
let response
|
||||
try{response=await originalFetch(...args)}catch(e){if(trace)trace.fetchFailed=true;throw e}
|
||||
if(sending){
|
||||
trace.httpStatus=response.status
|
||||
const accepted=await response.clone().json().catch(()=>({ok:false}))
|
||||
trace.providerAccepted=accepted.ok===true
|
||||
if(accepted.ok!==true){report.providerRejectionCodes??=[];report.providerRejectionCodes.push(accepted.error_code??null);return response}
|
||||
report.providerAcceptanceIds.push(accepted.result.message_id)
|
||||
if(suppressReceipt){trace.receiptSuppressed=true;suppressReceipt=false;throw Error('Own TEST197 receipt suppressed after confirmed provider acceptance')}
|
||||
}
|
||||
return response
|
||||
}
|
||||
assert.equal(await enqueueTelegramOutbox({id:id2,dedupeKey:id2,payload:payload(2)}),true)
|
||||
let outcome;let overview;
|
||||
if(!resume){
|
||||
suppressReceipt=true;outcome=await dispatchPendingOutbox(1);assert.equal(outcome.sent,0);assert.equal(outcome.held,1)
|
||||
overview=await getAlertsOutboxOverview();assert.equal(overview.counts.unknown,1);assert.equal(overview.items[0].id,id2)
|
||||
const before=report.providerSendCalls;await dispatchPendingOutbox(1);assert.equal(report.providerSendCalls,before)
|
||||
cases.push({name:'controlled lost receipt',providerAccepted:true,status:'unknown',automaticResend:false,physicalDisconnect:false})
|
||||
}else{await pool.query("UPDATE alert_outbox SET status='unknown',last_error='Own resume fixture: controlled unknown, no additional provider send' WHERE id=$1",[id2]);cases.push({name:'resume fixture',status:'unknown',providerSend:false,priorLiveEvidence:'telegram197-partial.json'})}
|
||||
const retry='/api/alerts/outbox/'+id2+'/retry',ack={expectedVersion:0,acknowledgedDuplicateRisk:true}
|
||||
const reader=await app.inject({method:'POST',url:retry,headers:{authorization:'Bearer '+readerLogin.data.access_token},payload:ack});assert.equal(reader.statusCode,403)
|
||||
const missing=await app.inject({method:'POST',url:retry,headers:{authorization:'Bearer '+adminToken},payload:{expectedVersion:0}});assert.equal(missing.statusCode,400)
|
||||
const approved=await app.inject({method:'POST',url:retry,headers:{authorization:'Bearer '+adminToken},payload:ack});assert.equal(approved.statusCode,200);assert.equal(approved.json().resolutionVersion,1)
|
||||
const stale=await app.inject({method:'POST',url:retry,headers:{authorization:'Bearer '+adminToken},payload:ack});assert.equal(stale.statusCode,409)
|
||||
outcome=await dispatchPendingOutbox(1);report.retryDiagnostic={outcome,rows:(await pool.query("SELECT status,retry_count,resolution_version,next_attempt_at,last_error FROM alert_outbox WHERE id=$1",[id2])).rows};assert.equal(outcome.sent,1)
|
||||
overview=await getAlertsOutboxOverview();assert.equal(overview.counts.sent,resume?1:2);assert.equal(overview.counts.unknown,0)
|
||||
assert.equal(Number((await pool.query('SELECT count(*) AS n FROM alert_history WHERE sent_ok=true')).rows[0].n),resume?1:2)
|
||||
cases.push({name:'explicit retry with real Portal JWT',reader:403,missingAck:400,admin:200,staleVersion:409,resolutionVersion:1,intentionalDuplicate:true,sentHistoryRows:resume?1:2})
|
||||
assert.equal(report.providerSendCalls,resume?1:3);assert.equal(report.providerAcceptanceIds.length,resume?1:3)
|
||||
report.passed=true
|
||||
}catch(e){
|
||||
fs.writeFileSync(root+'/private-failure.log',String(e.stack??e),{mode:0o600});report.errorName=e.name
|
||||
}finally{
|
||||
globalThis.fetch=originalFetch
|
||||
if(app)await app.close()
|
||||
for(const id of ownedUsers){const deleted=await portalCall('/api/v1/admin/users/'+id,'DELETE',undefined,adminToken);assert.equal(deleted.status,204)}
|
||||
report.ownPortalUsersRemoved=true
|
||||
await closePool()
|
||||
fs.writeFileSync(root+'/result.json',JSON.stringify(report,null,2));console.log(JSON.stringify({passed:report.passed,providerSendCalls:report.providerSendCalls,errorName:report.errorName,ownPortalUsersRemoved:report.ownPortalUsersRemoved}))
|
||||
}
|
||||
if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,27 @@
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
const pg = createRequire('/app/backend/package.json')('pg');
|
||||
const database = process.env.MMAPP_OWN_SQL_DB; assert(/^mm_accept_166_\d+$/.test(database));
|
||||
const url = new URL(process.env.DATABASE_URL); assert.equal(url.pathname, '/' + database); assert.equal(url.username, database);
|
||||
const client = new pg.Client({ connectionString: url.toString() }); await client.connect();
|
||||
const role = (await client.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]; assert(Object.values(role).every(v => v === false)); await client.end();
|
||||
const pkg = JSON.parse(fs.readFileSync('/checks/package245.json'));
|
||||
assert.equal(pkg.scripts['test:traffic-flow'].split('geoip-download.test.ts').length - 1, 1);
|
||||
assert.equal(pkg.scripts['test:all'].split('npm run test:traffic-flow').length - 1, 1);
|
||||
fs.writeFileSync('/app/backend/package.json', JSON.stringify(pkg, null, 2));
|
||||
const report = { date: '2026-10-14', block: '12.5.245', scope: 'Changed traffic-flow profile only, source acceptance image + current npm command metadata + owned PostgreSQL', passed: false, newGeoipHttpTestIncludedOnce: true, testAllProfileIncludedOnce: true, productionImagesChanged: false };
|
||||
try {
|
||||
const started = Date.now();
|
||||
const output = execFileSync('npm', ['--prefix', 'backend', 'run', 'test:traffic-flow'], { cwd: '/app', timeout: 180000, maxBuffer: 8 * 1024 ** 2, encoding: 'utf8', env: { ...process.env, NODE_ENV: 'test', DATABASE_PATH: '/tmp/' + database + '/legacy.db', GEOIP_DIR: '/tmp/' + database + '/geoip' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
fs.writeFileSync('/checks/traffic-flow245-private.log', output, { mode: 0o600 });
|
||||
assert(output.includes('GeoIP HTTP: whole-body timeout, declared/chunked limits,304 and recovery passed'));
|
||||
report.durationMs = Date.now() - started;
|
||||
report.unavailableSqlBranches = output.split('\n').filter(s => /\bskip(?:ped)?\b/i.test(s) && /PostgreSQL|owned|database|MMAPP_OWN|не пустая БД/i.test(s)).map(s => s.trim());
|
||||
const expectedOptionalSkips = ['Monthly SQL: skip (requires an owned isolated database)', 'IPv6 UDP/worker/SQL: skip (requires an owned isolated database)'];
|
||||
assert.deepEqual(report.unavailableSqlBranches, expectedOptionalSkips);
|
||||
report.optionalSqlBranchesRequireTheirOwnDedicatedDatabase = true;
|
||||
report.profileExitCode = 0; report.passed = true;
|
||||
} catch (e) { report.failure = { name: e.name }; if (e.stdout || e.stderr) fs.writeFileSync('/checks/traffic-flow245-private.log', String(e.stdout ?? '') + '\n' + String(e.stderr ?? ''), { mode: 0o600 }); process.exitCode = 1; }
|
||||
finally { fs.writeFileSync('/checks/traffic-flow245.json', JSON.stringify(report, null, 2) + '\n'); console.log(JSON.stringify(report)); }
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import {execFileSync} from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import pg from 'pg'
|
||||
const name=process.env.MMAPP_OWN_SQL_DB,url=process.env.DATABASE_URL,base='/app/backend/acceptance'
|
||||
assert(/^mm_accept_166_\d+$/.test(name));assert.equal(new URL(url).pathname,'/'+name);assert.equal(new URL(url).username,name)
|
||||
const report={date:'2026-10-13',block:'12.5.187',database:name,productionDatabaseTouched:false,productionEnvCopied:false,cases:[],scriptSha256:crypto.createHash('sha256').update(await fs.readFile(new URL(import.meta.url))).digest('hex')}
|
||||
const client=new pg.Client({connectionString:url});await client.connect()
|
||||
const check=async()=>{assert.equal((await client.query('SELECT current_database() AS n')).rows[0].n,name)}
|
||||
const init=()=>execFileSync(process.execPath,['--input-type=module','-e',"const {initDatabase}=await import('/app/backend/dist/db/bootstrap.js');const {closePool}=await import('/app/backend/dist/db/index.js');await initDatabase();await closePool();"],{env:{...process.env,DATABASE_PATH:'/tmp/'+name+'/missing.db'},stdio:'pipe'})
|
||||
const directory='/tmp/'+name;await fs.mkdir(directory);let passed=false
|
||||
try{
|
||||
await check();const role=(await client.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0];assert(Object.values(role).every(v=>v===false))
|
||||
init();const at=new Date().toISOString(),earlier=new Date(Date.parse(at)-30_000).toISOString(),identity=JSON.stringify([at,'*1'])
|
||||
const id=(await client.query("INSERT INTO servers(name,host,enabled,username,password,updated_at) VALUES('Own restore187','127.0.0.1',false,'fixture','fixture',$1) RETURNING id",[at])).rows[0].id
|
||||
await client.query("INSERT INTO traffic_samples(server_id,interface_name,sampled_at,rx_bytes,tx_bytes,flags) VALUES($1,'ether1',$2,0,0,13),($1,'ether1',$3,1024,1024,13)",[id,earlier,at])
|
||||
// An archive with the exact pre-0012 column/marker shape, constructed only in this owned database.
|
||||
await check();await client.query('ALTER TABLE traffic_samples DROP COLUMN native_counter_identity');await client.query("DELETE FROM schema_migrations WHERE id='0012_traffic_counter_identity'")
|
||||
const old=directory+'/legacy.dump',native=directory+'/native.dump'
|
||||
const dump=file=>execFileSync('pg_dump',['--format=custom','--no-owner','--no-acl','--file',file,'--dbname',url],{stdio:'pipe'})
|
||||
dump(old);init();await client.query("UPDATE traffic_samples SET native_counter_identity='mutated' WHERE server_id=$1",[id])
|
||||
const {restorePartitionedDatabase}=await import('/app/backend/dist/services/system-database-partition-restore.js')
|
||||
const restore=async file=>{await check();await restorePartitionedDatabase(file,url,{prepareSql:async p=>{assert(p.startsWith(directory+'/'));await fs.writeFile(p,'',{flag:'wx',mode:0o600})},command:async(cmd,args)=>{assert(['pg_restore','psql'].includes(cmd));if(cmd==='psql')assert.equal(args[args.indexOf('-d')+1],url);return execFileSync(cmd,args,{stdio:'pipe'})}})}
|
||||
await restore(old);assert.equal((await client.query("SELECT count(*)::int AS n FROM information_schema.columns WHERE table_schema='public' AND table_name='traffic_samples' AND column_name='native_counter_identity'")).rows[0].n,0);report.cases.push({case:'legacy archive restores original schema',passed:true})
|
||||
init();let rows=(await client.query('SELECT native_counter_identity,flags,rx_bytes FROM traffic_samples WHERE server_id=$1 ORDER BY sampled_at',[id])).rows
|
||||
assert.equal(rows.length,2);assert(rows.every(r=>r.native_counter_identity===null&&r.flags===13));assert.deepEqual(rows.map(r=>Number(r.rx_bytes)),[0,1024]);report.cases.push({case:'bootstrap migrates legacy archive without guessed identities or flag rewrite',passed:true})
|
||||
await client.query('UPDATE traffic_samples SET native_counter_identity=$1,flags=CASE WHEN rx_bytes=0 THEN 45 ELSE 61 END WHERE server_id=$2',[identity,id]);dump(native)
|
||||
await client.query('DELETE FROM traffic_samples WHERE server_id=$1',[id]);assert.equal((await client.query('SELECT count(*)::int AS n FROM traffic_samples WHERE server_id=$1',[id])).rows[0].n,0)
|
||||
await restore(native);rows=(await client.query('SELECT native_counter_identity,flags,rx_bytes FROM traffic_samples WHERE server_id=$1 ORDER BY sampled_at',[id])).rows
|
||||
assert.deepEqual(rows.map(r=>r.flags),[45,61]);assert(rows.every(r=>r.native_counter_identity===identity));assert.deepEqual(rows.map(r=>Number(r.rx_bytes)),[0,1024]);report.cases.push({case:'native archive preserves identities, continuity markers and counters',passed:true})
|
||||
assert.equal((await client.query("SELECT count(*)::int AS n FROM schema_migrations WHERE id='0012_traffic_counter_identity'")).rows[0].n,1)
|
||||
report.cases.push({case:'partitioned archive restore and current migration remain valid',passed:true});report.clientVersion=execFileSync('pg_dump',['--version'],{encoding:'utf8'}).trim();report.serverVersion=(await client.query('SHOW server_version')).rows[0].server_version;passed=true
|
||||
}catch(e){report.errorName=e.name;process.exitCode=1}
|
||||
finally{await client.end();report.passed=passed;await fs.writeFile(base+'/restore187-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {createServer} from 'node:http'
|
||||
import {spawn} from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''))
|
||||
const url=new URL(process.env.DATABASE_URL);assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js')
|
||||
const {initDatabase}=await import('/app/backend/dist/db/bootstrap.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database)
|
||||
assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const baseline=process.env.MMAPP_LOCK_LOSS_BASELINE==='yes',children=[],cases=[];let holdNext=false,heldSeen=()=>{},releaseHeld=()=>{},interfaceRequests=0,serverId
|
||||
const http=createServer((req,res)=>{res.setHeader('content-type','application/json');const respond=()=>res.end(JSON.stringify(req.url.includes('/wireguard/')?[]:[{'.id':'*1',name:'ether1',running:true,disabled:false,'rx-byte':'1000','tx-byte':'1000'}]));if(req.url.includes('/interface')&&!req.url.includes('/wireguard/')){interfaceRequests++;if(holdNext){holdNext=false;releaseHeld=respond;heldSeen();return}}respond()})
|
||||
await new Promise(r=>http.listen(0,'127.0.0.1',r));const address=http.address()
|
||||
function start(){const code=`import {pool,closePool} from '/app/backend/dist/db/index.js';import {collectTrafficOnce} from '/app/backend/dist/services/traffic-collector.js';try{console.log(JSON.stringify(await collectTrafficOnce()))}finally{await closePool()}`;const child=spawn(process.execPath,['--input-type=module','-e',code],{env:process.env,stdio:['ignore','pipe','pipe']});children.push(child);let out='',err='';child.stdout.on('data',b=>out+=b);child.stderr.on('data',b=>err+=b);const done=new Promise((resolve,reject)=>{child.on('error',reject);child.on('exit',(exit,signal)=>resolve({exit,signal,out,err}))});return {child,done}}
|
||||
async function held(){holdNext=true;const seen=new Promise(r=>heldSeen=r);const job=start();await Promise.race([seen,job.done.then(()=>{throw Error('Collector ended before held native request')}),new Promise((_,reject)=>{const timer=setTimeout(()=>reject(Error('Held request deadline')),8000);timer.unref()})]);return job}
|
||||
async function count(){return Number((await pool.query('SELECT count(*) AS n FROM traffic_samples WHERE server_id=$1',[serverId])).rows[0].n)}
|
||||
const report={date:'2026-10-13',block:'12.5.200',database,baseline,cases,passed:false,scope:'Production compiled collector, independent Linux child processes, owned restricted PostgreSQL and local RouterOS HTTP fixture; no production PID/native CHR touched'}
|
||||
try{
|
||||
await initDatabase();serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own-lifecycle200','127.0.0.1',$1,false,true,'fixture','fixture') RETURNING id",[address.port])).rows[0].id)
|
||||
await pool.query('INSERT INTO traffic_settings(id,enabled,interval_sec) VALUES(1,true,30) ON CONFLICT(id) DO UPDATE SET enabled=true,interval_sec=30')
|
||||
const crashed=await held();crashed.child.kill('SIGKILL');const crash=await crashed.done;assert.equal(crash.signal,'SIGKILL');releaseHeld();assert.equal(await count(),0)
|
||||
let recovered=await start().done;assert.equal(recovered.exit,0);assert.equal(JSON.parse(recovered.out).fatalError,undefined);assert.equal(await count(),1);cases.push({name:'process crash releases advisory lock',signal:'SIGKILL',crashedWaveRows:0,recoveryWaveRows:1})
|
||||
const beforeSettings=(await pool.query('SELECT last_collected_at FROM traffic_settings WHERE id=1')).rows[0].last_collected_at
|
||||
const lost=await held();const locks=(await pool.query("SELECT a.pid,a.datname,a.usename FROM pg_locks l JOIN pg_stat_activity a ON a.pid=l.pid WHERE l.locktype='advisory' AND l.objid=8723105 AND l.classid=0 AND l.objsubid=1 AND l.granted AND a.datname=$1 AND a.usename=$1",[database])).rows;assert.equal(locks.length,1);assert.equal(locks[0].datname,database);assert.equal(locks[0].usename,database)
|
||||
assert.equal((await pool.query('SELECT pg_terminate_backend($1) AS terminated',[locks[0].pid])).rows[0].terminated,true)
|
||||
await new Promise(r=>setTimeout(r,150));releaseHeld();const ended=await lost.done
|
||||
if(baseline){assert.notEqual(ended.exit,0);assert(ended.err.includes("Unhandled 'error' event"));cases.push({name:'lock connection loss baseline',unhandledClientError:true,nonzeroChildExit:true})}
|
||||
else{assert.equal(ended.exit,0);assert.match(JSON.parse(ended.out).fatalError,/Соединение блокировки/);assert.equal(await count(),1);assert.equal(String((await pool.query('SELECT last_collected_at FROM traffic_settings WHERE id=1')).rows[0].last_collected_at),String(beforeSettings));cases.push({name:'lock connection loss',collectorProcessSurvived:true,fatalSnapshot:true,staleSampleRows:0,staleSettingsWrite:false})}
|
||||
recovered=await start().done;assert.equal(recovered.exit,0);assert.equal(JSON.parse(recovered.out).fatalError,undefined);assert.equal(await count(),2);cases.push({name:'next process recovers after connection loss',waveRows:2});report.interfaceRequests=interfaceRequests;report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/lifecycle200-private.log',String(e.stack??e),{mode:0o600})}
|
||||
finally{releaseHeld();for(const c of children)if(c.exitCode===null&&c.signalCode===null)c.kill('SIGKILL');http.closeAllConnections();await new Promise(r=>http.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/lifecycle200-'+(baseline?'baseline':'fixed')+'-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}
|
||||
if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,140 @@
|
||||
import fs from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium, expect } from '@playwright/test';
|
||||
import axe from 'axe-core';
|
||||
|
||||
// Deliberately read-only. The fixture stays outside Git; no response fixtures are used.
|
||||
const credentials = JSON.parse(fs.readFileSync(process.env.MMAPP_PORTAL_FIXTURE || 'tmp/portal195-private.json'));
|
||||
const output = process.env.MMAPP_UI_AUDIT_OUTPUT || 'tmp/ui260-audit.json';
|
||||
assert(!fs.existsSync(output), 'Never overwrite an acceptance report');
|
||||
const origin = 'http://localhost:3000';
|
||||
const routes = (process.env.MMAPP_UI_AUDIT_ROUTES || '/dashboard,/servers,/uptime,/traffic,/statistics,/wireguard,/ipsec,/gre,/vxlan,/ospf,/bgp,/filters,/backups,/certificates,/containers,/alerts,/settings,/releases').split(',');
|
||||
assert(routes.every(path => /^\/[a-z-]+$/.test(path)));
|
||||
const requiredReads = {
|
||||
'/dashboard': ['/api/servers', '/api/uptime/overview', '/api/filters/rules', '/api/bgp/catalog', '/api/internet-path/latest', '/api/filters/gre-tunnels', '/api/wireguard', '/api/traffic/servers', '/api/vxlan'],
|
||||
'/servers': ['/api/servers'], '/uptime': ['/api/uptime/overview'], '/traffic': ['/api/traffic/servers'],
|
||||
'/statistics': ['/api/statistics'], '/wireguard': ['/api/wireguard'], '/ipsec': ['/api/ipsec'],
|
||||
'/gre': ['/api/gre/tunnels'], '/vxlan': ['/api/vxlan'], '/ospf': ['/api/ospf/catalog'],
|
||||
'/bgp': ['/api/bgp/catalog'], '/filters': ['/api/filters/rules'], '/backups': ['/api/backups'],
|
||||
'/certificates': ['/api/certificates/state'], '/containers': ['/api/containers/state'], '/alerts': ['/api/alerts'],
|
||||
'/users': ['/api/users'], '/firewall': ['/api/firewall/all'], '/probes': ['/api/servers'], '/terminal': ['/api/servers'],
|
||||
'/data-collection': ['/api/scheduler/status'], '/recursive-routes': ['/api/servers'],
|
||||
'/route-optimizer': ['/api/servers', '/api/filters/rules', '/api/uptime/speed-probes'],
|
||||
'/network-map': ['/api/servers', '/api/filters/gre-tunnels', '/api/uptime/speed-probes'],
|
||||
'/ip-ranges': ['/api/evobgp/settings'], '/asns': ['/api/evobgp/settings'], '/communities': ['/api/evobgp/settings'], '/domains': ['/api/evobgp/settings'],
|
||||
};
|
||||
const report = {
|
||||
date: '2026-10-15', block: process.env.MMAPP_UI_AUDIT_BLOCK || '12.5.260', origin, realPortalUiLogin: true,
|
||||
responseMocks: false, deviceWritesPermitted: false, cases: [], blockedWrites: [], readOnlyPosts: [],
|
||||
bounds: ['Default rendered state only; data can still be refreshing', 'Synthetic lab timing over SSH, no CPU/network throttling; not field Core Web Vitals', 'Automated WCAG-tagged checks, not manual screen-reader certification'],
|
||||
};
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
|
||||
try {
|
||||
for (const theme of ['dark', 'light']) {
|
||||
const context = await browser.newContext();
|
||||
await context.addInitScript(value => {
|
||||
localStorage.setItem('rl-theme', value);
|
||||
window.__acceptanceMetrics = { lcp: null, cls: 0, longTasks: 0, longTaskMs: 0 };
|
||||
for (const type of ['largest-contentful-paint', 'layout-shift', 'longtask']) {
|
||||
try {
|
||||
new PerformanceObserver(list => {
|
||||
for (const e of list.getEntries()) {
|
||||
const m = window.__acceptanceMetrics;
|
||||
if (type === 'largest-contentful-paint') m.lcp = e.startTime;
|
||||
if (type === 'layout-shift' && !e.hadRecentInput) m.cls += e.value;
|
||||
if (type === 'longtask') { m.longTasks++; m.longTaskMs += e.duration; }
|
||||
}
|
||||
}).observe({ type, buffered: true });
|
||||
} catch { /* Browser may not expose every observer. */ }
|
||||
}
|
||||
}, theme);
|
||||
const page = await context.newPage();
|
||||
await page.goto(origin + '/dashboard');
|
||||
await page.getByLabel('Email', { exact: true }).fill(credentials.email);
|
||||
await page.getByLabel('Пароль', { exact: true }).fill(credentials.password);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Путь страницы' })).toBeVisible({ timeout: 30000 });
|
||||
await page.route('**/api/**', async route => {
|
||||
const r = route.request();
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(r.method())) {
|
||||
const path = new URL(r.url()).pathname;
|
||||
// These POST handlers were inspected: DNS resolution, remote GET catalog,
|
||||
// and a computed OSPF preview. None applies device configuration.
|
||||
const readQuery = r.method() === 'POST' && (path === '/api/network/resolve-hosts' || path === '/api/evobgp/catalog' || /^\/api\/servers\/\d+\/ospf\/optimize\/preview$/.test(path));
|
||||
if (process.env.MMAPP_UI_AUDIT_ALLOW_READ_POSTS === 'true' && readQuery) {
|
||||
report.readOnlyPosts.push({ theme, path });
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
report.blockedWrites.push({ theme, method: r.method(), path: new URL(r.url()).pathname });
|
||||
await route.abort('blockedbyclient');
|
||||
} else await route.continue();
|
||||
});
|
||||
for (const path of routes) for (const width of [1440, 320]) {
|
||||
const item = { theme, path, width, pageErrors: [], apiErrors: [] };
|
||||
const successfulReads = new Set();
|
||||
const onError = error => item.pageErrors.push(error.name);
|
||||
const onResponse = response => {
|
||||
const u = new URL(response.url());
|
||||
if (u.origin === origin && u.pathname.startsWith('/api/') && response.status() >= 400) item.apiErrors.push({ path: u.pathname, status: response.status() });
|
||||
if (u.origin === origin && response.status() === 200 && response.request().method() === 'GET') successfulReads.add(u.pathname);
|
||||
};
|
||||
page.on('pageerror', onError); page.on('response', onResponse);
|
||||
try {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
const start = Date.now();
|
||||
await page.goto(origin + path, { waitUntil: 'domcontentloaded', timeout: 35000 });
|
||||
await expect(page.locator('#main-content')).toBeVisible({ timeout: 30000 });
|
||||
await expect(page.getByRole('navigation', { name: 'Путь страницы' })).toBeVisible();
|
||||
item.mainVisibleMs = Date.now() - start;
|
||||
if (requiredReads[path]) {
|
||||
await expect.poll(() => requiredReads[path].every(endpoint => successfulReads.has(endpoint)), { timeout: 35000 }).toBe(true);
|
||||
item.requiredReads = requiredReads[path];
|
||||
item.dataResponsesMs = Date.now() - start;
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
await Promise.race([
|
||||
Promise.allSettled(document.getAnimations().filter(a => a.effect?.getComputedTiming().iterations !== Infinity).map(a => a.finished)),
|
||||
new Promise(resolve => setTimeout(resolve, 1500)),
|
||||
]);
|
||||
});
|
||||
item.layout = await page.evaluate(() => ({
|
||||
overflow: document.documentElement.scrollWidth > innerWidth + 1,
|
||||
mainOverflow: document.querySelector('#main-content').scrollWidth > document.querySelector('#main-content').clientWidth + 1,
|
||||
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||
pendingSkeletons: document.querySelectorAll('#main-content [data-slot="skeleton"]').length,
|
||||
}));
|
||||
assert.equal(item.layout.theme, theme);
|
||||
if (process.env.MMAPP_UI_AUDIT_KPI_LABELS === 'true') {
|
||||
item.kpiLabels = await page.locator('[aria-label^="Сводка"] .text-muted-foreground.truncate').evaluateAll(elements => elements.filter(el => el.getClientRects().length).map(el => ({ label: el.textContent.trim(), clipped: el.scrollWidth > el.clientWidth + 1 })));
|
||||
}
|
||||
item.performance = await page.evaluate(() => {
|
||||
const n = performance.getEntriesByType('navigation')[0];
|
||||
return { ...window.__acceptanceMetrics, ttfbMs: n.responseStart - n.requestStart, domInteractiveMs: n.domInteractive, durationMs: n.duration };
|
||||
});
|
||||
await page.addScriptTag({ content: axe.source });
|
||||
item.violations = await page.evaluate(async values => {
|
||||
const result = await window.axe.run({ include: ['#main-content', '[aria-label="Основная навигация"]'] }, { runOnly: { type: 'tag', values } });
|
||||
return result.violations.map(v => ({ id: v.id, impact: v.impact, targets: v.nodes.map(n => n.target) }));
|
||||
}, tags);
|
||||
item.auditCompleted = true;
|
||||
} catch (error) { item.failure = { name: error.name, message: error.message.slice(0, 250) }; }
|
||||
finally {
|
||||
page.off('pageerror', onError); page.off('response', onResponse);
|
||||
report.cases.push(item);
|
||||
fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n');
|
||||
console.log(JSON.stringify({ theme, path, width, violations: item.violations?.map(v => v.id), overflow: item.layout?.overflow, failure: item.failure?.name }));
|
||||
}
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
report.completed = true;
|
||||
report.passed = report.blockedWrites.length === 0 && report.cases.every(c => c.auditCompleted && !c.layout.overflow && !c.layout.mainOverflow && c.violations.length === 0 && c.pageErrors.length === 0 && c.apiErrors.length === 0 && !c.kpiLabels?.some(label => label.clipped));
|
||||
if (!report.passed) process.exitCode = 1;
|
||||
} finally {
|
||||
fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n');
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own217.invalid';process.env.AUTH_PORTAL_URL='https://own217.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const baseline=process.env.MMAPP_BASELINE==='true',cases=[];let phase='populated',nativeCalls=0,nativeWrites=0,app
|
||||
const collections={dhcp:[{interface:'ether1',address:'192.0.2.10/24',gateway:'192.0.2.254',status:'bound'}],addresses:[{address:'192.0.2.10/24',interface:'ether1'}],routes:[{'dst-address':'0.0.0.0/0',gateway:'192.0.2.254','immediate-gw':'192.0.2.254%ether1',active:'true',distance:'1'}]}
|
||||
const native=createServer((req,res)=>{nativeCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');const section=req.url==='/rest/ip/dhcp-client'?'dhcp':req.url==='/rest/ip/address'?'addresses':'routes';if(phase==='fail-'+section){res.statusCode=401;res.end(JSON.stringify({error:401,message:'own217 denied'}));return}res.end(JSON.stringify(phase==='malformed-'+section?[{unknown:'fixture'}]:phase==='empty'?[]:collections[section]))});await new Promise(r=>native.listen(0,'127.0.0.1',r))
|
||||
const report={date:'2026-10-14',block:'12.5.217',baseline,database,cases,scope:'Compiled WAN runtime route/auth, owned SQL/native HTTP fixture; real CHR untouched',passed:false}
|
||||
try{
|
||||
await initDatabase();const uplinks=[{id:'wan1',iface:'ether1',name:'WAN-test',isp:'Fixture',ip:'198.51.100.10',maxDl:100,maxUl:50}],id=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type,wan_uplinks) VALUES('own217-native','127.0.0.1',$1,false,true,'fixture','fixture','home-router',$2::jsonb) RETURNING id",[native.address().port,JSON.stringify(uplinks)])).rows[0].id)
|
||||
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=permissions=>app.jwt.sign({sub:'own217-reader',apps:['mm'],permissions,iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token(['mm:servers:read'])},endpoint='/api/servers/'+id+'/wan-runtime'
|
||||
for(const [name,h,status] of [['anonymous',{},401],['wrong permission',{authorization:'Bearer '+token(['mm:network:read'])},403]]){const before=nativeCalls,r=await app.inject({method:'GET',url:endpoint,headers:h});assert.equal(r.statusCode,status);assert.equal(nativeCalls,before);cases.push({phase:name,status,nativeCalls:0})}
|
||||
for(phase of ['populated','empty','fail-dhcp','fail-addresses','fail-routes','malformed-dhcp','malformed-addresses','malformed-routes','recovery']){
|
||||
const before=nativeCalls,r=await app.inject({method:'GET',url:endpoint,headers}),body=r.json(),failed=phase.startsWith('fail-')||phase.startsWith('malformed-');assert.equal(r.statusCode,failed&&!baseline?502:200)
|
||||
if(failed&&!baseline)assert.match(body.error,/WAN:/)
|
||||
else{assert.deepEqual(Object.keys(body).sort(),['defaultGateway','defaultInterface','immediateGateway','uplinks']);assert.equal(body.uplinks.length,1);if(['populated','recovery'].includes(phase)){assert.equal(body.defaultGateway,'192.0.2.254');assert.equal(body.defaultInterface,'ether1');assert.equal(body.uplinks[0].leasedIp,'192.0.2.10');assert.equal(body.uplinks[0].isDefault,true)}if(phase==='empty'){assert.equal(body.defaultGateway,null);assert.equal(body.uplinks[0].leasedIp,null);assert.equal(body.uplinks[0].isDefault,false)}}
|
||||
cases.push({phase,status:r.statusCode,nativeCalls:nativeCalls-before})
|
||||
}
|
||||
phase='populated';for(const type of ['home-router','exit-node']){await pool.query('UPDATE servers SET enabled=false,type=$1 WHERE id=$2',[type,id]);const before=nativeCalls,r=await app.inject({method:'GET',url:endpoint,headers});assert.equal(r.statusCode,baseline?200:409);if(!baseline){assert.equal(nativeCalls,before);assert.match(r.json().error,/выключен/)}else assert(nativeCalls>before);cases.push({phase:'disabled',type,status:r.statusCode,nativeCalls:nativeCalls-before})}
|
||||
for(const [path,status] of [['/api/servers/9999999/wan-runtime',404],['/api/servers/1oops/wan-runtime',400]]){const before=nativeCalls,r=await app.inject({method:'GET',url:path,headers});assert.equal(r.statusCode,status);assert.equal(nativeCalls,before);cases.push({phase:'missing-invalid',status,nativeCalls:0})}
|
||||
assert.equal(nativeWrites,0);report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/wan217-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();report.nativeWrites=nativeWrites;fs.writeFileSync('/app/backend/acceptance/wan217-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import {createServer} from 'node:http'
|
||||
import {randomBytes} from 'node:crypto'
|
||||
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
|
||||
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
|
||||
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own208.invalid';process.env.AUTH_PORTAL_URL='https://own208.invalid'
|
||||
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
|
||||
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
|
||||
const key=randomBytes(32).toString('base64'),publicKey=randomBytes(32).toString('base64'),baseline=process.env.MMAPP_WG_SECRET_EXPECT==='leak',cases=[];let calls=0,app
|
||||
const native=createServer((req,res)=>{calls++;assert.equal(req.method,'GET');res.setHeader('content-type','application/json');res.end(JSON.stringify(req.url.endsWith('/interface/wireguard')?[{'.id':'*1',name:'own208-wg','listen-port':'55108',mtu:'1420','public-key':publicKey,'private-key':key,disabled:'false',running:'true'}]:[]))});await new Promise(r=>native.listen(0,'127.0.0.1',r))
|
||||
const report={date:'2026-10-13',block:'12.5.208',database,baseline,cases,scope:'Production compiled WireGuard GET/auth/mapper, own restricted PostgreSQL and local RouterOS HTTP fixture with nonempty synthetic private key; isolated signed JWT',passed:false}
|
||||
try{await initDatabase();const port=native.address().port;await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own208-native','127.0.0.1',$1,false,true,'fixture','fixture')",[port]);app=await buildApp({logger:false,startScheduler:false});await app.ready()
|
||||
for(const role of ['reader','writer','admin']){const permission='mm:network:'+({reader:'read',writer:'write',admin:'admin'}[role]);const token=app.jwt.sign({sub:'own208-'+role,apps:['mm'],permissions:[permission],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});for(const flag of ['', '?includePrivateKey=1','?includePrivateKey=true']){const before=calls,r=await app.inject({method:'GET',url:'/api/wireguard'+flag,headers:{authorization:'Bearer '+token}});const denied=!baseline&&role==='reader'&&flag!=='';assert.equal(r.statusCode,denied?403:200);if(denied){assert.equal(calls,before);assert(!r.body.includes(key))}else{const dto=r.json();assert.equal(dto.interfaces.length,1);assert.equal(dto.interfaces[0].publicKey,publicKey);assert.equal(dto.interfaces[0].privateKey,flag?key:undefined)}cases.push({role,flag,status:r.statusCode,privateKeyExposed:!denied&&!!flag,nativeRequestCount:calls-before})}}
|
||||
const fakeAdmin=app.jwt.sign({sub:'own208-is-admin',apps:['mm'],permissions:['mm:network:read'],is_admin:true,iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});const before=calls,r=await app.inject({method:'GET',url:'/api/wireguard?includePrivateKey=1',headers:{authorization:'Bearer '+fakeAdmin}});assert.equal(r.statusCode,baseline?200:403);if(!baseline)assert.equal(calls,before);cases.push({name:'is_admin alone',status:r.statusCode,permissionRequired:true});assert.equal((await app.inject({method:'GET',url:'/api/wireguard?includePrivateKey=1'})).statusCode,401);cases.push({role:'unauthenticated',status:401});report.passed=true
|
||||
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/wireguard208-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/wireguard208-'+(baseline?'baseline':'fixed')+'-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}
|
||||
if(!report.passed)process.exitCode=1
|
||||
@@ -0,0 +1,108 @@
|
||||
/** Build-time only. Fixed operator artifact; never invoked by server startup. */
|
||||
import assert from 'node:assert/strict'
|
||||
import {createHash} from 'node:crypto'
|
||||
import {isBuiltin} from 'node:module'
|
||||
import {readFile,writeFile,mkdir,stat,lstat,readdir} from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {fileURLToPath,pathToFileURL} from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
export const OPERATOR_SOURCES=Object.freeze([
|
||||
'database-writer-bootstrap-operator.mjs','database-writer-profile-operator.mts',
|
||||
'writer-profile/operator-files.mjs','writer-profile/operator-contract.mjs','writer-profile/publisher.mjs','writer-profile/context.mjs',
|
||||
'writer-profile/catalogue.mts','writer-profile/catalogue-value-bridge.ts','writer-profile/catalogue-projection.ts','writer-profile/catalogue-leaves.ts',
|
||||
'writer-profile/application-writer-profile-catalogue.ts','writer-profile/application-writer-profile-catalogue-values.ts',
|
||||
'writer-profile/application-writer-profile-catalogue-parent-policy.ts','writer-profile/application-writer-profile-canonical.ts',
|
||||
'writer-profile/fixed44-build-sql.ts','writer-profile/fixed44-build-definition.ts','writer-profile/expanded44-data-policy.ts',
|
||||
])
|
||||
const emitted=source=>source.replace(/\.mts$/,'.mjs').replace(/\.ts$/,'.js')
|
||||
const digest=value=>createHash('sha256').update(value).digest('hex')
|
||||
const packageName=specifier=>specifier.startsWith('@')?specifier.split('/').slice(0,2).join('/'):specifier.split('/')[0]
|
||||
/** The transformer changes only module specifier AST nodes, never SQL/string data. */
|
||||
export function compileOperatorModule(source,fileName){
|
||||
const rewrite=specifier=>specifier.replace(/^(\.\.\/|\.\.\/\.\.\/)src\//,'$1dist/')
|
||||
const result=ts.transpileModule(source,{fileName,reportDiagnostics:true,compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext,verbatimModuleSyntax:true,newLine:ts.NewLineKind.LineFeed},transformers:{before:[context=>{
|
||||
const visit=node=>{
|
||||
if(ts.isImportDeclaration(node)&&ts.isStringLiteral(node.moduleSpecifier))return context.factory.updateImportDeclaration(node,node.modifiers,node.importClause,context.factory.createStringLiteral(rewrite(node.moduleSpecifier.text)),node.attributes)
|
||||
if(ts.isExportDeclaration(node)&&node.moduleSpecifier&&ts.isStringLiteral(node.moduleSpecifier))return context.factory.updateExportDeclaration(node,node.modifiers,node.isTypeOnly,node.exportClause,context.factory.createStringLiteral(rewrite(node.moduleSpecifier.text)),node.attributes)
|
||||
if(ts.isCallExpression(node)&&node.expression.kind===ts.SyntaxKind.ImportKeyword){
|
||||
assert(node.arguments.length===1&&ts.isStringLiteral(node.arguments[0]),'Only fixed literal operator dynamic imports are supported')
|
||||
return context.factory.updateCallExpression(node,node.expression,node.typeArguments,[context.factory.createStringLiteral(rewrite(node.arguments[0].text))])
|
||||
}
|
||||
return ts.visitEachChild(node,visit,context)
|
||||
}
|
||||
return sourceFile=>ts.visitNode(sourceFile,visit)
|
||||
}]}})
|
||||
const errors=result.diagnostics?.filter(item=>item.category===ts.DiagnosticCategory.Error)??[]
|
||||
assert.equal(errors.length,0,'Operator transpilation diagnostics: '+errors.map(item=>ts.flattenDiagnosticMessageText(item.messageText,' ')).join('; '))
|
||||
return result.outputText
|
||||
}
|
||||
export function moduleSpecifiers(source,fileName){
|
||||
const parsed=ts.createSourceFile(fileName,source,ts.ScriptTarget.ES2022,true,ts.ScriptKind.JS),specifiers=[]
|
||||
const visit=node=>{
|
||||
if((ts.isImportDeclaration(node)||ts.isExportDeclaration(node))&&node.moduleSpecifier){assert(ts.isStringLiteral(node.moduleSpecifier));specifiers.push(node.moduleSpecifier.text)}
|
||||
if(ts.isCallExpression(node)&&node.expression.kind===ts.SyntaxKind.ImportKeyword){assert(node.arguments.length===1&&ts.isStringLiteral(node.arguments[0]),'Computed runtime import refused');specifiers.push(node.arguments[0].text)}
|
||||
ts.forEachChild(node,visit)
|
||||
}
|
||||
visit(parsed);return specifiers
|
||||
}
|
||||
export async function buildDatabaseOperator({backendDirectory=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),outputDirectory=path.join(backendDirectory,'dist-operator')}={}){
|
||||
const backend=path.resolve(backendDirectory),output=path.resolve(outputDirectory),packageJson=JSON.parse(await readFile(path.join(backend,'package.json'),'utf8'))
|
||||
const modules=new Map(),allowed=new Set(OPERATOR_SOURCES.map(emitted)),runtimeDependencies=new Set(),distImports=new Set()
|
||||
const productionDependency=specifier=>{
|
||||
if(isBuiltin(specifier))return
|
||||
const dependency=packageName(specifier);assert(Object.hasOwn(packageJson.dependencies,dependency),'Operator runtime dependency must be production: '+dependency);runtimeDependencies.add(dependency)
|
||||
}
|
||||
for(const file of OPERATOR_SOURCES){
|
||||
const raw=await readFile(path.join(backend,'scripts',file)),code=compileOperatorModule(new TextDecoder('utf-8',{fatal:true}).decode(raw),file),target=emitted(file)
|
||||
const imports=moduleSpecifiers(code,target)
|
||||
for(const specifier of imports){
|
||||
assert(!/\.(?:ts|mts)$/.test(specifier)&&!/(?:^|\/)src\//.test(specifier),'Source runtime import refused')
|
||||
if(specifier.startsWith('.')){
|
||||
const resolved=path.posix.normalize(path.posix.join(path.posix.dirname(target),specifier))
|
||||
if(resolved.startsWith('../dist/')){
|
||||
const relative=resolved.slice('../dist/'.length)
|
||||
assert(relative.endsWith('.js')&&!relative.split('/').includes('..'),'Fixed compiled backend helper required')
|
||||
assert((await stat(path.join(backend,'dist',relative))).isFile(),'Run backend tsc before operator build')
|
||||
distImports.add(relative)
|
||||
}else assert(allowed.has(resolved),'Operator import outside fixed build graph: '+specifier)
|
||||
}else productionDependency(specifier)
|
||||
}
|
||||
modules.set(target,{code,source:file,sourceSha256:digest(raw),sha256:digest(code),imports})
|
||||
}
|
||||
// Validate the referenced compiled helper closure as part of the production
|
||||
// build, not only in tests. Keep this bounded to the small operator graph.
|
||||
const backendDistModules=new Map()
|
||||
async function visitDist(file){
|
||||
assert(file.endsWith('.js')&&!/[\\:]/.test(file)&&!file.startsWith('/')&&!file.split('/').includes('..')&&!file.split('/').includes('src'),'Compiled helper escaped backend/dist')
|
||||
if(backendDistModules.has(file))return
|
||||
assert(backendDistModules.size<64,'Operator compiled helper graph exceeded 64 modules')
|
||||
const raw=await readFile(path.join(backend,'dist',file)),imports=moduleSpecifiers(new TextDecoder('utf-8',{fatal:true}).decode(raw),file)
|
||||
backendDistModules.set(file,{file,sha256:digest(raw),imports})
|
||||
for(const specifier of imports){
|
||||
assert(!/\.(?:ts|mts)$/.test(specifier)&&!/(?:^|\/)src\//.test(specifier),'Source runtime import refused')
|
||||
if(specifier.startsWith('.'))await visitDist(path.posix.normalize(path.posix.join(path.posix.dirname(file),specifier)))
|
||||
else productionDependency(specifier)
|
||||
}
|
||||
}
|
||||
for(const file of distImports)await visitDist(file)
|
||||
// Refuse stale/unlisted output rather than shipping an extra file (e.g. a key)
|
||||
// through Docker's directory COPY. Never delete/adopt unknown build contents.
|
||||
try{
|
||||
assert((await lstat(output)).isDirectory(),'Ordinary operator output directory required')
|
||||
for(const item of await readdir(output,{recursive:true,withFileTypes:true})){
|
||||
const relative=path.relative(output,path.join(item.parentPath,item.name)).split(path.sep).join('/')
|
||||
assert(item.isDirectory()?relative==='writer-profile':item.isFile()&&(allowed.has(relative)||relative==='build-manifest.json'),'Unexpected operator output entry: '+relative)
|
||||
}
|
||||
}catch(error){if(error.code!=='ENOENT')throw error}
|
||||
// Validate the whole fixed graph before publishing any generated module.
|
||||
await mkdir(output,{recursive:true})
|
||||
for(const [file,module] of modules){await mkdir(path.dirname(path.join(output,file)),{recursive:true});await writeFile(path.join(output,file),module.code)}
|
||||
const manifest={format:'application-database-operator-build-v1',compilerVersion:ts.version,entrypoints:['database-writer-bootstrap-operator.mjs','database-writer-profile-operator.mjs'],modules:[...modules].map(([file,module])=>({file,source:module.source,sourceSha256:module.sourceSha256,sha256:module.sha256,imports:module.imports})),backendDistImports:[...distImports].sort(),backendDistModules:[...backendDistModules.values()].sort((a,b)=>a.file.localeCompare(b.file)),runtimeDependencies:[...runtimeDependencies].sort(),automaticStartup:false,readyAuthorized:false}
|
||||
await writeFile(path.join(output,'build-manifest.json'),JSON.stringify(manifest,null,2)+'\n')
|
||||
return manifest
|
||||
}
|
||||
if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){
|
||||
assert.equal(process.argv.length,2,'Build takes no runtime configuration or key arguments')
|
||||
const result=await buildDatabaseOperator();console.log(JSON.stringify({event:'database-operator-built',modules:result.modules.length,automaticStartup:false,readyAuthorized:false}))
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {readFile,writeFile,mkdir,copyFile,mkdtemp,rename} from 'node:fs/promises'
|
||||
import {spawnSync} from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import {fileURLToPath,pathToFileURL} from 'node:url'
|
||||
import {buildDatabaseOperator,compileOperatorModule,moduleSpecifiers,OPERATOR_SOURCES} from './build-database-operator.mjs'
|
||||
|
||||
const backend=process.env.OPERATOR_BUILD_TEST_BACKEND??path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..')
|
||||
test('AST rewrite changes static/export/dynamic module specifiers and preserves SQL/string values',()=>{
|
||||
const code=compileOperatorModule(`import {x} from '../src/db/x.js'; export {y} from '../../src/db/y.js'; const module=()=>import('../src/db/z.js'); export const sql="SELECT '../src/db/x.js'";`,'fixture.mts')
|
||||
assert.deepEqual(moduleSpecifiers(code,'fixture.mjs'),['../dist/db/x.js','../../dist/db/y.js','../dist/db/z.js'])
|
||||
assert(code.includes(`SELECT '../src/db/x.js'`));assert.throws(()=>compileOperatorModule('const load=name=>import(name)','fixture.mts'),/fixed literal/)
|
||||
})
|
||||
test('fixed artifact is reproducible, resolves the compiled backend graph, and runs with plain Node22',async()=>{
|
||||
assert.equal(Number(process.versions.node.split('.')[0]),22,'Run this acceptance with the production Node22 major')
|
||||
const temporaryParent=process.env.OPERATOR_BUILD_TEST_TMP??path.resolve(backend,'../tmp');await mkdir(temporaryParent,{recursive:true})
|
||||
const temporary=await mkdtemp(path.join(temporaryParent,'db-operator-build-')),projectBackend=path.join(temporary,'backend'),output=path.join(projectBackend,'dist-operator')
|
||||
const packageJson=JSON.parse(await readFile(path.join(backend,'package.json'),'utf8'))
|
||||
await mkdir(projectBackend,{recursive:true});await writeFile(path.join(projectBackend,'package.json'),JSON.stringify({type:'module',dependencies:packageJson.dependencies}))
|
||||
const first=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output}),second=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output})
|
||||
assert.deepEqual(first,second);assert.equal(first.modules.length,OPERATOR_SOURCES.length);assert.equal(first.modules.length,17);assert.deepEqual(first.runtimeDependencies,['dotenv','pg','zod']);assert.equal(first.backendDistModules.length,9)
|
||||
const copied=new Set()
|
||||
async function copyDist(relative){
|
||||
if(copied.has(relative))return;copied.add(relative)
|
||||
const original=path.join(backend,'dist',relative),target=path.join(projectBackend,'dist',relative),source=await readFile(original,'utf8')
|
||||
await mkdir(path.dirname(target),{recursive:true});await copyFile(original,target)
|
||||
for(const specifier of moduleSpecifiers(source,relative)){
|
||||
assert(!specifier.includes('/src/'));assert(!/\.(?:ts|mts)$/.test(specifier))
|
||||
if(specifier.startsWith('.')){const dependency=path.posix.normalize(path.posix.join(path.posix.dirname(relative),specifier));assert(!dependency.startsWith('../'));await copyDist(dependency)}
|
||||
else if(!specifier.startsWith('node:')){const name=specifier.startsWith('@')?specifier.split('/').slice(0,2).join('/'):specifier.split('/')[0];assert(Object.hasOwn(packageJson.dependencies,name),'Compiled helper has undeclared production dependency '+name)}
|
||||
}
|
||||
}
|
||||
for(const relative of first.backendDistImports)await copyDist(relative)
|
||||
assert.deepEqual([...copied].sort(),first.backendDistModules.map(item=>item.file).sort())
|
||||
// Exercise the real build entry point with its default backend/output paths.
|
||||
for(const file of OPERATOR_SOURCES){const target=path.join(projectBackend,'scripts',file);await mkdir(path.dirname(target),{recursive:true});await copyFile(path.join(backend,'scripts',file),target)}
|
||||
const buildScript=path.join(projectBackend,'scripts','build-database-operator.mjs');await copyFile(fileURLToPath(new URL('./build-database-operator.mjs',import.meta.url)),buildScript)
|
||||
const built=spawnSync(process.execPath,[buildScript],{cwd:temporary,env:{...process.env,NODE_OPTIONS:''},encoding:'utf8',timeout:30000})
|
||||
assert.equal(built.status,0,built.stderr);assert.equal(JSON.parse(built.stdout.trim()).modules,17)
|
||||
assert.deepEqual(JSON.parse(await readFile(path.join(output,'build-manifest.json'),'utf8')),first)
|
||||
const configFile=path.join(projectBackend,'dist','config.js'),originalConfig=await readFile(configFile,'utf8')
|
||||
for(const injected of ["import 'tsx';","import '../src/config.js';","import '../outside.js';"]){
|
||||
await writeFile(configFile,originalConfig+'\n'+injected)
|
||||
await assert.rejects(buildDatabaseOperator({backendDirectory:projectBackend,outputDirectory:output}),/production|Source runtime import|escaped backend\/dist/)
|
||||
}
|
||||
await writeFile(configFile,originalConfig)
|
||||
await writeFile(path.join(output,'unexpected-private.pem'),'not a key; stale-output test')
|
||||
await assert.rejects(buildDatabaseOperator({backendDirectory:backend,outputDirectory:output}),/Unexpected operator output entry/)
|
||||
const {unlink}=await import('node:fs/promises');await unlink(path.join(output,'unexpected-private.pem'))
|
||||
await rename(path.join(projectBackend,'scripts'),path.join(temporary,'build-inputs'))
|
||||
const guard=path.join(temporary,'runtime-guard.mjs'),telemetry=path.join(temporary,'telemetry.json')
|
||||
const denied=[...new Set([...Object.keys(packageJson.devDependencies),'typescript','tsx'])]
|
||||
// Block development imports and connections in the actual plain-Node process.
|
||||
await writeFile(guard,`import {registerHooks,syncBuiltinESMExports} from 'node:module';import net from 'node:net';import tls from 'node:tls';import {writeFileSync} from 'node:fs';const denied=new Set(${JSON.stringify(denied)});let connections=0;const refuse=()=>{connections++;throw Error('Unexpected operator network connection')};net.Socket.prototype.connect=refuse;tls.connect=refuse;syncBuiltinESMExports();registerHooks({resolve(specifier,context,next){const name=specifier.startsWith('@')?specifier.split('/').slice(0,2).join('/'):specifier.split('/')[0];if(denied.has(name)||/\\.(?:ts|mts)$/.test(specifier)||specifier.includes('/backend/src/'))throw Error('Nonproduction import '+specifier);return next(specifier,context)}});process.on('exit',()=>writeFileSync(${JSON.stringify(telemetry)},JSON.stringify({connections})));`)
|
||||
const env={...process.env,NODE_ENV:'production',NODE_OPTIONS:'',AUTH_REQUIRED:'false',DOTENV_CONFIG_QUIET:'true'}
|
||||
// The workspace tmp projection resolves installed packages from its ancestor
|
||||
// node_modules; the runtime hook rejects every development dependency.
|
||||
const modulesToImport=first.modules.map(item=>path.join(output,item.file))
|
||||
const importer=path.join(temporary,'import-all.mjs');await writeFile(importer,`import {pathToFileURL} from 'node:url';for(const file of ${JSON.stringify(modulesToImport)})await import(pathToFileURL(file).href);console.log(JSON.stringify({imported:${modulesToImport.length},readyAuthorized:false}));`)
|
||||
const imported=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,importer],{cwd:temporary,env,encoding:'utf8',timeout:30000})
|
||||
assert.equal(imported.status,0,imported.stderr);assert.equal(JSON.parse(imported.stdout.trim()).imported,17);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0)
|
||||
const refusals=[]
|
||||
for(const [entry,event] of [['database-writer-bootstrap-operator.mjs','application-writer-bootstrap-refused'],['database-writer-profile-operator.mjs','application-writer-profile-publication-refused']]){
|
||||
const child=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,path.join(output,entry),'invalid'],{cwd:temporary,env,encoding:'utf8',timeout:30000})
|
||||
assert.equal(child.status,1,child.stderr);assert.equal(child.stdout,'');assert.equal(JSON.parse(child.stderr.trim()).event,event);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0);refusals.push(event)
|
||||
}
|
||||
const receipt={node:process.version,projection:temporary,modules:first.modules.length,compiledBackendModules:copied.size,buildCliExitCode:built.status,importedModules:modulesToImport.length,invalidArgumentsRefused:refusals,developmentDependenciesBlocked:denied,networkConnections:0,productionActivated:false,readyAuthorized:false}
|
||||
await writeFile(path.join(temporary,'acceptance-receipt.json'),JSON.stringify(receipt,null,2)+'\n');console.log(JSON.stringify(receipt))
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/** Offline operator-only issuer proposal. Never imported by runtime/startup.
|
||||
* Effective key/registry ACL isolation is required before deployment acceptance;
|
||||
* mode bits and possession of CLI arguments do not establish that isolation. */
|
||||
import { createPrivateKey, createPublicKey, sign } from "node:crypto"
|
||||
import { mkdir, open, realpath } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { z } from "zod"
|
||||
import { DATABASE_SOURCE_SIGNATURE_DOMAINS, sourceBindingBodySchema, sourceBindingBytes, sourceBindingCanonical, sourceBindingSha256,
|
||||
readDatabaseSourceBindingRegistry, type DatabaseSourceDeploymentPin } from "../src/db/database-source-binding.js"
|
||||
|
||||
const requestSchema = sourceBindingBodySchema.omit({ format: true, ordinal: true })
|
||||
export type DatabaseSourceOperatorRequest = z.infer<typeof requestSchema>
|
||||
function outside(parent: string, child: string) {
|
||||
const relative = path.relative(parent, child)
|
||||
return !!relative && (relative.startsWith(".." + path.sep) || relative === ".." || path.isAbsolute(relative))
|
||||
}
|
||||
export async function provisionDatabaseSourceBindingOffline(options: { request: DatabaseSourceOperatorRequest; privateKeyFile: string;
|
||||
outputDirectory: string; previousPin?: DatabaseSourceDeploymentPin }) {
|
||||
const request = requestSchema.parse(options.request)
|
||||
const privatePath = await realpath(options.privateKeyFile), output = path.resolve(options.outputDirectory)
|
||||
const parent = await realpath(path.dirname(output))
|
||||
if (parent !== path.dirname(output) || !outside(output, privatePath)) throw Error("Private operator key must be outside new registry, without ambiguous parent links")
|
||||
const stat = await open(privatePath, "r")
|
||||
let privateBytes: Buffer
|
||||
try { const meta = await stat.stat(); if (!meta.isFile() || meta.size < 1 || meta.size > 8192) throw Error("Bounded operator private key required"); privateBytes = await stat.readFile() }
|
||||
finally { await stat.close() }
|
||||
const privateKey = createPrivateKey(privateBytes); privateBytes.fill(0)
|
||||
if (privateKey.type !== "private" || privateKey.asymmetricKeyType !== "ed25519") throw Error("Independent Ed25519 operator key required")
|
||||
const publicKey = createPublicKey(privateKey), spki = publicKey.export({ type: "spki", format: "der" })
|
||||
const publicKeySpkiSha256 = sourceBindingSha256(spki)
|
||||
const previous = options.previousPin ? await readDatabaseSourceBindingRegistry(options.previousPin) : undefined
|
||||
if (previous && (previous.body.installationId !== request.installationId || previous.body.sourceIdentityId !== request.sourceIdentityId ||
|
||||
previous.publicKeySpkiSha256 !== publicKeySpkiSha256 || previous.body.generation === request.generation || previous.body.ordinal >= Number.MAX_SAFE_INTEGER)) throw Error("Previous current head/identity/key or distinct new generation required")
|
||||
const body = sourceBindingBodySchema.parse({ ...request, format: "database-source-binding-v1", ordinal: previous ? previous.body.ordinal + 1 : 1 })
|
||||
const signed = (domain: string, payload: unknown) => sourceBindingBytes({ body: payload,
|
||||
signature: sign(null, Buffer.from(domain + sourceBindingCanonical(payload)), privateKey).toString("base64url") })
|
||||
const bindingBytes = signed(DATABASE_SOURCE_SIGNATURE_DOMAINS.binding, body), bindingSha256 = sourceBindingSha256(bindingBytes)
|
||||
const head = { format: "database-source-registry-head-v1", installationId: body.installationId, sourceIdentityId: body.sourceIdentityId,
|
||||
generation: body.generation, ordinal: body.ordinal, bindingSha256, previousHeadSha256: previous?.headSha256 ?? null }
|
||||
const headBytes = signed(DATABASE_SOURCE_SIGNATURE_DOMAINS.head, head), currentHeadSha256 = sourceBindingSha256(headBytes)
|
||||
// No recursive mkdir and no existing-state adoption. A partial output remains
|
||||
// for explicit operator review; uncertainty is never retried/overwritten.
|
||||
await mkdir(output, { mode: 0o700 })
|
||||
const publish = async (filename: string, bytes: Buffer | string) => {
|
||||
const handle = await open(path.join(output, filename), "wx", 0o600)
|
||||
try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
|
||||
}
|
||||
await publish("operator-public.pem", publicKey.export({ type: "spki", format: "pem" }))
|
||||
await publish("binding-" + bindingSha256 + ".json", bindingBytes)
|
||||
await publish("head-" + currentHeadSha256 + ".json", headBytes)
|
||||
const deploymentPin = { registryDirectory: output, installationId: body.installationId, publicKeySpkiSha256, currentHeadSha256 }
|
||||
// Public review artifact only; not installed into any application config.
|
||||
await publish("proposed-deployment-pin.json", sourceBindingBytes(deploymentPin))
|
||||
return Object.freeze({ deploymentPin: Object.freeze(deploymentPin), keyIsolationVerified: false as const,
|
||||
platformProtectionVerified: false as const, generationAuthenticated: false as const, readyAuthorized: false as const })
|
||||
}
|
||||
|
||||
export async function databaseSourceBindingOperatorCli(args: string[]) {
|
||||
const allowed = new Set(["request", "private-key", "output", "previous-pin"]), values = new Map<string, string>()
|
||||
for (const arg of args) {
|
||||
const match = /^--([a-z-]+)=(.+)$/.exec(arg)
|
||||
if (!match || !allowed.has(match[1]) || values.has(match[1])) throw Error("Exact explicit offline operator arguments required")
|
||||
values.set(match[1], match[2])
|
||||
}
|
||||
if (!["request", "private-key", "output"].every(key => values.has(key))) throw Error("Missing explicit offline operator arguments")
|
||||
const boundedJson = async (filename: string) => {
|
||||
const handle = await open(filename, "r")
|
||||
try { const stat = await handle.stat(); if (!stat.isFile() || stat.size < 1 || stat.size > 8192) throw Error("Bounded operator input required"); return JSON.parse(await handle.readFile("utf8")) as unknown }
|
||||
finally { await handle.close() }
|
||||
}
|
||||
const request = requestSchema.parse(await boundedJson(values.get("request")!))
|
||||
const previousPin = values.has("previous-pin") ? await boundedJson(values.get("previous-pin")!) as DatabaseSourceDeploymentPin : undefined
|
||||
return provisionDatabaseSourceBindingOffline({ request, privateKeyFile: values.get("private-key")!, outputDirectory: values.get("output")!, previousPin })
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/** Explicit offline operator init. No database, application imports or activation. */
|
||||
import assert from 'node:assert/strict'
|
||||
import {createHash, generateKeyPairSync} from 'node:crypto'
|
||||
import {constants} from 'node:fs'
|
||||
import {lstat, realpath, open, mkdir, link, unlink} from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {pathToFileURL} from 'node:url'
|
||||
|
||||
const digest = bytes => createHash('sha256').update(bytes).digest('hex')
|
||||
const hex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
|
||||
const uuid = value => typeof value === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value)
|
||||
const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key))
|
||||
const roles = ['runtime', 'operator', 'capture', 'publisher', 'control']
|
||||
export function deploymentRequest(value) {
|
||||
assert(exact(value, ['format', 'installationId', 'sourceHeadSha256', 'source', 'roles', 'tls']))
|
||||
assert(value.format === 'application-writer-bootstrap-request-v1' && uuid(value.installationId) && hex(value.sourceHeadSha256))
|
||||
assert(exact(value.source, ['systemIdentifier', 'databaseOid', 'databaseName', 'serverVersionNum', 'inRecovery']))
|
||||
assert(typeof value.source.systemIdentifier === 'string' && /^[1-9][0-9]{0,19}$/.test(value.source.systemIdentifier) && BigInt(value.source.systemIdentifier) <= 18446744073709551615n)
|
||||
assert(typeof value.source.databaseOid === 'string' && /^[1-9][0-9]{0,9}$/.test(value.source.databaseOid) && BigInt(value.source.databaseOid) <= 4294967295n)
|
||||
assert(typeof value.source.databaseName === 'string' && /^[^\x00-\x1f\x7f]{1,63}$/.test(value.source.databaseName))
|
||||
assert(Number.isInteger(value.source.serverVersionNum) && value.source.serverVersionNum >= 180000 && value.source.serverVersionNum <= 189999 && value.source.inRecovery === false)
|
||||
assert(exact(value.roles, roles))
|
||||
for (const role of roles) {
|
||||
const pair = value.roles[role]
|
||||
assert(exact(pair, ['name', 'signedOid']) && typeof pair.name === 'string' && /^[a-z_][a-z0-9_]{0,62}$/.test(pair.name))
|
||||
assert(typeof pair.signedOid === 'string' && /^[1-9][0-9]{0,9}$/.test(pair.signedOid) && BigInt(pair.signedOid) <= 4294967295n)
|
||||
}
|
||||
assert(new Set(roles.map(role => value.roles[role].name)).size === 5 && new Set(roles.map(role => value.roles[role].signedOid)).size === 5)
|
||||
assert(exact(value.tls, ['serverName', 'caSha256', 'spkiSha256']) && typeof value.tls.serverName === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(value.tls.serverName) && hex(value.tls.caSha256) && hex(value.tls.spkiSha256))
|
||||
return structuredClone(value)
|
||||
}
|
||||
export function createBootstrapMaterial(raw, requestSha256) {
|
||||
const request = deploymentRequest(raw)
|
||||
assert(hex(requestSha256))
|
||||
const {privateKey, publicKey} = generateKeyPairSync('ed25519')
|
||||
const publicKeyPem = publicKey.export({format: 'pem', type: 'spki'}).toString()
|
||||
const publicKeySpkiSha256 = digest(publicKey.export({format: 'der', type: 'spki'}))
|
||||
return {
|
||||
privateKeyPem: privateKey.export({format: 'pem', type: 'pkcs8'}).toString(), publicKeyPem,
|
||||
bootstrap: {format: 'application-writer-bootstrap-v1', verificationSubject: {installationId: request.installationId, publicKeyPem, publicKeySpkiSha256},
|
||||
deploymentRequest: request, deploymentRequestSha256: requestSha256, keyOrigin: 'explicit-offline-operator-init',
|
||||
databaseObserved: false, rolesAuthenticated: false, sourceAuthenticated: false, approvedDefinitionSha256: null,
|
||||
publicationAuthorized: false, generationAuthenticated: false, readyAuthorized: false},
|
||||
}
|
||||
}
|
||||
async function protectedDirectory(directory) {
|
||||
assert(process.platform !== 'win32' && typeof process.getuid === 'function', 'Protected POSIX ownership required; Windows ACL backend unavailable')
|
||||
assert(path.isAbsolute(directory) && await realpath(directory) === directory)
|
||||
const uid = process.getuid(), stat = await lstat(directory)
|
||||
assert(stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === uid && (stat.mode & 0o077) === 0)
|
||||
for (let ancestor = path.dirname(directory);; ancestor = path.dirname(ancestor)) {
|
||||
const metadata = await lstat(ancestor)
|
||||
assert(metadata.isDirectory() && !metadata.isSymbolicLink() && (metadata.uid === uid || metadata.uid === 0))
|
||||
assert((metadata.mode & 0o022) === 0 || metadata.uid === 0 && (metadata.mode & 0o1000) !== 0)
|
||||
if (ancestor === path.dirname(ancestor)) break
|
||||
}
|
||||
return stat
|
||||
}
|
||||
async function publish(directory, name, bytes) {
|
||||
await protectedDirectory(directory)
|
||||
const temporary = path.join(directory, '.' + name + '.pending'), destination = path.join(directory, name)
|
||||
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
||||
try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
|
||||
await link(temporary, destination) // Atomic no-replace publication, never rename-overwrite.
|
||||
await unlink(temporary)
|
||||
const parent = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
|
||||
try { await parent.sync() } finally { await parent.close() }
|
||||
}
|
||||
export async function initializeBootstrap({outputDirectory, deploymentFile, deploymentSha256}) {
|
||||
assert(path.isAbsolute(outputDirectory) && path.resolve(outputDirectory) === outputDirectory && hex(deploymentSha256))
|
||||
await protectedDirectory(path.dirname(outputDirectory))
|
||||
assert(path.isAbsolute(deploymentFile) && await realpath(deploymentFile) === deploymentFile)
|
||||
const requestHandle = await open(deploymentFile, constants.O_RDONLY | constants.O_NOFOLLOW)
|
||||
let bytes
|
||||
try {
|
||||
const before = await requestHandle.stat()
|
||||
assert(before.isFile() && before.uid === process.getuid() && (before.mode & 0o077) === 0 && before.size > 0 && before.size <= 16384)
|
||||
bytes = Buffer.alloc(before.size + 1)
|
||||
let size = 0
|
||||
while (size < bytes.length) { const read = await requestHandle.read(bytes, size, bytes.length - size, size); if (!read.bytesRead) break; size += read.bytesRead }
|
||||
const after = await requestHandle.stat()
|
||||
assert(size === before.size && after.size === before.size && after.mtimeMs === before.mtimeMs && after.ctimeMs === before.ctimeMs)
|
||||
bytes = bytes.subarray(0, size)
|
||||
} finally { await requestHandle.close() }
|
||||
assert(digest(bytes) === deploymentSha256, 'Explicit independently approved deployment digest required')
|
||||
const request = deploymentRequest(JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes)))
|
||||
await mkdir(outputDirectory, {mode: 0o700}) // EEXIST refuses; never adopt/overwrite.
|
||||
const material = createBootstrapMaterial(request, deploymentSha256)
|
||||
await publish(outputDirectory, 'signing-private.pem', material.privateKeyPem)
|
||||
await publish(outputDirectory, 'operator-public.pem', material.publicKeyPem)
|
||||
await publish(outputDirectory, 'bootstrap.json', JSON.stringify(material.bootstrap, null, 2) + '\n') // Completion marker last.
|
||||
return {event: 'application-writer-bootstrap-initialized', installationId: request.installationId,
|
||||
publicKeySpkiSha256: material.bootstrap.verificationSubject.publicKeySpkiSha256,
|
||||
deploymentRequestSha256: deploymentSha256, databaseObserved: false, publicationAuthorized: false, readyAuthorized: false}
|
||||
}
|
||||
export async function main(args) {
|
||||
assert(args.length === 7 && args[0] === 'init')
|
||||
const values = new Map()
|
||||
for (let i = 1; i < args.length; i += 2) { assert(['--output', '--deployment', '--deployment-sha256'].includes(args[i]) && !values.has(args[i])); values.set(args[i], args[i + 1]) }
|
||||
return initializeBootstrap({outputDirectory: values.get('--output'), deploymentFile: values.get('--deployment'), deploymentSha256: values.get('--deployment-sha256')})
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
try { console.log(JSON.stringify(await main(process.argv.slice(2)))) }
|
||||
catch { console.error(JSON.stringify({event: 'application-writer-bootstrap-refused', publicationAuthorized: false, readyAuthorized: false})); process.exitCode = 1 }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {createHash, createPrivateKey, createPublicKey, sign, verify} from 'node:crypto'
|
||||
import {mkdtemp, rm, writeFile, readdir, readFile, stat, chmod} from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {tmpdir} from 'node:os'
|
||||
import {deploymentRequest, createBootstrapMaterial, initializeBootstrap, main} from './database-writer-bootstrap-operator.mjs'
|
||||
const hash = b => createHash('sha256').update(b).digest('hex')
|
||||
const request = () => ({format: 'application-writer-bootstrap-request-v1', installationId: 'f8b410ef-11ba-4419-b6a6-e735e505450b', sourceHeadSha256: 'a'.repeat(64),
|
||||
source: {systemIdentifier: '7387529813445422155', databaseOid: '16384', databaseName: 'operator-fixture', serverVersionNum: 180004, inRecovery: false},
|
||||
roles: Object.fromEntries(['runtime', 'operator', 'capture', 'publisher', 'control'].map((name, i) => [name, {name: 'fixture_' + name, signedOid: String(20000 + i)}])),
|
||||
tls: {serverName: 'db.example.invalid', caSha256: 'b'.repeat(64), spkiSha256: 'c'.repeat(64)}})
|
||||
test('real distinct Ed25519 keypairs sign/verify and preserve externally pinned subject without authorization', () => {
|
||||
const value = request(), a = createBootstrapMaterial(value, 'd'.repeat(64)), b = createBootstrapMaterial(value, 'd'.repeat(64))
|
||||
const privateKey = createPrivateKey(a.privateKeyPem), publicKey = createPublicKey(a.publicKeyPem), message = Buffer.from('owned-key-roundtrip')
|
||||
assert(verify(null, message, publicKey, sign(null, message, privateKey))); assert(!verify(null, Buffer.from('changed'), publicKey, sign(null, message, privateKey)))
|
||||
assert.equal(hash(publicKey.export({format: 'der', type: 'spki'})), a.bootstrap.verificationSubject.publicKeySpkiSha256)
|
||||
assert.notEqual(a.publicKeyPem, b.publicKeyPem); assert.equal(a.bootstrap.verificationSubject.installationId, value.installationId)
|
||||
assert.equal(a.bootstrap.publicationAuthorized, false); assert.equal(a.bootstrap.readyAuthorized, false); assert.equal(a.bootstrap.approvedDefinitionSha256, null)
|
||||
assert.equal(a.bootstrap.databaseObserved, false); assert(!JSON.stringify(a.bootstrap).includes('PRIVATE KEY')); value.roles.runtime.name = 'changed'; assert.notEqual(a.bootstrap.deploymentRequest.roles.runtime.name, 'changed')
|
||||
})
|
||||
test('missing roles/head/transport pin and malformed identities refuse rather than invent trust', () => {
|
||||
for (const change of [v => delete v.roles.control, v => delete v.sourceHeadSha256, v => delete v.tls.spkiSha256, v => v.roles.operator = v.roles.runtime, v => v.source.databaseOid = 16384, v => v.source.inRecovery = true, v => v.extra = true]) {
|
||||
const value = request(); change(value); assert.throws(() => deploymentRequest(value))
|
||||
}
|
||||
})
|
||||
test('unknown/duplicate CLI options refuse without an implicit startup mode', async () => {
|
||||
for (const args of [[], ['publish'], ['init', '--output', '/x', '--output', '/y', '--deployment-sha256', 'd'.repeat(64)]]) await assert.rejects(main(args))
|
||||
})
|
||||
test('actual protected filesystem init, no-replace and mismatched digest; unsupported Windows writes nothing', async () => {
|
||||
const parent = await mkdtemp(path.join(tmpdir(), 'mm-bootstrap-init-')), output = path.join(parent, 'keys'), file = path.join(parent, 'deployment.json'), bytes = Buffer.from(JSON.stringify(request()))
|
||||
try {
|
||||
await chmod(parent, 0o700); await writeFile(file, bytes, {mode: 0o600})
|
||||
const options = {outputDirectory: output, deploymentFile: file, deploymentSha256: hash(bytes)}
|
||||
if (process.platform === 'win32') { await assert.rejects(initializeBootstrap(options), /Windows ACL backend unavailable/); assert.deepEqual(await readdir(parent), ['deployment.json']); return }
|
||||
await assert.rejects(initializeBootstrap({...options, deploymentSha256: '0'.repeat(64)})); assert.deepEqual(await readdir(parent), ['deployment.json'])
|
||||
const receipt = await initializeBootstrap(options); assert.equal(receipt.publicationAuthorized, false); assert(!JSON.stringify(receipt).includes('PRIVATE KEY'))
|
||||
assert.equal((await stat(output)).mode & 0o777, 0o700); assert.equal((await stat(path.join(output, 'signing-private.pem'))).mode & 0o777, 0o600)
|
||||
const first = await readFile(path.join(output, 'signing-private.pem')); await assert.rejects(initializeBootstrap(options), {code: 'EEXIST'}); assert.deepEqual(await readFile(path.join(output, 'signing-private.pem')), first)
|
||||
assert.deepEqual((await readdir(output)).sort(), ['bootstrap.json', 'operator-public.pem', 'signing-private.pem'])
|
||||
assert.equal(JSON.parse(await readFile(path.join(output, 'bootstrap.json'), 'utf8')).verificationSubject.publicKeySpkiSha256, receipt.publicKeySpkiSha256)
|
||||
} finally { await rm(parent, {recursive: true, force: true}) }
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Explicit operator process only. No HTTP, runtime import, role provisioning or READY. */
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import {pathToFileURL} from 'node:url'
|
||||
import {createPublicKey} from 'node:crypto'
|
||||
import {readProtected,protectedDirectory,sha} from './writer-profile/operator-files.mjs'
|
||||
import {approval,reconciliationApproval,poolOptions,roleKeys,assertJsDriverEnvironment} from './writer-profile/operator-contract.mjs'
|
||||
import {deploymentRequest} from './database-writer-bootstrap-operator.mjs'
|
||||
const hex=(v:unknown)=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v)
|
||||
const parse=(b:Buffer)=>JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(b))
|
||||
export async function main(args:string[]){
|
||||
const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal']
|
||||
assert(['publish','reconcile','preflight'].includes(args[0])&&args.length===1+2*flags.length)
|
||||
assertJsDriverEnvironment(process.env)
|
||||
const values=new Map<string,string>()
|
||||
for(let i=1;i<args.length;i+=2){assert(args[i].startsWith('--')&&flags.includes(args[i].slice(2))&&!values.has(args[i].slice(2))&&args[i+1]);values.set(args[i].slice(2),args[i+1])}
|
||||
const get=(name:string)=>values.get(name)!
|
||||
for(const flag of flags.filter(f=>f.endsWith('sha256')))assert(hex(get(flag)))
|
||||
const bootstrap=parse(await readProtected(get('bootstrap'),32768,get('bootstrap-sha256')))
|
||||
assert(bootstrap.format==='application-writer-bootstrap-v1'&&bootstrap.publicationAuthorized===false&&bootstrap.readyAuthorized===false)
|
||||
const request=deploymentRequest(bootstrap.deploymentRequest),subject=bootstrap.verificationSubject
|
||||
assert(subject.installationId===request.installationId&&hex(subject.publicKeySpkiSha256)&&sha(createPublicKey(subject.publicKeyPem).export({format:'der',type:'spki'}))===subject.publicKeySpkiSha256)
|
||||
const pinned=(args[0]==='reconcile'?reconciliationApproval:approval)(parse(await readProtected(get('approval'),16384,get('approval-sha256'))))
|
||||
const definition=parse(await readProtected(get('definition'),4194304,get('definition-sha256')))
|
||||
const transport=parse(await readProtected(get('transport'),16384,get('transport-sha256')))
|
||||
const ca=await readProtected(get('ca'),65536,request.tls.caSha256)
|
||||
const options=poolOptions(transport,request,ca)
|
||||
await protectedDirectory(get('journal'));await protectedDirectory(get('source-registry'))
|
||||
const privateKeyFile=path.join(path.dirname(get('bootstrap')),'signing-private.pem')
|
||||
if(args[0]==='publish'){const key=await readProtected(privateKeyFile,8192);key.fill(0)}
|
||||
// All explicit trust files are checked before app helper/PG imports. No .env URL is used.
|
||||
// Existing source helper loads config/dotenv. Complete it BEFORE pg's first
|
||||
// import so a newly loaded NODE_PG_FORCE_NATIVE cannot select another driver.
|
||||
const {createDatabaseSourceBindingVerifier}=await import('../src/db/database-source-binding.js')
|
||||
assertJsDriverEnvironment(process.env)
|
||||
const [{default:pg},{createConnectedPublisher},{collectSource44Catalogue,approvedDefinition}]=await Promise.all([
|
||||
import('pg'),import('./writer-profile/publisher.mjs'),import('./writer-profile/catalogue.mjs')])
|
||||
assertJsDriverEnvironment(process.env)
|
||||
assert.equal(approvedDefinition(definition),pinned.definitionSha256)
|
||||
const installation=Object.freeze({...subject,roles:Object.fromEntries(roleKeys.map(k=>[k,request.roles[k].signedOid])),roleBindings:request.roles,expectedSource:request.source})
|
||||
const verifier=createDatabaseSourceBindingVerifier({registryDirectory:get('source-registry'),installationId:subject.installationId,publicKeySpkiSha256:get('source-spki-sha256'),currentHeadSha256:request.sourceHeadSha256})
|
||||
const publisher=createConnectedPublisher(installation,(s:Parameters<typeof collectSource44Catalogue>[0])=>collectSource44Catalogue(s,installation),verifier,pinned)
|
||||
const pool=new pg.Pool(options)
|
||||
// A pool-level async error never becomes raw stdout or a retry signal.
|
||||
let asyncFailure=false;pool.on('error',()=>{asyncFailure=true})
|
||||
let result,primary:unknown,failed=false
|
||||
try{result=args[0]==='preflight'?await publisher.preflightCurrentProfile(pool,{deadlineAt:Date.now()+120000}):args[0]==='reconcile'?await publisher.reconcilePendingPublication(pool,{journalDirectory:get('journal'),deadlineAt:Date.now()+120000}):await publisher.publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256:pinned.expectedHeadSha256,journalDirectory:get('journal'),validityMs:pinned.validityMs,deadlineAt:Date.now()+120000})}
|
||||
catch(error){primary=error;failed=true}
|
||||
try{await pool.end()}catch(error){primary??=error;failed=true}
|
||||
if(args[0]==='preflight'&&result?.state==='matched'&&!failed&&!asyncFailure)return {event:'application-writer-profile-preflight',outcome:'matched',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,sourceMatched:true,rolesMatched:true,catalogueMatched:true,writerAuthorized:false,cleanupUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
|
||||
if(result?.outcome==='committed')return {event:'application-writer-profile-publication',outcome:'committed',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,
|
||||
cleanupUnconfirmed:result.cleanupUnconfirmed||asyncFailure||failed,journalUnconfirmed:!!result.journalUnconfirmed,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
|
||||
if(args[0]==='reconcile'&&result&&!failed&&!asyncFailure&&['idle','confirmed','not-committed'].includes(result.state))return {event:'application-writer-profile-reconciliation',outcome:result.state,headSha256:result.headSha256??null,cleanupUnconfirmed:false,journalUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
|
||||
throw primary??Error('Publisher terminal unconfirmed')
|
||||
}
|
||||
if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){
|
||||
try{const result=await main(process.argv.slice(2));console.log(JSON.stringify(result));if(result.cleanupUnconfirmed||result.journalUnconfirmed)process.exitCode=2}
|
||||
catch{console.error(JSON.stringify({event:'application-writer-profile-publication-refused',automaticReplay:false,inspectPendingBeforeRetry:true,readyAuthorized:false}));process.exitCode=1}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {generateKeyPairSync,createHash,X509Certificate,sign} from 'node:crypto'
|
||||
import {rootCertificates} from 'node:tls'
|
||||
import {createRequire} from 'node:module'
|
||||
import {approval,reconciliationApproval,poolOptions,STARTUP_OPTIONS,assertJsDriverEnvironment} from './writer-profile/operator-contract.mjs'
|
||||
import {bytes,canonical,POLICY_DOMAIN,signedEnvelope} from '../src/db/application-writer-profile-format.ts'
|
||||
const hash=(v:Buffer|string)=>createHash('sha256').update(v).digest('hex')
|
||||
const pinned=()=>({format:'application-writer-publication-approval-v1',manifestSha256:'a'.repeat(64),definitionSha256:'b'.repeat(64),catalogSha256:'c'.repeat(64),expectedHeadSha256:null,validityMs:86400000,expiresAt:Date.now()+60000})
|
||||
test('approval is explicit, exact, future-bounded and does not learn missing expected fields',()=>{
|
||||
const value=pinned();assert.deepEqual(approval(value),value)
|
||||
for(const change of [(v:any)=>delete v.definitionSha256,(v:any)=>delete v.catalogSha256,(v:any)=>v.expiresAt=Date.now(),(v:any)=>v.validityMs=86400001,(v:any)=>v.expectedHeadSha256='observed',(v:any)=>v.provider=true]){const v=pinned();change(v);assert.throws(()=>approval(v))}
|
||||
})
|
||||
test('isolated Pool1 options require pinned CA/host/role/database and native TLS verification',()=>{
|
||||
const ca=Buffer.from(rootCertificates[0]),request={roles:{publisher:{name:'installation_policy_signer'}},source:{databaseName:'owned'},tls:{serverName:'db.example.invalid',caSha256:hash(ca),spkiSha256:hash(new X509Certificate(ca).publicKey.export({format:'der',type:'spki'}))}}
|
||||
const transport={host:'db.example.invalid',port:5432,user:'installation_policy_signer',password:'test-only',database:'owned'},config=poolOptions(transport,request,ca)
|
||||
assert.equal(config.max,1);assert.equal(config.connectionTimeoutMillis,5000);assert.equal(config.ssl.rejectUnauthorized,true)
|
||||
assert(config.ssl.checkServerIdentity('attacker.invalid',new X509Certificate(ca).toLegacyObject()) instanceof Error)
|
||||
for(const changed of [{...transport,user:'admin'},{...transport,host:'attacker.invalid'},{...transport,database:'other'},{...transport,connectionString:'postgres://env-substitution'},{...transport,port:0}])assert.throws(()=>poolOptions(changed,request,ca))
|
||||
assert.throws(()=>poolOptions(transport,request,Buffer.from('not approved CA')))
|
||||
})
|
||||
test('real production envelope parser accepts operator-key signature and rejects tamper/foreign key',()=>{
|
||||
const now=Date.now(),{publicKey,privateKey}=generateKeyPairSync('ed25519'),installation={installationId:'788629ed-e00d-4af8-bc33-c705e5a7b62d',publicKeyPem:publicKey.export({type:'spki',format:'pem'}).toString(),publicKeySpkiSha256:hash(publicKey.export({type:'spki',format:'der'}))}
|
||||
const body={format:'application-writer-policy-v1',installationId:installation.installationId,epoch:'1',issuedAt:now,notBefore:now,notAfter:now+86400000,source:{systemIdentifier:'7348518042453055651',databaseOid:'16384',databaseName:'owned',serverVersionNum:180004,inRecovery:false},sourceObservationSha256:'a'.repeat(64),roles:{runtime:'20001',operator:'20002',capture:'20003',publisher:'20004',control:'20005'},manifestSha256:'b'.repeat(64),definitionSha256:'c'.repeat(64),catalogSha256:'d'.repeat(64),futureLeaves:'fixed12-native-policy-v1'}
|
||||
const signature=sign(null,Buffer.from(POLICY_DOMAIN+canonical(body)),privateKey).toString('base64url')
|
||||
assert.equal(signedEnvelope(bytes({body,signature}),'policy',installation).body.epoch,'1')
|
||||
assert.throws(()=>signedEnvelope(bytes({body:{...body,catalogSha256:'e'.repeat(64)},signature}),'policy',installation))
|
||||
const other=generateKeyPairSync('ed25519').publicKey;assert.throws(()=>signedEnvelope(bytes({body,signature}),'policy',{...installation,publicKeyPem:other.export({type:'spki',format:'pem'}).toString(),publicKeySpkiSha256:hash(other.export({type:'spki',format:'der'}))}))
|
||||
})
|
||||
test('actual pg ConnectionParameters cannot inherit malicious PGOPTIONS and native selection refuses',()=>{
|
||||
const require=createRequire(new URL('../package.json',import.meta.url)),ConnectionParameters=require('pg/lib/connection-parameters')
|
||||
const original=process.env.PGOPTIONS;process.env.PGOPTIONS='-c role=attacker -c search_path=attacker'
|
||||
try{
|
||||
const ca=Buffer.from(rootCertificates[0]),request={roles:{publisher:{name:'installation_policy_signer'}},source:{databaseName:'owned'},tls:{serverName:'db.example.invalid',caSha256:hash(ca),spkiSha256:'a'.repeat(64)}}
|
||||
const config=poolOptions({host:'db.example.invalid',port:5432,user:'installation_policy_signer',password:'test-only',database:'owned'},request,ca)
|
||||
const actual=new ConnectionParameters(config);assert.equal(actual.options,STARTUP_OPTIONS);assert(!actual.options.includes('attacker'));assert.equal(actual.user,'installation_policy_signer');assert.equal(actual.ssl.rejectUnauthorized,true)
|
||||
}finally{if(original===undefined)delete process.env.PGOPTIONS;else process.env.PGOPTIONS=original}
|
||||
assert.doesNotThrow(()=>assertJsDriverEnvironment({}));for(const value of ['1','0','',undefined])assert.throws(()=>assertJsDriverEnvironment({NODE_PG_FORCE_NATIVE:value}))
|
||||
})
|
||||
|
||||
test('reconciliation accepts expired original approval as copied inspection data; publication still refuses',()=>{
|
||||
const value={...pinned(),expiresAt:1},copy=reconciliationApproval(value)
|
||||
assert.deepEqual(copy,value);assert.notEqual(copy,value);assert.throws(()=>approval(value))
|
||||
copy.catalogSha256='d'.repeat(64);assert.equal(value.catalogSha256,'c'.repeat(64))
|
||||
const future=pinned();assert.deepEqual(approval(future),reconciliationApproval(future))
|
||||
})
|
||||
test('reconciliation keeps exact digest/head/schema and original lifetime bounds',()=>{
|
||||
for(const mutate of [(v:any)=>delete v.definitionSha256,(v:any)=>v.catalogSha256='observed',(v:any)=>v.expectedHeadSha256='foreign',(v:any)=>v.extra=true,(v:any)=>v.format='other',(v:any)=>v.validityMs=0,(v:any)=>v.validityMs=86400001,(v:any)=>v.expiresAt=0,(v:any)=>v.expiresAt=-1,(v:any)=>v.expiresAt=Number.MAX_SAFE_INTEGER+1]){
|
||||
const value={...pinned(),expiresAt:1};mutate(value);assert.throws(()=>reconciliationApproval(value));assert.throws(()=>approval(value))
|
||||
}
|
||||
})
|
||||
test('publication expiry guard remains mandatory at the real clock boundary',()=>{
|
||||
const value=pinned();value.expiresAt=Date.now();assert.throws(()=>approval(value));assert.deepEqual(reconciliationApproval(value),value)
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
// Candidate component tests. Actual signature/current reader, modeled PG/source/catalogue.
|
||||
// No genuine SQL, TLS session, filesystem ACL or generation acceptance is claimed.
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import {createRequire} from 'node:module'
|
||||
import {runInNewContext} from 'node:vm'
|
||||
import {EventEmitter} from 'node:events'
|
||||
import * as format from '../src/db/application-writer-profile-format.ts'
|
||||
import {readCurrentApplicationWriterProfile} from '../src/db/application-writer-profile-registry-read.ts'
|
||||
import {syntheticFixture,stagedModelClient} from '../src/db/application-writer-profile-registry-read-model.test.ts'
|
||||
const require=createRequire(new URL('../package.json',import.meta.url)),ts=require('typescript'),pg=require('pg')
|
||||
const candidate=await fs.readFile(new URL('./writer-profile/publisher.mjs',import.meta.url),'utf8')
|
||||
interface PreflightDiagnostics {state:string;headSha256:string;sourceMatched:boolean;rolesMatched:boolean;catalogueMatched:boolean;writerAuthorized:boolean;generationAuthenticated:boolean;readyAuthorized:boolean}
|
||||
interface PublisherModule {createConnectedPublisher(installation:unknown,collect:()=>Promise<unknown>,sourceVerifier:{verify:()=>Promise<void>},approval:unknown):{preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise<PreflightDiagnostics>}}
|
||||
interface OperatorModule {main(args:string[]):Promise<{event:string;outcome:string;writerAuthorized:boolean;readyAuthorized:boolean}>}
|
||||
function compile<T>(source:string,modules:Record<string,unknown>):T{
|
||||
const exports:Record<string,unknown>={};const compiled=ts.transpileModule(source.split('\nif(process.argv[1]')[0].replaceAll('import.meta.url',JSON.stringify('file:///candidate-not-cli.mts')),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
|
||||
runInNewContext(compiled,{exports,require:(name:string)=>{assert(name in modules,'Unapproved import '+name);return modules[name]},Buffer,URL,TextDecoder,Date,setTimeout,clearTimeout,Object,Error,AggregateError,Promise,Map,Set,process:{env:{},argv:[]}}, {filename:'candidate.mjs'})
|
||||
// The isolated VM loads the exact source above; tests exercise this narrow export contract.
|
||||
return exports as T
|
||||
}
|
||||
function fixture(fault=''){
|
||||
const f=syntheticFixture('1'),catalogue={catalogSha256:'f'.repeat(64),definitionSha256:f.policy.definitionSha256,leafCount:117},security={sha256:'e'.repeat(64),roles:{...f.policy.roles}}
|
||||
f.policy.catalogSha256=format.sha(format.canonical({base:catalogue.catalogSha256,security:security.sha256}))
|
||||
if(fault==='signed-source')f.policy.source.databaseOid='999'
|
||||
if(fault==='signed-role')f.policy.roles.operator='99'
|
||||
if(fault==='signed-catalogue')f.policy.catalogSha256='1'.repeat(64)
|
||||
if(fault==='signed-definition')f.policy.definitionSha256='1'.repeat(64)
|
||||
if(fault==='signed-manifest')f.policy.manifestSha256='1'.repeat(64)
|
||||
if(fault==='expired'){f.policy.issuedAt=Date.now()-60000;f.policy.notBefore=f.policy.issuedAt;f.policy.notAfter=Date.now()-1000}
|
||||
const raw=f.signBody(f.policy,format.POLICY_DOMAIN),hash=format.sha(raw),top=f.state.latest[0]
|
||||
top.policy_sha256=hash;top.head_bytes=f.signBody({format:'application-writer-head-v1',installationId:f.subject.installationId,epoch:'1',policySha256:hash,previousHeadSha256:null},format.HEAD_DOMAIN);top.head_sha256=format.sha(top.head_bytes)
|
||||
f.state.current[0].head_sha256=top.head_sha256;f.state.policy=[{policy_sha256:hash,policy_bytes:raw}]
|
||||
if(fault==='tamper')f.state.policy[0].policy_bytes=Buffer.from(raw.toString().replace('synthetic','tampered'))
|
||||
if(fault==='empty'){f.state.current=[];f.state.latest=[];f.state.policy=[]}
|
||||
const model=stagedModelClient(f.state),queries:string[]=[],events:string[]=[];let clocks=0
|
||||
const client=Object.assign(new EventEmitter(),{query:async(sql:string,values?:unknown[])=>{
|
||||
queries.push(sql)
|
||||
if(sql.includes('clock_timestamp()')&&++clocks===2&&fault==='late-clock')return {rows:[{now:'1'}]}
|
||||
if(sql.startsWith('WITH')||sql.includes('clock_timestamp()'))return model.client.query(sql,values)
|
||||
if(sql.startsWith('SELECT pg_catalog.pg_advisory_unlock'))return {rows:[{released:true}]}
|
||||
if(sql==='ROLLBACK')return {rows:[],command:fault==='rollback-ack'?'':'ROLLBACK'}
|
||||
return {rows:[],command:sql.split(' ')[0]}
|
||||
},release:(bad:boolean)=>{events.push('release:'+bad)}})
|
||||
const pool=new pg.Pool();pool.connect=async()=>client
|
||||
const source={systemIdentifier:'123',databaseOid:'456',databaseName:'synthetic',serverVersionNum:180004,inRecovery:false}
|
||||
let contexts=0,verifications=0
|
||||
const installation={...f.subject,expectedSource:source,roleBindings:{publisher:{name:'publisher',signedOid:'14'}}}
|
||||
const approval={expiresAt:Date.now()+60000,manifestSha256:'b'.repeat(64),definitionSha256:catalogue.definitionSha256,catalogSha256:format.sha(format.canonical({base:catalogue.catalogSha256,security:security.sha256}))}
|
||||
const modules={
|
||||
pg,'node:crypto':require('node:crypto'),'node:fs/promises':{},'node:path':require('node:path'),
|
||||
'./operator-files.mjs':{withJournalLease:()=>{throw Error('Preflight must not use publication journal')},writeFile:()=>{throw Error('No publication writes')}},
|
||||
'../../src/db/database-source-observation.js':{captureDatabaseSourceObservation:async()=>{events.push('capture-source');return {}},assertDatabaseSourceObservationCurrent:async()=>{events.push('fresh-source');if(fault==='late-source')throw Error('Source changed')}},
|
||||
'../../src/db/migration-manifest.js':{MIGRATION_MANIFEST:[],migrationManifestDigest:()=> 'b'.repeat(64)},
|
||||
'../../src/db/application-writer-profile-format.js':format,
|
||||
'./context.mjs':{ownedContext:async()=>{contexts++;if(fault==='late-actor'&&contexts===2)throw Error('Actor changed');return {pid:1,source}},sameOwned:(a:unknown,b:unknown)=>assert.deepEqual(a,b),sourceCoordinates:(v:{source:typeof source})=>v.source,protectedStructure:async()=>{events.push('roles');if(fault==='role-structure')throw Error('Unsafe role');return security},readCurrent:readCurrentApplicationWriterProfile,HISTORY:'history',CURRENT:'current'},
|
||||
}
|
||||
const api=compile<PublisherModule>(candidate,modules).createConnectedPublisher(installation,async()=>{events.push('catalogue');if(fault==='catalogue-eof')throw Error('Incomplete EOF');return catalogue},{verify:async()=>{verifications++;events.push('verify-source');if(fault==='source-verifier')throw Error('Unsigned source');if(fault==='late-expiry'&&verifications===2)approval.expiresAt=1}},approval)
|
||||
return {run:()=>api.preflightCurrentProfile(pool,{deadlineAt:Date.now()+30000}),queries,events,f}
|
||||
}
|
||||
test('current44 preflight authenticates signed current and actual component inputs on one read-only lease',async()=>{
|
||||
const f=fixture(),result=await f.run();assert.equal(result.state,'matched');assert.equal(result.headSha256,f.f.state.current[0].head_sha256)
|
||||
assert.equal(result.sourceMatched,true);assert.equal(result.rolesMatched,true);assert.equal(result.catalogueMatched,true)
|
||||
assert.equal(result.writerAuthorized,false);assert.equal(result.generationAuthenticated,false);assert.equal(result.readyAuthorized,false)
|
||||
assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1);assert(f.queries.includes('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'));assert.equal(f.queries.filter(q=>q==='ROLLBACK').length,1)
|
||||
assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.events.at(-1),'release:false')
|
||||
assert.deepEqual(f.events.slice(0,6),['capture-source','verify-source','roles','catalogue','verify-source','fresh-source'])
|
||||
})
|
||||
for(const fault of ['tamper','empty','expired','signed-source','signed-role','signed-catalogue','signed-definition','signed-manifest','role-structure','catalogue-eof','source-verifier','late-source','late-actor','late-expiry','late-clock','rollback-ack'])test('preflight refuses '+fault+' without any write or replay',async()=>{
|
||||
const f=fixture(fault);await assert.rejects(f.run());assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1)
|
||||
})
|
||||
test('existing operator main dispatches preflight only after protected input validation, without private key or publication',async()=>{
|
||||
const cli=await fs.readFile(new URL('./database-writer-profile-operator.mts',import.meta.url),'utf8'),f=syntheticFixture('1'),reads:string[]=[],events:string[]=[]
|
||||
const request={installationId:f.subject.installationId,roles:Object.fromEntries(['runtime','operator','capture','publisher','control'].map((k,i)=>[k,{name:k,signedOid:String(i+11)}])),source:f.policy.source,sourceHeadSha256:'a'.repeat(64),tls:{caSha256:'a'.repeat(64)}}
|
||||
const files:Record<string,unknown>={bootstrap:{format:'application-writer-bootstrap-v1',publicationAuthorized:false,readyAuthorized:false,deploymentRequest:request,verificationSubject:f.subject},approval:{expiresAt:Date.now()+60000,definitionSha256:'b'.repeat(64)},definition:{},transport:{},ca:'fixture'}
|
||||
let failEnd=false,asyncError=false
|
||||
const retainedPools:Pool[]=[]
|
||||
class Pool extends EventEmitter{constructor(){super();retainedPools.push(this)}async end(){events.push('pool-end');if(failEnd)throw Error('Pool cleanup unconfirmed')}}
|
||||
const result={state:'matched',epoch:'1',headSha256:'c'.repeat(64),notAfter:Date.now()+60000}
|
||||
const modules={
|
||||
'node:assert/strict':assert,'node:path':require('node:path'),'node:url':require('node:url'),'node:crypto':require('node:crypto'),
|
||||
'./writer-profile/operator-files.mjs':{readProtected:async(file:string)=>{reads.push(file);assert(file in files);return Buffer.from(JSON.stringify(files[file]))},protectedDirectory:async()=>{},sha:format.sha},
|
||||
'./writer-profile/operator-contract.mjs':{approval:(value:unknown)=>{events.push('approval');return value},reconciliationApproval:()=>{throw Error('No expired reconciliation bypass')},poolOptions:()=>{events.push('transport');return {}},roleKeys:['runtime','operator','capture','publisher','control'],assertJsDriverEnvironment:()=>{}},
|
||||
'./database-writer-bootstrap-operator.mjs':{deploymentRequest:(value:unknown)=>value},'../src/db/database-source-binding.js':{createDatabaseSourceBindingVerifier:()=>({})},
|
||||
pg:{Pool},'./writer-profile/publisher.mjs':{createConnectedPublisher:()=>({preflightCurrentProfile:async()=>{events.push('preflight');if(asyncError){const activePool=retainedPools.at(-1);assert(activePool);activePool.emit('error',Error('Async pool fault'))}return result},publishCurrentProfile:()=>{throw Error('No publish')},reconcilePendingPublication:()=>{throw Error('No reconcile')}})},
|
||||
'./writer-profile/catalogue.mjs':{collectSource44Catalogue:()=>{},approvedDefinition:()=> 'b'.repeat(64)},
|
||||
}
|
||||
const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal'],args=['preflight',...flags.flatMap(flag=>['--'+flag,flag.endsWith('sha256')?'a'.repeat(64):flag])]
|
||||
const output=await compile<OperatorModule>(cli,modules).main(args)
|
||||
assert.equal(output.event,'application-writer-profile-preflight');assert.equal(output.outcome,'matched');assert.equal(output.writerAuthorized,false);assert.equal(output.readyAuthorized,false)
|
||||
assert.deepEqual(reads,['bootstrap','approval','definition','transport','ca']);assert.deepEqual(events,['approval','transport','preflight','pool-end'])
|
||||
failEnd=true;await assert.rejects(compile<OperatorModule>(cli,modules).main(args),/Pool cleanup unconfirmed/)
|
||||
failEnd=false;asyncError=true;await assert.rejects(compile<OperatorModule>(cli,modules).main(args),/Publisher terminal unconfirmed/)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
if(!process.send)throw Error('Owned profile load client requires an IPC parent')
|
||||
function requireLoopback(value){const url=new URL(value);if(url.protocol!=='http:'||!['127.0.0.1','localhost','[::1]'].includes(url.hostname)||url.username||url.password)throw Error('Owned test client only permits localhost HTTP')}
|
||||
import {Client,StreamableHTTPClientTransport} from '@modelcontextprotocol/client'
|
||||
import {getHeapStatistics} from 'node:v8'
|
||||
const clients=new Map(),requests=new Map(),streams=new Map();let serial=0,peakRss=0,peakHeap=0
|
||||
const sample=()=>{const m=process.memoryUsage();peakRss=Math.max(peakRss,m.rss);peakHeap=Math.max(peakHeap,m.heapUsed);return{memory:m,peakRss,peakHeap,maxRssBytes:process.resourceUsage().maxRSS*1024,effectiveHeapSizeLimitBytes:getHeapStatistics().heap_size_limit}}
|
||||
const timer=setInterval(sample,20)
|
||||
const summarize=result=>{const row=result.structuredContent?.result??{};return{isError:result.isError===true,structuredContent:{result:{dataQuality:row.dataQuality??null,collectorQualityUnavailableReason:row.collectorQualityUnavailableReason,retainedRowsBytes:row.retainedRowsBytes,analyticsBytes:row.analytics?.bytes,eventBytes:row.events?.map(x=>x.data?.bytes),exporters:row.exporters?.length,clients:row.clients?.length,month:row.month}},hiddenSourceLeaked:JSON.stringify(result).includes('HIDDEN_MEMORY_SOURCE'),encodedBytes:Buffer.byteLength(JSON.stringify(result)),error:result.isError?result.content:undefined}}
|
||||
async function run(message){
|
||||
const p=message.payload??{}
|
||||
if(p.endpoint)requireLoopback(p.endpoint);if(p.url)requireLoopback(p.url)
|
||||
if(message.operation==='connect'){
|
||||
const client=new Client({name:'external-owned-memory-client',version:'1'});await client.connect(new StreamableHTTPClientTransport(new URL(p.endpoint+'/mcp'),{requestInit:{headers:{authorization:'Bearer '+p.token}}}));const id=++serial;clients.set(id,client);return{id}
|
||||
}
|
||||
if(message.operation==='tool')return summarize(await clients.get(p.clientId).callTool(p.input))
|
||||
if(message.operation==='close'){await clients.get(p.clientId)?.close();clients.delete(p.clientId);return{closed:true}}
|
||||
if(message.operation==='sse-open'){
|
||||
const abort=new AbortController(),response=await fetch(p.url,{signal:abort.signal});if(response.status!==200)throw Error('SSE status '+response.status)
|
||||
const reader=response.body.getReader(),decoder=new TextDecoder();let text=''
|
||||
for(;;){const chunk=await reader.read();if(chunk.done)throw Error('SSE closed before sample');text+=decoder.decode(chunk.value,{stream:true});const match=/event: (sample|error)\ndata: ([^\n]+)\n\n/.exec(text);if(match){if(match[1]!=='sample')throw Error('SSE source unavailable');const body=JSON.parse(match[2]),id=++serial;streams.set(id,{abort,reader});return{id,bytes:body.bytes}}}
|
||||
}
|
||||
if(message.operation==='sse-close'){const stream=streams.get(p.streamId);stream?.abort.abort();await stream?.reader.cancel().catch(()=>{});streams.delete(p.streamId);return{closed:true}}
|
||||
if(message.operation==='http'){
|
||||
const abort=new AbortController();requests.set(message.id,abort)
|
||||
try{const response=await fetch(p.url,{...p.options,signal:abort.signal}),body=await response.arrayBuffer();let summary;if(response.status===200&&(p.url.includes('/exporters')||p.url.includes('/analytics'))){const parsed=JSON.parse(new TextDecoder().decode(body));summary={exporters:parsed.exporters?.length,bytes:parsed.bytes}}return{status:response.status,bytes:body.byteLength,summary,text:body.byteLength<4096?new TextDecoder().decode(body):''}}
|
||||
finally{requests.delete(message.id)}
|
||||
}
|
||||
if(message.operation==='batch'){
|
||||
const response=await fetch(p.endpoint+'/mcp',{method:'POST',headers:{authorization:'Bearer '+p.token,'content-type':'application/json',accept:'application/json, text/event-stream','mcp-protocol-version':'2025-06-18'},body:JSON.stringify([1,2].map(id=>({jsonrpc:'2.0',id,method:'tools/call',params:{name:'mm_netflow_analytics_report_get',arguments:{serverId:1,minutes:5}}})))})
|
||||
if(response.status!==200)throw Error('Batch HTTP status '+response.status)
|
||||
const reader=response.body.getReader(),decoder=new TextDecoder(),messages=[];let text=''
|
||||
for(;;){const chunk=await reader.read();if(chunk.done)break;text+=decoder.decode(chunk.value,{stream:true});let end;while((end=text.indexOf('\n\n'))>=0){const frame=text.slice(0,end);text=text.slice(end+2);for(const line of frame.split('\n'))if(line.startsWith('data: ')){const row=JSON.parse(line.slice(6));messages.push(row);if(row.error||row.result?.isError)process.send?.({type:'client-progress',id:message.id,refusals:1})}}}
|
||||
if(messages.length!==2)throw Error('Expected two batch replies')
|
||||
return{successes:messages.filter(row=>!row.error&&!row.result?.isError).length,refusals:messages.filter(row=>row.error||row.result?.isError).length}
|
||||
}
|
||||
throw Error('Unknown external client operation')
|
||||
}
|
||||
process.on('message',async message=>{
|
||||
if(message.type==='abort'){requests.get(message.requestId)?.abort();return}
|
||||
if(message.type==='shutdown'){for(const abort of requests.values())abort.abort();for(const stream of streams.values())stream.abort.abort();for(const client of clients.values())try{await client.close()}catch{}clearInterval(timer);process.send?.({type:'client-report',report:sample()},()=>process.exit(0));return}
|
||||
if(message.type!=='client-command')return
|
||||
try{const result=await run(message);process.send?.({type:'client-result',id:message.id,ok:true,result})}
|
||||
catch(error){process.send?.({type:'client-result',id:message.id,ok:false,error:error instanceof Error?error.message:String(error)})}
|
||||
})
|
||||
process.send?.({type:'client-ready',report:sample()})
|
||||
@@ -0,0 +1,39 @@
|
||||
import Database from "better-sqlite3"
|
||||
import fs from "node:fs"
|
||||
|
||||
const path = process.argv[2] ?? "./mikrotik.db"
|
||||
if (!fs.existsSync(path)) {
|
||||
console.error("NO_SQLITE", path)
|
||||
process.exit(2)
|
||||
}
|
||||
const st = fs.statSync(path)
|
||||
const db = new Database(path, { readonly: true, fileMustExist: true })
|
||||
const tables = db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY 1`)
|
||||
.all()
|
||||
console.log("FILE_BYTES", st.size)
|
||||
console.log("TABLES", tables.length)
|
||||
for (const t of tables) {
|
||||
const c = db.prepare(`SELECT COUNT(*) AS c FROM "${t.name}"`).get()
|
||||
console.log(`COUNT\t${t.name}\t${c.c}`)
|
||||
}
|
||||
try {
|
||||
const d = db.prepare(`SELECT COUNT(*) AS c FROM (
|
||||
SELECT dedupe_key FROM alert_outbox GROUP BY dedupe_key HAVING COUNT(*) > 1
|
||||
)`).get()
|
||||
console.log("DUP_DEDUPE_KEYS", d.c)
|
||||
} catch (e) {
|
||||
console.log("DUP_DEDUPE_ERR", e instanceof Error ? e.message : e)
|
||||
}
|
||||
for (const [label, sql] of [
|
||||
["ORPHAN_FLOW_MINUTE", `SELECT COUNT(*) c FROM flow_minute_stats s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
|
||||
["ORPHAN_FLOW_BUCKETS", `SELECT COUNT(*) c FROM flow_buckets s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
|
||||
["ORPHAN_BINDINGS", `SELECT COUNT(*) c FROM user_interface_bindings s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
|
||||
]) {
|
||||
try {
|
||||
console.log(label, db.prepare(sql).get().c)
|
||||
} catch (e) {
|
||||
console.log(label + "_ERR", e instanceof Error ? e.message : e)
|
||||
}
|
||||
}
|
||||
db.close()
|
||||
@@ -0,0 +1,134 @@
|
||||
/** External SDK consumer: imports NO server/store/runtime/database module. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { randomUUID, createHash } from 'node:crypto'
|
||||
import { Client, StreamableHTTPClientTransport, auth } from '@modelcontextprotocol/client'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
|
||||
const endpoint = new URL(process.env.OWN_MCP_ENDPOINT), origin = endpoint.origin
|
||||
assert.equal(endpoint.hostname, '127.0.0.1'); assert.equal(process.env.OWN_EXTERNAL_CLIENT, 'true')
|
||||
let phase = 'start', next = 0, maxResponseBytes = 0, stderrBytes = 0, bridgePid = null
|
||||
const pending = new Map(), cases = []
|
||||
const activeClients = new Set()
|
||||
let closing = false
|
||||
async function closeClients() { await Promise.allSettled([...activeClients].map(client => client.close())) }
|
||||
async function interrupted() { if (closing) return; closing = true; await closeClients(); process.disconnect?.(); process.exitCode = 1 }
|
||||
process.on('SIGTERM', interrupted); process.on('SIGINT', interrupted); process.on('disconnect', interrupted)
|
||||
process.on('message', m => { if (m?.kind === 'reply') { const p = pending.get(m.id); pending.delete(m.id); if (m.error) p?.reject(Error('Fixture control refused')); else p?.resolve(m.value) } })
|
||||
const control = command => new Promise((resolve, reject) => { const id = ++next; pending.set(id, { resolve, reject }); process.send({ kind: 'control', command, id }) })
|
||||
async function boundedFetch(input, init) {
|
||||
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url)
|
||||
assert.equal(url.origin, origin, 'External fixture refuses all nonlocal origins')
|
||||
const response = await fetch(input, { ...init, redirect: 'manual', signal: init?.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(5000)]) : AbortSignal.timeout(5000) })
|
||||
const chunks = []; let count = 0
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
for (;;) { const item = await reader.read(); if (item.done) break; count += item.value.length; if (count >= 1048576) { await reader.cancel(); throw Error('Own external client response bound') }; chunks.push(item.value) }
|
||||
} finally { reader.releaseLock() }
|
||||
}
|
||||
const bytes = Buffer.concat(chunks); maxResponseBytes = Math.max(maxResponseBytes, bytes.length)
|
||||
return new Response(bytes.length ? bytes : null, { status: response.status, statusText: response.statusText, headers: response.headers })
|
||||
}
|
||||
function content(result) {
|
||||
assert(!result.isError)
|
||||
const text = result.content.find(c => c.type === 'text')?.text
|
||||
const parsed = JSON.parse(text)
|
||||
assert(!text.includes(process.env.OWN_SERVER_SECRET)); assert(Buffer.byteLength(text) < 65536)
|
||||
return parsed
|
||||
}
|
||||
async function denied(work) { try { const r = await work(); assert.equal(r?.isError, true) } catch (e) { if (e?.code === 'ERR_ASSERTION') throw e; assert(!/timeout|timed out/i.test(String(e?.message)), 'A timeout is not authorization denial') } }
|
||||
async function opened(mode, token) {
|
||||
const client = new Client({ name: 'actual-external-process-' + mode, version: '1.0.0' })
|
||||
activeClients.add(client)
|
||||
let transport
|
||||
if (mode === 'http') transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { authorization: 'Bearer ' + token } }, fetch: boundedFetch })
|
||||
else {
|
||||
transport = new StdioClientTransport({ command: process.execPath, args: ['--import', import.meta.resolve('tsx'), process.env.OWN_BRIDGE_FILE],
|
||||
cwd: process.cwd(), env: { MCP_URL: endpoint.href, MCP_TOKEN: token }, stderr: 'pipe', maxBufferSize: 131072 })
|
||||
transport.stderr?.on('data', chunk => { stderrBytes += chunk.length; const text = String(chunk); assert(!text.includes(token)); assert(stderrBytes < 1048576) })
|
||||
const start = transport.start.bind(transport)
|
||||
transport.start = async () => { await start(); bridgePid = transport.pid; assert(bridgePid && bridgePid !== process.pid && bridgePid !== Number(process.env.OWN_SERVER_PID)); process.send({ kind: 'bridge-pid', pid: bridgePid }) }
|
||||
}
|
||||
const connecting = client.connect(transport, { timeout: 10000 })
|
||||
try { await connecting; return client }
|
||||
catch (e) { await client.close().catch(() => {}); throw e }
|
||||
}
|
||||
async function scoped(client) {
|
||||
const list = await client.listTools({}, { timeout: 5000 })
|
||||
assert.deepEqual(list.tools.map(t => t.name), ['mm_servers_list'])
|
||||
const result = content(await client.callTool({ name: 'mm_servers_list', arguments: { limit: 1, offset: 0 } }, undefined, { timeout: 5000 }))
|
||||
assert.equal(result.items.length, 1); assert.equal(result.items[0].id, Number(process.env.OWN_ALLOWED_SERVER))
|
||||
await denied(() => client.callTool({ name: 'mm_servers_list', arguments: { serverId: Number(process.env.OWN_FORBIDDEN_SERVER) } }, undefined, { timeout: 5000 }))
|
||||
await denied(() => client.callTool({ name: 'mm_servers_list', arguments: { limit: 501 } }, undefined, { timeout: 5000 }))
|
||||
await denied(() => client.callTool({ name: 'mm_servers_delete_plan', arguments: { serverId: Number(process.env.OWN_ALLOWED_SERVER) } }, undefined, { timeout: 5000 }))
|
||||
}
|
||||
try {
|
||||
assert.notEqual(process.pid, Number(process.env.OWN_SERVER_PID))
|
||||
for (const mode of ['http', 'stdio']) {
|
||||
phase = mode + ':issue'; const initial = await control('new-connection'); let token = initial.token
|
||||
phase = mode + ':initialize'; let client = await opened(mode, token)
|
||||
try {
|
||||
await scoped(client); cases.push(mode + ': separate SDK process initializes, filters tool/host scope, bounds page/result and redacts credentials')
|
||||
if (mode === 'stdio' && process.env.OWN_EXTERNAL_CLEANUP_FAULT === 'failure') throw Error('Owned deliberate external client failure with bridge active')
|
||||
if (mode === 'stdio' && process.env.OWN_EXTERNAL_CLEANUP_FAULT === 'timeout') { process.send({ kind: 'arm-cleanup-timeout' }); await new Promise(() => {}) }
|
||||
phase = mode + ':result-bound'; await control('large-result')
|
||||
try {
|
||||
const bounded = content(await client.callTool({ name: 'mm_servers_list', arguments: { limit: 1 } }, undefined, { timeout: 5000 }))
|
||||
assert.equal(bounded.items[0].comment.length, 16000); assert(!bounded.items[0].comment.includes('OWNED_TRUNCATION_TAIL'))
|
||||
cases.push(mode + ': actual production public-string16k bound truncates owned40k comment through real SDK result')
|
||||
} finally { await control('reset-result') }
|
||||
phase = mode + ':rotate'; const rotated = await control('rotate'); await denied(() => client.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 }))
|
||||
await client.close(); await assert.rejects(opened(mode, token)); token = rotated.token; client = await opened(mode, token)
|
||||
content(await client.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 })); cases.push(mode + ': old credential and reconnect refused after rotation; fresh transport reconnect succeeds')
|
||||
phase = mode + ':pause'; await control('pause'); await denied(() => client.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 }))
|
||||
await control('resume'); content(await client.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 })); cases.push(mode + ': established client observes pause/resume on real requests')
|
||||
phase = mode + ':revoke'; await control('revoke'); await denied(() => client.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 }))
|
||||
await client.close(); await assert.rejects(opened(mode, token)); cases.push(mode + ': permanent revocation blocks established and newly initialized external clients')
|
||||
} finally { await client.close().catch(() => {}) }
|
||||
}
|
||||
phase = 'http:body-bound'; const bound = await control('new-connection')
|
||||
const oversized = await fetch(endpoint, { method: 'POST', headers: { authorization: 'Bearer ' + bound.token, 'content-type': 'application/json', accept: 'application/json,text/event-stream' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'mm_servers_list', arguments: { padding: 'x'.repeat(1048576) } } }), signal: AbortSignal.timeout(5000) })
|
||||
assert.equal(oversized.status, 413); assert((await oversized.text()).length < 65536)
|
||||
cases.push('actual HTTP request envelope over1MiB refused before tool execution; successful client results remain bounded')
|
||||
|
||||
phase = 'sdk:pkce'; const grant = await control('new-connection')
|
||||
const metadata = { client_name: 'owned-external-sdk-pkce', redirect_uris: [origin + '/owned-callback'], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', scope: 'mcp' }
|
||||
const registered = await boundedFetch(origin + '/api/mcp/oauth/clients', { method: 'POST', headers: { authorization: 'Bearer ' + process.env.OWN_PORTAL_JWT, 'content-type': 'application/json' }, body: JSON.stringify({ name: metadata.client_name, redirectUris: metadata.redirect_uris }) })
|
||||
assert.equal(registered.status, 200); const clientId = (await registered.json()).clientId
|
||||
let tokens, verifier, authorizationCode, authorizationIssuer, discovery, consentAllowed = true
|
||||
const state = randomUUID()
|
||||
const provider = { redirectUrl: metadata.redirect_uris[0], clientMetadata: metadata, clientInformation: () => ({ client_id: clientId, token_endpoint_auth_method: 'none' }),
|
||||
tokens: () => tokens, saveTokens: v => { tokens = v }, saveCodeVerifier: v => { verifier = v }, codeVerifier: () => verifier, state: () => state,
|
||||
discoveryState: () => discovery, saveDiscoveryState: v => { discovery = v }, invalidateCredentials: scope => { if (scope === 'tokens') tokens = undefined },
|
||||
redirectToAuthorization: async url => {
|
||||
if (!consentAllowed) throw Error('Synthetic consent disabled after revocation')
|
||||
assert.equal(url.origin, origin); assert.equal(url.searchParams.get('code_challenge_method'), 'S256')
|
||||
assert.equal(url.searchParams.get('code_challenge'), createHash('sha256').update(verifier).digest('base64url'))
|
||||
const response = await boundedFetch(url); assert.equal(response.status, 302)
|
||||
const requestId = new URL(response.headers.get('location')).searchParams.get('oauthRequest'); assert(requestId)
|
||||
const consent = await boundedFetch(origin + '/api/mcp/oauth/consent', { method: 'POST', headers: { authorization: 'Bearer ' + process.env.OWN_PORTAL_JWT, 'content-type': 'application/json' }, body: JSON.stringify({ requestId, connectionId: grant.connectionId, approve: true }) })
|
||||
assert.equal(consent.status, 200); const callback = new URL((await consent.json()).redirectUrl)
|
||||
assert.equal(callback.searchParams.get('state'), state); authorizationCode = callback.searchParams.get('code'); authorizationIssuer = callback.searchParams.get('iss')
|
||||
} }
|
||||
phase = 'sdk:pkce-redirect'; assert.equal(await auth(provider, { serverUrl: endpoint, scope: 'mcp', fetchFn: boundedFetch }), 'REDIRECT')
|
||||
assert(authorizationCode && verifier); assert.equal(authorizationIssuer, origin)
|
||||
phase = 'sdk:pkce-exchange'; assert.equal(await auth(provider, { serverUrl: endpoint, authorizationCode, iss: authorizationIssuer, fetchFn: boundedFetch }), 'AUTHORIZED')
|
||||
assert(tokens?.access_token && tokens?.refresh_token)
|
||||
const oauthClient = new Client({ name: 'external-sdk-real-pkce-mock-portal', version: '1.0.0' })
|
||||
activeClients.add(oauthClient)
|
||||
try {
|
||||
phase = 'sdk:pkce-initialize'; await oauthClient.connect(new StreamableHTTPClientTransport(endpoint, { authProvider: provider, fetch: boundedFetch }), { timeout: 10000 })
|
||||
content(await oauthClient.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 }))
|
||||
cases.push('installed SDK auth() discovers actual OAuth metadata, produces PKCE/S256, exchanges one-use code and calls scoped tool; portal JWT/consent is synthetic')
|
||||
phase = 'sdk:pkce-revoke'; const access = tokens.access_token, refresh = tokens.refresh_token
|
||||
const revoked = await boundedFetch(origin + '/oauth/revoke', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: refresh, client_id: clientId }).toString() }); assert.equal(revoked.status, 200)
|
||||
consentAllowed = false; await assert.rejects(opened('http', access))
|
||||
await denied(() => oauthClient.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 }))
|
||||
cases.push('actual OAuth family revocation refuses established and fresh external transport; no implicit new consent granted')
|
||||
} finally { await oauthClient.close().catch(() => {}) }
|
||||
process.send({ kind: 'result', result: { cases: cases.length, names: cases, externalClientPid: process.pid, serverPid: Number(process.env.OWN_SERVER_PID), bridgePid,
|
||||
sdkOnlyExternalClient: true, maxObservedHttpResponseBytes: maxResponseBytes, clientResponseLimitBytes: 1048576, stdioSdkBufferLimitBytes: 131072,
|
||||
actualPublicStringLimitObserved: 16000, wholeGenericResultEnvelopeLimitAccepted: false,
|
||||
syntheticPortalJwtAndConsent: true, liveAuthPortal: false, livePortalSender: false, endUserUi: false, wholeExternalDirectionComplete: false } })
|
||||
} catch (error) { process.send?.({ kind: 'failure', phase, errorType: error?.name ?? 'Error', completedCases: cases }); process.exitCode = 1 }
|
||||
finally { closing = true; await closeClients(); process.disconnect?.() }
|
||||
@@ -0,0 +1,36 @@
|
||||
import EmbeddedPostgres from 'embedded-postgres'
|
||||
import { mkdtemp, rm, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { randomBytes, createHash } from 'node:crypto'
|
||||
import { createServer } from 'node:net'
|
||||
import { spawn } from 'node:child_process'
|
||||
const sourceRoot = resolve(process.argv[2] ?? fileURLToPath(new URL('../../../', import.meta.url)))
|
||||
const output = resolve(sourceRoot, 'tmp/mcp-external-process-acceptance'); await mkdir(output, { recursive: true })
|
||||
const paths = ['backend/src/scripts/mcp-stdio.ts', 'backend/src/routes/mcp-transport.ts', 'backend/src/routes/mcp-oauth.ts', 'backend/src/modules/mcp/store.ts',
|
||||
'backend/src/modules/mcp/runtime.ts', 'backend/src/modules/mcp/actions.ts', 'backend/src/modules/mcp/security.ts', 'backend/src/modules/mcp/protocol-limits.ts',
|
||||
'backend/src/modules/mcp/portal-revocation.ts', 'backend/src/plugins/auth.ts', 'backend/src/index.ts', 'node_modules/@modelcontextprotocol/client/package.json', 'node_modules/@modelcontextprotocol/server/package.json']
|
||||
async function pins() { const result = []; for (const path of paths) result.push({ path, sha256: createHash('sha256').update(await readFile(resolve(sourceRoot, path))).digest('hex') }); return result }
|
||||
const before = await pins(), directory = await mkdtemp(join(resolve(sourceRoot, 'tmp'), 'mm-mcp-external-')), state = join(directory, 'state'); await mkdir(state)
|
||||
const listener = createServer(); await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)); const port = listener.address().port; await new Promise(resolve => listener.close(resolve))
|
||||
const password = randomBytes(24).toString('hex')
|
||||
const postgres = new EmbeddedPostgres({ databaseDir: join(directory, 'pg'), port, user: 'mcp_test', password, persistent: true, createPostgresUser: false,
|
||||
initdbFlags: ['--encoding=UTF8'], postgresFlags: ['-h', '127.0.0.1'], onLog: () => {}, onError: () => {} })
|
||||
try {
|
||||
await postgres.initialise(); await postgres.start()
|
||||
process.exitCode = await new Promise((done, reject) => {
|
||||
const child = spawn(process.execPath, ['--import', import.meta.resolve('tsx'), fileURLToPath(new URL('./server-fixture.ts', import.meta.url))], { cwd: directory,
|
||||
windowsHide: true, stdio: 'inherit', env: { ...Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path'].filter(k => process.env[k]).map(k => [k, process.env[k]])),
|
||||
OWN_EXTERNAL_CLEANUP_FAULT: process.env.OWN_EXTERNAL_CLEANUP_FAULT, OWN_EXTERNAL_SOURCE_ROOT: sourceRoot, OWN_EXTERNAL_RESULT_FILE: join(output, 'result.json'), NODE_ENV: 'test', OWN_EXTERNAL_PROCESS_PG: 'true', DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`, DATABASE_PATH: join(directory, 'no-legacy.sqlite'),
|
||||
MCP_STATE_DIR: state, AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: 'owned-external-synthetic-issuer-test-only', AUTH_ISSUER: 'https://synthetic-portal.invalid', AUTH_PORTAL_URL: 'https://synthetic-portal.invalid' } })
|
||||
child.once('error', reject); child.once('exit', code => done(code ?? 1))
|
||||
})
|
||||
const after = await pins(), unchanged = JSON.stringify(before) === JSON.stringify(after)
|
||||
await writeFile(join(output, 'source-pins.json'), JSON.stringify({ before, after, unchanged, exitCode: process.exitCode }, null, 2))
|
||||
console.log('External-client source/SDK pins unchanged:', unchanged); if (!unchanged) process.exitCode = 1
|
||||
} finally {
|
||||
await postgres.stop(); const target = resolve(directory), rel = relative(resolve(resolve(sourceRoot, 'tmp')), target)
|
||||
if (!rel || rel.startsWith('..') || isAbsolute(rel) || !target.includes('mm-mcp-external-')) throw Error('Unsafe exact owned cleanup')
|
||||
await rm(target, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 }); console.log('Owned external-client PostgreSQL stopped and exact temp directory removed')
|
||||
}
|
||||
process.exit(process.exitCode ?? 0)
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { spawn } from "node:child_process"
|
||||
import { createServer } from "node:net"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { resolve, isAbsolute } from "node:path"
|
||||
import { SignJWT } from "jose"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
assert.equal(process.env.OWN_EXTERNAL_PROCESS_PG, "true"); assert.equal(process.env.NODE_ENV, "test")
|
||||
assert(process.env.OWN_EXTERNAL_SOURCE_ROOT && isAbsolute(process.env.OWN_EXTERNAL_SOURCE_ROOT))
|
||||
assert(process.env.OWN_EXTERNAL_RESULT_FILE && isAbsolute(process.env.OWN_EXTERNAL_RESULT_FILE))
|
||||
const source = (path: string) => pathToFileURL(resolve(process.env.OWN_EXTERNAL_SOURCE_ROOT!, "backend/src", path)).href
|
||||
const database = new URL(process.env.DATABASE_URL ?? "")
|
||||
assert.equal(database.hostname, "127.0.0.1"); assert.equal(database.username, "mcp_test"); assert.equal(database.pathname, "/postgres"); assert.notEqual(database.port, "5432")
|
||||
const listener = createServer(); await new Promise<void>(r => listener.listen(0, "127.0.0.1", r)); const port = (listener.address() as { port: number }).port
|
||||
await new Promise<void>(r => listener.close(() => r()))
|
||||
const origin = `http://127.0.0.1:${port}`, endpoint = origin + "/mcp"
|
||||
process.env.MCP_PUBLIC_URL = endpoint; process.env.MCP_UI_URL = origin; process.env.CORS_ORIGIN = origin
|
||||
const { pool, closePool } = await import(source("db/index.js"))
|
||||
const { applySqlMigrations } = await import(source("db/migrate.js"))
|
||||
const { mcpConnectionCreateSchema } = await import("@mmapp/contracts/mcp")
|
||||
const { createMcpConnection, rotateConnectionToken, setConnectionStatus, updateMcpSettings } = await import(source("modules/mcp/store.js"))
|
||||
const { env } = await import(source("config.js"))
|
||||
const { buildApp } = await import(source("index.js"))
|
||||
const actor = { id: "owned-external-process-owner", email: "external@example.invalid", name: "Owned external SDK fixture", apps: ["mm"],
|
||||
permissions: ["mm:settings:admin", "mm:mcp:admin", "mm:servers:admin"], expiresAt: Date.now() + 3600000, issuedAt: Date.now() - 1000 }
|
||||
let currentId: string | undefined, app: Awaited<ReturnType<typeof buildApp>> | undefined, child: ReturnType<typeof spawn> | undefined
|
||||
const credentials: string[] = []
|
||||
const bridgePids = new Set<number>()
|
||||
let childExit: Promise<number> | undefined
|
||||
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
function alive(pid: number) { try { process.kill(pid, 0); return true } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; throw error } }
|
||||
async function drainChildAndBridges() {
|
||||
if (child && child.exitCode === null && child.signalCode === null) { child.kill(); await Promise.race([childExit, delay(5000).then(() => { throw Error("Owned external child failed to exit") })]) }
|
||||
const deadline = Date.now() + 5000
|
||||
while ([...bridgePids].some(alive) && Date.now() < deadline) await delay(25)
|
||||
const remaining = [...bridgePids].filter(alive)
|
||||
for (const pid of remaining) process.kill(pid)
|
||||
const killDeadline = Date.now() + 5000
|
||||
while (remaining.some(alive) && Date.now() < killDeadline) await delay(25)
|
||||
assert(!remaining.some(alive), "Owned SDK bridge grandchild failed to exit")
|
||||
console.log("Owned client and all observed production bridge processes exited:", JSON.stringify({ childPid: child?.pid, exitCode: child?.exitCode, signalCode: child?.signalCode, bridgePids: [...bridgePids] }))
|
||||
}
|
||||
try {
|
||||
await applySqlMigrations(pool)
|
||||
for (const table of ["traffic_settings", "uptime_settings", "traffic_flow_settings", "servers_api_ping_settings", "certificate_renew_settings", "backup_schedule_settings", "geoip_settings", "internet_path_settings", "router_log_settings"]) await pool.query(`UPDATE ${table} SET enabled=false WHERE id=1`)
|
||||
const secret = "OWNED_DEVICE_CREDENTIAL_MUST_NOT_ESCAPE"
|
||||
const allowed = (await pool.query("INSERT INTO servers(name,host,enabled,password) VALUES('owned allowed','192.0.2.1',false,$1) RETURNING id", [secret])).rows[0].id
|
||||
const forbidden = (await pool.query("INSERT INTO servers(name,host,enabled) VALUES('owned forbidden','192.0.2.2',false) RETURNING id")).rows[0].id
|
||||
await updateMcpSettings({ enabled: true }, actor)
|
||||
app = await buildApp({ startScheduler: false, logger: false }); await app.listen({ port, host: "127.0.0.1" })
|
||||
const jwt = await new SignJWT({ sub: actor.id, apps: actor.apps, permissions: actor.permissions }).setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret))
|
||||
const transcript: unknown[] = []; let failure: unknown, result: unknown
|
||||
child = spawn(process.execPath, [fileURLToPath(new URL("./client.mjs", import.meta.url))], { cwd: process.cwd(), windowsHide: true, stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
env: { ...Object.fromEntries(["SystemRoot", "WINDIR", "TEMP", "TMP", "PATH", "Path"].filter(k => process.env[k]).map(k => [k, process.env[k]!])),
|
||||
OWN_EXTERNAL_CLIENT: "true", OWN_MCP_ENDPOINT: endpoint, OWN_SERVER_PID: String(process.pid), OWN_ALLOWED_SERVER: String(allowed), OWN_FORBIDDEN_SERVER: String(forbidden),
|
||||
OWN_BRIDGE_FILE: fileURLToPath(new URL(source("scripts/mcp-stdio.ts"))), OWN_PORTAL_JWT: jwt, OWN_SERVER_SECRET: secret, OWN_EXTERNAL_CLEANUP_FAULT: process.env.OWN_EXTERNAL_CLEANUP_FAULT } })
|
||||
childExit = new Promise<number>(resolve => { child!.once("exit", status => resolve(status ?? 1)); child!.once("error", () => resolve(1)) })
|
||||
let stderr = "", stdout = ""
|
||||
child.stderr?.on("data", chunk => { stderr += String(chunk); if (stderr.length > 1048576) child?.kill() })
|
||||
child.stdout?.on("data", chunk => { stdout += String(chunk); if (stdout.length > 1048576) child?.kill() })
|
||||
child.on("message", async (raw: unknown) => {
|
||||
const message = raw as { kind: string; command?: string; id?: number; pid?: number; result?: unknown; phase?: string; errorType?: string }
|
||||
if (message.kind === "bridge-pid") { assert(Number.isSafeInteger(message.pid) && message.pid! > 0 && message.pid !== process.pid && message.pid !== child?.pid); bridgePids.add(message.pid!); return }
|
||||
if (message.kind === "arm-cleanup-timeout" && process.env.OWN_EXTERNAL_CLEANUP_FAULT === "timeout") { setTimeout(() => { failure = { phase: "owned-deliberate-cleanup-timeout" }; child?.kill() }, 25); return }
|
||||
if (message.kind === "result") { result = message.result; return }
|
||||
if (message.kind === "failure") { failure = { phase: message.phase, errorType: message.errorType }; return }
|
||||
if (message.kind !== "control" || !Number.isSafeInteger(message.id)) return
|
||||
try {
|
||||
let value: unknown
|
||||
if (message.command === "new-connection") {
|
||||
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Owned external SDK process", mode: "observer", expiresAt: new Date(Date.now() + 1200000).toISOString(),
|
||||
permissions: ["mm:servers:read"], scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false }, limits: { callsPerMinute: 200 } }), actor)
|
||||
currentId = issued.connection.id; credentials.push(issued.token); value = { token: issued.token, connectionId: currentId }
|
||||
} else if (message.command === "large-result" || message.command === "reset-result") {
|
||||
await pool.query("UPDATE servers SET comment=$2 WHERE id=$1", [allowed, message.command === "large-result" ? "Z".repeat(40000) + "OWNED_TRUNCATION_TAIL" : ""])
|
||||
value = { ok: true }
|
||||
} else {
|
||||
assert(currentId)
|
||||
if (message.command === "rotate") { const rotated = await rotateConnectionToken(currentId, actor); credentials.push(rotated.token); value = rotated }
|
||||
else if (message.command === "pause" || message.command === "resume" || message.command === "revoke") { await setConnectionStatus(currentId, message.command === "pause" ? "paused" : message.command === "resume" ? "active" : "revoked", actor); value = { ok: true } }
|
||||
else throw Error("Unsupported exact fixture control")
|
||||
}
|
||||
transcript.push({ command: message.command, connectionId: currentId })
|
||||
child?.send({ kind: "reply", id: message.id, value })
|
||||
} catch { child?.send({ kind: "reply", id: message.id, error: true }) }
|
||||
})
|
||||
const timer = setTimeout(() => { failure = { phase: "whole-external-client-deadline" }; child?.kill() }, 120000)
|
||||
const code = await childExit; clearTimeout(timer)
|
||||
if (code !== 0 || !result) { console.log("OWNED_EXTERNAL_PROCESS_FAILURE", JSON.stringify({ code, failure, stdout: stdout.slice(0,1000), stderr: stderr.slice(0,1000) })); throw Error("External SDK process did not complete") }
|
||||
const logs = JSON.stringify((await pool.query("SELECT tool,input,result,error_code FROM mcp_requests ORDER BY created_at")).rows)
|
||||
for (const token of [...credentials, jwt, secret]) { assert(!logs.includes(token)); assert(!stdout.includes(token)); assert(!stderr.includes(token)) }
|
||||
const final = { ...(result as object), actualOwnPg: true, actualProductionHttpServer: true, serverFixturePid: process.pid,
|
||||
controlTransitions: transcript, auditCredentialLeakAbsent: true, nativeRouterCalls: 0, productionDatabaseTouched: false }
|
||||
await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2))
|
||||
console.log("Owned external process SDK result:", JSON.stringify(final))
|
||||
} finally {
|
||||
try { await drainChildAndBridges() } finally { try { if (app) await app.close() } finally { await closePool() } }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Explicit offline command only. Run with the TS loader; never startup/import.
|
||||
import { databaseSourceBindingOperatorCli } from './database-source-binding-operator.ts'
|
||||
try {
|
||||
const result = await databaseSourceBindingOperatorCli(process.argv.slice(2))
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
} catch {
|
||||
// Do not reflect request contents, key data or untrusted filenames.
|
||||
console.error('Offline operator source-binding provisioning refused; inspect the explicitly selected output before any new manual invocation')
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Synthetic signatures/files only. Deliberately discard configured DB URLs,
|
||||
// environment preload hooks and application secrets, including during SQL suites.
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
if (process.argv.length !== 2) throw Error('Source-binding synthetic runner takes no arguments')
|
||||
const require = createRequire(import.meta.url)
|
||||
const env = Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL']
|
||||
.filter(key => typeof process.env[key] === 'string').map(key => [key, process.env[key]]))
|
||||
const child = spawn(process.execPath, ['--import', pathToFileURL(require.resolve('tsx')).href, '--test',
|
||||
fileURLToPath(new URL('../src/db/database-source-binding.test.ts', import.meta.url))], {
|
||||
cwd: new URL('../', import.meta.url), env: { ...env, MMAPP_OWN_SOURCE_BINDING_SYNTHETIC: 'true' },
|
||||
windowsHide: true, stdio: 'inherit',
|
||||
})
|
||||
child.once('error', () => { console.error('Source-binding synthetic child could not start'); process.exitCode = 1 })
|
||||
child.once('exit', code => { process.exitCode = code ?? 1 })
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import ts from 'typescript'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { resolve } from 'node:path'
|
||||
const require=createRequire(import.meta.url),zod=require('zod'),here=import.meta.dirname
|
||||
function evaluate(source,mocks={}){
|
||||
const exports={},module={exports}
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText
|
||||
new Function('require','exports','module',code)(id=>{if(id==='zod')return zod;if(Object.hasOwn(mocks,id))return mocks[id];throw Error('Unexpected dependency '+id)},exports,module)
|
||||
return module.exports
|
||||
}
|
||||
const contracts=evaluate(readFileSync(resolve(here,'../../packages/contracts/src/traffic-flow.ts'),'utf8'))
|
||||
let quality=null,slotReleases=0
|
||||
const qualityHelper=evaluate(readFileSync(resolve(here,'../src/modules/mcp/flow-read-quality.ts'),'utf8'),{
|
||||
'@mmapp/contracts/traffic-flow':contracts,'../../services/traffic-flow-ingest.js':{getFlowRuntimeCounters:async()=>({dataQuality:quality})},'./traffic-history-actions.js':{assertTrafficHistoryAccess:async()=>{}},
|
||||
})
|
||||
const server={id:1,name:'selected',type:'jump-host'},dto={bpsNow:0,bpsWire:0,bpsOverlay:0,bytesWire:0,live:false,rxSeries:[],txSeries:[],services:[]}
|
||||
const window={rows:[],topRowsPerBucketLimit:20,topConversationsLimit:20,since:'from',until:'to'}
|
||||
class McpError extends Error{constructor(code,message,statusCode){super(message);this.code=code;this.statusCode=statusCode}}
|
||||
const mocks={
|
||||
'./flow-read-quality.js':qualityHelper,
|
||||
'@mmapp/contracts/traffic-flow':{...contracts,flowAnalyticsDtoSchema:{parse:x=>x},flowMapHopsDtoSchema:{parse:x=>x}},
|
||||
'../../services/traffic-flow-analytics.js':{buildFlowAnalytics:async()=>dto,getFlowMonthly:async()=>({month:'2026-10',bytes:0,countries:[],services:[],asns:[]}),safeBuildLiveFlowSample:async()=>({event:'sample',data:dto}),LIVE_ANALYTICS_MINUTES:5},
|
||||
'../../services/traffic-flow-map-hops.js':{buildFlowMapHops:async()=>({live:false,hops:[]})},
|
||||
'../../services/traffic-flow-source-limits.js':{readFlowSourceLimits:async()=>({topN:20}),readFlowMapSourceLimits:async()=>({topN:20,mapServiceMinSharePct:5})},
|
||||
'../servers/repository/servers-repository.js':{getServerRowById:async()=>server},
|
||||
'../../services/traffic-flow-server-source.js':{getBoundedFlowServerRowById:async()=>server},
|
||||
'./flow-source-scope.js':{assertBoundedFlowSource:async()=>{},flowCatalogDevices:async()=>({selected:[server],incomplete:false})},
|
||||
'./traffic-history-actions.js':{assertTrafficHistoryAccess:async()=>{},assertTrafficHistorySource:async()=>{}},
|
||||
'./security.js':{McpError},'node:util':require('node:util'),'node:timers/promises':require('node:timers/promises'),
|
||||
'../../services/traffic-flow-live-slots.js':{FLOW_LIVE_TICK_MS:2000,tryAcquireFlowLiveSlot:()=>true,releaseFlowLiveSlot:()=>slotReleases++},
|
||||
'../../services/traffic-flow-settings.js':{getTrafficFlowSettingsRow:async()=>({topN:20})},
|
||||
'../../services/traffic-flow-ifindex.js':{resolveIfaceName:()=>''},
|
||||
'../../services/traffic-flow-talkers-model.js':{projectFlowTalkers:()=>({talkers:[],totalConversations:0})},
|
||||
'../../services/traffic-flow-catalog-read.js':{readFlowCatalogWindow:async()=>window,readFlowExporterInputs:async()=>({window,catalog:{list:[server]},configuredIds:new Set([1]),configurationMembershipVerified:true}),readFlowClientInputs:async()=>({users:[],total:0,window}),observeFlowExporter:()=>({id:'1'}),observeFlowClient:()=>({})},
|
||||
'./catalog-scope.js':{catalogPaging:zod.z.object({offset:zod.z.number().default(0),limit:zod.z.number().default(50)}),catalogDevices:async()=>({selected:[server],incomplete:false})},
|
||||
}
|
||||
const actions=[]
|
||||
for(const [file,method] of [['flow-catalog-actions.ts','registerFlowCatalogActions'],['flow-analytics-actions.ts','registerFlowAnalyticsActions'],['flow-map-actions.ts','registerFlowMapActions'],['flow-live-actions.ts','registerFlowLiveActions']]){
|
||||
const actionModule=evaluate(readFileSync(resolve(here,'../src/modules/mcp',file),'utf8'),mocks)
|
||||
actionModule[method]((...args)=>actions.push({id:args[0],schema:args[7],read:args[8].read}))
|
||||
}
|
||||
assert.equal(actions.length,7)
|
||||
const base={version:1,collectorEpoch:'00000000-0000-4000-8000-000000000001',scope:'collector-global',exportCoverage:'unverified',localAdmission:'partial',records:'1',bytes:'10',packets:'1',knownLowerBounds:{records:'1',bytes:'10',packets:'1'},firstAt:'2026-10-29T00:00:00.000Z',lastAt:'2026-10-29T00:00:00.000Z',intervalUnknown:false,reasons:{'aggregate-capacity':true,'ring-capacity':false,'key-width':false,'unsafe-numeric':false},counterOverflow:false,rawRowsPolicy:'topN-and-pending-cap-not-complete',lifetimeScope:'current-collector-isolate'}
|
||||
const clean={...base,localAdmission:'no-local-loss-observed',records:'0',bytes:'0',packets:'0',knownLowerBounds:{records:'0',bytes:'0',packets:'0'},firstAt:null,lastAt:null,reasons:{'aggregate-capacity':false,'ring-capacity':false,'key-width':false,'unsafe-numeric':false}}
|
||||
const cases=[null,base,{...base,localAdmission:'loss-quantity-unknown',bytes:null},{...base,localAdmission:'no-local-loss-observed'},clean]
|
||||
let count=0
|
||||
for(const globalAllowed of [false,true])for(const state of cases){
|
||||
quality=state
|
||||
for(const action of actions){
|
||||
const input=action.schema.parse(action.id==='traffic.flow.monthly.read'?{month:'2026-10'}:{serverId:1}),context={connection:{policy:{scope:{serverIds:[1],allowGlobal:globalAllowed,operationIds:globalAllowed?['netflow.settings.read']:[]}}}}
|
||||
const output=await action.read(input,context),expected=contracts.flowIngestQualitySchema.safeParse(globalAllowed?state:null)
|
||||
assert.deepEqual(output.dataQuality,expected.success?expected.data:null,action.id)
|
||||
assert.equal(output.collectorQualityAvailable,expected.success,action.id)
|
||||
assert.equal(output.collectorQualityScope,'current-collector-isolate',action.id)
|
||||
assert.equal(output.incomplete,['traffic.flow.talkers.read','traffic.flow.analytics.read','traffic.flow.map.read','traffic.flow.live.read'].includes(action.id),action.id+' preserves original result completeness')
|
||||
assert.equal(output.collectorObservationIncomplete,!expected.success||expected.data.localAdmission!=='no-local-loss-observed',action.id)
|
||||
assert.equal(output.coverageVerified,false,action.id);assert.equal(output.exportCoverage,'unverified',action.id)
|
||||
count++
|
||||
}
|
||||
}
|
||||
assert.equal(slotReleases,10)
|
||||
console.log('PASS '+count+' actual registered MCP read adapter invocations: unavailable, partial, unknown quantities and invalid healthy claim; inert source/auth adapters; real semantic quality schema')
|
||||
@@ -0,0 +1,76 @@
|
||||
import EmbeddedPostgres from 'embedded-postgres'
|
||||
import { build, stop } from 'esbuild'
|
||||
import { mkdtemp, rm, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, dirname } from 'node:path'
|
||||
import { createServer } from 'node:net'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { spawn } from 'node:child_process'
|
||||
import assert from 'node:assert/strict'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
if (process.argv.length !== 2) throw Error('Read HTTP acceptance requires its own disposable cluster and no arguments')
|
||||
const repo = resolve(import.meta.dirname, '../..')
|
||||
const directory = await mkdtemp(join(tmpdir(), 'mm-flow-http-owned-'))
|
||||
const bundleDirectory = await mkdtemp(join(repo, 'tmp', 'mm-flow-http-owned-bundle-'))
|
||||
const listener = createServer()
|
||||
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve))
|
||||
const port = listener.address().port
|
||||
await new Promise(resolve => listener.close(resolve))
|
||||
const password = randomBytes(24).toString('hex')
|
||||
const postgres = new EmbeddedPostgres({
|
||||
databaseDir: join(directory, 'pg'), port, user: 'mcp_test', password,
|
||||
persistent: true, createPostgresUser: false, initdbFlags: ['--encoding=UTF8'],
|
||||
postgresFlags: ['-h', '127.0.0.1', '-c', 'work_mem=4MB'],
|
||||
onLog: () => {}, onError: () => {},
|
||||
})
|
||||
let started = false
|
||||
let outcome = 1
|
||||
try {
|
||||
const file = join(bundleDirectory, 'read-http-owned.mjs')
|
||||
// Current application sources only: no candidate overlays or generated mocks.
|
||||
await build({
|
||||
entryPoints: [join(repo, 'backend/src/services/traffic-flow-read-http.owned.test.ts')],
|
||||
outfile: file, platform: 'node', format: 'esm', bundle: true,
|
||||
packages: 'external', sourcemap: 'inline',
|
||||
banner: { js: "import {createRequire as __ownedCreateRequire} from 'node:module';const require=__ownedCreateRequire(import.meta.url);" },
|
||||
plugins: [{ name: 'contracts-source', setup(builder) {
|
||||
builder.onResolve({ filter: /^@mmapp\/contracts(?:\/.*)?$/ }, args => ({
|
||||
path: resolve(repo, 'packages/contracts/src', args.path.replace(/^@mmapp\/contracts\/?/, '') || 'index') + '.ts',
|
||||
}))
|
||||
builder.onLoad({ filter: /\.ts$/ }, async args => ({
|
||||
contents: (await readFile(args.path, 'utf8')).replaceAll('import.meta.url', JSON.stringify(pathToFileURL(args.path).href)),
|
||||
loader: 'ts',
|
||||
}))
|
||||
} }],
|
||||
})
|
||||
await postgres.initialise()
|
||||
await postgres.start()
|
||||
started = true
|
||||
const exit = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [file], {
|
||||
cwd: directory, windowsHide: true, stdio: 'inherit',
|
||||
env: { ...process.env,
|
||||
DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`,
|
||||
DATABASE_PATH: join(directory, 'unused.sqlite'), MCP_STATE_DIR: join(directory, 'state'),
|
||||
AUTH_REQUIRED: 'true', AUTH_ISSUER: 'https://auth.test.local',
|
||||
AUTH_JWT_SECRET: 'owned-flow-http-only-secret', MCP_PUBLIC_URL: 'http://localhost:8000/mcp', NODE_ENV: 'test',
|
||||
NODE_PATH: join(repo, 'node_modules'),
|
||||
},
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('exit', resolve)
|
||||
})
|
||||
outcome = exit ?? 1
|
||||
} finally {
|
||||
if (started) await postgres.stop()
|
||||
assert.equal(dirname(resolve(directory)), resolve(tmpdir()))
|
||||
assert.match(directory, /mm-flow-http-owned-/)
|
||||
await rm(directory, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
|
||||
assert.equal(dirname(resolve(bundleDirectory)), resolve(repo, 'tmp'))
|
||||
assert.match(bundleDirectory, /mm-flow-http-owned-bundle-/)
|
||||
await rm(bundleDirectory, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
|
||||
stop()
|
||||
console.log('Owned read HTTP cluster stopped and temporary resources removed')
|
||||
}
|
||||
process.exitCode = outcome
|
||||
@@ -0,0 +1,28 @@
|
||||
import{readFile,writeFile,mkdtemp,rm}from'node:fs/promises'
|
||||
import{resolve,join,dirname}from'node:path'
|
||||
import{spawn}from'node:child_process'
|
||||
import assert from'node:assert/strict'
|
||||
import ts from'typescript'
|
||||
import{build,stop}from'esbuild'
|
||||
const repo=resolve(import.meta.dirname,'../..'),folder=await mkdtemp(join(repo,'tmp','mm-flow-profile-startup-owned-'))
|
||||
async function run(file,cwd,explicit,expected){
|
||||
const env={...process.env,NODE_ENV:'test',AUTH_REQUIRED:'false',DATABASE_URL:'postgresql://unused:unused@127.0.0.1:1/unused',EXPECT_PROFILE:expected};delete env.MM_FLOW_READ_RESOURCE_PROFILE;if(explicit!==undefined)env.MM_FLOW_READ_RESOURCE_PROFILE=explicit
|
||||
return new Promise((resolve,reject)=>{let output='';const child=spawn(process.execPath,[file],{cwd,env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',x=>output+=x);child.stderr.on('data',x=>output+=x);child.once('error',reject);child.once('exit',code=>resolve({code,output}))})
|
||||
}
|
||||
try{
|
||||
const source=await readFile(resolve(repo,'backend/src/services/traffic-flow-read-resource-profile.ts'),'utf8'),js=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText
|
||||
await writeFile(join(folder,'profile.mjs'),js);await writeFile(join(folder,'eager-negative.mjs'),js+'\ngetFlowReadResourceProfile();\n');await writeFile(join(folder,'load-env.mjs'),"import{config}from'dotenv';config({quiet:true});");await writeFile(join(folder,'.env'),'MM_FLOW_READ_RESOURCE_PROFILE=constrained\n')
|
||||
for(const[profile,success]of[['profile.mjs',true],['eager-negative.mjs',false]]){
|
||||
await writeFile(join(folder,'policy.mjs'),`import{getFlowReadResourceProfile}from'./${profile}';import'./load-env.mjs';export const policy=getFlowReadResourceProfile();`)
|
||||
await writeFile(join(folder,'order.mjs'),"import assert from'node:assert/strict';import{policy}from'./policy.mjs';assert.equal(policy.name,'constrained');")
|
||||
const result=await run(join(folder,'order.mjs'),folder,undefined,'constrained');if(success)assert.equal(result.code,0,result.output);else{assert.notEqual(result.code,0);assert.match(result.output,/standard/)}
|
||||
}
|
||||
const abs=path=>JSON.stringify(resolve(repo,path).replaceAll('\\','/'))
|
||||
await writeFile(join(folder,'actual.ts'),`import{getFlowReadResourceProfile}from${abs('backend/src/services/traffic-flow-read-resource-profile.ts')};import{FLOW_READ_RESERVATION_BYTES}from${abs('backend/src/services/traffic-flow-read-policy.ts')};import{pool}from${abs('backend/src/db/index.ts')};import assert from'node:assert/strict';const p=getFlowReadResourceProfile();assert.equal(p.name,process.env.EXPECT_PROFILE);assert.equal(FLOW_READ_RESERVATION_BYTES,p.reservationBytes);assert.equal(p.parallelSql,8);process.env.MM_FLOW_READ_RESOURCE_PROFILE=p.name==='standard'?'constrained':'standard';assert.equal(getFlowReadResourceProfile().name,p.name);await pool.end();`)
|
||||
await build({entryPoints:[join(folder,'actual.ts')],outfile:join(folder,'actual.mjs'),bundle:true,format:'esm',platform:'node',packages:'external'})
|
||||
for(const[label,dotenv,explicit,expected]of[['standard','standard',undefined,'standard'],['dotenv-constrained','constrained',undefined,'constrained'],['explicit-standard','constrained','standard','standard'],['invalid','constrained','invalid','INVALID']]){
|
||||
await writeFile(join(folder,'.env'),'MM_FLOW_READ_RESOURCE_PROFILE='+dotenv+'\n');const result=await run(join(folder,'actual.mjs'),folder,explicit,expected)
|
||||
if(expected==='INVALID'){assert.notEqual(result.code,0);assert.match(result.output,/MM_FLOW_READ_RESOURCE_PROFILE must be/)}else assert.equal(result.code,0,label+':'+result.output)
|
||||
}
|
||||
console.log('PASS pure import order/.env actual policy/explicit override/invalid profile/immutable selection')
|
||||
}finally{stop();assert.equal(dirname(folder),resolve(repo,'tmp'));await rm(folder,{recursive:true,force:true,maxRetries:10,retryDelay:100})}
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import EmbeddedPostgres from 'embedded-postgres'
|
||||
import pg from 'pg'
|
||||
import { mkdtemp, rm, readFile } from 'node:fs/promises'
|
||||
import ts from 'typescript'
|
||||
import { createRequire } from 'node:module'
|
||||
import { getTableColumns } from 'drizzle-orm'
|
||||
import { drizzle } from 'drizzle-orm/node-postgres'
|
||||
import { servers } from '../src/db/schema.js'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, dirname } from 'node:path'
|
||||
import { createServer } from 'node:net'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { readBoundedFlowSql } from '../src/services/traffic-flow-bounded-sql.js'
|
||||
|
||||
if(process.argv.length!==2)throw Error('Read source acceptance requires its own disposable cluster and no arguments')
|
||||
const directory=await mkdtemp(join(tmpdir(),'mm-flow-read-owned-'))
|
||||
const listener=createServer();await new Promise<void>(resolve=>listener.listen(0,'127.0.0.1',resolve))
|
||||
const address=listener.address();assert.ok(address&&typeof address!=='string');const port=address.port
|
||||
await new Promise<void>(resolve=>listener.close(()=>resolve()))
|
||||
const password=randomBytes(24).toString('hex')
|
||||
const postgres=new EmbeddedPostgres({databaseDir:join(directory,'pg'),port,user:'mcp_test',password,persistent:true,createPostgresUser:false,initdbFlags:['--encoding=UTF8'],postgresFlags:['-h','127.0.0.1','-c','work_mem=4MB','-c','statement_timeout=10000'],onLog:()=>{},onError:()=>{}})
|
||||
let pool:pg.Pool|undefined,started=false
|
||||
try{
|
||||
await postgres.initialise();await postgres.start();started=true
|
||||
pool=new pg.Pool({host:'127.0.0.1',port,user:'mcp_test',password,database:'postgres',max:2})
|
||||
const max={maxRows:3,maxEncodedBytes:1024}
|
||||
const query='SELECT i AS id,$1::text AS text FROM generate_series(1,3) i ORDER BY i'
|
||||
const normal=await pool.query(query,['я'])
|
||||
assert.deepEqual(await readBoundedFlowSql(pool,query,['я'],max),normal.rows)
|
||||
assert.deepEqual(await readBoundedFlowSql(pool,'SELECT 1 id WHERE false',[],max),[])
|
||||
await assert.rejects(readBoundedFlowSql(pool,'SELECT i FROM generate_series(1,4) i',[],max),/превышает/)
|
||||
await assert.rejects(readBoundedFlowSql(pool,'SELECT repeat(\'я\',10000) text',[],max),/превышает/)
|
||||
await assert.rejects(readBoundedFlowSql(pool,'SELECT repeat(\'a\',600) text FROM generate_series(1,2)',[],max),/превышает/)
|
||||
const large='SELECT 9223372036854775807::bigint AS counter,\'2026-10-29T12:00:00Z\'::timestamptz AS at,123456789012345678901234567890::numeric AS exact'
|
||||
assert.deepEqual(await readBoundedFlowSql(pool,large,[],max),(await pool.query(large)).rows,'bigint/numeric/timestamp parser behavior is preserved')
|
||||
for(const [name,value] of [['__flowRowCount',"'1'::text"],['__flowEncodedBytes',"'1'::text"],['__flowPresent','true']])await assert.rejects(readBoundedFlowSql(pool,`SELECT ${value} AS "${name}"`,[],max),/Reserved NetFlow source column collision/)
|
||||
let overflowPayloads=0
|
||||
const watched={query:async(sql:string,values:unknown[])=>{const result=await pool!.query(sql,values);overflowPayloads=result.rows.filter(row=>row.__flowPresent===true).length;return result}}
|
||||
await assert.rejects(readBoundedFlowSql(watched,'SELECT repeat(\'a\',1000000) text FROM generate_series(1,20)',[],max),/превышает/)
|
||||
assert.equal(overflowPayloads,0,'20 MiB source must send only a null sentinel row; no source payload is decoded in Node')
|
||||
await pool.query('CREATE TABLE flow_read_growth(text text);INSERT INTO flow_read_growth VALUES(\'a\')')
|
||||
let calls=0,growthDecodedRows=0
|
||||
const growing={query:async(sql:string,values:unknown[])=>{
|
||||
const result=await pool!.query(sql,values);calls++
|
||||
if(calls===1)await pool!.query('UPDATE flow_read_growth SET text=repeat(\'a\',100000)')
|
||||
if(calls===2)growthDecodedRows=result.rows.filter(row=>row.__flowPresent===true).length
|
||||
return result
|
||||
}}
|
||||
await assert.rejects(readBoundedFlowSql(growing,'SELECT text FROM flow_read_growth',[],max),/превышает/)
|
||||
assert.equal(calls,2);assert.equal(growthDecodedRows,0,'Concurrent growth cannot bypass the preflight reservation')
|
||||
const columns=Object.entries(getTableColumns(servers)),quote=(s:string)=>'"'+s.replaceAll('"','""')+'"'
|
||||
await pool.query('CREATE TABLE servers('+columns.map(([,column])=>quote(column.name)+' '+column.getSQLType()).join(',')+')')
|
||||
await pool.query('INSERT INTO servers(id,name,host,created_at,updated_at) VALUES(1,\'selected\',\'192.0.2.1\',\'2026-10-29T00:00:00Z\',\'2026-10-29T00:00:00Z\')')
|
||||
const code=ts.transpileModule(await readFile(new URL('../src/services/traffic-flow-server-source.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText
|
||||
const serverModule={exports:{} as {readBoundedFlowServerRows:(ids:number[])=>Promise<unknown[]>}},localRequire=createRequire(import.meta.url)
|
||||
const mocks:Record<string,unknown>={'drizzle-orm':localRequire('drizzle-orm'),'../db/schema.js':{servers},'../db/index.js':{pool},'./traffic-flow-bounded-sql.js':{readBoundedFlowSql}}
|
||||
new Function('require','module','exports',code)((id:string)=>{if(!(id in mocks))throw Error('Unexpected dependency '+id);return mocks[id]},serverModule,serverModule.exports)
|
||||
assert.deepEqual(await serverModule.exports.readBoundedFlowServerRows([1]),await drizzle(pool).select().from(servers),'Full server rows preserve Drizzle fingerprint inputs including timestamp strings')
|
||||
await pool.query('UPDATE servers SET password=repeat(\'x\',5*1024*1024)')
|
||||
await assert.rejects(serverModule.exports.readBoundedFlowServerRows([1]),/превышает/)
|
||||
console.log('PASS actual owned PostgreSQL: complete rows, empty result, count refusal, UTF-8 byte refusal, cumulative source refusal, parser parity, oversize no-decode sentinel and concurrent-growth fence')
|
||||
console.log('PASS actual bounded server source versus Drizzle mapping parity; 5 MiB credential/config text refused before source decode')
|
||||
}finally{
|
||||
await pool?.end();if(started)await postgres.stop()
|
||||
// Only the exact private mkdtemp child is eligible; never shell deletion.
|
||||
assert.equal(dirname(resolve(directory)),resolve(tmpdir()))
|
||||
assert.equal(directory.includes('mm-flow-read-owned-'),true)
|
||||
await rm(directory,{recursive:true,force:true,maxRetries:20,retryDelay:100})
|
||||
console.log('Owned source SQL cluster stopped and temporary resources removed')
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import EmbeddedPostgres from 'embedded-postgres'
|
||||
import { build,stop } from 'esbuild'
|
||||
import { mkdtemp,readFile,rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve,join,dirname } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createServer } from 'node:net'
|
||||
import { spawn } from 'node:child_process'
|
||||
import assert from 'node:assert/strict'
|
||||
if(process.argv.length!==2)throw Error('Profile settings tests accept no database or target arguments')
|
||||
const terminal=child=>child.exitCode!==null||child.signalCode!==null||child.pid===undefined
|
||||
async function stopOwnedChild(child,graceful=false){
|
||||
if(!child||terminal(child))return
|
||||
await new Promise((done,reject)=>{
|
||||
let timer,forceTimer
|
||||
const finish=()=>{clearTimeout(timer);clearTimeout(forceTimer);child.removeListener('exit',finish);done()}
|
||||
child.once('exit',finish)
|
||||
const force=()=>{
|
||||
if(terminal(child)){finish();return}
|
||||
child.kill('SIGKILL')
|
||||
forceTimer=setTimeout(()=>{if(terminal(child))finish();else{child.removeListener('exit',finish);reject(Error('Owned child failed to reach confirmed terminal state: '+child.pid))}},10000)
|
||||
}
|
||||
timer=setTimeout(force,graceful?5000:10000)
|
||||
if(graceful&&child.connected)child.send({type:'shutdown'},error=>{if(error&&!terminal(child))child.kill()})
|
||||
else child.kill()
|
||||
if(terminal(child))finish()
|
||||
})
|
||||
}
|
||||
const repo=resolve(import.meta.dirname,'../..'),bundle=await mkdtemp(join(repo,'tmp','mm-profile-cold-bundle-'))
|
||||
try{
|
||||
const compiled=join(bundle,'case.mjs'),driverFile=resolve(repo,'backend/scripts/flow-read-profile-http-client.mjs')
|
||||
await build({entryPoints:[resolve(repo,'backend/src/services/traffic-flow-settings-read-profile.owned.test.ts')],outfile:compiled,platform:'node',format:'esm',bundle:true,packages:'external',banner:{js:"import{createRequire as __req}from'node:module';const require=__req(import.meta.url);"},plugins:[{name:'current-source',setup(b){b.onResolve({filter:/^@mmapp\/contracts(?:\/.*)?$/},a=>({path:resolve(repo,'packages/contracts/src',a.path.replace(/^@mmapp\/contracts\/?/,'')||'index')+'.ts'}));b.onLoad({filter:/\.ts$/},async a=>({contents:(await readFile(a.path,'utf8')).replaceAll('import.meta.url',JSON.stringify(pathToFileURL(a.path).href)),loader:'ts'}))}}]})
|
||||
for(const profile of ['standard','constrained']){
|
||||
const directory=await mkdtemp(join(tmpdir(),'mm-flow-profile-cold-owned-')),listener=createServer()
|
||||
await new Promise(resolve=>listener.listen(0,'127.0.0.1',resolve));const port=listener.address().port;await new Promise(resolve=>listener.close(resolve))
|
||||
const password=randomBytes(24).toString('hex'),postgres=new EmbeddedPostgres({databaseDir:join(directory,'pg'),port,user:'mcp_test',password,persistent:true,createPostgresUser:false,initdbFlags:['--encoding=UTF8'],postgresFlags:['-h','127.0.0.1','-c','shared_buffers=64MB','-c','work_mem=2MB'],onLog:()=>{},onError:()=>{}})
|
||||
let started=false,client,app
|
||||
try{
|
||||
await postgres.initialise();await postgres.start();started=true
|
||||
await new Promise((done,reject)=>{client=spawn(process.execPath,[driverFile],{cwd:directory,windowsHide:true,stdio:['ignore','inherit','inherit','ipc'],env:{...process.env,NODE_OPTIONS:'--max-old-space-size=192 --max-semi-space-size=16'}});client.on('message',message=>{if(message.type==='client-ready')done();if((message.type==='client-result'||message.type==='client-progress')&&app?.connected)app.send(message)});client.once('error',reject);client.once('exit',code=>{if(!app)reject(Error('Client startup exit '+code))})})
|
||||
const outcome=await new Promise((done,reject)=>{
|
||||
app=spawn(process.execPath,[compiled],{cwd:directory,windowsHide:true,stdio:['ignore','inherit','inherit','ipc'],env:{...process.env,NODE_OPTIONS:'--max-old-space-size=192 --max-semi-space-size=16',MM_FLOW_READ_RESOURCE_PROFILE:profile,MMAPP_OWN_FLOW_PROFILE_TEST:'true',DATABASE_URL:`postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`,DATABASE_PATH:join(directory,'unused.sqlite'),MCP_STATE_DIR:join(directory,'state'),AUTH_REQUIRED:'true',AUTH_ISSUER:'https://auth.test.local',AUTH_JWT_SECRET:'owned-profile-only',MCP_PUBLIC_URL:'http://localhost:8000/mcp',NODE_ENV:'test'}})
|
||||
const timeout=setTimeout(()=>reject(Error('Profile cold test timed out')),120000)
|
||||
app.on('message',message=>{if(message.type==='client-command')client.send(message)})
|
||||
app.once('error',error=>{clearTimeout(timeout);reject(error)});app.once('exit',code=>{clearTimeout(timeout);done(code??1)})
|
||||
})
|
||||
assert.equal(outcome,0,profile+' public/settings profile failed')
|
||||
}finally{
|
||||
const stopped=await Promise.allSettled([stopOwnedChild(app),stopOwnedChild(client,true)])
|
||||
for(const result of stopped)if(result.status==='rejected')throw result.reason
|
||||
if(started)await postgres.stop()
|
||||
assert.equal(dirname(resolve(directory)),resolve(tmpdir()));assert.match(directory,/mm-flow-profile-cold-owned-/);await rm(directory,{recursive:true,force:true,maxRetries:20,retryDelay:100})
|
||||
}
|
||||
}
|
||||
}finally{stop();assert.equal(dirname(resolve(bundle)),resolve(repo,'tmp'));await rm(bundle,{recursive:true,force:true,maxRetries:20,retryDelay:100})}
|
||||
|
||||
// All owned children and files are confirmed closed above; avoid library ambient handles keeping the CLI alive.
|
||||
console.log('PASS both owned profile fixtures and confirmed child cleanup')
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,364 @@
|
||||
import EmbeddedPostgres from 'embedded-postgres'
|
||||
import { runOwnedVrfProductionProfile } from './vrf-production-profile.mjs'
|
||||
import { mkdtemp, rm, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, relative, isAbsolute, delimiter, dirname } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createServer } from 'node:net'
|
||||
import { spawn, execFile } from 'node:child_process'
|
||||
|
||||
// Explicit targeted reruns use a fresh owned regression database. They never
|
||||
// claim the primary integration or terminal restore was rerun.
|
||||
// Fixed groups only; ordinary full/selected regression semantics stay unchanged.
|
||||
function profilesOnlyGroup(args) {
|
||||
const groups = args.filter(arg => arg.startsWith('--profiles-group='))
|
||||
if (!groups.length) return null
|
||||
const group = groups[0].slice('--profiles-group='.length)
|
||||
if (groups.length !== 1 || args.length !== 3 || !args.includes('--all') || !args.includes('--profiles-only') || !['base37', 'vrf1'].includes(group)) {
|
||||
throw new Error('Fixed profile group requires exactly --all --profiles-only --profiles-group=base37|vrf1')
|
||||
}
|
||||
return group
|
||||
}
|
||||
function regressionProfiles(profiles, selected, group) {
|
||||
if (group === null) return [...new Set([...profiles, 'test:logs:pg', ...(!selected ? ['test:vrf:production'] : [])])]
|
||||
if (!['base37', 'vrf1'].includes(group) || selected || profiles.length !== 36 || new Set(profiles).size !== 36 || profiles.includes('test:logs:pg') || profiles.includes('test:vrf:production')) {
|
||||
throw new Error('Fixed 37+1 profile coverage changed; review groups before running')
|
||||
}
|
||||
return group === 'base37' ? [...profiles, 'test:logs:pg'] : ['test:vrf:production']
|
||||
}
|
||||
// End fixed-group pure selectors.
|
||||
const profilesGroup = profilesOnlyGroup(process.argv.slice(2))
|
||||
const profilesOnly = process.argv.includes('--profiles-only')
|
||||
if (profilesOnly && (process.argv.length !== 4 + (profilesGroup ? 1 : 0) || !process.argv.includes('--all') ||
|
||||
process.argv.slice(2).some(arg => !['--all', '--profiles-only', ...(profilesGroup ? ['--profiles-group=' + profilesGroup] : [])].includes(arg)))) {
|
||||
throw new Error('Profiles-only requires exactly --all --profiles-only, without restore, selected profiles or other flags')
|
||||
}
|
||||
const regressionOnly = process.argv.includes('--regression-only')
|
||||
if (regressionOnly && (!process.argv.includes('--all') || process.argv.filter(arg => arg.startsWith('--profile=')).length !== 1 ||
|
||||
process.argv.slice(2).some(arg => !['--all', '--regression-only'].includes(arg) && !arg.startsWith('--profile=')))) {
|
||||
throw new Error('Regression-only requires exactly --all --regression-only --profile=<known script>, without restore or other profiles')
|
||||
}
|
||||
const operatorDockerSql = process.argv.includes('--operator-docker-sql')
|
||||
if (operatorDockerSql && process.argv.slice(2).some(arg => arg !== '--operator-docker-sql')) throw new Error('Operator Docker SQL requires a separate disposable cluster without restore')
|
||||
const pollMemoryCost = process.argv.includes('--poll-memory-cost')
|
||||
const pollMemoryBootstrap = process.argv.includes('--poll-memory-bootstrap')
|
||||
const pollMemory = process.argv.includes('--poll-memory') || pollMemoryCost || pollMemoryBootstrap
|
||||
if (pollMemory && (process.argv.length !== 3 || process.argv[2] !== (pollMemoryBootstrap ? '--poll-memory-bootstrap' : pollMemoryCost ? '--poll-memory-cost' : '--poll-memory'))) throw new Error('Compiled poll memory requires its own disposable cluster without other profiles')
|
||||
const pollMemoryEnvironment = Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL']
|
||||
.filter(key => typeof process.env[key] === 'string').map(key => [key, process.env[key]]))
|
||||
const pollJournalSnapshot = process.argv.includes('--poll-journal-snapshot')
|
||||
if (pollJournalSnapshot && process.argv.slice(2).some(arg => arg !== '--poll-journal-snapshot')) throw new Error('Journal snapshot lock evidence requires its own disposable cluster')
|
||||
const principalSecuritySource = process.argv.includes('--principal-security-source')
|
||||
if (principalSecuritySource && process.argv.slice(2).some(arg => arg !== '--principal-security-source')) throw new Error('Principal security source requires its own disposable cluster')
|
||||
const pollArchiveFlags = ['--poll-terminal-archive', '--poll-terminal-archive-lost-ack', '--poll-terminal-archive-history', '--poll-terminal-archive-history-cold', '--poll-terminal-archive-claim-loss']
|
||||
const chosenArchiveFlags = process.argv.slice(2).filter(arg => pollArchiveFlags.includes(arg))
|
||||
const pollArchiveLostAck = chosenArchiveFlags[0] === '--poll-terminal-archive-lost-ack'
|
||||
const pollArchiveCold = chosenArchiveFlags[0] === '--poll-terminal-archive-history-cold'
|
||||
const pollArchiveHistory = chosenArchiveFlags[0] === '--poll-terminal-archive-history' || pollArchiveCold
|
||||
const pollArchiveClaimLoss = chosenArchiveFlags[0] === '--poll-terminal-archive-claim-loss'
|
||||
const pollTerminalArchive = chosenArchiveFlags.length > 0
|
||||
if (pollTerminalArchive && (chosenArchiveFlags.length !== 1 || process.argv.slice(2).some(arg => ![chosenArchiveFlags[0], '--restore'].includes(arg)))) throw new Error('Poll terminal archive requires its own disposable cluster')
|
||||
const pollArchiveSchema = process.argv.includes('--poll-archive-schema')
|
||||
if (pollArchiveSchema && process.argv.slice(2).some(arg => arg !== '--poll-archive-schema')) throw new Error('Poll archive schema requires its own disposable cluster')
|
||||
const telemetrySettingsConfiguration = process.argv.includes('--telemetry-settings-configuration')
|
||||
if (telemetrySettingsConfiguration && process.argv.slice(2).some(arg => arg !== '--telemetry-settings-configuration')) throw new Error('Telemetry settings configuration requires its own disposable cluster')
|
||||
const routingReservationSchema = process.argv.includes('--routing-reservation-schema')
|
||||
if (routingReservationSchema && process.argv.slice(2).some(arg => arg !== '--routing-reservation-schema')) throw new Error('Routing reservation schema requires its own disposable cluster')
|
||||
const routingForwardRounds = process.argv.includes('--routing-network-forward-rounds')
|
||||
if (routingForwardRounds && process.argv.slice(2).some(arg => arg !== '--routing-network-forward-rounds')) throw new Error('Routing forward rounds requires its own disposable cluster')
|
||||
const flowActiveServices = process.argv.includes('--flow-active-services')
|
||||
if (flowActiveServices && process.argv.slice(2).some(arg => arg !== '--flow-active-services')) throw new Error('Flow active services requires its own disposable cluster')
|
||||
const flowActiveRebuild = process.argv.includes('--flow-active-rebuild')
|
||||
if (flowActiveRebuild && process.argv.slice(2).some(arg => arg !== '--flow-active-rebuild')) throw new Error('Flow active rebuild requires its own disposable cluster')
|
||||
const flowQuality = process.argv.includes('--flow-quality')
|
||||
if (flowQuality && process.argv.slice(2).some(arg => arg !== '--flow-quality')) throw new Error('Flow quality requires its own disposable cluster')
|
||||
const flowWorkerIpc = process.argv.includes('--flow-worker-ipc')
|
||||
if (flowWorkerIpc && process.argv.slice(2).some(arg => arg !== '--flow-worker-ipc')) throw new Error('Flow worker IPC requires its own disposable cluster')
|
||||
const manualUptime = process.argv.includes('--manual-uptime')
|
||||
if (manualUptime && process.argv.slice(2).some(arg => arg !== '--manual-uptime')) throw new Error('Manual uptime requires its own disposable cluster')
|
||||
const manualPingGroup = process.argv.includes('--manual-ping-group')
|
||||
if (manualPingGroup && process.argv.slice(2).some(arg => arg !== '--manual-ping-group')) throw new Error('Manual Ping group requires its own disposable cluster')
|
||||
const manualTraffic = process.argv.includes('--manual-traffic')
|
||||
if (manualTraffic && process.argv.slice(2).some(arg => arg !== '--manual-traffic')) throw new Error('Manual traffic requires its own disposable cluster')
|
||||
const manualApiPing = process.argv.includes('--manual-api-ping')
|
||||
if (manualApiPing && process.argv.slice(2).some(arg => arg !== '--manual-api-ping')) throw new Error('Manual API Ping requires its own disposable cluster')
|
||||
const simpleSpeed = process.argv.includes('--simple-speed')
|
||||
if (simpleSpeed && process.argv.slice(2).some(arg => arg !== '--simple-speed')) throw new Error('Simple speed acceptance requires its own disposable cluster')
|
||||
const simpleSpeedChr = process.argv.includes('--simple-speed-chr')
|
||||
if (simpleSpeedChr && process.argv.slice(2).some(arg => arg !== '--simple-speed-chr')) throw new Error('Simple speed CHR acceptance requires its own disposable cluster')
|
||||
const publicRegistryMetadata = process.argv.includes('--public-registry-metadata')
|
||||
if (publicRegistryMetadata && process.argv.slice(2).some(arg => arg !== '--public-registry-metadata')) throw new Error('Public registry metadata requires a separate disposable cluster without restore')
|
||||
const operatorPeerMetadata = process.argv.includes('--operator-peer-metadata')
|
||||
if (operatorPeerMetadata && process.argv.slice(2).some(arg => arg !== '--operator-peer-metadata')) throw new Error('Operator peer metadata requires a separate disposable cluster without restore')
|
||||
const externalPeerMetadata = process.argv.includes('--external-peer-metadata')
|
||||
if (externalPeerMetadata && process.argv.slice(2).some(arg => arg !== '--external-peer-metadata')) throw new Error('Guarded external peer metadata requires a separate disposable cluster without restore')
|
||||
const mcpOperationOrigin = process.argv.includes('--mcp-operation-origin')
|
||||
if (mcpOperationOrigin && process.argv.slice(2).some(arg => arg !== '--mcp-operation-origin')) throw new Error('MCP operation root requires a separate disposable cluster without restore')
|
||||
const mcpSpeedOriginal = process.argv.includes('--mcp-speed-original')
|
||||
if (mcpSpeedOriginal && process.argv.slice(2).some(arg => arg !== '--mcp-speed-original')) throw new Error('MCP speed component requires a separate disposable cluster without restore')
|
||||
const routerConfigurationGuard = process.argv.includes('--router-configuration-guard')
|
||||
if (routerConfigurationGuard && process.argv.slice(2).some(arg => arg !== '--router-configuration-guard')) throw new Error('Router configuration guard requires a separate disposable cluster without restore')
|
||||
const scheduledSpeedOriginal = process.argv.includes('--scheduled-speed-original')
|
||||
if (scheduledSpeedOriginal && process.argv.slice(2).some(arg => arg !== '--scheduled-speed-original')) throw new Error('Scheduled speed origin requires a separate disposable cluster without restore')
|
||||
const bandwidthSpeedWorkflow = process.argv.includes('--bandwidth-speed-workflow')
|
||||
if (bandwidthSpeedWorkflow && process.argv.slice(2).some(arg => arg !== '--bandwidth-speed-workflow')) throw new Error('Whole speed workflow requires its own disposable cluster; restore is not implemented')
|
||||
const restSpeedOriginal = process.argv.includes('--rest-speed-original')
|
||||
if (restSpeedOriginal && process.argv.slice(2).some(arg => arg !== '--rest-speed-original')) throw new Error('REST speed origin acceptance requires its own disposable cluster; restore is not implemented for this profile')
|
||||
const operationLocks = process.argv.includes('--operation-locks')
|
||||
const serviceExecutionOrigin = process.argv.includes('--service-execution-origin')
|
||||
const bandwidthNodeSource = process.argv.includes('--bandwidth-node-source')
|
||||
const bandwidthExternalRegistry = process.argv.includes('--bandwidth-external-registry')
|
||||
if (bandwidthExternalRegistry && process.argv.slice(2).some(arg => arg !== '--bandwidth-external-registry')) throw new Error('External registry acceptance requires its own disposable cluster; restore is not implemented for this profile')
|
||||
const bandwidthNodeRegistry = process.argv.includes('--bandwidth-node-registry')
|
||||
const bandwidthControlSource = process.argv.includes('--bandwidth-control-source')
|
||||
const bandwidthAdmission = process.argv.includes('--bandwidth-admission')
|
||||
const diagnosticCancelHost = process.argv.includes('--diagnostic-cancel-host')
|
||||
const ipsecExportHttp = process.argv.includes('--ipsec-export-http')
|
||||
const ipsecExportJournal = process.argv.includes('--ipsec-export-journal')
|
||||
const routingRecoveryAuthority = process.argv.includes('--routing-recovery-authority')
|
||||
const legacyFlowRebuild = process.argv.includes('--legacy-flow-rebuild')
|
||||
const serverPollStorageFresh = process.argv.includes('--server-poll-storage-fresh')
|
||||
const serverPollStorage = process.argv.includes('--server-poll-storage')
|
||||
const serverPollDurable = process.argv.includes('--server-poll-durable')
|
||||
const collectorPartitionWriters = process.argv.includes('--collector-partition-writers')
|
||||
const routingReservations = process.argv.includes('--routing-reservations')
|
||||
const routingReservationRuntime = process.argv.includes('--routing-reservation-runtime')
|
||||
const routingNetworkNative = process.argv.includes('--routing-network-native')
|
||||
const migrationDataInventory = process.argv.includes('--migration-data-inventory')
|
||||
const migrationSchemaInventory = process.argv.includes('--migration-schema-inventory')
|
||||
const databaseSchemaControl = process.argv.includes('--database-schema-control')
|
||||
const controlledMigrations = process.argv.includes('--controlled-migrations')
|
||||
const controlledSchemaReference = process.argv.includes('--controlled-schema-reference')
|
||||
const probeStorageHistory = process.argv.includes('--probe-storage-history')
|
||||
const probeStorageJournal = process.argv.includes('--probe-storage-journal')
|
||||
const probeLegacyOrphans = process.argv.includes('--probe-legacy-orphans')
|
||||
const probeStorageFresh = process.argv.includes('--probe-storage-fresh')
|
||||
for (const [selected, flag] of [[operationLocks, '--operation-locks'], [serviceExecutionOrigin, '--service-execution-origin'], [bandwidthNodeSource, '--bandwidth-node-source'], [bandwidthNodeRegistry, '--bandwidth-node-registry'], [bandwidthExternalRegistry, '--bandwidth-external-registry'], [bandwidthControlSource, '--bandwidth-control-source'], [bandwidthAdmission, '--bandwidth-admission'], [serverPollStorageFresh, '--server-poll-storage-fresh'], [serverPollStorage, '--server-poll-storage'], [diagnosticCancelHost, '--diagnostic-cancel-host'], [ipsecExportHttp, '--ipsec-export-http'], [ipsecExportJournal, '--ipsec-export-journal'], [routingRecoveryAuthority, '--routing-recovery-authority'], [legacyFlowRebuild, '--legacy-flow-rebuild'], [serverPollDurable, '--server-poll-durable'], [collectorPartitionWriters, '--collector-partition-writers'], [routingReservations, '--routing-reservations'], [routingReservationRuntime, '--routing-reservation-runtime'], [routingNetworkNative, '--routing-network-native'], [migrationDataInventory, '--migration-data-inventory'], [migrationSchemaInventory, '--migration-schema-inventory'], [databaseSchemaControl, '--database-schema-control'], [controlledMigrations, '--controlled-migrations'], [controlledSchemaReference, '--controlled-schema-reference'], [probeStorageHistory, '--probe-storage-history'], [probeStorageJournal, '--probe-storage-journal'], [probeLegacyOrphans, '--probe-legacy-orphans'], [probeStorageFresh, '--probe-storage-fresh']]) {
|
||||
if (selected && process.argv.slice(2).some(arg => ![flag, '--restore'].includes(arg))) throw new Error('Focused writer/recovery acceptance requires a separate disposable run')
|
||||
}
|
||||
const probeJournal = process.argv.includes('--server-probe-journal')
|
||||
if (probeJournal && process.argv.slice(2).some(arg => !['--server-probe-journal', '--restore'].includes(arg))) throw new Error('Server probe journal acceptance requires a separate disposable run')
|
||||
const flowHostKeys = process.argv.includes('--flow-host-keys')
|
||||
if (flowHostKeys && process.argv.slice(2).some(arg => !['--flow-host-keys','--restore'].includes(arg))) throw new Error('Host keys acceptance requires a separate disposable run')
|
||||
const flowRebuildMcp = process.argv.includes('--flow-rebuild-mcp')
|
||||
if (flowRebuildMcp && process.argv.slice(2).some(arg => arg !== '--flow-rebuild-mcp')) throw new Error('MCP flow rebuild acceptance requires a separate disposable run')
|
||||
const flowRebuild = process.argv.includes('--flow-rebuild')
|
||||
if (flowRebuild && process.argv.slice(2).some(arg => arg !== '--flow-rebuild')) throw new Error('Flow rebuild SQL acceptance requires a separate disposable run')
|
||||
const flowWrites = process.argv.includes('--flow-writes')
|
||||
if (flowWrites && process.argv.slice(2).some(arg => arg !== '--flow-writes')) throw new Error('Flow writer SQL acceptance requires a separate disposable run')
|
||||
const chrGreMutation = process.argv.includes('--chr-gre-mutation')
|
||||
const chrGreRevision = process.argv.includes('--chr-gre-revision')
|
||||
const chrGreRevisionRecovery = process.argv.includes('--chr-gre-revision-recovery')
|
||||
const chrRead = process.argv.includes('--chr-read')
|
||||
const chrIdentity = process.argv.includes('--chr-ospf-identity-read')
|
||||
const chrRecursive = process.argv.includes('--chr-recursive-restore')
|
||||
const chrFirewallRestore = process.argv.includes('--chr-firewall-restore')
|
||||
const chrAddress = process.argv.includes('--chr-address-list')
|
||||
const chrFirewallWrite = process.argv.includes('--chr-firewall-write')
|
||||
const chrFirewallMove = process.argv.includes('--chr-firewall-move')
|
||||
const chrVpn = process.argv.includes('--chr-vpn-catalog')
|
||||
const chrFilters = process.argv.includes('--chr-filter-write')
|
||||
const chrLegacyOspf = process.argv.includes('--chr-ospf-legacy')
|
||||
const greRevision = process.argv.includes('--gre-revision')
|
||||
const applicationAdapters = process.argv.includes('--application-adapters')
|
||||
const scopedRoutingDiagnostics = process.argv.includes('--scoped-routing-diagnostics')
|
||||
const interfaceEventsCompatibility = process.argv.includes('--interface-events-compatibility')
|
||||
const wireguardExport = process.argv.includes('--wireguard-export')
|
||||
if (wireguardExport && process.argv.includes('--wireguard-transfer')) throw new Error('Choose WireGuard transfer or export acceptance mode')
|
||||
const wireguardTransfer = process.argv.includes('--wireguard-transfer') || wireguardExport
|
||||
const scopedCheckpointAdapters = process.argv.includes('--scoped-checkpoint-adapters')
|
||||
const nativeConservation = process.argv.includes('--native-conservation')
|
||||
const nativeCreateId = process.argv.includes('--native-create-id')
|
||||
const moduleCompletion = process.argv.includes('--module-completion')
|
||||
const completionTarget = process.argv.find(arg => arg.startsWith('--only='))?.slice(7) ?? 'all'
|
||||
if (!['all','wireguard','ipsec-init','ipsec-restore','ipsec-delete','ipsec-create','vrf'].includes(completionTarget) || (completionTarget !== 'all' && !moduleCompletion)) throw new Error('Unknown isolated module target')
|
||||
if (moduleCompletion && process.argv.slice(2).some(arg => !['--module-completion', '--restore', `--only=${completionTarget}`].includes(arg))) throw new Error('Module completion acceptance requires a separate disposable run')
|
||||
const ipsecAdapters = process.argv.includes('--ipsec-adapters') || moduleCompletion
|
||||
if (nativeCreateId && (ipsecAdapters || process.argv.includes('--recovery-maintenance'))) throw new Error('Choose native CREATE ID or another focused acceptance mode')
|
||||
if (ipsecAdapters && process.argv.includes('--recovery-maintenance')) throw new Error('Choose IPsec adapters or recovery maintenance acceptance mode')
|
||||
if (nativeConservation && (nativeCreateId || ipsecAdapters || process.argv.includes('--recovery-maintenance'))) throw new Error('Choose native conservation or another focused acceptance mode')
|
||||
const recoveryMaintenance = process.argv.includes('--recovery-maintenance') || ipsecAdapters || nativeCreateId || nativeConservation
|
||||
if (Number(chrGreMutation) + Number(chrGreRevision) + Number(chrGreRevisionRecovery) + Number(chrFirewallRestore) + Number(chrAddress) + Number(chrFirewallWrite) + Number(chrRead) + Number(chrIdentity) + Number(chrRecursive) + Number(chrFirewallMove) + Number(chrVpn) + Number(chrFilters) + Number(chrLegacyOspf) + Number(process.argv.includes('--chr')) > 1) throw new Error('Choose exactly one CHR acceptance mode')
|
||||
const chr = process.argv.includes('--chr') || chrGreMutation || chrGreRevision || chrGreRevisionRecovery || chrFirewallRestore || chrAddress || chrFirewallWrite || chrRead || chrIdentity || chrRecursive || chrFirewallMove || chrVpn || chrFilters || chrLegacyOspf
|
||||
if (recoveryMaintenance && (chr || greRevision || applicationAdapters || scopedRoutingDiagnostics || interfaceEventsCompatibility || wireguardTransfer || scopedCheckpointAdapters)) throw new Error('Recovery maintenance SQL/HTTP mode cannot run with another focused mode')
|
||||
if (scopedCheckpointAdapters && (chr || greRevision || applicationAdapters || scopedRoutingDiagnostics || interfaceEventsCompatibility || wireguardTransfer)) throw new Error('Scoped checkpoint SQL/HTTP mode cannot run with another focused mode')
|
||||
if (wireguardTransfer && (chr || greRevision || applicationAdapters || scopedRoutingDiagnostics || interfaceEventsCompatibility)) throw new Error('WireGuard transfer SQL/HTTP mode cannot run with another focused mode')
|
||||
if (interfaceEventsCompatibility && (chr || greRevision || applicationAdapters || scopedRoutingDiagnostics)) throw new Error('Interface events SQL/HTTP mode cannot run with other focused modes')
|
||||
if (scopedRoutingDiagnostics && (chr || greRevision || applicationAdapters)) throw new Error('Scoped routing SQL/HTTP mode cannot run with other focused modes')
|
||||
if (applicationAdapters && (chr || greRevision)) throw new Error('Application SQL/HTTP mode cannot run with other focused modes')
|
||||
if (greRevision && chr) throw new Error('GRE revision SQL/HTTP mode cannot run with CHR acceptance')
|
||||
if (chr) {
|
||||
if (!process.env.MMAPP_TEST_CHR_HOSTS || !process.env.MMAPP_TEST_CHR_USER) throw new Error('CHR hosts and user must be explicitly configured')
|
||||
if (!process.env.MMAPP_TEST_CHR_PASSWORD) {
|
||||
if (!process.stdin.isTTY) throw new Error('Use a terminal for the private CHR password prompt')
|
||||
process.stdout.write('CHR password (hidden): ')
|
||||
process.env.MMAPP_TEST_CHR_PASSWORD = await new Promise((resolve, reject) => {
|
||||
let value = ''
|
||||
const timer = setTimeout(() => finish(new Error('Password input timed out')), 60000)
|
||||
const finish = error => {
|
||||
clearTimeout(timer); process.stdin.off('data', read); process.stdin.setRawMode(false); process.stdin.pause(); process.stdout.write('\n')
|
||||
if (error) reject(error); else resolve(value)
|
||||
}
|
||||
const read = chunk => {
|
||||
for (const character of chunk.toString()) {
|
||||
if (character === '\r' || character === '\n') { finish(); return }
|
||||
if (character === '\u0003') { finish(new Error('Cancelled')); return }
|
||||
if (character === '\u007f' || character === '\b') value = value.slice(0, -1)
|
||||
else value += character
|
||||
}
|
||||
}
|
||||
process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.on('data', read)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A private disposable cluster: never reads backend/.env or the application DB.
|
||||
const directory = await mkdtemp(join(tmpdir(), 'mm-mcp-test-'))
|
||||
const listener = createServer()
|
||||
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve))
|
||||
const port = listener.address().port
|
||||
await new Promise(resolve => listener.close(resolve))
|
||||
const password = randomBytes(24).toString('hex')
|
||||
const postgres = new EmbeddedPostgres({ databaseDir: join(directory, 'pg'), port, user: 'mcp_test', password,
|
||||
persistent: true, createPostgresUser: false, initdbFlags: ['--encoding=UTF8'], postgresFlags: ['-h', '127.0.0.1'],
|
||||
onLog: () => {}, onError: () => {} })
|
||||
let exitCode = 1
|
||||
let vrfChildrenExitConfirmed = true
|
||||
try {
|
||||
await postgres.initialise()
|
||||
await postgres.start()
|
||||
const binaries = await import(`@embedded-postgres/${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`)
|
||||
const testPath = [process.env.MMAPP_PG_CLIENT_BIN, dirname(binaries.postgres), process.env.PATH ?? process.env.Path ?? ''].filter(Boolean).join(delimiter)
|
||||
if (!profilesOnly && (process.argv.includes('--restore') || process.env.MMAPP_PG_CLIENT_BIN)) {
|
||||
for (const command of ['pg_dump', 'pg_restore']) await new Promise((resolve, reject) => {
|
||||
execFile(command, ['--version'], { env: { ...process.env, PATH: testPath }, windowsHide: true, timeout: 5000, maxBuffer: 4096 }, error => {
|
||||
if (error) reject(new Error(`Restore acceptance requires working ${command}; set MMAPP_PG_CLIENT_BIN to the PostgreSQL client bin directory`))
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
console.log('PostgreSQL restore client tools preflight PASS')
|
||||
}
|
||||
if (regressionOnly) console.log('Selected regression only: primary integration and terminal restore are not run')
|
||||
if (profilesOnly) console.log('Profiles-only regression matrix: primary integration NOT RUN; terminal restore NOT RUN; full acceptance is not claimed')
|
||||
const result = regressionOnly || profilesOnly ? 0 : await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, pollMemory ? ['--max-old-space-size=192', '--max-semi-space-size=16', fileURLToPath(new URL('../dist-mcp-memory/services/server-poll-memory.owned.test.js', import.meta.url))] : ['--import', 'tsx', principalSecuritySource ? 'src/modules/mcp/principal-security-source.owned.test.ts' : pollJournalSnapshot ? 'src/services/server-poll-journal-snapshot.owned.test.ts' : pollTerminalArchive ? 'src/services/server-poll-terminal-archive.owned.test.ts' : pollArchiveSchema ? 'src/db/server-poll-terminal-archives.sql.test.ts' : telemetrySettingsConfiguration ? 'src/modules/mcp/telemetry-settings-configuration.owned.test.ts' : routingReservationSchema ? 'src/db/routing-reservation-schema.owned.test.ts' : routingForwardRounds ? 'src/modules/mcp/routing-network-forward-rounds.owned.test.ts' : flowActiveServices ? 'src/services/traffic-flow-active-services.owned.test.ts' : flowActiveRebuild ? 'src/services/traffic-flow-active-rebuild.owned.test.ts' : flowQuality ? 'src/services/traffic-flow-quality.owned.test.ts' : flowWorkerIpc ? 'src/services/traffic-flow-worker-ipc.owned.test.ts' : manualUptime ? 'src/modules/mcp/manual-uptime-http.test.ts' : manualPingGroup ? 'src/modules/mcp/manual-ping-group-http.test.ts' : manualTraffic ? 'src/modules/mcp/manual-traffic-http.test.ts' : manualApiPing ? 'src/modules/mcp/manual-api-ping-http.test.ts' : simpleSpeedChr ? 'src/modules/mcp/simple-speed-chr.test.ts' : simpleSpeed ? 'src/modules/mcp/simple-speed-http.test.ts' : externalPeerMetadata ? 'src/services/bandwidth-external-peer-metadata.sql.test.ts' : operatorPeerMetadata ? 'src/services/bandwidth-operator-metadata.sql.test.ts' : publicRegistryMetadata ? 'src/services/bandwidth-public-registry.sql.test.ts' : operatorDockerSql ? 'src/services/bandwidth-operator-docker-stop.sql.test.ts' : mcpOperationOrigin ? 'src/modules/mcp/operation-execution-origin.sql.test.ts' : mcpSpeedOriginal ? 'src/modules/mcp/speed-bandwidth-original.sql.test.ts' : routerConfigurationGuard ? 'src/services/router-configuration-guard.sql.test.ts' : scheduledSpeedOriginal ? 'src/services/bandwidth-scheduled-speed-original.sql.test.ts' : bandwidthSpeedWorkflow ? 'src/services/bandwidth-speed-workflow.sql.test.ts' : restSpeedOriginal ? 'src/services/bandwidth-rest-speed-original.sql.test.ts' : operationLocks ? 'src/services/operation-config-lock-lease.sql.test.ts' : serviceExecutionOrigin ? 'src/modules/mcp/service-execution-origin.sql.test.ts' : bandwidthNodeSource ? 'src/services/bandwidth-node-source.sql.test.ts' : bandwidthExternalRegistry ? 'src/services/bandwidth-external-node-registry.sql.test.ts' : bandwidthNodeRegistry ? 'src/services/bandwidth-node-registry.sql.test.ts' : bandwidthControlSource ? 'src/services/bandwidth-control-source.sql.test.ts' : bandwidthAdmission ? 'src/services/bandwidth-admission.sql.test.ts' : serverPollStorageFresh ? 'src/services/server-poll-storage-fresh.sql.test.ts' : serverPollStorage ? 'src/services/server-poll-storage.sql.test.ts' : probeStorageFresh ? 'src/services/server-probe-storage-fresh.sql.test.ts' : probeLegacyOrphans ? 'src/services/server-probe-legacy-orphans.sql.test.ts' : diagnosticCancelHost ? 'src/modules/mcp/diagnostic-cancellation-storage.sql.test.ts' : ipsecExportHttp ? 'src/modules/mcp/ipsec-existing-certificate-export-http.sql.test.ts' : ipsecExportJournal ? 'src/services/ipsec-existing-certificate-export-rest-journal.sql.test.ts' : migrationDataInventory ? 'src/db/migration-data-inventory.sql.test.ts' : probeStorageHistory ? 'src/services/server-probe-storage-history.sql.test.ts' : probeStorageJournal ? 'src/services/server-probe-storage-journal.sql.test.ts' : controlledSchemaReference ? 'src/db/controlled-schema-reference.sql.test.ts' : controlledMigrations ? 'src/db/controlled-migrations.sql.test.ts' : databaseSchemaControl ? 'src/db/database-schema-control.sql.test.ts' : migrationSchemaInventory ? 'src/db/migration-schema-inventory.sql.test.ts' : routingNetworkNative ? 'src/modules/mcp/routing-network-native.sql.test.ts' : routingReservationRuntime ? 'src/modules/mcp/routing-reservation-runtime.sql.test.ts' : routingReservations ? 'src/modules/mcp/routing-reservation.sql.test.ts' : collectorPartitionWriters ? 'src/services/collector-partition-writes.sql.test.ts' : serverPollDurable ? 'src/services/server-poll-durable-session.sql.test.ts' : routingRecoveryAuthority ? 'src/modules/mcp/routing-recovery-authority.sql.test.ts' : legacyFlowRebuild ? 'src/test/legacy-flow-rebuild-sql.ts' : probeJournal ? 'src/services/server-probe-journal.sql.test.ts' : flowHostKeys ? 'src/modules/mcp/flow-host-keys-http.test.ts' : flowRebuildMcp ? 'src/modules/mcp/flow-facts-rebuild-http.test.ts' : flowRebuild ? 'src/test/flow-facts-rebuild-sql.ts' : flowWrites ? 'src/services/traffic-flow-write-transaction.pg.test.ts' : nativeConservation ? 'src/modules/mcp/native-conservation-sql.test.ts' : nativeCreateId ? 'src/modules/mcp/native-create-id-http.test.ts' : ipsecAdapters ? "src/mLine truncated
|
||||
cwd: pollMemory ? directory : new URL('../', import.meta.url), stdio: 'inherit', env: { ...(pollMemory ? pollMemoryEnvironment : process.env),
|
||||
DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`,
|
||||
DATABASE_PATH: join(directory, 'no-legacy-database.sqlite'), SQLITE_IMPORT_FULL_HISTORY: 'false',
|
||||
MCP_STATE_DIR: join(directory, 'state'), AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: 'mcp-integration-test-only', MMAPP_TEST_WG_EXPORT_ONLY: String(wireguardExport),
|
||||
MCP_PUBLIC_URL: 'http://localhost:8000/mcp', NODE_ENV: 'test', PATH: testPath, NODE_OPTIONS: pollMemory ? '' : process.env.NODE_OPTIONS, MMAPP_OWN_PRINCIPAL_SECURITY_SOURCE: String(principalSecuritySource), MMAPP_OWN_POLL_JOURNAL_SNAPSHOT: String(pollJournalSnapshot), MMAPP_OWN_POLL_MEMORY: String(pollMemory), MMAPP_OWN_POLL_BOOTSTRAP: String(pollMemoryBootstrap), MMAPP_OWN_POLL_COST_DIAGNOSTICS: String(pollMemoryCost), MMAPP_OWN_POLL_ARCHIVE_CLAIM_LOSS: String(pollArchiveClaimLoss), MMAPP_OWN_POLL_ARCHIVE_HISTORY: String(pollArchiveHistory), MMAPP_OWN_POLL_ARCHIVE_COLD: String(pollArchiveCold), MMAPP_OWN_POLL_ARCHIVE_LOST_ACK: String(pollArchiveLostAck), MMAPP_OWN_POLL_TERMINAL_ARCHIVE: String(pollTerminalArchive), MMAPP_OWN_POLL_ARCHIVE_SCHEMA: String(pollArchiveSchema), MMAPP_OWN_TELEMETRY_SETTINGS_CONFIGURATION: String(telemetrySettingsConfiguration), MMAPP_OWN_ROUTING_RESERVATION_SCHEMA: String(routingReservationSchema), MMAPP_OWN_ROUTING_FORWARD_ROUNDS: String(routingForwardRounds), MMAPP_OWN_FLOW_ACTIVE_SERVICES: String(flowActiveServices), MMAPP_OWN_FLOW_ACTIVE_REBUILD: String(flowActiveRebuild), MMAPP_OWN_FLOW_QUALITY: String(flowQuality), MMAPP_OWN_FLOW_WORKER_IPC: String(flowWorkerIpc), MMAPP_OWN_MANUAL_UPTIME: String(manualUptime), MMAPP_OWN_MANUAL_PING_GROUP: String(manualPingGroup), MMAPP_OWN_MANUAL_TRAFFIC: String(manualTraffic), MMAPP_OWN_MANUAL_API_PING: String(manualApiPing), MMAPP_OWN_SIMPLE_SPEED: String(simpleSpeed), MMAPP_OWN_SIMPLE_SPEED_CHR: String(simpleSpeedChr), MMAPP_OWN_DIAGNOSTIC_CANCEL_HOST: String(diagnosticCancelHost), MMAPP_OWN_OPERATOR_DOCKER_SQL: String(operatorDockerSql), MMAPP_OWN_PUBLIC_REGISTRY_METADATA: String(publicRegistryMetadata), MMAPP_OWN_OPERATOR_PEER_METADATA: String(operatorPeerMetadata), MMAPP_OWN_EXTERNAL_PEER_METADATA: String(externalPeerMetadata), MMAPP_OWN_BANDWIDTH_EXTERNAL_REGISTRY: String(externalPeerMetadata || operatorPeerMetadata || publicRegistryMetadata || operatorDockerSql || mcpSpeedOriginal || bandwidthSpeedWorkflow || bandwidthExternalRegistry), MMAPP_OWN_BANDWIDTH_SPEED_WORKFLOW: String(bandwidthSpeedWorkflow), MMAPP_OWN_REST_SPEED_ORIGINAL: String(restSpeedOriginal), MMAPP_OWN_MCP_OPERATION_ORIGIN: String(mcpOperationOrigin), MMAPP_OWN_MCP_SPEED_ORIGINAL: String(mcpSpeedOriginal), MMAPP_OWN_SCHEDULED_SPEED_ORIGINAL: String(scheduledSpeedOriginal), MMAPP_TEST_ROUTER_GUARD_OWNED: String(routerConfigurationGuard), MMAPP_TEST_ROUTER_GUARD_CLUSTER_DISPOSABLE: String(routerConfigurationGuard), MMAPP_TEST_ROUTER_GUARD_ADMIN_URL: routerConfigurationGuard ? `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres` : '', MMAPP_OWN_BANDWIDTH_ADMISSION: String(externalPeerMetadata || operatorPeerMetadata || publicRegistryMetadata || operatorDockerSql || mcpOperationOrigin || mcpSpeedOriginal || scheduledSpeedOriginal || bandwidthSpeedWorkflow || restSpeedOriginal || bandwidthExternalRegistry || operationLocks || serviceExecutionOrigin || bandwidthAdmission || bandwidthControlSource || bandwidthNodeRegistry || bandwidthNodeSource), MMAPP_OWN_BANDWIDTH_RESTORE: String((bandwidthAdmission || bandwidthControlSource || bandwidthNodeRegistry || bandwidthNodeSource) && process.argv.includes('--restore')), MMAPP_OWN_IPSEC_EXPORT_HTTP: String(ipsecExportHttp), MMAPP_OWN_IPSEC_EXPORT_JOURNAL: String(ipsecExportJournal), MMAPP_OWN_SQL_DB: 'postgres', MMAPP_OWN_PROBE_LEGACY_ORPHANS: String(probeLegacyOrphans), MMAPP_OWN_PROBE_STORAGE_HISTORY: String(probeStorageHistory), MMAPP_OWN_PROBE_STORAGE_FRESH: String(probeStorageFresh), MMAPP_OWN_PROBE_STORAGE_JOURNAL: String(probeStorageJournal || probeStorageFresh), MMAPP_OWN_CONTROLLED_SCHEMA_REFERENCE: String(controlledSchemaReference), MMAPP_OWN_CONTROLLED_MIGRATIONS: String(controlledMigrations), MMAPP_OWN_DATABASE_SCHEMA_CONTROL: String(databaseSchemaControl), MMAPP_OWN_MIGRATION_DATA_INVENTORY: String(migrationDataInventory), MMAPP_OWN_MIGRATION_SCHEMA_INVENTORY: String(migrationSchemaInventory), MMAPP_OWN_ROUTING_NETWORK_NATIVE: String(routingNetworkNative), MMAPP_OWN_ROUTING_RESERVATIONS: String(routingReservations || routingReservationRuntime), MMAPP_OWN_COLLECTOR_PARTITION_WRITERS: String(collectorPartitionWriters), MMAPP_OWN_SERVER_POLL_STORAGE_FRESH: String(serverPollStorageFresh), MMAPP_OWN_SERVER_POLL_STORAGE: String(serverPollStorage), MMAPP_OWN_SERVER_POLL_DURABLE: String(serverPollDurable), MMAPP_OWN_ROUTING_RECOVERY_AUTHORITY: String(routingRecoveryAuthority), MMAPP_OWN_LEGACY_FLOW_REBUILD: String(legacyFlowRebuild), MMAPP_OWN_PROBE_JOURNAL: String(probeJournal), MMAPP_MODULE_COMPLETION_ONLY: String(moduleCompletion), MMAPP_MODULE_COMPLETION_TARGET: completionTarget, MMAPP_OWN_FLOW_HOST_KEYS: String(flowHostKeys), MMAPP_OWN_MCP_FLOW_REBUILD: String(flowRebuildMcp), MMAPP_OWN_FLOW_WRITES: String(flowWrites), MMAPP_OWN_FLOW_REBUILD: String(flowRebuild) },
|
||||
})
|
||||
child.once('error', reject); child.once('exit', code => resolve(code ?? 1))
|
||||
})
|
||||
exitCode = result
|
||||
if (result === 0 && !regressionOnly && !profilesOnly && !pollMemory && !principalSecuritySource && !pollJournalSnapshot && !pollArchiveSchema && !telemetrySettingsConfiguration && !routingReservationSchema && !routingForwardRounds && !flowActiveServices && !flowActiveRebuild && !flowQuality && !flowWorkerIpc && !manualUptime && !manualPingGroup && !manualTraffic && !manualApiPing && !simpleSpeed && !simpleSpeedChr && !externalPeerMetadata && !operatorPeerMetadata && !publicRegistryMetadata && !operatorDockerSql && !mcpOperationOrigin && !mcpSpeedOriginal && !routerConfigurationGuard && !scheduledSpeedOriginal && !bandwidthSpeedWorkflow && !restSpeedOriginal && !operationLocks && !serviceExecutionOrigin && !bandwidthNodeSource && !bandwidthNodeRegistry && !bandwidthExternalRegistry && !bandwidthControlSource && !bandwidthAdmission && !probeLegacyOrphans && !probeStorageJournal && !probeStorageHistory && !probeJournal && !chr && !flowWrites && !flowRebuild && !flowRebuildMcp && (process.argv.includes('--restore') || process.env.MMAPP_PG_CLIENT_BIN)) {
|
||||
exitCode = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['--import', 'tsx', 'src/modules/mcp/database-restore.test.ts'], {
|
||||
cwd: new URL('../', import.meta.url), stdio: 'inherit', env: { ...process.env,
|
||||
DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`,
|
||||
DATABASE_PATH: join(directory, 'no-legacy-database.sqlite'), SQLITE_IMPORT_FULL_HISTORY: 'false',
|
||||
MCP_STATE_DIR: join(directory, 'state'), AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: 'mcp-integration-test-only',
|
||||
MCP_PUBLIC_URL: 'http://localhost:8000/mcp', NODE_ENV: 'test', PATH: testPath,
|
||||
MMAPP_OWN_SQL_DB: 'postgres', MMAPP_OWN_TERMINAL_RESTORE: 'true', MMAPP_OWN_TERMINAL_DATA_EQUIVALENCE: String(migrationDataInventory) },
|
||||
})
|
||||
child.once('error', reject); child.once('exit', code => resolve(code ?? 1))
|
||||
})
|
||||
}
|
||||
else if (result === 0 && pollArchiveSchema) console.log('Poll archive schema: owned nonempty40 preservation, numbered41 and structural refusal guards; no positive MCP archive claim')
|
||||
else if (result === 0 && telemetrySettingsConfiguration) console.log('Telemetry settings: genuine MCP/SQL configuration binding, collector metadata tolerated; no native or external sources')
|
||||
else if (result === 0 && routingReservationSchema) console.log('Routing reservation numbered0040: ownSQL fresh/legacy/manual preservation; no READY, backfill, capabilities or public native activation')
|
||||
else if (result === 0 && routingForwardRounds) console.log('Routing forward rounds: genuine approved running CONFIG_APPLIED, shared cumulative native budget; logs/security/autoRecovery remain unaccepted')
|
||||
else if (result === 0 && flowActiveServices) console.log('Flow active settings/purge: production services and main Worker; disposable SQL/localhost only')
|
||||
else if (result === 0 && flowActiveRebuild) console.log('Flow active rebuild: production shared service and genuine Worker drain; disposable SQL/localhost only')
|
||||
else if (result === 0 && flowQuality) console.log('Flow quality: production engine and genuine receipt SQL, explicit COMMIT ACK fault on owned cluster only; no CHR/whole-installation RAM claim')
|
||||
else if (result === 0 && flowWorkerIpc) console.log('Flow worker IPC: production main/Worker lifecycle, localhost UDP, owned PostgreSQL only; real CHR and concurrent-spawn acceptance remain separate')
|
||||
else if (result === 0 && manualUptime) console.log('Manual uptime: full resources then ping, actual shared service and deferred alerts; owned PostgreSQL/localhost only')
|
||||
else if (result === 0 && manualPingGroup) console.log('Manual Ping group: existing normalization/mutex/samples/conditional global alerts; owned PostgreSQL/localhost only')
|
||||
else if (result === 0 && manualTraffic) console.log('Manual traffic: full scheduler/events/settings/retention/alerts shared by REST and MCP; owned PostgreSQL/localhost only')
|
||||
else if (result === 0 && manualApiPing) console.log('Manual API Ping: ordinary global collector with alerts hook, own PostgreSQL and localhost only')
|
||||
else if (result === 0 && simpleSpeed) console.log('Simple RouterOS speed: public MCP/REST, ordinary shared service, own PostgreSQL and localhost RouterOS only')
|
||||
else if (result === 0 && simpleSpeedChr) console.log('Simple RouterOS speed: own PostgreSQL, real two-CHR bounded test and ordinary MCP approval; no router configuration changes')
|
||||
else if (result === 0 && externalPeerMetadata) console.log('Guarded peer sources: actual managed/external SQL-owner and localhost license composition; Q/catalog attestation synthetic, full operator window remains open')
|
||||
else if (result === 0 && operatorPeerMetadata) console.log('Operator peer metadata: actual managed endpoint SQL and genuine localhost license origin; synthetic Q, external metadata and full operator window remain open')
|
||||
else if (result === 0 && publicRegistryMetadata) console.log('Public registry metadata: actual owned PostgreSQL and same physical lease; no secret/Q/native/write authority or backend-window acceptance')
|
||||
else if (result === 0 && operatorDockerSql) console.log('Operator Docker SQL: actual owned PostgreSQL with mocked Docker HTTP only; not a real Docker/backend-window acceptance')
|
||||
else if (result === 0 && mcpOperationOrigin) console.log('MCP operation root: owned PostgreSQL ordinary runner provenance; no restore/recovery, global writer activation, CHR or deployment')
|
||||
else if (result === 0 && mcpSpeedOriginal) console.log('MCP speed: genuine trusted service context with real connection/grant/SQL, host receipt and localhost native; synthetic Q/mapping; not a public MCP runner/action acceptance')
|
||||
else if (result === 0 && routerConfigurationGuard) console.log('Router configuration guard: dedicated owned databases/roles and localhost native fixture; no application migration, production activation or real CHR')
|
||||
else if (result === 0 && scheduledSpeedOriginal) console.log('Scheduled speed original: genuine fixed-job scheduler authority, actual owned SQL and localhost read-only TCP; no legacy collector, native bandwidth, history or runtime activation')
|
||||
else if (result === 0 && bandwidthSpeedWorkflow) console.log('Whole speed workflow: actual REST/JWT, signed v2 control SQL, native fixture HTTP and history; synthetic Q/mapping; no runtime activation or real CHR')
|
||||
else if (result === 0 && restSpeedOriginal) console.log('REST speed original profile: owned application SQL and localhost read-only TCP; no bandwidth POST, history, runtime activation or real CHR')
|
||||
else if (result === 0 && operationLocks) console.log('Operation lock profile: owned PostgreSQL/localhost fixtures; no terminal restore or real CHR')
|
||||
else if (result === 0 && serviceExecutionOrigin) console.log('Service origin profile: owned SQL only; no terminal restore or native networking')
|
||||
else if (result === 0 && (bandwidthAdmission || bandwidthControlSource || bandwidthNodeRegistry || bandwidthExternalRegistry || bandwidthNodeSource)) console.log('Bandwidth proposal profile: dedicated owned data round-trip only; no normal enrollment or production restore claim')
|
||||
else if (result === 0 && (probeLegacyOrphans || probeStorageJournal || probeStorageHistory || probeJournal)) console.log('Legacy-only profile: positive current-version terminal restore belongs to --probe-storage-fresh; no migration marker fabricated')
|
||||
else if (result === 0 && profilesOnly) console.log(profilesGroup ? 'Profiles-only fixed group: ' + profilesGroup + '; partial matrix on its own fresh database' : 'Profiles-only: running the unchanged complete regression list plus mandatory test:logs:pg on one fresh owned database')
|
||||
else if (result === 0 && serverPollStorageFresh && process.env.MMAPP_OWN_SERVER_POLL_SETTINGS_EPOCH === 'true') console.log('Targeted settings epoch upgrade and actual pg_dump/restore accepted; full primary/poll-storage terminal restore not run')
|
||||
else if (result === 0 && !chr && !flowWrites && !flowRebuild && !flowRebuildMcp) console.log('Terminal restore not run: use --restore with PostgreSQL client tools on PATH, or set MMAPP_PG_CLIENT_BIN')
|
||||
if (exitCode === 0 && (serverPollStorage || serverPollStorageFresh) && (process.argv.includes('--restore') || process.env.MMAPP_PG_CLIENT_BIN)) {
|
||||
exitCode = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['--import', 'tsx', 'src/services/server-poll-storage-restore.sql.test.ts'], {
|
||||
cwd: new URL('../', import.meta.url), stdio: 'inherit', env: { ...process.env,
|
||||
DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`,
|
||||
DATABASE_PATH: join(directory, 'no-legacy-database.sqlite'), SQLITE_IMPORT_FULL_HISTORY: 'false',
|
||||
MCP_STATE_DIR: join(directory, 'state'), AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: 'mcp-integration-test-only',
|
||||
NODE_ENV: 'test', PATH: testPath, MMAPP_OWN_SERVER_POLL_STORAGE_RESTORE: 'true' },
|
||||
})
|
||||
child.once('error', reject); child.once('exit', code => resolve(code ?? 1))
|
||||
})
|
||||
}
|
||||
if (exitCode === 0 && process.argv.includes('--all')) {
|
||||
if (!process.env.npm_execpath) throw new Error('Run --all through npm run test:mcp:pg:all')
|
||||
const database = 'mm_accept_166_' + Date.now()
|
||||
await postgres.createDatabase(database)
|
||||
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
|
||||
let profiles = packageJson.scripts['test:all'].split(/\s*&&\s*/).map(command => command.replace('npm run ', ''))
|
||||
if (process.argv.includes('--remaining')) profiles = profiles.slice(profiles.indexOf('test:traffic-flow'))
|
||||
const selected = process.argv.find(value => value.startsWith('--profile='))?.slice('--profile='.length)
|
||||
if (selected) {
|
||||
if (![...profiles, 'test:logs:pg', 'test:vrf:production'].includes(selected)) throw new Error('Unknown regression profile')
|
||||
profiles = [selected]
|
||||
}
|
||||
const failures = []
|
||||
const requiredProfiles = regressionProfiles(profiles, selected, profilesGroup)
|
||||
for (const script of requiredProfiles) {
|
||||
if (script === 'test:vrf:production') {
|
||||
vrfChildrenExitConfirmed = false
|
||||
const receipt = await runOwnedVrfProductionProfile({ directory, database, databaseUrl: `postgresql://mcp_test:${password}@127.0.0.1:${port}/${database}` })
|
||||
vrfChildrenExitConfirmed = receipt.childExitConfirmed
|
||||
if (!receipt.accepted) failures.push(script)
|
||||
if (!vrfChildrenExitConfirmed) break
|
||||
continue
|
||||
}
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [process.env.npm_execpath, 'run', script], {
|
||||
cwd: new URL('../', import.meta.url), stdio: 'inherit', env: { ...process.env,
|
||||
DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/${database}`,
|
||||
DATABASE_PATH: join(directory, 'no-legacy-database.sqlite'), SQLITE_IMPORT_FULL_HISTORY: 'false',
|
||||
MMAPP_OWN_SQL_DB: database, MCP_STATE_DIR: join(directory, 'regression-state'),
|
||||
REQUIRE_TEST_POSTGRES: '1',
|
||||
LOGS_PERFORMANCE: process.argv.includes('--performance') ? '1' : '0',
|
||||
AUTH_REQUIRED: 'false', NODE_ENV: 'test' },
|
||||
})
|
||||
child.once('error', reject); child.once('exit', code => resolve(code ?? 1))
|
||||
})
|
||||
if (status !== 0) failures.push(script)
|
||||
}
|
||||
exitCode = failures.length ? 1 : 0
|
||||
console.log('Regression failed profiles:', failures.length ? failures.join(', ') : 'none')
|
||||
if (profilesOnly) console.log('Profiles-only matrix result:', exitCode === 0 ? 'PASS' : 'FAIL', 'profiles=' + requiredProfiles.length, ...(profilesGroup ? ['group=' + profilesGroup, 'completeMatrix=false'] : []), 'primaryIntegration=NOT_RUN terminalRestore=NOT_RUN fullAcceptance=false')
|
||||
}
|
||||
} catch (error) {
|
||||
exitCode = 1
|
||||
console.error(error)
|
||||
} finally {
|
||||
await postgres.stop()
|
||||
const rel = relative(resolve(tmpdir()), resolve(directory))
|
||||
if (!rel || rel.startsWith('..') || isAbsolute(rel) || !rel.startsWith('mm-mcp-test-')) throw new Error('Unsafe test cleanup path')
|
||||
if (vrfChildrenExitConfirmed) await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 })
|
||||
else console.error('VRF child exit unconfirmed; own test directory retained')
|
||||
process.exitCode = exitCode
|
||||
}
|
||||
// embedded-postgres installs a beforeExit hook that otherwise forces exit(0).
|
||||
process.exit(exitCode)
|
||||
@@ -0,0 +1,67 @@
|
||||
import EmbeddedPostgres from 'embedded-postgres'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdir, mkdtemp, readdir, readFile, writeFile, cp, rm } from 'node:fs/promises'
|
||||
import { resolve, join, relative, dirname } from 'node:path'
|
||||
import { randomBytes, createHash } from 'node:crypto'
|
||||
import { createServer } from 'node:net'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const allowed = new Set(['--default-scoped', '--main-only', '--check-only'])
|
||||
if (process.argv.slice(2).some(arg => !allowed.has(arg)) || new Set(process.argv.slice(2)).size !== process.argv.slice(2).length || process.argv.includes('--default-scoped') && process.argv.includes('--main-only')) throw new Error('Usage: node backend/scripts/test-routing-controller-baseline.mjs [--default-scoped | --main-only] [--check-only]')
|
||||
const root = resolve(import.meta.dirname, '../..'), require = createRequire(import.meta.url), ts = require('typescript')
|
||||
const mode = process.argv.includes('--default-scoped') ? 'default-scoped' : process.argv.includes('--main-only') ? 'main-only' : 'main-and-forward-rounds'
|
||||
const target = join(root, 'backend/src/modules/mcp/routing-controller-active-writer.ts')
|
||||
await readFile(target)
|
||||
await readFile(join(root, 'backend/src/modules/mcp/routing-main-transition.ts'))
|
||||
await readFile(join(root, 'backend/src/modules/mcp/routing-controller-baseline-acceptance.ts'))
|
||||
if (process.argv.includes('--check-only')) { console.log(JSON.stringify({ mode, sourcePresent: true, acceptanceExecuted: false, productionApplied: false })); process.exit(0) }
|
||||
await mkdir(join(root, 'tmp'), { recursive: true })
|
||||
const directory = await mkdtemp(join(root, 'tmp/routing-controller-baseline-owned-')), output = join(directory, 'dist'), sourcePins = []
|
||||
const hash = buffer => createHash('sha256').update(buffer).digest('hex')
|
||||
async function compile(sourceDirectory) {
|
||||
for (const entry of await readdir(sourceDirectory, { withFileTypes: true })) {
|
||||
const path = join(sourceDirectory, entry.name)
|
||||
if (entry.isDirectory()) { await compile(path); continue }
|
||||
const name = relative(join(root, 'backend/src'), path).replaceAll('\\', '/')
|
||||
if (name.endsWith('.test.ts') || name.endsWith('.d.ts')) continue
|
||||
const bytes = await readFile(path), emitted = join(output, name.replace(/\.ts$/, '.js'))
|
||||
await mkdir(dirname(emitted), { recursive: true })
|
||||
await writeFile(emitted, name.endsWith('.ts') ? ts.transpileModule(bytes.toString('utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, esModuleInterop: true, resolveJsonModule: true } }).outputText : bytes)
|
||||
sourcePins.push({ path: name, sourceSha256: hash(bytes), emittedSha256: hash(await readFile(emitted)) })
|
||||
}
|
||||
}
|
||||
await writeFile(join(directory, 'package.json'), '{"type":"module"}')
|
||||
await compile(join(root, 'backend/src'))
|
||||
await cp(join(root, 'backend/drizzle'), join(directory, 'drizzle'), { recursive: true })
|
||||
await writeFile(join(directory, 'snapshot-manifest.json'), JSON.stringify({ mode, productionApplied: false, wholeMutableWorktreeFrozen: false, sourcePins }, null, 2))
|
||||
const listener = createServer()
|
||||
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve))
|
||||
const port = listener.address().port
|
||||
await new Promise(resolve => listener.close(resolve))
|
||||
const password = randomBytes(24).toString('hex')
|
||||
const postgres = new EmbeddedPostgres({ databaseDir: join(directory, 'pg'), port, user: 'mcp_test', password,
|
||||
persistent: true, createPostgresUser: false, initdbFlags: ['--encoding=UTF8'], postgresFlags: ['-h', '127.0.0.1'], onLog: () => {}, onError: () => {} })
|
||||
let exitCode = 1, log = ''
|
||||
console.log(`Owned routing baseline acceptance: ${directory}; mode=${mode}`)
|
||||
try {
|
||||
await postgres.initialise(); await postgres.start()
|
||||
const retained = Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path', 'LOCALAPPDATA', 'APPDATA'].filter(key => process.env[key]).map(key => [key, process.env[key]]))
|
||||
exitCode = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [join(output, 'test/routing-controller-baseline-acceptance-main.js')], { cwd: directory, windowsHide: true,
|
||||
env: { ...retained, DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`, DATABASE_PATH: join(directory, 'no-legacy.sqlite'),
|
||||
MCP_STATE_DIR: join(directory, 'state'), NODE_ENV: 'test', AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: 'routing-baseline-test-only', MCP_PUBLIC_URL: 'http://localhost:8000/mcp',
|
||||
MMAPP_ROUTING_BASELINE_OWN_SQL: 'true', MMAPP_ROUTING_BASELINE_MODE: mode } })
|
||||
child.stdout.on('data', bytes => { process.stdout.write(bytes); log += bytes }); child.stderr.on('data', bytes => { process.stderr.write(bytes); log += bytes })
|
||||
child.once('error', reject); child.once('exit', code => resolve(code ?? 1))
|
||||
})
|
||||
} finally {
|
||||
await writeFile(join(directory, 'runtime.log'), log)
|
||||
// embedded-postgres waits for a new exit event; avoid waiting again if its
|
||||
// owned child has already exited. This never targets another database.
|
||||
if (postgres.process?.exitCode === null) await postgres.stop()
|
||||
if (dirname(resolve(directory)) !== resolve(root, 'tmp') || !directory.includes('routing-controller-baseline-owned-')) throw new Error('Owned routing cleanup boundary changed')
|
||||
await rm(join(directory, 'pg'), { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
|
||||
console.log('Owned routing PostgreSQL stopped and its data removed; snapshot/log evidence retained')
|
||||
}
|
||||
console.log(JSON.stringify({ runDirectory: directory, mode, exitCode, productionApplied: false }))
|
||||
process.exitCode = exitCode
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Fixed local delayed-approval acceptance; only the original guarded runner
|
||||
* creates/removes a disposable PostgreSQL. No target or restore arguments. */
|
||||
import assert from 'node:assert/strict'
|
||||
import {spawn} from 'node:child_process'
|
||||
import {dirname,resolve} from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
assert.equal(process.argv.length,2)
|
||||
const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..')
|
||||
const environment={...process.env,NODE_ENV:'test',MMAPP_OWN_SERVER_POLL_DELAYED_APPROVAL:'true',
|
||||
MMAPP_OWN_SERVER_POLL_HTTP_ROUTE:'false',MMAPP_OWN_SERVER_POLL_SETTINGS_EPOCH:'false'}
|
||||
// This fixed profile never requests the original runner's full terminal restore.
|
||||
delete environment.MMAPP_PG_CLIENT_BIN
|
||||
const child=spawn(process.execPath,[resolve(root,'backend/scripts/test-mcp-postgres.mjs'),'--server-poll-storage-fresh'],{
|
||||
cwd:root,windowsHide:true,stdio:'inherit',env:environment,
|
||||
})
|
||||
const code=await new Promise((done,reject)=>{child.once('error',reject);child.once('exit',code=>done(code??1))})
|
||||
process.exitCode=code
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Fixed disposable PostgreSQL profile, no target/deploy/restore arguments. */
|
||||
import assert from 'node:assert/strict'
|
||||
import {spawn} from 'node:child_process'
|
||||
import {dirname,resolve} from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
assert.equal(process.argv.length,2)
|
||||
const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..')
|
||||
const environment={...process.env,NODE_ENV:'test',MMAPP_OWN_SERVER_POLL_ATOMIC_MAINTENANCE:'true',MMAPP_OWN_SERVER_POLL_DELAYED_APPROVAL:'false',MMAPP_OWN_SERVER_POLL_HTTP_ROUTE:'false',MMAPP_OWN_SERVER_POLL_SETTINGS_EPOCH:'false'}
|
||||
delete environment.MMAPP_PG_CLIENT_BIN
|
||||
const child=spawn(process.execPath,[resolve(root,'backend/scripts/test-mcp-postgres.mjs'),'--server-poll-storage-fresh'],{cwd:root,windowsHide:true,stdio:'inherit',env:environment})
|
||||
const code=await new Promise((done,reject)=>{child.once('error',reject);child.once('exit',code=>done(code??1))})
|
||||
process.exitCode=code
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Owns no application resources directly: the original guarded PostgreSQL
|
||||
* runner creates/removes its fresh cluster and supplies every connection. */
|
||||
import assert from 'node:assert/strict'
|
||||
import {spawn} from 'node:child_process'
|
||||
import {dirname,resolve} from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
assert.equal(process.argv.length,2,'HTTP poll acceptance takes no target or connection arguments')
|
||||
const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..')
|
||||
const child=spawn(process.execPath,[resolve(root,'backend/scripts/test-mcp-postgres.mjs'),'--server-poll-storage-fresh'],{
|
||||
cwd:root,windowsHide:true,stdio:'inherit',env:{...process.env,NODE_ENV:'test',MMAPP_OWN_SERVER_POLL_HTTP_ROUTE:'true'},
|
||||
})
|
||||
const code=await new Promise((done,reject)=>{child.once('error',reject);child.once('exit',code=>done(code??1))})
|
||||
process.exitCode=code
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Fixed-target numbered migration and restore acceptance. Only the existing guarded runner creates
|
||||
* the disposable PostgreSQL; no connection/remote/restore arguments accepted. */
|
||||
import assert from 'node:assert/strict'
|
||||
import {spawn} from 'node:child_process'
|
||||
import {dirname,resolve} from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
assert.equal(process.argv.length,2,'Settings epoch acceptance takes no target or connection arguments')
|
||||
const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..')
|
||||
const child=spawn(process.execPath,[resolve(root,'backend/scripts/test-mcp-postgres.mjs'),'--server-poll-storage-fresh'],{
|
||||
cwd:root,windowsHide:true,stdio:'inherit',env:{...process.env,NODE_ENV:'test',MMAPP_OWN_SERVER_POLL_SETTINGS_EPOCH:'true',MMAPP_OWN_SERVER_POLL_HTTP_ROUTE:'false'},
|
||||
})
|
||||
const code=await new Promise((done,reject)=>{child.once('error',reject);child.once('exit',code=>done(code??1))})
|
||||
process.exitCode=code
|
||||
@@ -0,0 +1,87 @@
|
||||
// Isolated regression only: no application config, configured DB URL or startup.
|
||||
import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, relative, isAbsolute, basename } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { createRequire } from 'node:module'
|
||||
import { randomBytes, createHash } from 'node:crypto'
|
||||
import { createServer } from 'node:net'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length > 1 || args[0] && !args[0].startsWith('--source-root=')) throw Error('Only --source-root=PATH review override is supported')
|
||||
const sourceRoot = args[0] ? resolve(args[0].slice('--source-root='.length)) : fileURLToPath(new URL('../../', import.meta.url))
|
||||
const require = createRequire(join(sourceRoot, 'backend/package.json'))
|
||||
const { default: EmbeddedPostgres } = await import(pathToFileURL(require.resolve('embedded-postgres')).href)
|
||||
const sources = ['backend/src/services/partition-write-transaction.ts', 'backend/src/db/sqlite-import-control-writes.ts',
|
||||
'backend/src/db/migration-schema-inventory.ts', 'backend/src/db/migration-data-inventory.ts', 'backend/src/db/schema.ts', 'backend/src/db/index.ts']
|
||||
const ownFiles = [fileURLToPath(import.meta.url), fileURLToPath(new URL('../src/db/database-source-capture-adopted.sql-helper.ts', import.meta.url)),
|
||||
fileURLToPath(new URL('../src/db/database-source-capture-adopted.sql.test.ts', import.meta.url))]
|
||||
async function pins() {
|
||||
const files = [...sources.map(path => ({ path, absolute: join(sourceRoot, path) })), ...ownFiles.map(absolute => ({ path: 'executed-fixture/' + basename(absolute), absolute }))]
|
||||
return Promise.all(files.map(async ({ path, absolute }) => ({ path, sha256: createHash('sha256').update(await readFile(absolute)).digest('hex') })))
|
||||
}
|
||||
const before = await pins()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'mm-source-capture-'))
|
||||
const password = randomBytes(24).toString('hex'), ownerToken = randomBytes(24).toString('hex')
|
||||
let postgres, failure, phase = 'port-and-owner-manifest', failedPhase, childExitCode = 1, unchanged = false, clusterStopped = false, directoryRemoved = false
|
||||
try {
|
||||
const listener = createServer()
|
||||
await new Promise((done, reject) => { listener.once('error', reject); listener.listen(0, '127.0.0.1', done) })
|
||||
const port = listener.address().port
|
||||
await new Promise((done, reject) => listener.close(error => error ? reject(error) : done()))
|
||||
await writeFile(join(directory, 'owned-source-capture.json'), JSON.stringify({ ownerToken, port, username: 'source_operator' }), { flag: 'wx' })
|
||||
postgres = new EmbeddedPostgres({ databaseDir: join(directory, 'pg'), port, user: 'source_operator', password,
|
||||
persistent: true, createPostgresUser: false, initdbFlags: ['--encoding=UTF8'], postgresFlags: ['-h', '127.0.0.1'], onLog: () => {}, onError: () => {} })
|
||||
phase = 'initialise-owned-postgres'; await postgres.initialise()
|
||||
phase = 'start-owned-postgres'; await postgres.start()
|
||||
phase = 'owned-sql-child'
|
||||
childExitCode = await new Promise((done, reject) => {
|
||||
const child = spawn(process.execPath, ['--import', pathToFileURL(require.resolve('tsx')).href, ownFiles[2]], {
|
||||
cwd: directory, windowsHide: true, stdio: 'inherit', env: {
|
||||
...Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path'].filter(key => process.env[key]).map(key => [key, process.env[key]])),
|
||||
MMAPP_OWN_SOURCE_CAPTURE: 'true', MMAPP_OWN_SOURCE_CAPTURE_DIRECTORY: directory, MMAPP_OWN_SOURCE_CAPTURE_ROOT: sourceRoot,
|
||||
MMAPP_OWN_SOURCE_CAPTURE_TOKEN: ownerToken, MMAPP_OWN_SOURCE_CAPTURE_ADMIN_URL: `postgresql://source_operator:${password}@127.0.0.1:${port}/postgres`,
|
||||
},
|
||||
})
|
||||
// No child subprocesses are spawned by the SQL fixture. Kill only this
|
||||
// exact Node child on timeout, then await exit before stopping own cluster.
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => { timedOut = true; child.kill(); }, 120000)
|
||||
child.once('error', error => { clearTimeout(timer); reject(error) })
|
||||
child.once('exit', code => { clearTimeout(timer); done(timedOut ? 1 : code ?? 1) })
|
||||
})
|
||||
} catch (error) { failure = error; failedPhase = phase }
|
||||
finally {
|
||||
try {
|
||||
if (postgres) await postgres.stop()
|
||||
clusterStopped = true
|
||||
} catch (error) { failure ??= error }
|
||||
// Delete only when stop succeeded, retaining failed-stop files for diagnosis.
|
||||
if (clusterStopped) {
|
||||
try {
|
||||
const root = resolve(tmpdir()), target = resolve(directory), rel = relative(root, target)
|
||||
if (!rel || rel.startsWith('..') || isAbsolute(rel) || !basename(target).startsWith('mm-source-capture-')) throw Error('Unsafe owned source capture cleanup path')
|
||||
await rm(target, { recursive: true, force: true }); directoryRemoved = true
|
||||
} catch (error) { failure ??= error }
|
||||
}
|
||||
try {
|
||||
const after = await pins()
|
||||
unchanged = JSON.stringify(before) === JSON.stringify(after)
|
||||
await mkdir(join(sourceRoot, 'tmp'), { recursive: true })
|
||||
const evidencePath = join(sourceRoot, 'tmp', basename(directory) + '-pins.json')
|
||||
await writeFile(evidencePath, JSON.stringify({ format: 'adopted-source-capture-owned-pins-v1', date: new Date().toISOString(),
|
||||
before, after, unchanged, childExitCode, clusterStopped, directoryRemoved,
|
||||
allWriterDrainAuthenticated: false, sourceGenerationAuthenticated: false, readyAuthorized: false }, null, 2))
|
||||
console.log('Owned source capture evidence:', evidencePath)
|
||||
} catch (error) { failure ??= error }
|
||||
}
|
||||
console.log(JSON.stringify({ childExitCode, sourcePinsUnchanged: unchanged, clusterStopped, directoryRemoved,
|
||||
allWriterDrainAuthenticated: false, sourceGenerationAuthenticated: false, readyAuthorized: false }))
|
||||
// Never print connection strings or credentials in fixture error output.
|
||||
if (failure) {
|
||||
const text = String(failure)
|
||||
const knownCode = ['ENOMEM', 'EACCES', 'EPERM', 'ENOENT', 'EADDRINUSE', 'ENOSPC'].find(code => text.includes(code)) ?? 'UNCLASSIFIED'
|
||||
console.error(JSON.stringify({ ownedRunnerFailure: true, phase: failedPhase ?? 'pins-or-cleanup', knownCode }))
|
||||
}
|
||||
process.exitCode = failure || !unchanged || !clusterStopped || !directoryRemoved ? 1 : childExitCode
|
||||
@@ -0,0 +1,51 @@
|
||||
// Permanent runner candidate: fresh loopback PostgreSQL, no app config/.env.
|
||||
import EmbeddedPostgres from 'embedded-postgres'
|
||||
import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { randomBytes, createHash } from 'node:crypto'
|
||||
import { createServer } from 'node:net'
|
||||
import { spawn } from 'node:child_process'
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length > 1 || args[0] && !args[0].startsWith('--production-root=')) throw Error('Only an explicit source-root override is supported for TMP review')
|
||||
const sourceRoot = args[0] ? resolve(args[0].slice('--production-root='.length)) : fileURLToPath(new URL('../../', import.meta.url))
|
||||
const sources = ['backend/src/services/partition-write-transaction.ts', 'backend/src/db/sqlite-import-control-writes.ts', 'backend/src/db/sqlite-import.ts', 'backend/src/db/schema.ts']
|
||||
async function pins() {
|
||||
const result = []
|
||||
for (const path of sources) result.push({ path, sha256: createHash('sha256').update(await readFile(join(sourceRoot, path))).digest('hex') })
|
||||
return result
|
||||
}
|
||||
const before = await pins(), directory = await mkdtemp(join(tmpdir(), 'mm-import-control-'))
|
||||
const listener = createServer(); await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve))
|
||||
const port = listener.address().port; await new Promise(resolve => listener.close(resolve))
|
||||
const password = randomBytes(24).toString('hex')
|
||||
const postgres = new EmbeddedPostgres({ databaseDir: join(directory, 'pg'), port, user: 'import_operator', password,
|
||||
persistent: true, createPostgresUser: false, initdbFlags: ['--encoding=UTF8'], postgresFlags: ['-h', '127.0.0.1'], onLog: () => {}, onError: () => {} })
|
||||
try {
|
||||
await postgres.initialise(); await postgres.start()
|
||||
process.exitCode = await new Promise((done, reject) => {
|
||||
const child = spawn(process.execPath, ['--import', import.meta.resolve('tsx'), fileURLToPath(new URL('../src/db/sqlite-import-control-writes.sql.test.ts', import.meta.url))], {
|
||||
cwd: directory, windowsHide: true, stdio: 'inherit', env: {
|
||||
...Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path'].filter(k => process.env[k]).map(k => [k, process.env[k]])),
|
||||
MMAPP_OWN_IMPORT_CONTROL: 'true', MMAPP_OWN_IMPORT_DIRECTORY: directory, MMAPP_OWN_IMPORT_SOURCE_ROOT: sourceRoot,
|
||||
MMAPP_OWN_IMPORT_ADMIN_URL: `postgresql://import_operator:${password}@127.0.0.1:${port}/postgres` },
|
||||
})
|
||||
child.once('error', reject); child.once('exit', code => done(code ?? 1))
|
||||
})
|
||||
const after = await pins(), unchanged = JSON.stringify(before) === JSON.stringify(after)
|
||||
const report = { format: 'sqlite-import-control-owned-pins-v1', date: new Date().toISOString(), before, after, unchanged, childExitCode: process.exitCode }
|
||||
// Keep generated evidence out of source directories after integration.
|
||||
// This is a local test artifact, never a production authority record.
|
||||
await mkdir(join(sourceRoot, 'tmp'), { recursive: true })
|
||||
await writeFile(join(sourceRoot, 'tmp', 'sqlite-import-control-source-pins.json'), JSON.stringify(report, null, 2))
|
||||
console.log('Current production source pins unchanged:', unchanged)
|
||||
if (!unchanged) process.exitCode = 1
|
||||
} finally {
|
||||
await postgres.stop()
|
||||
const root = resolve(tmpdir()), target = resolve(directory), rel = relative(root, target)
|
||||
if (!rel || rel.startsWith('..') || isAbsolute(rel) || !target.includes('mm-import-control-')) throw Error('Unsafe owned import cleanup path')
|
||||
await rm(target, { recursive: true, force: true })
|
||||
console.log('Owned importer PostgreSQL stopped and exact temp directory removed')
|
||||
}
|
||||
process.exit(process.exitCode ?? 0)
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { spawnSync } from "node:child_process"
|
||||
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))
|
||||
const npmCli = process.env.npm_execpath
|
||||
if (!npmCli) throw new Error("Запускайте тесты через npm --prefix backend test")
|
||||
for (const command of pkg.scripts["test:all"].split(/\s*&&\s*/)) {
|
||||
const match = /^npm run (test:[\w-]+)$/.exec(command)
|
||||
if (!match) throw new Error("Неизвестный формат профиля backend tests")
|
||||
const result = spawnSync(process.execPath, [npmCli, "run", match[1]], {
|
||||
cwd: new URL("../", import.meta.url),
|
||||
env: { ...process.env, NODE_ENV: "test" },
|
||||
stdio: "inherit",
|
||||
})
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdir, writeFile, realpath, lstat, mkdtemp, open } from 'node:fs/promises'
|
||||
import { basename, join, resolve, relative, isAbsolute } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
|
||||
const diagnosticLogCap = 1024 * 1024
|
||||
const diagnosticNames = new Set(['Error', 'AssertionError', 'McpError', 'TypeError', 'RangeError'])
|
||||
const diagnosticCodes = new Set(['ERR_ASSERTION', 'VERIFICATION_FAILED', 'DEVICE_UNAVAILABLE', 'PLAN_EXPIRED', 'RATE_LIMITED', 'VRF_ACK_JOURNAL_UNCONFIRMED', 'VRF_VERIFIED_CHECKPOINT_UNCONFIRMED', '42883', '22P02'])
|
||||
/** Pure projection only: no arbitrary log text, SQL, native rows or credentials. */
|
||||
export function projectVrfDiagnostics(log, privateValues = [], expectedSlice = null) {
|
||||
assert.equal(typeof log, 'string'); assert(Array.isArray(privateValues))
|
||||
assert(Buffer.byteLength(log) <= diagnosticLogCap, 'Diagnostic source log exceeded existing cap')
|
||||
let redacted = log
|
||||
for (const value of [...privateValues, 'owned-vrf-loopback-password'].filter(value => typeof value === 'string' && value).sort((a,b) => b.length-a.length)) redacted = redacted.split(value).join('[REDACTED]')
|
||||
redacted = redacted.replace(/postgres(?:ql)?:\/\/[^\s'"]+/gi, '[REDACTED_DATABASE_URL]')
|
||||
.replace(/mmcp_[a-z]+_[A-Za-z0-9_-]+/g, '[REDACTED_MCP_TOKEN]')
|
||||
.replace(/\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED_JWT]')
|
||||
const records = [], acceptedCaseSequence = []; let malformedLines = 0, omittedLines = 0, incrementalRecords = 0, truncatedRecords = 0, droppedCaseLabels = 0, lastAcceptedCase = null, finalComponentPresent = false
|
||||
const labelPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/
|
||||
const retainLabel = label => { if (typeof label !== 'string' || !labelPattern.test(label)) { droppedCaseLabels++; return }; if (acceptedCaseSequence.length === 128) { acceptedCaseSequence.shift(); droppedCaseLabels++ }; acceptedCaseSequence.push(label); lastAcceptedCase = label }
|
||||
const retain = value => { if (records.length === 128) { records.shift(); truncatedRecords++ } records.push(value) }
|
||||
for (const line of redacted.split(/\r?\n/).filter(Boolean)) {
|
||||
let value; try { value = JSON.parse(line) } catch {
|
||||
malformedLines++
|
||||
const name = /^(Error|AssertionError|McpError|TypeError|RangeError)(?:[ :\[]|$)/.exec(line)?.[1]
|
||||
if (name) retain({ diagnosticError: true, safeName: name, safeCode: line.includes('[ERR_ASSERTION]') ? 'ERR_ASSERTION' : 'other' })
|
||||
const normalized = line.replaceAll('\\', '/'), position = /vrf-production-execution-owned\.(?:ts|mts):(\d{1,7}):(\d{1,7})\)?$/.exec(normalized)
|
||||
if (position && (normalized.includes('/backend/src/test/vrf-production-execution-owned.ts:') || normalized.includes('/tmp/mcp-vrf-all68-staged-20261101/vrf-production-execution-owned.mts:'))) retain({ source: 'owned-vrf-fixture', line: Number(position[1]), column: Number(position[2]) })
|
||||
continue
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) { omittedLines++; continue }
|
||||
const row = {}
|
||||
if (value.acceptedCase) { incrementalRecords++; row.acceptedCaseRecord = true; const labels = Array.isArray(value.acceptedCase) ? value.acceptedCase : [value.acceptedCase]; if (labels.length <= 68) for (const label of labels) retainLabel(label); else droppedCaseLabels += labels.length }
|
||||
if (Array.isArray(value.accepted)) { row.finalCaseCount = value.accepted.length; if (expectedSlice !== null && value.slice === expectedSlice) finalComponentPresent = true }
|
||||
if (['planned','queued','running','applied','failed','cancelled','unknown','expired','rejected','unconfirmed'].includes(value.operationState)) row.operationState = value.operationState
|
||||
for (const key of ['phase', 'elapsedMs', 'nativeReads', 'nativeWrites', 'roundTrips', 'lastSqlMs', 'totalSqlMs', 'maxSqlMs']) if (Number.isSafeInteger(value[key]) && value[key] >= 0) row[key] = value[key]
|
||||
if (value.diagnosticWaitSql === true) row.diagnosticWaitSql = true
|
||||
if (value.diagnosticError === true) { row.diagnosticError = true; row.safeName = diagnosticNames.has(value.safeName) ? value.safeName : 'other'; row.safeCode = diagnosticCodes.has(value.safeCode) ? value.safeCode : 'other' }
|
||||
if (!Object.keys(row).length) { omittedLines++; continue }
|
||||
retain(row)
|
||||
}
|
||||
return { version: 1, rawLogRetained: false, sourceCapBytes: diagnosticLogCap, capturedLogBytes: Buffer.byteLength(log), malformedLines, omittedLines, incrementalRecords, truncatedRecords, finalComponentPresent, acceptedCaseSequence, lastAcceptedCase, droppedCaseLabels, records }
|
||||
}
|
||||
export function vrfSliceDiagnostic(result, log, childBudgetMs, privateValues = []) {
|
||||
assert(result && typeof result === 'object')
|
||||
assert(['baseline','physical','vlan','disabled','mixed','reconcile','sql-journal','guards','coexistence','alias'].includes(result.slice))
|
||||
assert(Number.isSafeInteger(result.code) && typeof result.childExitConfirmed === 'boolean')
|
||||
assert(Number.isSafeInteger(result.caseCount) && result.caseCount >= 0)
|
||||
assert.equal(childBudgetMs, result.slice === 'baseline' ? 240000 : 120000)
|
||||
assert(result.failure === null || ['child-deadline','child-log-limit','child-signal','child-error','child-error-after-pid','child-exit-unconfirmed','missing-component-receipt'].includes(result.failure))
|
||||
assert(result.accepted === (result.code === 0 && result.failure === null && result.childExitConfirmed))
|
||||
return { ...result, capMs: childBudgetMs, diagnostics: projectVrfDiagnostics(log, privateValues, result.slice), scope: 'same-owned-PG-test-only', actualRouter: false, forwardingVerified: false }
|
||||
}
|
||||
async function writeDiagnostic(path, value, maxBytes = 65536) {
|
||||
const bytes = Buffer.from(JSON.stringify(value, null, 2) + '\n'); assert(bytes.length <= maxBytes)
|
||||
const stream = await open(path, 'wx', 0o600)
|
||||
try { await stream.writeFile(bytes); await stream.sync() } finally { await stream.close() }
|
||||
}
|
||||
|
||||
/** Existing test runner owns the PG lifecycle. This component creates only
|
||||
* bounded Node children on that same synthetic DB, with an empty private cwd.
|
||||
* It never reads backend/.env, creates a PG, or accepts an application URL. */
|
||||
export async function runOwnedVrfProductionProfile({ directory, database, databaseUrl }) {
|
||||
const target = new URL(databaseUrl)
|
||||
assert.equal(target.hostname, '127.0.0.1'); assert.equal(target.username, 'mcp_test')
|
||||
assert(/^mm_accept_166_\d+$/.test(database)); assert.equal(target.pathname, '/' + database)
|
||||
const owned = resolve(directory)
|
||||
assert.equal(await realpath(owned), owned); assert(basename(owned).startsWith('mm-mcp-test-'))
|
||||
assert(!(await lstat(owned)).isSymbolicLink())
|
||||
// Fixed destination derived from this source module, never caller/env controlled.
|
||||
const repository = fileURLToPath(new URL('../../', import.meta.url)), reportRoot = join(repository, 'tmp')
|
||||
assert.equal(await realpath(reportRoot), reportRoot); assert(!(await lstat(reportRoot)).isSymbolicLink())
|
||||
const diagnosticDirectory = await mkdtemp(join(reportRoot, 'mcp-vrf-profile-'))
|
||||
assert.equal(await realpath(diagnosticDirectory), diagnosticDirectory); assert(!(await lstat(diagnosticDirectory)).isSymbolicLink())
|
||||
assert.equal(relative(reportRoot, diagnosticDirectory), basename(diagnosticDirectory)); assert(basename(diagnosticDirectory).startsWith('mcp-vrf-profile-'))
|
||||
const persist = (name, value) => writeDiagnostic(join(diagnosticDirectory, name), value)
|
||||
const diagnosticPath = relative(repository, diagnosticDirectory)
|
||||
await persist('started.json', { version: 1, status: 'running', accepted: false, childExitConfirmed: false, scope: 'same-owned-PG-test-only' })
|
||||
console.log(JSON.stringify({ profile: 'test:vrf:production', diagnosticDirectory: diagnosticPath, status: 'running' }))
|
||||
const results = []
|
||||
let currentSlice = null, currentChildExitConfirmed = false, lastSliceResult = null
|
||||
try {
|
||||
const entry = fileURLToPath(new URL('../src/test/vrf-production-execution-owned.ts', import.meta.url))
|
||||
const report = fileURLToPath(new URL('../../docs/mcp-chr-vrf-companion-native-v5.json', import.meta.url))
|
||||
const loader = pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href
|
||||
const safe = Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL'].filter(key => typeof process.env[key] === 'string').map(key => [key, process.env[key]]))
|
||||
for (const slice of ['baseline', 'physical', 'vlan', 'disabled', 'mixed', 'reconcile', 'sql-journal', 'guards', 'coexistence', 'alias']) {
|
||||
currentSlice = slice; currentChildExitConfirmed = false; lastSliceResult = null
|
||||
await persist(slice + '-started.json', { version: 1, slice, status: 'running', accepted: false, childExitConfirmed: false })
|
||||
const childBudgetMs = slice === 'baseline' ? 240000 : 120000
|
||||
const cwd = join(owned, 'vrf-production-' + slice), rel = relative(owned, cwd)
|
||||
assert(rel && !rel.startsWith('..') && !isAbsolute(rel)); await mkdir(cwd)
|
||||
assert.equal(await realpath(cwd), cwd); assert(!(await lstat(cwd)).isSymbolicLink())
|
||||
let log = '', failure = null, exited = false, stopTimer, unknownTimer
|
||||
const authSecret = randomBytes(32).toString('hex')
|
||||
const child = spawn(process.execPath, ['--import', loader, entry], { cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: {
|
||||
...safe, NODE_OPTIONS: '', NODE_ENV: 'test', DATABASE_URL: databaseUrl,
|
||||
DATABASE_PATH: join(cwd, 'no-legacy.sqlite'), MCP_STATE_DIR: join(cwd, 'state'),
|
||||
AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: authSecret, MCP_PUBLIC_URL: 'http://localhost:8000/mcp',
|
||||
MMAPP_OWN_VRF_EXECUTION: 'true', MMAPP_VRF_EXECUTION_SLICE: slice, MMAPP_VRF_NATIVE_REPORT: report,
|
||||
MMAPP_VRF_SHARED_OWNED_PG: 'true', MMAPP_OWN_SQL_DB: database,
|
||||
} })
|
||||
const stop = reason => {
|
||||
failure ??= reason
|
||||
if (exited) return
|
||||
child.kill('SIGTERM')
|
||||
stopTimer ??= setTimeout(() => { if (!exited) child.kill('SIGKILL') }, 5000)
|
||||
}
|
||||
const deadline = setTimeout(() => stop('child-deadline'), childBudgetMs)
|
||||
for (const stream of [child.stdout, child.stderr]) stream.on('data', bytes => {
|
||||
if (Buffer.byteLength(log) + bytes.length > 1024 * 1024) { stop('child-log-limit'); return }
|
||||
log += bytes.toString()
|
||||
})
|
||||
const code = await new Promise(resolveExit => {
|
||||
child.once('exit', (status, signal) => { exited = true; if (signal) failure ??= 'child-signal'; resolveExit(status ?? 1) })
|
||||
child.once('error', () => { failure ??= 'child-error'; if (!child.pid) { exited = true; resolveExit(1) } else stop('child-error-after-pid') })
|
||||
unknownTimer = setTimeout(() => { if (!exited) { stop('child-exit-unconfirmed'); resolveExit(1) } }, childBudgetMs + 15000)
|
||||
})
|
||||
clearTimeout(deadline); clearTimeout(stopTimer); clearTimeout(unknownTimer)
|
||||
const expectedCases = { baseline: 11, physical: 7, vlan: 9, disabled: 9, mixed: 7, reconcile: 6, 'sql-journal': 1, guards: 11, coexistence: 3, alias: 4 }
|
||||
const component = log.trim().split(/\r?\n/).flatMap(line => { try { return [JSON.parse(line)] } catch { return [] } }).findLast(value => value.slice === slice && Array.isArray(value.accepted))
|
||||
if (!component || component.accepted.length !== expectedCases[slice] || component.actualSql !== true || component.actualCrypto !== true || component.actualAuthPolicyApprovalRuntime !== true || component.fakeVerifier !== false || component.actualRouter !== false || component.forwardingVerified !== false) failure ??= 'missing-component-receipt'
|
||||
const result = { slice, accepted: code === 0 && !failure && exited, caseCount: component?.accepted?.length ?? 0, code, failure, childPid: child.pid ?? null, childExitConfirmed: exited }
|
||||
lastSliceResult = result; currentChildExitConfirmed = exited
|
||||
const diagnostic = vrfSliceDiagnostic(result, log, childBudgetMs, [databaseUrl, target.password, decodeURIComponent(target.password), authSecret])
|
||||
await persist(slice + '-result.json', diagnostic)
|
||||
// Persist outside the owner's cleanup boundary BEFORE any next slice/return.
|
||||
await writeFile(join(cwd, 'fixture.log'), JSON.stringify(diagnostic.diagnostics, null, 2))
|
||||
await writeFile(join(cwd, 'acceptance.json'), JSON.stringify(result, null, 2))
|
||||
results.push(result)
|
||||
console.log(JSON.stringify({ profile: 'test:vrf:production', ...result }))
|
||||
if (!result.accepted) break
|
||||
}
|
||||
const receipt = { accepted: results.length === 10 && results.every(result => result.accepted), childExitConfirmed: results.every(result => result.childExitConfirmed), sameOwnedPg: true, actualRouter: false, forwardingVerified: false, results }
|
||||
await persist('terminal.json', { version: 1, status: 'terminal', ...receipt, requiredCases: 68, completedCases: results.reduce((sum, value) => sum + value.caseCount, 0), diagnosticDirectory: diagnosticPath })
|
||||
await writeFile(join(owned, 'vrf-production-acceptance.json'), JSON.stringify(receipt, null, 2))
|
||||
return receipt
|
||||
} catch {
|
||||
await persist('wrapper-failure.json', { version: 1, status: 'refused', accepted: false, phase: currentSlice, failure: 'wrapper-exception', childExitConfirmed: currentChildExitConfirmed && results.every(value => value.childExitConfirmed), completedSlices: results.length, lastSliceResult, results })
|
||||
throw Error('VRF profile refused; bounded diagnostics retained at ' + diagnosticPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createHash} from 'node:crypto'
|
||||
|
||||
/** Exact writer-profile v1 canonical bytes. Pure value codec; no trust/authority. */
|
||||
export function canonical(value:unknown):string {
|
||||
return JSON.stringify(value,(_key,item:unknown)=>item&&typeof item==='object'&&!Array.isArray(item)
|
||||
?Object.fromEntries(Object.entries(item).sort(([a],[b])=>a<b?-1:a>b?1:0)):item)
|
||||
}
|
||||
export const sha=(value:Uint8Array|string)=>createHash('sha256').update(value).digest('hex')
|
||||
export const bytes=(value:unknown)=>Buffer.from(canonical(value)+'\n','utf8')
|
||||
@@ -0,0 +1,9 @@
|
||||
// Fixed12 source-partition DATA policy, candidate build artifact for review.
|
||||
// No observed-schema enrollment, caller policy, expected catalogue or authority.
|
||||
import {canonical,sha} from './application-writer-profile-canonical.js'
|
||||
const PERIODS=Object.freeze({flow_buckets:'d',flow_minute_stats:'d',flow_minute_dims:'d',flow_daily_dims:'m',flow_hour_facts:'d',flow_daily_facts:'m',traffic_samples:'w',servers_rest_ping_samples:'w',uptime_probe_samples:'w',uptime_resource_samples:'w',server_snapshots:'w',internet_path_snapshots:'w'} as const)
|
||||
export type FixedCatalogueParentPeriodData='d'|'w'|'m'
|
||||
export const FIXED_CATALOGUE_PARENT_POLICY_DATA_SHA256=sha(canonical(PERIODS))
|
||||
export function fixedCatalogueParentPeriodData(name:string):FixedCatalogueParentPeriodData|undefined {
|
||||
return Object.hasOwn(PERIODS,name)?PERIODS[name as keyof typeof PERIODS]:undefined
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Private typed VALUE framing only. No SQL/pool/role/current-schema attestation.
|
||||
// Transport limits are inherited unchanged from the accepted paged DATA codec;
|
||||
// they DO NOT expand the legacy2048 total-constraint policy.
|
||||
import {createHash,type Hash} from 'node:crypto'
|
||||
import {canonical as writerCanonical} from './application-writer-profile-canonical.js'
|
||||
|
||||
export type Json = null|boolean|number|string|Json[]|{[key:string]:Json}
|
||||
export type CatalogueKind='global'|'base'|'leaf'
|
||||
export interface CatalogueRecord {kind:CatalogueKind;identity:string;value:{[key:string]:Json}}
|
||||
export const FRAME_LIMITS=Object.freeze({chunkBytes:4096,pageRows:128,recordBytes:2*1024*1024,streamBytes:64*1024*1024,identityBytes:1024,depth:64})
|
||||
export interface StreamMetrics {records:number;chunks:number;pages:number;payloadBytes:number;estimatedDataRowBytes:number;maxPagePayloadBytes:number;maxRecordBytes:number}
|
||||
export interface CatalogueValueDigest {format:'catalogue-values-v2';baseSha256:string;leafSha256:string;snapshotSha256:string;metrics:Readonly<StreamMetrics>;authority:false}
|
||||
const rank:Record<CatalogueKind,number>={global:0,base:1,leaf:2}
|
||||
const domain=(name:string)=>createHash('sha256').update('mmapp:catalogue-'+name+':v2\0')
|
||||
function object(value:unknown):value is Record<string,unknown>{return !!value&&typeof value==='object'&&!Array.isArray(value)&&Object.getPrototypeOf(value)===Object.prototype}
|
||||
function exactJson(value:Json,depth=0):void {
|
||||
if(depth>FRAME_LIMITS.depth)throw Error('Catalogue nesting bound')
|
||||
if(value===null||typeof value==='boolean'||typeof value==='string')return
|
||||
if(typeof value==='number'){if(!Number.isSafeInteger(value)||Object.is(value,-0))throw Error('Exact integer/string catalogue scalar required');return}
|
||||
if(Array.isArray(value)){for(const item of value)exactJson(item,depth+1);return}
|
||||
if(!object(value))throw Error('Plain catalogue JSON required')
|
||||
for(const key of Object.keys(value))exactJson(value[key] as Json,depth+1)
|
||||
}
|
||||
function canonical(value:Json):string{exactJson(value);return writerCanonical(value)}
|
||||
function update(hash:Hash,encoded:Buffer){const length=Buffer.alloc(4);length.writeUInt32BE(encoded.length);hash.update(length);hash.update(encoded)}
|
||||
function record(bytes:Buffer,kind:CatalogueKind,identity:string):CatalogueRecord{
|
||||
const parsed:unknown=JSON.parse(new TextDecoder('utf8',{fatal:true}).decode(bytes))
|
||||
if(!object(parsed)||Object.keys(parsed).sort().join(',')!=='identity,kind,value'||parsed.kind!==kind||parsed.identity!==identity||!object(parsed.value))throw Error('Exact catalogue record shape required')
|
||||
// canonical() also rejects numbers/prototypes outside the scalar contract.
|
||||
canonical(parsed as unknown as Json)
|
||||
return parsed as unknown as CatalogueRecord
|
||||
}
|
||||
export class CatalogueValueStream {
|
||||
private readonly base=domain('base-values');private readonly leaf=domain('leaf-values');private readonly snapshot=domain('snapshot-values')
|
||||
private readonly metrics:StreamMetrics={records:0,chunks:0,pages:0,payloadBytes:0,estimatedDataRowBytes:0,maxPagePayloadBytes:0,maxRecordBytes:0}
|
||||
private next=1n;private parts:Buffer[]=[];private bytes=0;private expectedParts=0;private currentKind?:CatalogueKind;private currentIdentity=''
|
||||
private previous?:{kind:CatalogueKind;identity:string};private finished=false;private accepting=false;private failed=false;private baseRecords=0;private leafRecords=0;
|
||||
*acceptPage(rows:readonly unknown[]):Generator<CatalogueRecord>{
|
||||
if(this.finished||this.accepting||this.failed||!Array.isArray(rows)||rows.length>FRAME_LIMITS.pageRows){this.failed=true;throw Error('Closed/oversized catalogue page')}
|
||||
this.accepting=true;let complete=false
|
||||
try{
|
||||
this.metrics.pages++;let pageBytes=0
|
||||
for(const raw of rows){
|
||||
if(!object(raw)||Object.keys(raw).sort().join(',')!=='chunk_count,chunk_index,identity,kind,payload,record_id')throw Error('Exact cursor row shape required')
|
||||
const {record_id:id,chunk_index:part,chunk_count:count,kind,identity,payload}=raw
|
||||
if(typeof id!=='string'||!/^[1-9][0-9]{0,18}$/.test(id)||BigInt(id)!==this.next||typeof kind!=='string'||!Object.hasOwn(rank,kind)||typeof identity!=='string'||Buffer.byteLength(identity)<1||Buffer.byteLength(identity)>FRAME_LIMITS.identityBytes||!Number.isInteger(part)||!Number.isInteger(count)||typeof part!=='number'||typeof count!=='number'||count<1||count>FRAME_LIMITS.recordBytes/FRAME_LIMITS.chunkBytes||part!==this.parts.length||part>=count||!Buffer.isBuffer(payload)||payload.length<1||payload.length>FRAME_LIMITS.chunkBytes||part<count-1&&payload.length!==FRAME_LIMITS.chunkBytes)throw Error('Catalogue order/chunk continuity refused')
|
||||
const k=kind as CatalogueKind
|
||||
if(part===0){if(this.previous&&(rank[k]<rank[this.previous.kind]||rank[k]===rank[this.previous.kind]&&Buffer.compare(Buffer.from(identity),Buffer.from(this.previous.identity))<=0))throw Error('Strict catalogue identity order required');this.currentKind=k;this.currentIdentity=identity;this.expectedParts=count}
|
||||
if(k!==this.currentKind||identity!==this.currentIdentity||count!==this.expectedParts)throw Error('Changed record framing refused')
|
||||
this.parts.push(Buffer.from(payload));this.bytes+=payload.length;pageBytes+=payload.length;this.metrics.chunks++;this.metrics.payloadBytes+=payload.length
|
||||
// Text protocol bytea uses hex. DataRow header + six field lengths + actual
|
||||
// other text field sizes; RowDescription/ReadyForQuery/JS heap are separate.
|
||||
this.metrics.estimatedDataRowBytes+=31+Buffer.byteLength(id)+String(part).length+String(count).length+Buffer.byteLength(k)+Buffer.byteLength(identity)+2+2*payload.length
|
||||
if(this.bytes>FRAME_LIMITS.recordBytes||pageBytes>FRAME_LIMITS.pageRows*FRAME_LIMITS.chunkBytes||this.metrics.payloadBytes>FRAME_LIMITS.streamBytes)throw Error('Catalogue byte budget exceeded')
|
||||
if(part===count-1){
|
||||
const value=record(Buffer.concat(this.parts,this.bytes),k,identity),encoded=Buffer.from(canonical(value as unknown as Json))
|
||||
if(encoded.length>FRAME_LIMITS.recordBytes)throw Error('Canonical catalogue record overflow')
|
||||
const dynamic=k==='leaf'||k==='global'&&identity==='source_size'
|
||||
update(this.snapshot,encoded);update(dynamic?this.leaf:this.base,encoded)
|
||||
if(dynamic)this.leafRecords++;else this.baseRecords++
|
||||
this.metrics.records++;this.metrics.maxRecordBytes=Math.max(this.metrics.maxRecordBytes,this.bytes);this.previous={kind:k,identity};this.next++;this.parts=[];this.bytes=0;this.expectedParts=0
|
||||
yield value
|
||||
}
|
||||
}
|
||||
this.metrics.maxPagePayloadBytes=Math.max(this.metrics.maxPagePayloadBytes,pageBytes)
|
||||
complete=true
|
||||
}finally{this.accepting=false;if(!complete)this.failed=true}
|
||||
}
|
||||
finish():CatalogueValueDigest{
|
||||
if(this.finished||this.accepting||this.failed||this.parts.length)throw Error('Catalogue stream incomplete/closed')
|
||||
this.finished=true
|
||||
// Each digest includes its own record count, so empty/partitioned streams
|
||||
// cannot acquire an ambiguous concatenation encoding.
|
||||
const digest=(hash:Hash,count:number)=>{const end=Buffer.alloc(8);end.writeBigUInt64BE(BigInt(count));return hash.update(end).digest('hex')}
|
||||
return Object.freeze({format:'catalogue-values-v2',baseSha256:digest(this.base,this.baseRecords),leafSha256:digest(this.leaf,this.leafRecords),snapshotSha256:digest(this.snapshot,this.metrics.records),metrics:Object.freeze({...this.metrics}),authority:false})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Fail-closed DATA counter for the unchanged legacy total constraint ceiling.
|
||||
* Paging is not permission to reset/reinterpret that total on every page. */
|
||||
export class FixedCatalogueConstraintRowBudgetData {
|
||||
private count=0;private failed=false
|
||||
acceptRows(rows:readonly unknown[]):number {
|
||||
if(this.failed||!Array.isArray(rows)){this.failed=true;throw Error('Closed/invalid constraint row budget')}
|
||||
if(this.count+rows.length>2048){this.failed=true;throw Error('Legacy total constraint row cap2048 exceeded')}
|
||||
this.count+=rows.length;return this.count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Private staged full-catalogue DATA verifier. No pool, issuer, cache, authority or READY.
|
||||
// Fixed source44 candidate is statically imported; callers cannot supply expected JSON/policy.
|
||||
import {canonical,sha} from './application-writer-profile-canonical.js'
|
||||
import {fixedCatalogueParentPeriodData} from './application-writer-profile-catalogue-parent-policy.js'
|
||||
import {type CatalogueRecord} from './application-writer-profile-catalogue-values.js'
|
||||
import {BUILD44_PROVENANCE,FIXED44_ROLE_TEMPLATE} from './fixed44-build-definition.js'
|
||||
import {GLOBALS,GROUPS,object,rows,sorted,text,projectCatalogueData,type Inventory,type Row,type Json} from './catalogue-projection.js'
|
||||
import {validateCatalogueLeafData} from './catalogue-leaves.js'
|
||||
import {EXPANDED44_DATA_POLICY,Expanded44TopologyBudgetData} from './expanded44-data-policy.js'
|
||||
export type LogicalRole='runtime'|'operator'|'capture'|'publisher'|'control'
|
||||
// Same DATA shape as RegistryRoleBindingsData. Provenance must come from protected host separately.
|
||||
export type InstallationRoleBindingsData=Readonly<Record<LogicalRole,Readonly<{name:string;signedOid:string}>>>
|
||||
const ROLE_KEYS:readonly LogicalRole[]=['runtime','operator','capture','publisher','control']
|
||||
const EXTRAS=['source_size','namespace_policy','publisher_column_escape'] as const
|
||||
const exact=(a:unknown,b:unknown,label:string)=>{if(canonical(a)!==canonical(b))throw Error('Fixed44 catalogue refused: '+label)}
|
||||
function captureBindings(input:InstallationRoleBindingsData):Readonly<Record<LogicalRole,string>> {
|
||||
exact(Object.keys(input).sort(),[...ROLE_KEYS].sort(),'binding roles')
|
||||
const names=new Set<string>(),oids=new Set<string>(),result={} as Record<LogicalRole,string>
|
||||
for(const role of ROLE_KEYS){const binding=input[role];exact(Object.keys(binding).sort(),['name','signedOid'],'binding fields')
|
||||
// Deliberately narrow deparse envelope; unsupported quoted/reserved names fail closed.
|
||||
if(!/^[a-z_][a-z0-9_]{0,62}$/.test(binding.name)||!/^[1-9][0-9]{0,9}$/.test(binding.signedOid)||BigInt(binding.signedOid)>4294967295n||names.has(binding.name)||oids.has(binding.signedOid))throw Error('Explicit distinct installation role binding required')
|
||||
names.add(binding.name);oids.add(binding.signedOid);result[role]=binding.name
|
||||
}
|
||||
return Object.freeze(result)
|
||||
}
|
||||
function instantiateExpected(names:Readonly<Record<LogicalRole,string>>):Inventory {
|
||||
const walk=(value:Json,key=''):Json=>{if(typeof value==='string')return value.replace(/@mm-role:(runtime|operator|capture|publisher|control)@/g,(_match,role:LogicalRole)=>names[role]);if(Array.isArray(value)){const result=value.map(v=>walk(v));return key==='acl'?(result as string[]).sort():result}if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,walk(v,k)]));return value}
|
||||
const data=walk(FIXED44_ROLE_TEMPLATE as unknown as Json) as unknown as Inventory
|
||||
return Object.fromEntries(Object.entries(data).map(([key,data])=>[key,sorted(data)]))
|
||||
}
|
||||
export class Fixed44CatalogueDataVerifier {
|
||||
private readonly names:Readonly<Record<LogicalRole,string>>;private readonly expected:Inventory;private readonly bindingsDigest:string
|
||||
private readonly globals:Inventory={};private headers:Row[]|undefined;private globalDone=false;private closed=false;private failed=false
|
||||
private readonly bases=new Set<string>();private readonly leaves=new Set<string>();private readonly templates=new Map<string,{table:Row;groups:Inventory}>()
|
||||
private readonly topology=new Expanded44TopologyBudgetData();private counts?:Row
|
||||
constructor(bindings:InstallationRoleBindingsData){this.names=captureBindings(bindings);this.expected=instantiateExpected(this.names);this.bindingsDigest=sha(canonical(bindings))}
|
||||
consume(record:CatalogueRecord):void {if(this.closed||this.failed)throw Error('Closed/refused catalogue verifier');try{this.consumeOpen(record)}catch(error){this.failed=true;throw error}}
|
||||
private consumeOpen(record:CatalogueRecord):void {
|
||||
if(record.kind==='global'){
|
||||
if(this.globalDone)throw Error('Late global refused');exact(Object.keys(record.value),['rows'],'global shape');const data=rows(record.value.rows)
|
||||
if(record.identity==='base_tables'){if(this.headers)throw Error('Duplicate base header');this.headers=data;return}
|
||||
if(![...GLOBALS,...EXTRAS].includes(record.identity as typeof GLOBALS[number])||Object.hasOwn(this.globals,record.identity))throw Error('Unknown/duplicate global section')
|
||||
this.globals[record.identity]=data;return
|
||||
}
|
||||
this.closeGlobals()
|
||||
if(record.kind==='leaf'&&record.identity==='!inventory_counts'){if(this.counts||this.bases.size!==104)throw Error('Early/duplicate counts');exact(Object.keys(record.value).sort(),['base','leaf'],'count shape');this.counts=record.value;return}
|
||||
exact(Object.keys(record.value).sort(),['table',...GROUPS].sort(),'relation shape')
|
||||
const table=object(record.value.table),groups:Inventory={}
|
||||
if(table.relname!==record.identity||table.relpersistence!=='p')throw Error('Relation identity/persistence refused')
|
||||
for(const name of GROUPS)groups[name]=rows(record.value[name])
|
||||
if(record.kind==='base'){
|
||||
if(this.counts||table.relispartition!==false||this.bases.has(record.identity))throw Error('Duplicate/late base')
|
||||
const header=this.headers!.find(t=>t.relname===record.identity);if(!header)throw Error('Foreign base refused');exact(table,header,'header/table')
|
||||
const actual=projectCatalogueData({...this.globals,...groups,tables:this.headers!})
|
||||
for(const name of GROUPS)exact(actual[name],this.expected[name].filter(r=>r.relname===record.identity),'base '+name+' '+record.identity)
|
||||
this.topology.acceptRelation('base',record.identity,groups);this.bases.add(record.identity);if(fixedCatalogueParentPeriodData(record.identity))this.templates.set(record.identity,{table,groups});return
|
||||
}
|
||||
if(record.kind!=='leaf'||!this.counts||this.bases.size!==104||this.leaves.has(record.identity))throw Error('Early/duplicate leaf')
|
||||
const edges=groups.inheritance;if(edges.length!==1)throw Error('Exact one leaf edge required')
|
||||
const parent=this.templates.get(text(edges[0].parent));if(!parent)throw Error('Foreign leaf parent refused')
|
||||
const raw:Inventory={...this.globals,tables:[parent.table,table],inheritance:edges}
|
||||
for(const name of GROUPS)if(name!=='inheritance')raw[name]=[...parent.groups[name],...groups[name]]
|
||||
validateCatalogueLeafData(raw,parent.table,table);this.topology.acceptRelation('leaf',record.identity,groups,text(edges[0].parent));this.leaves.add(record.identity)
|
||||
}
|
||||
private closeGlobals():void {
|
||||
if(this.globalDone)return
|
||||
if(!this.headers)throw Error('Missing base header')
|
||||
exact(Object.keys(this.globals).sort(),[...GLOBALS,...EXTRAS].sort(),'complete globals')
|
||||
const blank=Object.fromEntries(GROUPS.map(name=>[name,[]])) as Inventory
|
||||
const projected=projectCatalogueData({...this.globals,...blank,tables:this.headers})
|
||||
exact(projected.tables,this.expected.tables,'exact source44 base identities/owner/ACL')
|
||||
for(const name of GLOBALS)exact(projected[name],this.expected[name],'global '+name)
|
||||
const size=this.globals.source_size
|
||||
if(size.length!==1)throw Error('Source size shape refused')
|
||||
exact(Object.keys(size[0]).sort(),['tables','function_bytes','default_bytes','check_bytes','trigger_bytes'].sort(),'source size fields')
|
||||
if(typeof size[0].tables!=='number'||!Number.isSafeInteger(size[0].tables)||size[0].tables<1||size[0].tables>EXPANDED44_DATA_POLICY.maxRelations)throw Error('Expanded44 total relation DATA cap exceeded')
|
||||
if(['function_bytes','default_bytes','check_bytes','trigger_bytes'].some(k=>typeof size[0][k]!=='string'||!/^[0-9]+$/.test(text(size[0][k]))||BigInt(text(size[0][k]))>2097152n))throw Error('Source byte bound refused')
|
||||
if(this.globals.namespace_policy.length!==1)throw Error('Namespace shape refused')
|
||||
exact(Object.keys(this.globals.namespace_policy[0]).sort(),['relations','rules','routines','types','namespaces','default_acl'].sort(),'namespace fields')
|
||||
if(Object.values(this.globals.namespace_policy[0]).some(v=>v!==false)||this.globals.publisher_column_escape.length)throw Error('Namespace/column escape refused')
|
||||
this.globalDone=true
|
||||
}
|
||||
finish(){if(this.closed||this.failed)throw Error('Closed/refused catalogue verifier');try{this.closeGlobals();if(this.bases.size!==104||this.templates.size!==12||!this.counts||this.counts.base!==104||this.counts.leaf!==this.leaves.size||this.globals.source_size[0].tables!==104+this.leaves.size)throw Error('Incomplete catalogue/counts');this.closed=true
|
||||
return Object.freeze({format:'fixed44-catalogue-expanded-data-v1',policyVersion:EXPANDED44_DATA_POLICY.version,topology:this.topology.finish(),admissionClassificationVerified:false,definitionSha256:BUILD44_PROVENANCE.templateSha256,manifestSha256:BUILD44_PROVENANCE.manifestSha256,baseRelations:this.bases.size,leafRelations:this.leaves.size,bindingsDataSha256:this.bindingsDigest,cleanFourRoleSqlVerified:false,productionInstallationPolicyApproved:false,authority:false,readyAuthorized:false,defaultSupportAuthorized:false})
|
||||
}catch(error){this.failed=true;throw error}}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Typed port of the accepted twelve-parent leaf checks, with duplicate detection in stream owner.
|
||||
import {canonical} from './application-writer-profile-canonical.js'
|
||||
import {fixedCatalogueParentPeriodData} from './application-writer-profile-catalogue-parent-policy.js'
|
||||
import {omit,sorted,text,type Inventory,type Row} from './catalogue-projection.js'
|
||||
const guards=['candidate_application_statement_guard','candidate_truncate_preadmitted','server_partition_writer_guard']
|
||||
const exact=(a:unknown,b:unknown,label:string)=>{if(canonical(a)!==canonical(b))throw Error('Catalogue leaf refused: '+label)}
|
||||
export function validateCatalogueLeafData(raw:Inventory,parent:Row,leaf:Row):void {
|
||||
const parentName=text(parent.relname),name=text(leaf.relname),period=fixedCatalogueParentPeriodData(parentName)
|
||||
const edges=raw.inheritance
|
||||
if(edges.length!==1||edges[0].parent!==parentName||edges[0].child!==name)throw Error('Exact direct leaf parent required')
|
||||
const suffix=name.slice(parentName.length+1)
|
||||
if(!period||!name.startsWith(parentName+'_')||!new RegExp('^'+period+'_[0-9]{'+(period==='m'?6:8)+'}$').test(suffix)||leaf.relkind!=='r'||leaf.relpersistence!=='p'||leaf.relispartition!==true||leaf.owner!==parent.owner||leaf.namespace!=='public'||leaf.relrowsecurity!==false||leaf.relforcerowsecurity!==false||leaf.partition_key!==null)throw Error('Fixed managed leaf identity/owner refused')
|
||||
const stamp=suffix.slice(2),iso=stamp.slice(0,4)+'-'+stamp.slice(4,6)+'-'+(period==='m'?'01':stamp.slice(6,8))
|
||||
const start=new Date(iso+'T00:00:00.000Z'),end=new Date(start)
|
||||
if(!Number.isFinite(+start)||start.toISOString().slice(0,10)!==iso||period==='w'&&start.getUTCDay()!==1)throw Error('Leaf calendar refused')
|
||||
if(period==='m')end.setUTCMonth(end.getUTCMonth()+1);else end.setUTCDate(end.getUTCDate()+(period==='w'?7:1))
|
||||
if(start.getUTCFullYear()<100||end.getUTCFullYear()>9998)throw Error('Bounded leaf calendar refused')
|
||||
const bounds=/^FOR VALUES FROM \('([^']+)'\) TO \('([^']+)'\)$/.exec(text(leaf.partition_bound))
|
||||
const instant=(s:string)=>Date.parse(s.length===10?s+'T00:00:00.000Z':s)
|
||||
if(!bounds||instant(bounds[1])!==+start||instant(bounds[2])!==+end)throw Error('Leaf UTC bound refused')
|
||||
const acl=(r:Row)=>r.acl===null?null:(r.acl as string[]).slice().sort()
|
||||
exact(acl(leaf),acl(parent),'ACL')
|
||||
exact(leaf.reloptions,['flow_hour_facts','flow_daily_facts'].includes(parentName)?['fillfactor=70','autovacuum_vacuum_scale_factor=0.05','autovacuum_vacuum_cost_limit=2000']:null,'storage')
|
||||
exact(raw.columns.filter(r=>r.relname===name).map(r=>({...r,relname:parentName})),raw.columns.filter(r=>r.relname===parentName),'columns/defaults/identity')
|
||||
const functions=new Map(raw.functions.map(f=>[text(f.oid),text(f.namespace)+'.'+text(f.proname)+'('+text(f.arguments)+')']))
|
||||
const user=raw.triggers.filter(t=>t.relname===name&&!t.tgisinternal),source=raw.triggers.filter(t=>t.relname===parentName&&!t.tgisinternal)
|
||||
if(user.length!==3||source.length!==3||new Set(user.map(t=>t.tgname)).size!==3||user.some(t=>!guards.includes(text(t.tgname))))throw Error('Leaf guard set refused')
|
||||
for(const t of user){const p=source.find(p=>p.tgname===t.tgname)
|
||||
if(!p||t.tgtype!==p.tgtype||t.tgenabled!=='A'||functions.get(text(t.tgfoid))!==functions.get(text(p.tgfoid))||t.tgnargs!==0||t.arg_bytes!==0||t.unconditional!==true||t.tgdeferrable!==false||t.tginitdeferred!==false||t.tgoldtable!==null||t.tgnewtable!==null||t.tgconstraint!=='0'||(t.tgname==='server_partition_writer_guard'?t.tgparentid!==p.oid:t.tgparentid!=='0'))throw Error('Leaf trigger structure refused')
|
||||
exact(text(t.definition).replaceAll(name,parentName),p.definition,'guard definition')
|
||||
}
|
||||
const inherited=raw.triggers.filter(t=>t.relname===name&&t.tgisinternal),parentInternal=raw.triggers.filter(t=>t.relname===parentName&&t.tgisinternal)
|
||||
if(inherited.length!==parentInternal.length||new Set(inherited.map(t=>t.tgparentid)).size!==inherited.length||inherited.some(t=>!parentInternal.some(p=>t.tgparentid===p.oid&&t.tgfoid===p.tgfoid&&t.tgtype===p.tgtype&&t.tgenabled===p.tgenabled&&t.tgnargs===p.tgnargs&&t.arg_bytes===p.arg_bytes&&t.unconditional===p.unconditional&&t.tgdeferrable===p.tgdeferrable&&t.tginitdeferred===p.tginitdeferred&&t.tgoldtable===p.tgoldtable&&t.tgnewtable===p.tgnewtable)))throw Error('Inherited FK trigger identity/state refused')
|
||||
const constraints=(relname:string)=>sorted(raw.constraints.filter(r=>r.relname===relname).map(r=>omit(r,['relname','conname'])))
|
||||
exact(constraints(name),constraints(parentName),'constraints')
|
||||
const indexes=(relname:string)=>sorted(raw.indexes.filter(r=>r.relname===relname).map(r=>{const m=/^CREATE (UNIQUE )?INDEX [^]+? ON (?:ONLY )?public\.[a-z_0-9]+ (USING [^]+)$/.exec(text(r.definition));if(!m)throw Error('Leaf index template refused');return {...omit(r,['relname','index_name','definition']),definition:m[2]}}))
|
||||
exact(indexes(name),indexes(parentName),'indexes')
|
||||
exact(raw.roleGrants.filter(r=>r.relname===name).map(r=>omit(r,['relname'])),raw.roleGrants.filter(r=>r.relname===parentName).map(r=>omit(r,['relname'])),'effective grants')
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Private DATA projection. OIDs remain checked through structural readers separately.
|
||||
import {canonical} from './application-writer-profile-canonical.js'
|
||||
export type Json=null|boolean|number|string|Json[]|{[key:string]:Json}
|
||||
export type Row=Record<string,Json>
|
||||
export type Inventory=Record<string,Row[]>
|
||||
export const GROUPS=['columns','triggers','constraints','indexes','roleGrants','inheritance'] as const
|
||||
export const GLOBALS=['database','effectiveDatabase','effectiveFunctions','effectiveSchemas','effectiveSequences','events','functions','identities','memberships','parameters','roles','schemas','settings','sequences'] as const
|
||||
export const sorted=(rows:Row[])=>rows.slice().sort((a,b)=>canonical(a)<canonical(b)?-1:canonical(a)>canonical(b)?1:0)
|
||||
export const omit=(row:Row,names:readonly string[]):Row=>Object.fromEntries(Object.entries(row).filter(([key])=>!names.includes(key)))
|
||||
export function text(value:Json|undefined):string {if(typeof value!=='string')throw Error('Catalogue string required');return value}
|
||||
export function object(value:Json|undefined):Row {if(!value||typeof value!=='object'||Array.isArray(value))throw Error('Catalogue object required');return value}
|
||||
export function rows(value:Json|undefined):Row[] {if(!Array.isArray(value))throw Error('Catalogue array required');return value.map(object)}
|
||||
const acl=(value:Json):Json=>value===null?null:rowsOfStrings(value).slice().sort()
|
||||
function rowsOfStrings(value:Json):string[]{if(!Array.isArray(value)||value.some(v=>typeof v!=='string'))throw Error('Catalogue string array required');return value as string[]}
|
||||
export function projectCatalogueData(raw:Inventory):Inventory {
|
||||
const bases=new Set(raw.tables.filter(t=>t.relispartition===false).map(t=>text(t.relname)))
|
||||
const names=new Map(raw.tables.map(t=>[text(t.oid),text(t.relname)]))
|
||||
const functions=new Map(raw.functions.map(f=>[text(f.oid),text(f.namespace)+'.'+text(f.proname)+'('+text(f.arguments)+')']))
|
||||
const base=(data:Row[])=>data.filter(r=>bases.has(text(r.relname)))
|
||||
const result:Inventory={
|
||||
tables:base(raw.tables).map(r=>({...omit(r,['oid','relpersistence']),acl:acl(r.acl)})),
|
||||
columns:base(raw.columns),identities:raw.identities.map(r=>omit(r,['relation_oid'])),
|
||||
sequences:raw.sequences.map(r=>{const owner=names.get(text(r.owner_relation_oid));if(!owner)throw Error('Sequence owner relation missing');return {...omit(r,['oid','owner_relation_oid']),acl:acl(r.acl),owner_relation:owner}}),
|
||||
functions:raw.functions.map(r=>({...omit(r,['oid']),acl:acl(r.acl)})),
|
||||
triggers:base(raw.triggers).map(t=>({...omit(t,['oid','tgfoid','tgparentid','tgconstraint']),tgname:t.tgisinternal?text(t.tgname).replace(/_[0-9]+$/,'_{oid}'):t.tgname,function:functions.get(text(t.tgfoid))??'pg_catalog#'+text(t.tgfoid),parent:t.tgparentid!=='0',constraint:t.tgconstraint!=='0',definition:t.tgisinternal?text(t.definition).replace(/(RI_ConstraintTrigger_[ac])_[0-9]+/g,'$1_{oid}'):t.definition})),
|
||||
inheritance:raw.inheritance.filter(r=>bases.has(text(r.child))),constraints:base(raw.constraints),indexes:base(raw.indexes),
|
||||
schemas:raw.schemas.map(r=>({...r,acl:acl(r.acl)})),database:raw.database.map(r=>({...r,datname:'current-database',acl:acl(r.acl)})),
|
||||
effectiveSchemas:raw.effectiveSchemas,effectiveDatabase:raw.effectiveDatabase,memberships:raw.memberships,
|
||||
effectiveFunctions:raw.effectiveFunctions.map(r=>omit(r,['oid'])),effectiveSequences:raw.effectiveSequences,
|
||||
events:raw.events.map(r=>{const fn=functions.get(text(r.function_oid));if(!fn)throw Error('Event function missing');return {...omit(r,['function_oid']),function:fn}}),
|
||||
parameters:raw.parameters,settings:raw.settings.map(r=>({...r,database:r.database==='all-databases'?r.database:'current-database'})),roles:raw.roles,roleGrants:base(raw.roleGrants),
|
||||
}
|
||||
return Object.fromEntries(Object.entries(result).map(([name,data])=>[name,sorted(data)]))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Typed bounded VALUE -> fixed44 semantic bridge. Requires real EOF, never emits authority.
|
||||
// SQL retained cursor/context/source-before-and-after remains the protected host's obligation.
|
||||
import {CatalogueValueStream} from './application-writer-profile-catalogue-values.js'
|
||||
import {Fixed44CatalogueDataVerifier,type InstallationRoleBindingsData} from './application-writer-profile-catalogue.js'
|
||||
export class Fixed44CatalogueValueBridgeData {
|
||||
private readonly values=new CatalogueValueStream();private readonly schema:Fixed44CatalogueDataVerifier
|
||||
private eof=false;private closed=false;private failed=false
|
||||
constructor(bindings:InstallationRoleBindingsData){this.schema=new Fixed44CatalogueDataVerifier(bindings)}
|
||||
acceptPage(page:readonly unknown[]):void {
|
||||
if(this.eof||this.closed||this.failed)throw Error('Closed/refused VALUE catalogue bridge')
|
||||
try{for(const record of this.values.acceptPage(page))this.schema.consume(record);if(page.length===0)this.eof=true}catch(error){this.failed=true;throw error}
|
||||
}
|
||||
finish(){if(this.closed||this.failed||!this.eof)throw Error('Real cursor EOF required for VALUE catalogue bridge');try{const values=this.values.finish(),catalogue=this.schema.finish();this.closed=true;return Object.freeze({values,catalogue,authority:false,sourceGenerationAuthenticated:false,allWriterDrainAuthenticated:false,readyAuthorized:false})}catch(error){this.failed=true;throw error}}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {Fixed44CatalogueValueBridgeData} from './catalogue-value-bridge.js'
|
||||
import {FIXED44_CATALOGUE_BUILD_SQL} from './fixed44-build-sql.js'
|
||||
import {BUILD44_PROVENANCE,FIXED44_ROLE_TEMPLATE} from './fixed44-build-definition.js'
|
||||
import {canonical,sha} from './application-writer-profile-canonical.js'
|
||||
export function approvedDefinition(value:unknown){
|
||||
assert(value&&typeof value==='object');const v=value as Record<string,unknown>
|
||||
assert.deepEqual(Object.keys(v).sort(),['format','provenance','template']);assert.equal(v.format,'application-writer-source44-definition-approval-v1')
|
||||
assert.equal(canonical(v.provenance),canonical(BUILD44_PROVENANCE));assert.equal(canonical(v.template),canonical(FIXED44_ROLE_TEMPLATE))
|
||||
return BUILD44_PROVENANCE.templateSha256
|
||||
}
|
||||
/** Real incremental DATA verifier on caller's retained transaction. No pool or attestor. */
|
||||
export async function collectSource44Catalogue(s:{query:(sql:string,values?:unknown[])=>Promise<{rows:Record<string,unknown>[]} >},installation:{roleBindings:ConstructorParameters<typeof Fixed44CatalogueValueBridgeData>[0]}){
|
||||
const settings=(await s.query("SELECT pg_catalog.current_setting('search_path') AS path,pg_catalog.current_setting('TimeZone') AS zone,pg_catalog.current_setting('DateStyle') AS dates")).rows
|
||||
assert.equal(settings.length,1);assert(['path','zone','dates'].every(k=>typeof settings[0][k]==='string'))
|
||||
let cursor=false,primary:unknown,failed=false
|
||||
try{
|
||||
await s.query("SELECT pg_catalog.set_config('search_path','pg_catalog, public',true),pg_catalog.set_config('TimeZone','UTC',true),pg_catalog.set_config('DateStyle','ISO, YMD',true)")
|
||||
const bindings=installation.roleBindings,values=['operator','runtime','capture','publisher','control'].map(k=>bindings[k as keyof typeof bindings].name)
|
||||
const bridge=new Fixed44CatalogueValueBridgeData(bindings)
|
||||
await s.query('DECLARE mm_publisher44 NO SCROLL CURSOR WITHOUT HOLD FOR '+FIXED44_CATALOGUE_BUILD_SQL,values);cursor=true
|
||||
for(;;){const page=(await s.query('FETCH FORWARD 128 FROM mm_publisher44')).rows;bridge.acceptPage(page);if(page.length===0)break}
|
||||
const result=bridge.finish();await s.query('CLOSE mm_publisher44');cursor=false
|
||||
return {catalogSha256:sha(canonical({values:result.values.snapshotSha256,catalogue:result.catalogue})),definitionSha256:result.catalogue.definitionSha256,leafCount:result.catalogue.leafRelations}
|
||||
}catch(error){primary=error;failed=true;throw error}
|
||||
finally{
|
||||
try{if(cursor)await s.query('CLOSE mm_publisher44');await s.query("SELECT pg_catalog.set_config('search_path',$1,true),pg_catalog.set_config('TimeZone',$2,true),pg_catalog.set_config('DateStyle',$3,true)",[settings[0].path,settings[0].zone,settings[0].dates])}
|
||||
catch(error){if(failed)throw new AggregateError([primary,error],'Catalogue and context cleanup failed');throw error}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Internal fixed SQL/state verification. No signing, provisioning, pool or issuer.
|
||||
import {canonical} from '../../src/db/application-writer-profile-format.js'
|
||||
import {readProtectedRegistryStructureData} from '../../src/db/application-writer-profile-registry-structure.js'
|
||||
export {readCurrentApplicationWriterProfile as readCurrent,HISTORY,CURRENT} from '../../src/db/application-writer-profile-registry-read.js'
|
||||
|
||||
|
||||
const same=(a,b,phase)=>{if(canonical(a)!==canonical(b))throw Error('Protected registry refused: '+phase)}
|
||||
export async function ownedContext(client,installation,actor,phase){
|
||||
const rows=(await client.query(`SELECT CURRENT_USER::text AS actor,SESSION_USER::text AS login,r.oid::text AS role_oid,
|
||||
d.oid::text AS database_oid,d.datname AS database_name,pg_catalog.pg_backend_pid() AS pid,
|
||||
pg_catalog.pg_current_xact_id_if_assigned()::text AS xid,pg_catalog.current_setting('transaction_read_only') AS read_only,
|
||||
pg_catalog.current_setting('transaction_isolation') AS isolation,pg_catalog.current_setting('server_version_num')::int AS version,
|
||||
pg_catalog.pg_is_in_recovery() AS recovery,control.system_identifier::text AS system_identifier,
|
||||
(EXTRACT(EPOCH FROM pg_catalog.clock_timestamp())*1000)::bigint::text AS server_now,
|
||||
EXISTS(SELECT 1 FROM pg_catalog.pg_locks l WHERE l.pid=pg_catalog.pg_backend_pid() AND l.locktype='advisory'
|
||||
AND l.database=d.oid AND l.classid=0 AND l.objid=8723110 AND l.objsubid=1 AND l.granted
|
||||
AND l.mode=ANY($1::text[])) AS admitted
|
||||
FROM pg_catalog.pg_roles r CROSS JOIN pg_catalog.pg_database d CROSS JOIN pg_catalog.pg_control_system() control
|
||||
WHERE r.rolname=CURRENT_USER AND d.datname=pg_catalog.current_database()`,[phase.startsWith('publisher')?['ExclusiveLock']:['ShareLock','ExclusiveLock']])).rows
|
||||
const c=rows[0],expectedOid=installation.roleBindings.publisher.signedOid
|
||||
if(actor!==installation.roleBindings.publisher.name||rows.length!==1||!c||c.actor!==actor||c.login!==actor||c.role_oid!==expectedOid||c.recovery!==false||c.version<180000||c.version>=190000||c.admitted!==true||!Number.isSafeInteger(c.pid)||!/^[1-9][0-9]*$/.test(c.database_oid)||!(/^[1-9][0-9]{0,19}$/).test(c.system_identifier))throw Error('Distinct actual actor/source/admission context refused')
|
||||
if(phase==='publisher-read'&&(c.read_only!=='on'||c.isolation!=='repeatable read')||phase==='publisher-inspect'&&(c.read_only!=='on'||c.isolation!=='read committed')||['publisher-write','operator'].includes(phase)&&(c.read_only!=='off'||c.isolation!=='read committed'||!(/^[1-9][0-9]*$/).test(c.xid??'')))throw Error('Distinct owned transaction context refused')
|
||||
return c
|
||||
}
|
||||
export function sameOwned(a,b){same(Object.fromEntries(['actor','login','role_oid','database_oid','database_name','pid','xid','read_only','isolation','version','recovery','system_identifier','admitted'].map(k=>[k,a[k]])),Object.fromEntries(['actor','login','role_oid','database_oid','database_name','pid','xid','read_only','isolation','version','recovery','system_identifier','admitted'].map(k=>[k,b[k]])),'backend/fullXID/lifetime')}
|
||||
export const sourceCoordinates=c=>({systemIdentifier:c.system_identifier,databaseOid:c.database_oid,databaseName:c.database_name,serverVersionNum:c.version,inRecovery:false})
|
||||
|
||||
export function protectedStructure(client,installation){return readProtectedRegistryStructureData(client,installation.roleBindings)}
|
||||
@@ -0,0 +1,93 @@
|
||||
// VERSIONED DATA-only candidate. Never installation policy, enrollment or writer permission.
|
||||
import {BUILD44_PROVENANCE,FIXED44_ROLE_TEMPLATE} from './fixed44-build-definition.js'
|
||||
import {fixedCatalogueParentPeriodData} from './application-writer-profile-catalogue-parent-policy.js'
|
||||
import {GROUPS,type Inventory} from './catalogue-projection.js'
|
||||
export const EXPANDED44_POLICY_VERSION='source44-managed-leaves-3000-data-v1' as const
|
||||
const PLANNED=2000,EXPIRED=1000,PER_PARENT=1000,LEAVES=PLANNED+EXPIRED
|
||||
const source=FIXED44_ROLE_TEMPLATE
|
||||
const baseNames=new Set(source.tables.map(row=>String(row.relname)))
|
||||
const parents=[...baseNames].filter(name=>fixedCatalogueParentPeriodData(name)!==undefined)
|
||||
const perRelation=new Map([...baseNames].map(name=>[name,Object.fromEntries(GROUPS.map(group=>[group,source[group].filter(row=>(group==='inheritance'?row.child:row.relname)===name).length])) as Record<typeof GROUPS[number],number>]))
|
||||
const base=Object.freeze(Object.fromEntries(GROUPS.map(group=>[group,source[group].length])) as Record<typeof GROUPS[number],number>)
|
||||
const leafMax=Object.freeze(Object.fromEntries(GROUPS.map(group=>[group,group==='inheritance'?1:Math.max(...parents.map(name=>perRelation.get(name)![group]))])) as Record<typeof GROUPS[number],number>)
|
||||
if(baseNames.size!==104||source.tables.length!==104||parents.length!==12||leafMax.columns!==18||leafMax.constraints!==17||leafMax.triggers!==5||leafMax.indexes!==2||leafMax.roleGrants!==4)throw Error('Exact reviewed source44 topology changed; rederive policy')
|
||||
for(const row of source.constraints)if(parents.includes(String(row.relname))&&!['n','c','p','f'].includes(String(row.contype)))throw Error('Unreviewed parent constraint semantics')
|
||||
const total=Object.freeze(Object.fromEntries(GROUPS.map(group=>[group,base[group]+LEAVES*leafMax[group]])) as Record<typeof GROUPS[number],number>)
|
||||
export const EXPANDED44_DATA_POLICY=Object.freeze({version:EXPANDED44_POLICY_VERSION,baseRelations:baseNames.size,parents:parents.length,maxPlannedLeaves:PLANNED,maxExpiredBacklog:EXPIRED,maxManagedLeaves:LEAVES,maxPerParent:PER_PARENT,maxRelations:baseNames.size+LEAVES,maxWeightedPlan:2000,baseRows:base,maxLeafRows:leafMax,maxRows:total,definitionSha256:BUILD44_PROVENANCE.templateSha256,authority:false,productionInstallationPolicyApproved:false,settingsAdmissionAuthorized:false,readyAuthorized:false})
|
||||
export const expanded44ParentRowCountsData=()=>Object.freeze(Object.fromEntries(parents.map(name=>[name,Object.freeze({...perRelation.get(name)!})])))
|
||||
export interface ManagedLeafData {readonly parent:string;readonly name:string}
|
||||
export function validateManagedLeafNameData(input:ManagedLeafData):void {
|
||||
if(!input||Object.keys(input).sort().join(',')!=='name,parent'||typeof input.parent!=='string'||typeof input.name!=='string')throw Error('Exact managed leaf DATA shape required')
|
||||
const period=fixedCatalogueParentPeriodData(input.parent)
|
||||
if(!period||!input.name.startsWith(input.parent+'_'+period+'_'))throw Error('Unknown/cross-parent managed leaf')
|
||||
const stamp=input.name.slice(input.parent.length+3)
|
||||
if(!new RegExp('^[0-9]{'+(period==='m'?6:8)+'}$').test(stamp))throw Error('Managed leaf name shape refused')
|
||||
const iso=stamp.slice(0,4)+'-'+stamp.slice(4,6)+'-'+(period==='m'?'01':stamp.slice(6,8)),start=new Date(iso+'T00:00:00Z'),end=new Date(start)
|
||||
if(!Number.isFinite(+start)||start.toISOString().slice(0,10)!==iso||period==='w'&&start.getUTCDay()!==1)throw Error('Managed leaf calendar refused')
|
||||
if(period==='m')end.setUTCMonth(end.getUTCMonth()+1);else end.setUTCDate(end.getUTCDate()+(period==='w'?7:1))
|
||||
if(start.getUTCFullYear()<100||end.getUTCFullYear()>9998)throw Error('Managed leaf bounded calendar refused')
|
||||
}
|
||||
/** Only exact template-validated relation counts enter this sticky cumulative DATA budget. */
|
||||
export class Expanded44TopologyBudgetData {
|
||||
private readonly names=new Set<string>();private readonly perParent=new Map<string,number>();private readonly rows=Object.fromEntries(GROUPS.map(g=>[g,0])) as Record<typeof GROUPS[number],number>
|
||||
private bases=0;private leaves=0;private failed=false;private closed=false
|
||||
acceptRelation(kind:'base'|'leaf',name:string,groups:Inventory,parent?:string):void {
|
||||
try{
|
||||
if(this.failed||this.closed||this.names.has(name)||this.names.size>=EXPANDED44_DATA_POLICY.maxRelations)throw Error('Closed/duplicate/total relation DATA envelope')
|
||||
const expected=kind==='base'&&parent===undefined?perRelation.get(name):kind==='leaf'&&parent!==undefined&&parents.includes(parent)?{...perRelation.get(parent)!,inheritance:1}:undefined
|
||||
if(!expected)throw Error('Unknown source44 base/parent')
|
||||
if(kind==='leaf'){
|
||||
validateManagedLeafNameData({name,parent:parent!})
|
||||
if(this.leaves>=LEAVES||(this.perParent.get(parent!)??0)>=PER_PARENT)throw Error('Managed leaf total/per-parent DATA envelope')
|
||||
}
|
||||
for(const group of GROUPS){if(!Array.isArray(groups[group])||groups[group].length!==expected[group]||this.rows[group]+groups[group].length>total[group])throw Error('Exact source44 '+group+' DATA row envelope')}
|
||||
this.names.add(name);for(const group of GROUPS)this.rows[group]+=groups[group].length
|
||||
if(kind==='base')this.bases++;else{this.leaves++;this.perParent.set(parent!,1+(this.perParent.get(parent!)??0))}
|
||||
}catch(error){this.failed=true;throw error}
|
||||
}
|
||||
finish(){if(this.failed||this.closed||this.bases!==104)throw Error('Incomplete/refused expanded topology DATA');this.closed=true;return Object.freeze({baseRelations:this.bases,leafRelations:this.leaves,rows:Object.freeze({...this.rows}),perParent:Object.freeze(Object.fromEntries(this.perParent)),authority:false})}
|
||||
}
|
||||
/** Proposed projected admission DATA, not a trusted expiry observation or permission.
|
||||
* Genuine host must classify current leaves and bind same-session maintenance/settings separately.
|
||||
* Planned can include future leaves not present yet. Unclassified current leaves refuse.
|
||||
*/
|
||||
export function validateExpanded44AdmissionData(input:Readonly<{current:readonly ManagedLeafData[];planned:readonly ManagedLeafData[];expiredBacklog:readonly ManagedLeafData[]}>){
|
||||
if(!input||Object.keys(input).sort().join(',')!=='current,expiredBacklog,planned')throw Error('Exact admission DATA fields required')
|
||||
const parse=(values:readonly ManagedLeafData[],cap:number)=>{if(!Array.isArray(values)||values.length>cap)throw Error('Admission DATA list envelope');const map=new Map<string,ManagedLeafData>();for(const row of values){validateManagedLeafNameData(row);if(map.has(row.name))throw Error('Duplicate admission leaf');map.set(row.name,row)}return map}
|
||||
const current=parse(input.current,LEAVES),planned=parse(input.planned,PLANNED),expired=parse(input.expiredBacklog,EXPIRED)
|
||||
let weighted=0;for(const row of planned.values())weighted+=['flow_hour_facts','flow_daily_facts'].includes(row.parent)?2:1
|
||||
if(weighted>2000)throw Error('Source weighted planned budget exceeded')
|
||||
const projected=new Map(planned)
|
||||
for(const [name,row]of expired){if(projected.has(name)||!current.has(name))throw Error('Overlapping/noncurrent expired DATA');projected.set(name,row)}
|
||||
for(const [name,row]of current){const classified=projected.get(name);if(!classified||classified.parent!==row.parent)throw Error('Unclassified/cross-parent current leaf DATA')}
|
||||
if(projected.size>LEAVES)throw Error('Projected managed leaves DATA envelope')
|
||||
const perParent=new Map<string,number>();for(const row of projected.values()){const count=1+(perParent.get(row.parent)??0);if(count>PER_PARENT)throw Error('Projected per-parent DATA envelope');perParent.set(row.parent,count)}
|
||||
return Object.freeze({version:EXPANDED44_POLICY_VERSION,currentLeaves:current.size,plannedLeaves:planned.size,expiredBacklog:expired.size,projectedLeaves:projected.size,weightedPlan:weighted,perParent:Object.freeze(Object.fromEntries(perParent)),authority:false,expiryClassificationAuthenticated:false,settingsAdmissionAuthorized:false,readyAuthorized:false})
|
||||
}
|
||||
|
||||
/** Exact query meaning, not deparser-byte guesses. Unknown internal tree sizes
|
||||
* remain the unchanged actual SQL scalar preflight's obligation. */
|
||||
export const EXPANDED44_SOURCE_SIZE_SEMANTICS_DATA=Object.freeze({
|
||||
function_bytes:'sum pg_proc.prosrc UTF8 bytes; fixed global functions, no per-leaf routine copy',
|
||||
default_bytes:'sum pg_attrdef.adbin::text pg_node_tree bytes; NOT default_expression deparser bytes',
|
||||
check_bytes:'sum pg_constraint.conbin::text pg_node_tree bytes; NOT pg_get_constraintdef bytes',
|
||||
trigger_bytes:'sum octet_length(tgqual::text)+octet_length(tgargs); NULL qualifier terms are ignored by SQL sum; NOT trigger definition bytes',
|
||||
unchangedEachSourceScalarLimitBytes:2097152,internalTreeSizesDerived:false,actualSqlRequired:true,authority:false
|
||||
})
|
||||
export const expanded44LeafSourceShapeFactsData=()=>Object.freeze(Object.fromEntries(parents.map(parent=>{
|
||||
const columns=source.columns.filter(row=>row.relname===parent),constraints=source.constraints.filter(row=>row.relname===parent),triggers=source.triggers.filter(row=>row.relname===parent)
|
||||
return [parent,Object.freeze({defaultExpressionRows:columns.filter(row=>row.default_expression!==null).length,checkExpressionRows:constraints.filter(row=>row.contype==='c').length,qualifierRows:triggers.filter(row=>row.unconditional!==true).length,argumentBytes:triggers.reduce((sum,row)=>sum+Number(row.arg_bytes),0),routineCopiesPerLeaf:0,internalTreeBytesKnown:false})]
|
||||
})))
|
||||
|
||||
/** Exact extremum of expression ROW COUNTS, not an invented pg_node_tree byte cap.
|
||||
* Unit leaf costs +1000 capacity/parent +3000 total => top3 source costs.
|
||||
* Different scalar extrema may require different valid topologies.
|
||||
*/
|
||||
export function expanded44SourceShapeExtremaData(){
|
||||
const facts=expanded44LeafSourceShapeFactsData()
|
||||
const maximize=(field:'defaultExpressionRows'|'checkExpressionRows')=>{
|
||||
const selected=Object.entries(facts).sort((a,b)=>b[1][field]-a[1][field]||(a[0]<b[0]?-1:1)).slice(0,3)
|
||||
return Object.freeze({perLeafRows:selected.reduce((sum,[,value])=>sum+value[field]*PER_PARENT,0),cohorts:Object.freeze(selected.map(([parent,value])=>Object.freeze({parent,leaves:PER_PARENT,rowsPerLeaf:value[field]})))})
|
||||
}
|
||||
return Object.freeze({baseDefaultExpressionRows:source.columns.filter(row=>row.default_expression!==null).length,baseCheckExpressionRows:source.constraints.filter(row=>row.contype==='c').length,baseQualifiedTriggerRows:source.triggers.filter(row=>row.unconditional!==true).length,maxAddedDefaultExpressionRows:maximize('defaultExpressionRows'),maxAddedCheckExpressionRows:maximize('checkExpressionRows'),perLeafQualifiedTriggerRows:0,perLeafRoutineCopies:0,internalTreeByteUpperBoundKnown:false,actualSqlSourceScalarGateRequired:true,authority:false})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// OFFLINE source-derived candidate; clean4role SQL verification PENDING. No installation enrollment.
|
||||
import type {Inventory} from "./catalogue-projection.js"
|
||||
export const BUILD44_PROVENANCE={"format":"source44-build-candidate-v1","manifestSha256":"fffae3381732e6f276ba276aca7185f6a87587edf8272ba42763b7f49bc59ef5","reviewed43Sha256":"4dd21cf909628f2750294e0d4398064d5219f6547c1bcebec910efabe9d57dcd","migration44Sha256":"37b88d1679be9bb47f16ccc8cae7373f20f9c05e7a8f662c3775f18d78bb5613","templateSha256":"56bbe286968fd950e5239672158f198eb2d600b7fa2fcb290c2ec5befd08d90c","baseTables":104,"newColumns":13,"newConstraints":21,"newFunctions":3,"newIndexes":5,"newApplicationTriggers":4,"newInheritedWriterGuards":3,"sourceNamingAlgorithm":"PG18 makeObjectName balanced ASCII byte truncation; ChooseConstraintName collision-free branch only; collisions refuse","namingSources":["https://raw.githubusercontent.com/postgres/postgres/REL_18_STABLE/src/backend/commands/indexcmds.c","https://raw.githubusercontent.com/postgres/postgres/REL_18_STABLE/src/backend/catalog/pg_constraint.c"],"cleanFourRoleSqlVerified":false,"productionInstallationPolicyApproved":false,"authority":false} as const
|
||||
export const FIXED44_ROLE_TEMPLATE:Readonly<Inventory>={"columns":[{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"access_scope","attnotnull":false,"attnum":8,"attstorage":"x","default_expression":null,"relname":"mcp_artifacts","type":"jsonb"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"account_private_key","attnotnull":true,"attnum":5,"attstorage":"x","default_expression":"''::text","relname":"acme_settings","type":"text"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"ack_proof","attnotnull":true,"attnum":5,"attstorage":"x","default_expression":null,"relname":"server_poll_acks","type":"text"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"acknowledged_at","attnotnull":false,"attnum":6,"attstorage":"p","default_expression":null,"relname":"mcp_drift","type":"timestamp with time zone"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"action","attnotnull":true,"attnum":3,"attstorage":"x","default_expression":null,"relname":"mcp_audit","type":"text"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"action","attnotnull":true,"attnum":6,"attstorage":"x","default_expression":"'route'::text","relname":"filter_rules","type":"text"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"action_id","attnotnull":true,"attnum":6,"attstorage":"x","default_expression":null,"relname":"vrf_production_tickets","type":"text"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"action_version","attnotnull":true,"attnum":7,"attstorage":"p","default_expression":null,"relname":"vrf_production_tickets","type":"integer"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"active","attnotnull":true,"attnum":6,"attstorage":"p","default_expression":"true","relname":"app_users","type":"boolean"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"actor_id","attnotnull":false,"attnum":3,"attstorage":"x","default_expression":null,"relname":"mcp_requests","type":"text"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"actor_id","attnotnull":true,"attnum":2,"attstorage":"x","default_expression":null,"relname":"mcp_audit","type":"text"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"actor_id","attnotnull":true,"attnum":3,"attstorage":"x","default_expression":null,"relname":"mcp_diagnostic_cancellations","type":"text"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"actor_id","attnotnull":true,"attnum":5,"attstorage":"x","default_expression":null,"relname":"server_probe_executions","type":"text"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"admissions","attnotnull":true,"attnum":2,"attstorage":"p","default_expression":"0","relname":"server_poll_storage_usage","type":"bigint"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"admissions","attnotnull":true,"attnum":2,"attstorage":"p","default_expression":"0","relname":"server_probe_storage_usage","type":"bigint"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"after_sealed","attnotnull":false,"attnum":4,"attstorage":"x","default_expression":null,"relname":"mcp_operation_checkpoints","type":"text"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"api_key","attnotnull":true,"attnum":3,"attstorage":"x","default_expression":"''::text","relname":"evobgp_settings","type":"text"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"applied_at","attnotnull":true,"attnum":2,"attstorage":"p","default_expression":"now()","relname":"schema_migrations","type":"timestamp with time zone"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"approved_at","attnotnull":false,"attnum":12,"attstorage":"p","default_expression":null,"relname":"mcp_plans","type":"timestamp with time zone"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"approved_by","attnotnull":false,"attnum":11,"attstorage":"x","default_expression":null,"relname":"mcp_plans","type":"text"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"archive_bytes","attnotnull":false,"attnum":21,"attstorage":"p","default_expression":null,"relname":"server_poll_storage_admissions","type":"integer"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"archive_bytes","attnotnull":true,"attnum":6,"attstorage":"p","default_expression":"0","relname":"server_poll_storage_usage","type":"bigint"},{"acl":null,"attcompression":"","attgenerated":"","attidentity":"","attname":"archive_proof","attnotnull":true,"attnum":17,"attstorage":"x","default_expression":null,"relname":"server_poll_archives","type":"text"},{"acl":null,"attcompression":"","attgenerateLine truncated
|
||||
@@ -0,0 +1,2 @@
|
||||
// Fixed query from accepted43, role-literal parameters only; limits/framing unchanged. Clean44 SQL proof pending.
|
||||
export const FIXED44_CATALOGUE_BUILD_SQL="WITH c_tables AS NOT MATERIALIZED (SELECT c.oid::text AS oid,n.nspname AS namespace,c.relname,c.relkind,c.relispartition,c.relpersistence,c.relrowsecurity,c.relforcerowsecurity,c.reloptions,\n pg_catalog.pg_get_userbyid(c.relowner) AS owner,c.relacl::text[] AS acl,pg_catalog.pg_get_expr(c.relpartbound,c.oid) AS partition_bound,\n pg_catalog.pg_get_partkeydef(c.oid) AS partition_key FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace\n WHERE n.nspname='public' AND c.relkind IN('r','p')),\nc_columns AS NOT MATERIALIZED (SELECT c.relname,a.attnum::int,a.attname,pg_catalog.format_type(a.atttypid,a.atttypmod) AS type,\n a.attnotnull,a.attidentity,a.attgenerated,a.attstorage,a.attcompression,a.attacl::text[] AS acl,pg_catalog.pg_get_expr(d.adbin,d.adrelid) AS default_expression\n FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid=a.attrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace\n LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid=a.attrelid AND d.adnum=a.attnum\n WHERE n.nspname='public' AND c.relkind IN('r','p') AND a.attnum>0 AND NOT a.attisdropped),\nc_identities AS NOT MATERIALIZED (SELECT c.relname,c.oid::text AS relation_oid,a.attnum::int AS column_number,a.attname,a.attidentity,\n pg_catalog.pg_get_serial_sequence(pg_catalog.format('%I.%I',n.nspname,c.relname),a.attname) AS sequence_name\n FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid=a.attrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace\n WHERE n.nspname='public' AND c.relkind IN('r','p') AND NOT c.relispartition AND a.attnum>0 AND NOT a.attisdropped AND a.attidentity<>'' LIMIT 33),\nc_sequences AS NOT MATERIALIZED (SELECT s.oid::text AS oid,n.nspname AS namespace,s.relname,pg_catalog.pg_get_userbyid(s.relowner) AS owner,\n s.relacl::text[] AS acl,q.seqtypid::int AS type_oid,q.seqstart::text,q.seqincrement::text,q.seqmax::text,q.seqmin::text,q.seqcache::text,q.seqcycle,\n d.refobjid::text AS owner_relation_oid,d.refobjsubid::int AS owner_column,d.deptype\n FROM pg_catalog.pg_class s JOIN pg_catalog.pg_namespace n ON n.oid=s.relnamespace JOIN pg_catalog.pg_sequence q ON q.seqrelid=s.oid\n LEFT JOIN pg_catalog.pg_depend d ON d.classid='pg_catalog.pg_class'::regclass AND d.objid=s.oid AND d.objsubid=0 AND d.refclassid='pg_catalog.pg_class'::regclass AND d.refobjsubid>0 AND d.deptype IN('a','i')\n WHERE n.nspname='public' LIMIT 33),\nc_functions AS NOT MATERIALIZED (SELECT p.oid::text,n.nspname AS namespace,p.proname,pg_catalog.pg_get_function_identity_arguments(p.oid) AS arguments,\n p.prokind,p.prosecdef,p.proconfig,p.proacl::text[] AS acl,pg_catalog.pg_get_userbyid(p.proowner) AS owner,l.lanname AS language,\n pg_catalog.pg_get_functiondef(p.oid) AS definition FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid=p.pronamespace\n JOIN pg_catalog.pg_language l ON l.oid=p.prolang WHERE n.nspname IN('public','mmapp_control') AND p.prokind IN('f','p') LIMIT 257),\nc_triggers AS NOT MATERIALIZED (SELECT t.oid::text AS oid,c.relname,t.tgname,t.tgtype::int,t.tgenabled,t.tgisinternal,t.tgfoid::text,t.tgparentid::text,t.tgconstraint::text,t.tgdeferrable,t.tginitdeferred,t.tgoldtable,t.tgnewtable,\n t.tgnargs,octet_length(t.tgargs)::int AS arg_bytes,t.tgqual IS NULL AS unconditional,pg_catalog.pg_get_triggerdef(t.oid) AS definition\n FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_class c ON c.oid=t.tgrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace\n WHERE n.nspname='public'),\nc_inheritance AS NOT MATERIALIZED (SELECT p.relname AS parent,c.relname AS child FROM pg_catalog.pg_inherits i\n JOIN pg_catalog.pg_class p ON p.oid=i.inhparent JOIN pg_catalog.pg_class c ON c.oid=i.inhrelid JOIN pg_catalog.pg_namespace n ON n.oid=p.relnamespace\n WHERE n.nspname='public' AND p.relkind IN('r','p')),\nc_constraints AS NOT MATERIALIZED (SELECT c.relname,k.conname,k.contype,k.convalidated,k.condeferrable,k.condeferred,\n pg_catalog.pg_get_constraintdef(k.oid) AS definition FROM pg_catalog.pg_constraint k JOIN pg_catalog.pg_class c ON c.oid=k.conrelid\n JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public'),\nc_indexes AS NOT MATERIALIZED (SELECT c.relname,i.relname AS index_name,x.indisvalid,x.indisready,x.indisunique,x.indisprimary,\n pg_catalog.pg_get_indexdef(i.oid) AS definition FROM pg_catalog.pg_index x JOIN pg_catalog.pg_class c ON c.oid=x.indrelid\n JOIN pg_catalog.pg_class i ON i.oid=x.indexrelid JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace\n WHERE n.nspname='public'),\nc_schemas AS NOT MATERIALIZED (SELECT nspname,pg_catalog.pg_get_userbyid(nspowner) AS owner,nspacl::text[] AS acl FROM pg_catalog.pg_namespace WHERE nspname IN('public','mmapp_control')),\nc_database AS NOT MATERIALIZED (SELECT datname,pg_catalog.pg_get_userbyid(datdba) AS owner,datacl::text[] AS acl FROM pg_catalog.pg_database WLine truncated
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {X509Certificate} from 'node:crypto'
|
||||
import {checkServerIdentity} from 'node:tls'
|
||||
import {sha} from './operator-files.mjs'
|
||||
export const roleKeys=Object.freeze(['runtime','operator','capture','publisher','control'])
|
||||
export const STARTUP_OPTIONS='-c statement_timeout=10000 -c lock_timeout=5000 -c client_encoding=UTF8'
|
||||
export function assertJsDriverEnvironment(environment){assert(!Object.hasOwn(environment,'NODE_PG_FORCE_NATIVE'),'Native pg driver selector refused')}
|
||||
const hex=v=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v)
|
||||
const exact=(v,keys)=>v&&typeof v==='object'&&!Array.isArray(v)&&Object.keys(v).length===keys.length&&keys.every(k=>Object.hasOwn(v,k))
|
||||
function parsedApproval(value){
|
||||
assert(exact(value,['format','manifestSha256','definitionSha256','catalogSha256','expectedHeadSha256','validityMs','expiresAt']))
|
||||
assert(value.format==='application-writer-publication-approval-v1'&&['manifestSha256','definitionSha256','catalogSha256'].every(k=>hex(value[k]))&&(value.expectedHeadSha256===null||hex(value.expectedHeadSha256)))
|
||||
assert(Number.isSafeInteger(value.validityMs)&&value.validityMs>0&&value.validityMs<=86400000&&Number.isSafeInteger(value.expiresAt)&&value.expiresAt>0)
|
||||
return structuredClone(value)
|
||||
}
|
||||
export function approval(value){
|
||||
const pinned=parsedApproval(value);assert(pinned.expiresAt>Date.now());return pinned
|
||||
}
|
||||
// Pinned historical inspection data only; never publication authorization.
|
||||
export function reconciliationApproval(value){return parsedApproval(value)}
|
||||
export function poolOptions(transport,request,ca){
|
||||
assert(exact(transport,['host','port','user','password','database']))
|
||||
assert(typeof transport.host==='string'&&transport.host===request.tls.serverName&&Number.isSafeInteger(transport.port)&&transport.port>0&&transport.port<=65535&&transport.user===request.roles.publisher.name&&typeof transport.password==='string'&&transport.password.length>0&&transport.password.length<=4096&&transport.database===request.source.databaseName)
|
||||
assert(sha(ca)===request.tls.caSha256);new X509Certificate(ca)
|
||||
return {host:transport.host,port:transport.port,user:transport.user,password:transport.password,database:transport.database,max:1,connectionTimeoutMillis:5000,idleTimeoutMillis:5000,statement_timeout:10000,lock_timeout:5000,options:STARTUP_OPTIONS,application_name:'mmapp-writer-publisher',client_encoding:'UTF8',
|
||||
ssl:{ca,servername:request.tls.serverName,rejectUnauthorized:true,checkServerIdentity(host,cert){
|
||||
const failed=checkServerIdentity(request.tls.serverName,cert);if(failed)return failed
|
||||
try{if(host!==request.tls.serverName||sha(new X509Certificate(cert.raw).publicKey.export({format:'der',type:'spki'}))!==request.tls.spkiSha256)return Error('Publisher TLS SPKI refused')}catch{return Error('Publisher TLS certificate refused')}
|
||||
}}}
|
||||
}
|
||||
export function assertPublicationApproval(current,catalogue,structure,pinned,manifestSha256){
|
||||
assert(pinned.expiresAt>Date.now()&&pinned.manifestSha256===manifestSha256&&pinned.definitionSha256===catalogue.definitionSha256)
|
||||
assert(sha(JSON.stringify({base:catalogue.catalogSha256,security:structure.sha256}))===pinned.catalogSha256)
|
||||
assert((current?.headSha256??null)===pinned.expectedHeadSha256)
|
||||
}
|
||||
Loaded 100 of 3835 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user