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
|
||||
Reference in new issue
Block a user