Files
MikrotikManager/backend/scripts/acceptance/s3-backups-api.mjs
T

29 lines
9.0 KiB
JavaScript

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;
}