'use strict'; // Explicitly authorized beta-only admin bootstrap and authenticated deployment checks. // Credentials remain in memory. The temporary key is removed even when a check fails. const assert = require('node:assert/strict'); const {randomBytes, createHash} = require('node:crypto'); const {spawnSync} = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); const root = path.resolve(__dirname, '../..'); const base = 'https://beta.durieux.me'; const compose = ['compose', '--env-file', '.env.go-shadow', '-f', 'backend/compose.shadow.yml', '-f', 'backend/compose.shadow-beta.yml']; const appFlow = process.argv.includes('--app-flow'); const monitoringOnly = process.argv.includes('--monitoring-only'); const app = appFlow || process.argv.includes('--app'); if(app) compose.push('-f','backend/compose.shadow-app.yml'); function docker(args, input) { const r = spawnSync('docker', args, {cwd:root, input, encoding:'utf8', timeout:30000}); if (r.error || r.status !== 0) throw Error('Shadow Docker operation failed'); return r.stdout.trim(); } function db(script) { return docker([...compose, 'exec', '-T', 'mongodb', 'mongosh', '--quiet', '--nodb', '--file', '/dev/stdin'], ` const c = new Mongo('mongodb://' + encodeURIComponent(process.env.MONGO_INITDB_ROOT_USERNAME) + ':' + encodeURIComponent(process.env.MONGO_INITDB_ROOT_PASSWORD) + '@127.0.0.1:27017/?authSource=admin'); const d = c.getDB('shadow'); ${script} `); } const report = {verifiedAt:new Date().toISOString(), hostname:'beta.durieux.me', configurationMode:app?'GitHub App enabled alongside legacy OAuth':'legacy OAuth only', checks:[], productionChanged:false}; const key = randomBytes(32).toString('hex'); const hash = createHash('sha256').update(key).digest('hex'); const id = randomBytes(12).toString('hex'); const repoId = 'go-beta-check-' + Date.now(); let bootstrapped = false, createdRepo = false; const requestIDs=new Set(); const fixture = appFlow ? {name:'tdurieux/synctex-js',branch:'master',file:'LICENSE',term:'MIT'} : {name:'octocat/Hello-World',branch:'master',file:'README',term:'Hello'}; async function call(method, route, body, expected=200, token=key, extraHeaders={}, binary=false) { const start = performance.now(); const response = await fetch(base+route, {method, headers:{...(token ? {Authorization:'Bearer '+token}:{}), 'Content-Type':'application/json', ...extraHeaders}, body:body===undefined?undefined:JSON.stringify(body), signal:AbortSignal.timeout(45000), redirect:'manual'}); assert.equal(response.status, expected, method+' '+route+' unexpected status; deployment='+response.headers.get('x-anonymous-deployment')); const requestID=response.headers.get('x-request-id'); assert(/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(requestID||''),'missing generated request ID on '+method+' '+route+' (status '+response.status+', deployment '+response.headers.get('x-anonymous-deployment')+')'); assert(!requestIDs.has(requestID),'request ID reused'); requestIDs.add(requestID); const headersMs=Math.round(performance.now()-start); const chunks=[]; let firstBodyByteMs=null; for await (const chunk of response.body) { if(chunk.length && firstBodyByteMs===null) firstBodyByteMs=Math.round(performance.now()-start); chunks.push(chunk); } const bytes = Buffer.concat(chunks); const text = bytes.toString('utf8'); let value; try {value=JSON.parse(text);} catch {value=text;} report.checks.push({method, route, requestID, status:response.status, expected, bytes:bytes.length, headersMs, firstBodyByteMs, elapsedMs:Math.round(performance.now()-start)}); assert.equal(response.headers.get('x-anonymous-deployment'), 'go-shadow-beta', 'wrong deployment'); assert.equal(response.status, expected, method+' '+route+' unexpected status'); assert(!text.includes(key) && !text.includes(hash), 'credential exposure'); return {value:binary?bytes:value,response}; } (async () => { assert(process.argv.includes('--bootstrap-admin'), 'Explicit --bootstrap-admin required; leaves the sole real beta account as admin'); for (const service of ['app','mongodb']) { const info=JSON.parse(docker(['inspect',docker([...compose,'ps','-q',service])]))[0]; assert.equal(info.Config.Labels['com.docker.compose.project'],'anonymous-go-shadow'); assert(Object.values(info.NetworkSettings.Ports||{}).every(v=>!v)); assert(info.Mounts.every(m=>(m.Type==='volume' && m.Name.startsWith('anonymous-go-shadow_')) || (app && service==='app' && m.Type==='bind' && m.Source===path.join(root,'secrets/github-app-beta.pem') && m.Destination==='/run/secrets/github-app-beta.pem' && !m.RW))); if(service==='mongodb') assert.deepEqual(Object.keys(info.NetworkSettings.Networks),['anonymous-go-shadow_default']); if(service==='app') { const env=Object.fromEntries(info.Config.Env.map(v=>{const i=v.indexOf('=');return [v.slice(0,i),v.slice(i+1)];})); const uri=new URL(env.MONGODB_URI); assert.equal(uri.hostname,'mongodb');assert.equal(uri.pathname,'/shadow'); assert.equal(env.APP_HOSTNAME,'beta.durieux.me'); } } await call('GET','/api/user',undefined,401,''); db(`const users=d.users.find({'externalIDs.github':{$exists:true},status:'active'}).toArray(); if(users.length!==1) throw Error('Expected exactly one active GitHub beta account'); const result=d.users.updateOne({_id:users[0]._id,status:'active'},{$set:{isAdmin:true},$push:{apiTokens:{_id:ObjectId('${id}'),name:'Temporary beta deployment validation',tokenHash:'${hash}',createdAt:new Date()}}}); if(result.modifiedCount!==1) throw Error('Bootstrap failed');`); bootstrapped=true; const {value:user}=await call('GET','/api/user'); assert.equal(user.isAdmin,true); for(const route of (monitoringOnly ? ['/api/admin/overview','/api/admin/queues','/api/admin/performance','/api/admin/errors','/api/admin/errors/stats'] : ['/github/connections','/api/providers','/api/user/dashboard','/api/user/default','/api/user/quota','/api/admin/overview','/api/admin/users','/api/admin/repos','/api/admin/queues','/api/admin/performance','/api/admin/errors','/api/admin/errors/stats','/api/admin/tokens'])) { const {value}=await call('GET',route); if(route==='/api/admin/queues') { for(const queue of value.queues) { for(const field of ['workers','completed24h','failed24h']) assert(Number.isFinite(queue[field]),'missing queue overview '+field); assert(queue.workers>=1,'beta worker not registered: '+queue.key); assert.equal(queue.concurrency,queue.key==='download'?3:5,'wrong beta worker concurrency'); } report.queueOverviewFields='passed'; } if(route==='/api/admin/overview') { for(const [group,field] of [['repos','activeRepos24h'],['repos','newRepos24h'],['users','newUsers24h'],['system','cpuPercent'],['system','memPercent'],['system','diskPercent']]) { assert(Number.isFinite(value[group]?.[field]),'Missing overview field '+group+'.'+field); } const today=new Date();today.setUTCHours(0,0,0,0); const current=value.history.filter(row=>new Date(row.date).getTime()===today.getTime()); assert.equal(current.length,1,'admin history should contain one current-day snapshot'); assert.equal(current[0].nbRepositories,value.repos.total,'admin history uses stale totals'); report.overviewHistoryFresh='passed'; report.overviewBrowserFields='passed'; } } const {value:errorList}=await call('GET','/api/admin/errors?limit=5001'); assert.equal(errorList.max,5000);assert.equal(errorList.limit,5000);assert.equal(errorList.available,true); const {value:beforeErrors}=await call('GET','/api/admin/errors/stats'); await call('GET','/api/repo/'+repoId+'-missing',undefined,404); const {value:afterErrors}=await call('GET','/api/admin/errors/stats'); assert.equal(afterErrors.available,true);assert.equal(afterErrors.buckets.length,24); assert(afterErrors.severity.info>=beforeErrors.severity.info+1,'missing routine 404 counter'); report.errorMonitoring={maxEntries:5000,hourlyBuckets:24,routine404Counted:true}; const windows=[]; for(const [queue,range,count] of [['download','1h',60],['download','6h',360],['download','24h',1440],['download','7d',10080],['remove','1h',60],['cache','1h',60],['download','unrecognized',60]]) { const {value:history}=await call('GET','/api/admin/queues/metrics?queue='+queue+'&range='+range); assert.equal(history.queue,queue);assert.equal(history.range,range);assert.equal(history.points.length,count); assert.equal(history.points.at(-1).ts-history.points[0].ts,(count-1)*60000); for(const point of history.points) for(const field of ['ts','completed','failed','avgMs']) assert(Number.isFinite(point[field]),'invalid queue point '+field); windows.push({queue,range,points:count}); } await call('GET','/api/admin/queues/metrics?queue=unknown',undefined,404); report.queueMetricWindows=windows; const statFields=['nbRepositories','nbUsers','nbPageViews','nbPullRequests']; const {value:publicStats}=await call('GET','/api/stat',undefined,200,''); for(const field of statFields) assert(Number.isFinite(publicStats[field]) && publicStats[field]>=0,'invalid public statistic '+field); const publicWindows=[]; for(const [query,days] of [['days=-2',1],['days=0',30],['days=0x10',16],['days=7suffix',7],['days=999',365],['days[]=7&days[]=30',7],['days[1]=7&days[0]=1',1],['days[x]=7',30]]) { const started=new Date();started.setUTCHours(0,0,0,0); const {value:history}=await call('GET','/api/stat/history?'+query,undefined,200,''); assert(Array.isArray(history) && history.length>0,'missing public history'); const lower=started.getTime()-(days-1)*86400000; let previous=-Infinity; for(const row of history) { const date=Date.parse(row.date); assert(Number.isFinite(date) && date>=lower && date>=previous,'invalid history window/order'); previous=date; for(const field of statFields) assert(Number.isFinite(row[field]) && row[field]>=0,'invalid history statistic '+field); } publicWindows.push({query,days,rows:history.length}); } report.publicStatistics={anonymousAccess:true,windows:publicWindows}; for(const query of ['statuses[]=','type[]=repo']) { const {value:dashboard}=await call('GET','/api/user/dashboard?'+query); assert.deepEqual(dashboard.items,[],'array-valued dashboard filter not preserved'); assert.equal(dashboard.filtered,0); } for(const query of ['sort[]=','cursor[]=']) await call('GET','/api/user/dashboard?'+query,undefined,400); report.dashboardExtendedQueries='passed'; const dateCursor=value=>Buffer.from(JSON.stringify({sort:'anonymizeDate',type:'repo',id:'000000000000000000000001',value})).toString('base64url'); const cursorRoute=value=>'/api/user/dashboard?sort=anonymizeDate&limit=5&cursor='+dateCursor(value); const {value:canonicalDates}=await call('GET',cursorRoute('2024-03-01T00:00:00.000Z')); for(const date of ['March 1 2024','2024-02-30','2024-02-29T24:00:00Z',['2024','3','1']]) { const {value}=await call('GET',cursorRoute(date)); assert.deepEqual(value.items,canonicalDates.items);assert.equal(value.cursor,canonicalDates.cursor); } await call('GET',cursorRoute('not a date'),undefined,400); report.dashboardDateCursors='passed'; for(const [query,expected] of [['minutes=6e1',60],['minutes[]=60',60],['minutes[0]=15',15]]) { const {value:performance}=await call('GET','/api/admin/performance?'+query); assert.equal(performance.windowMinutes,expected,'monitoring query window mismatch'); } report.monitoringExtendedQueries='passed'; for(const [query,queue,range,count] of [['queue[]=cache&range[]=6h','cache','6h',360],['range[]=','download','',60],['range=toString','download','toString',0]]) { const {value:history}=await call('GET','/api/admin/queues/metrics?'+query); assert.equal(history.queue,queue);assert.equal(history.range,range);assert.equal(history.points.length,count); } await call('GET','/api/admin/queues/metrics?queue[]=',undefined,404); const {value:arrayQueue}=await call('GET','/api/admin/queues?queue[]=remove'); assert.equal(arrayQueue.selectedQueue,'remove'); const {value:arrayErrors}=await call('GET','/api/admin/errors?offset[]=2&limit[]=7'); assert.equal(arrayErrors.offset,2);assert.equal(arrayErrors.limit,7);assert(arrayErrors.entries.length<=7); report.adminExtendedQueries='passed'; for(const query of ['connection=','connection[]=oauth','connection=oauth&connection[]=github-app']) { const {value:rejected}=await call('GET','/api/repo/octocat/Hello-World?'+query,undefined,400); assert.equal(rejected.error,'invalid_connection'); } report.previewConnectionQueries='passed'; for(const query of ['repositoryID[]=invalid','repositoryID=invalid','repositoryID[$exists]=true']) { const {value:rejected}=await call('GET','/api/repo/octocat/Hello-World?'+query,undefined,500); assert.equal(rejected.error,'internal_error'); } report.previewCacheHintRejections='passed'; for(const query of ['q[]=ab','q[length]=1']) { const {value}=await call('GET','/api/user/search/github-users?'+query); assert.deepEqual(value,[]); } for(const query of ['q=ab&q=cd','q[x]=ab','q[length][toString]=1']) { const {value}=await call('GET','/api/user/search/github-users?'+query,undefined,500); assert.equal(value.error,'internal_error'); } report.userSearchQueries='passed'; for(const kind of ['repos','users','conferences']) { const {value}=await call('GET','/api/admin/'+kind+'?page[]=2&limit[]=2&sort[]=status&direction[]=asc&format[]=csv'); assert.equal(value.page,2);assert.deepEqual(value.sort,{_id:-1}); const {value:large}=await call('GET','/api/admin/'+kind+'?page=1000001&limit=1'); assert.equal(large.page,1000001); for(const query of ['page=-1','page[toString]=x',...(kind==='users'?['limit=-1']:[])]) { const {value:rejected}=await call('GET','/api/admin/'+kind+'?'+query,undefined,500); assert.equal(rejected.error,'internal_error'); } } report.adminListPaginationQueries='passed'; for(const kind of ['repos','conferences']) { const {value}=await call('GET','/api/admin/'+kind+'?limit=-1'); assert(value.results.length<=1); const {value:flags}=await call('GET','/api/admin/'+kind+'?ready[]=true'); assert.deepEqual(flags.query,{$and:[{$or:[{status:'ready'}]}]}); } for(const kind of ['repos','users','conferences']) { const {value}=await call('GET','/api/admin/'+kind+'?search[]=x',undefined,500); assert.equal(value.error,'internal_error'); } report.adminListFilterQueries='passed'; const {value:arrayStatus}=await call('GET','/api/admin/users?status[]=active'); assert.deepEqual(arrayStatus.query.status,['active']); assert.deepEqual(arrayStatus.results,[]);assert(arrayStatus.total>=1); const {value:operatorStatus}=await call('GET','/api/admin/users?status[$in][]=active'); assert.deepEqual(operatorStatus.query.status,{$in:['active']}); assert(operatorStatus.results.length>=1); for(const route of ['/api/admin/users?status[a]=active','/api/admin/users?status[$in]=active','/api/admin/repos?owner[a]=x','/api/admin/repos?conference[$unknown]=x']) { const {value}=await call('GET',route,undefined,500);assert.equal(value.error,'internal_error'); } report.adminListCastingQueries='passed'; for(const [kind,field] of [['repos','anonymizeDate'],['users','dateOfEntry'],['conferences','startDate']]) { const {value}=await call('GET','/api/admin/'+kind+'?dateFrom[]=2024-02-30'); const filter=kind==='users'?value.query:value.query.$and[0]; assert.deepEqual(filter[field],{$gte:'2024-03-01T00:00:00.000Z'}); const {value:legacy}=await call('GET','/api/admin/'+kind+'?dateTo=March%201%202024'); const legacyFilter=kind==='users'?legacy.query:legacy.query.$and[0]; assert.deepEqual(legacyFilter[field],{$lte:'2024-03-01T00:00:00.000Z'}); const {value:invalid}=await call('GET','/api/admin/'+kind+'?dateFrom[toString]=x',undefined,500); assert.equal(invalid.error,'internal_error'); } report.adminListDateQueries='passed'; if(monitoringOnly) {report.result='passed';return;} if(appFlow) { const {value:connections}=await call('GET','/github/connections'); assert(connections.appConnected && !connections.appError && connections.installations.length>0,'App authorization or installation unavailable'); } const {value:issued}=await call('POST','/api/admin/tokens',{name:'Temporary API lifecycle check'}); assert(typeof issued.id==='string' && /^[a-f0-9]{24}$/.test(issued.id), 'invalid issued key ID'); try { assert(typeof issued.token==='string' && issued.token.length===64,'invalid issued key'); await call('GET','/api/user',undefined,200,issued.token); } finally { await call('DELETE','/api/admin/tokens/'+issued.id); } await call('GET','/api/user',undefined,401,issued.token); report.apiKeyLifecycle='passed'; const {response:login}=await call('POST','/github/login-token',{}); const setCookie=login.headers.get('set-cookie')||''; const cookie=setCookie.split(';')[0]; assert(cookie.startsWith('connect.sid='),'session cookie missing'); try { assert(/;\s*Secure/i.test(setCookie),'session cookie must be Secure'); assert(/;\s*HttpOnly/i.test(setCookie),'session cookie must be HttpOnly'); assert(/;\s*Max-Age=2592000(?:;|$)/i.test(setCookie),'session cookie must persist for 30 days'); assert(/;\s*Expires=/i.test(setCookie),'persistent session cookie must have an expiry'); assert(/;\s*SameSite=Lax/i.test(setCookie),'session cookie must use SameSite=Lax'); const {value:sessionUser}=await call('GET','/api/user',undefined,200,'',{Cookie:cookie}); assert(sessionUser.isAdmin===true && sessionUser.username===user.username,'session identity mismatch'); } finally { await call('GET','/api/user/logout',undefined,302,'',{Cookie:cookie}); } await call('GET','/api/user',undefined,401,'',{Cookie:cookie}); report.sessionLoginLogout='passed'; const {value:preview}=await call('POST','/api/anonymize-preview',{content:'hello Alice',options:{terms:['Alice']}}); assert.equal(preview.content,'hello XXXX-1'); const prefix='/api/providers/github/repository/'; const query='?name='+encodeURIComponent(fixture.name)+'&connection='+(appFlow?'github-app':'oauth'); await call('GET',prefix+'metadata'+query); const {value:branches}=await call('GET',prefix+'branches'+query); await call('GET',prefix+'readme'+query); const branch=branches.find(b=>b.name===fixture.branch); assert(branch && typeof branch.commit==='string' && /^[a-f0-9]{40}$/.test(branch.commit), 'public fixture branch missing'); let expected='XXXX-1 World!\n'; if(appFlow) { const source=await fetch('https://raw.githubusercontent.com/'+fixture.name+'/'+branch.commit+'/'+fixture.file,{signal:AbortSignal.timeout(15000)}); assert.equal(source.status,200,'public fixture source unavailable'); const original=await source.text(); assert(original.includes('MIT'),'fixture no longer exercises anonymization'); expected=original.replace(/\bMIT\b/gi,'XXXX-1'); } await call('POST','/api/repo',{repoId,fullName:fixture.name,connection:appFlow?'github-app':'oauth',source:{provider:'github',branch:fixture.branch,commit:branch.commit},terms:[fixture.term],options:{expirationMode:'never',image:true,pdf:true,link:true}}); createdRepo=true; if(process.argv.includes('--website')) db(`d.anonymizedrepositories.updateOne({repoId:'${repoId}'},{$set:{'options.page':true,'options.pageSource':{branch:'${fixture.branch}',path:'/'}}});`); if(appFlow) db(`const row=d.anonymizedrepositories.findOne({repoId:'${repoId}'}); if(row?.githubAccess?.kind!=='github-app') throw Error('App binding missing');`); let ready=false; for(let i=0;i<30;i++) { const {value:r}=await call('GET','/api/repo/'+repoId); if(r.status==='ready') {ready=true;break;} assert(r.status!=='error','repository processing failed'); await new Promise(r=>setTimeout(r,1000)); } assert(ready,'repository did not become ready'); const {value:content}=await call('GET','/api/repo/'+repoId+'/file/'+fixture.file,undefined,200,''); assert.equal(content,expected); async function checkWebsite() { if(!process.argv.includes('--website')) return; const page=await call('GET','/w/'+repoId+'/'+fixture.file,undefined,200,'',{'Accept-Encoding':'gzip'}); assert.equal(page.value,expected,'website output differs from file'); assert(page.response.headers.get('content-security-policy').includes('sandbox'),'website sandbox missing'); } await checkWebsite(); await call('GET','/api/repo/'+repoId+'/files',undefined,200,''); const {value:zip}=await call('GET','/api/repo/'+repoId+'/zip',undefined,200,'',{},true); function zipManifest(archive) { const unzip=spawnSync('python3',['-c',`import io,sys,zipfile,json,base64,hashlib v=json.load(sys.stdin) with zipfile.ZipFile(io.BytesIO(base64.b64decode(v['zip']))) as z: assert z.testzip() is None files=[n for n in z.namelist() if not n.endswith('/')] if v['single']: assert len(files)==1 matches=[n for n in files if n==v['file'] or n.endswith('/'+v['file'])] assert len(matches)==1 assert z.read(matches[0])==v['expected'].encode('utf8') print(json.dumps([[n, hashlib.sha256(z.read(n)).hexdigest()] for n in z.namelist()])) `],{input:JSON.stringify({zip:archive.toString('base64'),file:fixture.file,expected,single:!appFlow}),timeout:10000}); assert(!unzip.error && unzip.status===0,'ZIP CRC, entries or anonymized bytes mismatch'); return JSON.parse(unzip.stdout.toString()); } const originalManifest=zipManifest(zip); report.zipManifestSHA256=createHash('sha256').update(JSON.stringify([...originalManifest].sort((a,b)=>a[0].localeCompare(b[0])))).digest('hex'); report.zipEntries=originalManifest.length; report.zipSourceCommit=branch.commit; report.exactZipContents='passed'; if(process.argv.includes('--repeat-content')) { for(let i=0;i<2;i++) { const again=await call('GET','/api/repo/'+repoId+'/file/'+fixture.file,undefined,200,''); assert.equal(again.value,expected,'repeated file changed'); await checkWebsite(); const archive=await call('GET','/api/repo/'+repoId+'/zip',undefined,200,'',{},true); // Timed streaming flushes can change deflate framing, but not archive contents. assert.deepEqual(zipManifest(archive.value),originalManifest,'repeated ZIP contents changed'); } report.repeatedContentChecks=3; } const {value:metrics}=await call('GET','/api/admin/performance'); assert.equal(metrics.available,true,'performance history unavailable'); assert(metrics.routes.some(row=>row.metric==='request' && row.count>0),'missing request metrics'); assert(metrics.instances.some(row=>row.runtime==='go' && row.memory?.rss>0),'missing Go process sample'); assert(metrics.runtimeSeries.length>0,'missing runtime history'); for(const stage of ['repository','authorization','worker_wait','anonymize']) { assert(metrics.stages.some(row=>row.metric===stage && row.count>0),'missing stage '+stage); } report.performanceMonitoring={requests:true,processSamples:true,runtimeHistory:true,stages:['repository','authorization','worker_wait','anonymize'],upstreamObserved:metrics.stages.some(row=>row.metric==='upstream' && row.count>0),upstreamNote:'Shared-cache hits need no upstream read; provider timing is checked by the integration suite.'}; await call('GET','/api/user',undefined,401,randomBytes(32).toString('hex')); report.result='passed'; })().catch(e=>{report.result='failed';report.failure=e.message;process.exitCode=1;}).finally(async()=>{ try { if(createdRepo) { await call('DELETE','/api/repo/'+repoId); let removed=false; for(let i=0;i<30;i++) { const {value:r}=await call('GET','/api/repo/'+repoId); if(r.status==='removed') {removed=true;break;} await new Promise(r=>setTimeout(r,1000)); } assert(removed,'test repository removal did not finish'); await call('GET','/api/repo/'+repoId+'/file/'+fixture.file,undefined,410,''); report.testRepositoryRemoved=true; } } catch {report.testRepositoryRemoved=false;process.exitCode=1;} if(bootstrapped) { try { db(`d.users.updateOne({'apiTokens._id':ObjectId('${id}')},{$pull:{apiTokens:{_id:ObjectId('${id}')}}});`); await call('GET','/api/user',undefined,401); report.temporaryKeyRevoked=true; report.betaAccountAdmin=true; } catch {report.temporaryKeyRevoked=false;process.exitCode=1;} } report.uniqueRequestIDsVerified=requestIDs.size; if(process.exitCode) report.result='failed'; fs.writeFileSync(path.join(root,monitoringOnly?'backend/reports/shadow-monitoring-authenticated-deployment.json':appFlow?'backend/reports/shadow-app-authenticated-deployment.json':'backend/reports/shadow-authenticated-deployment.json'),JSON.stringify(report,null,2)+'\n'); console.log(JSON.stringify(report,null,2)); });