refactor: lay the repository out by role
All checks were successful
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Successful in 1m25s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m31s
All checks were successful
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Successful in 1m25s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m31s
local/ held six unrelated things under a name that stopped being true once it
became the production runtime: the service, the frontend, the tests, the ops
commands, the container definitions and the dependency lock, 65 files with
nothing to tell them apart.
app/ the service: api/ routers, core/ for identity, database, models,
prices and secret loading, and the worker, bootstrap and schema
tests/ the twelve suites, no longer inside the shipped package
ops/ backup, readiness, dependency audit, security summary
infra/ Dockerfiles, gateway templates, ClamAV and storage configuration,
the requirements and their hash lock
web/ the Site, Kanban and portal pages with their scripts
deploy/Dockerfile.api now copies app/ alone, so the tests stop shipping to
production; the local image still carries them, because the suites run inside
the stack's network.
Five kinds of reference had to follow, and each was found by something different
rather than by reading. Imports of the form "from . import db" survived a rewrite
that only matched "from .db import". Tests kept relative imports of modules that
had left the package. A mock.patch target names its module in a string, where no
import rewriting can see it. The browser test resolves a fixture by path. And the
release gate's markers pointed at local/runtime.py and local/worker.py, which is
the decay its new marker test exists to catch — it caught it.
Verified from docker compose down -v: the stack starts, all six integration
suites, both browser suites and the twenty-nine unit tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test suites. Not shipped in the production image."""
|
||||
220
tests/artwork_browser_test.mjs
Normal file
220
tests/artwork_browser_test.mjs
Normal file
@@ -0,0 +1,220 @@
|
||||
// Isolated upload/editor regression: no API, database, storage, or Docker services.
|
||||
// Run: node local/artwork_browser_test.mjs
|
||||
// Optional: ARTWORK_FILE=/absolute/path/to/image.jpg to also inspect a real image.
|
||||
import assert from 'node:assert/strict';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {createServer} from 'node:http';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {mkdtemp, readFile, writeFile, mkdir} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {resolve} from 'node:path';
|
||||
|
||||
const html=await readFile('web/index.html','utf8');
|
||||
const hashes=[...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
|
||||
.filter(m=>! /\bsrc\s*=/i.test(m[1]))
|
||||
.map(m=>"'sha256-"+createHash('sha256').update(m[2]).digest('base64')+"'");
|
||||
const server=createServer(async(req,res)=>{
|
||||
if(req.url.startsWith('/api/')){
|
||||
res.setHeader('Content-Type','application/json');
|
||||
res.end(JSON.stringify(req.url==='/api/session'?{cart_scope:'isolated-artwork-test'}:{customer:null}));
|
||||
return;
|
||||
}
|
||||
if(req.url==='/'){
|
||||
res.setHeader('Content-Type','text/html');
|
||||
res.setHeader('Content-Security-Policy',`default-src 'self'; script-src 'self' ${hashes.join(' ')}; script-src-attr 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'`);
|
||||
res.end(html);return;
|
||||
}
|
||||
// Serve any script the page asks for, resolved inside web/, rather than
|
||||
// a hardcoded list: the Site's behaviour is split across several files and a
|
||||
// list would silently 404 the next one added.
|
||||
if(/^\/[\w.-]+\.js$/.test(req.url)){
|
||||
try{
|
||||
const body=await readFile(resolve('web'+req.url));
|
||||
res.setHeader('Content-Type','text/javascript');res.end(body);return;
|
||||
}catch{ res.writeHead(404);res.end();return; }
|
||||
}
|
||||
res.writeHead(404);res.end();
|
||||
});
|
||||
await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
||||
const profile=await mkdtemp(tmpdir()+'/dtf-artwork-');
|
||||
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
|
||||
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
|
||||
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
|
||||
],{stdio:['ignore','ignore','pipe']});
|
||||
let stderr='',ws,next=0;
|
||||
chrome.stderr.on('data',b=>stderr+=b);
|
||||
const pending=new Map(),errors=[];
|
||||
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
async function waitFor(fn,label){
|
||||
const end=Date.now()+15000;
|
||||
while(Date.now()<end){if(await fn())return;await pause(50);}
|
||||
throw new Error('Timeout: '+label);
|
||||
}
|
||||
function call(method,params={}){
|
||||
return new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
|
||||
}
|
||||
async function evaluate(expression){
|
||||
const r=await call('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});
|
||||
if(r.exceptionDetails)throw new Error(JSON.stringify(r.exceptionDetails));
|
||||
return r.result.value;
|
||||
}
|
||||
const click=selector=>evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
|
||||
async function fill(selector,value){
|
||||
await evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(String(value))};el.dispatchEvent(new Event('input',{bubbles:true}));})()`);
|
||||
}
|
||||
async function packed(count){
|
||||
await waitFor(()=>evaluate(`window.lastLayout?.pos.length===${count} && !!document.querySelector('#vArea canvas') && Math.abs(metros-window.lastLayout.altura/100)<0.0001`),'packed canvas and matching metres');
|
||||
return evaluate(`({height:lastLayout.altura, pos:lastLayout.pos, metres:metros, billed:cobrar(metros), mode:modo, heading:$('montcabTit').textContent})`);
|
||||
}
|
||||
async function screenshot(name){
|
||||
// Finish editing as a customer does when leaving the field, refreshing the
|
||||
// item's quality/details as well as the already-live canvas.
|
||||
await evaluate(`$('lista').querySelector('[data-cm]')?.dispatchEvent(new Event('change',{bubbles:true}))`);
|
||||
await waitFor(()=>evaluate(`!!$('vArea').querySelector('canvas') && Math.abs(metros-montagemCm/100)<0.0001`),'screenshot layout');
|
||||
await evaluate(`$('foco').scrollIntoView({behavior:'instant',block:'start'})`);
|
||||
const r=await call('Page.captureScreenshot',{format:'png'});
|
||||
await mkdir('output/local',{recursive:true});
|
||||
await writeFile('output/local/'+name+'.png',Buffer.from(r.data,'base64'));
|
||||
}
|
||||
try{
|
||||
let port;
|
||||
await waitFor(async()=>{try{port=Number((await readFile(profile+'/DevToolsActivePort','utf8')).split('\n')[0]);return true;}catch{return false;}},'Chrome startup');
|
||||
const tab=await(await fetch(`http://127.0.0.1:${port}/json/new?about:blank`,{method:'PUT'})).json();
|
||||
ws=new WebSocket(tab.webSocketDebuggerUrl);
|
||||
await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;});
|
||||
ws.onmessage=event=>{
|
||||
const m=JSON.parse(event.data);
|
||||
if(m.id){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(m.error):p.resolve(m.result);}
|
||||
else if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails);
|
||||
};
|
||||
await call('Runtime.enable');await call('Page.enable');
|
||||
await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false});
|
||||
await call('Page.navigate',{url:`http://127.0.0.1:${server.address().port}/`});
|
||||
await waitFor(()=>evaluate(`typeof sel==='function' && typeof AUTO!=='undefined'`),'Site scripts');
|
||||
// Observe the actual engine, without replacing its placement or rendering.
|
||||
await evaluate(`(()=>{
|
||||
const original=encaixar;
|
||||
encaixar=(pieces,width)=>{const result=original(pieces,width);
|
||||
if(pieces.some(p=>p.a))window.lastLayout={altura:result.altura,pos:result.pos.map(p=>({x:p.x,y:p.y,w:p.w,h:p.h,rot:p.rot}))};
|
||||
return result;};
|
||||
window.sendImage=async(type='image/jpeg',drop=false)=>{
|
||||
const c=document.createElement('canvas');c.width=200;c.height=400;
|
||||
const ctx=c.getContext('2d');ctx.fillStyle='white';ctx.fillRect(0,0,200,400);
|
||||
ctx.fillStyle='#222';ctx.fillRect(85,20,20,360);ctx.fillRect(25,100,140,20);
|
||||
ctx.fillStyle='#e65c00';ctx.fillRect(130,280,45,80);
|
||||
const blob=await new Promise(r=>c.toBlob(r,type));
|
||||
const file=new File([blob],type==='image/jpeg'?'artwork.jpg':'artwork.png',{type});
|
||||
const dt=new DataTransfer();dt.items.add(file);
|
||||
if(drop)$('zona').dispatchEvent(new DragEvent('drop',{dataTransfer:dt,bubbles:true,cancelable:true}));
|
||||
else{$('inp').files=dt.files;$('inp').dispatchEvent(new Event('change',{bubbles:true}));}
|
||||
};
|
||||
// A genuine mounted sheet spans the film width with printable resolution.
|
||||
window.sendSheet=async()=>{
|
||||
const cm=larguraFilme(), px=Math.ceil(cm/2.54*150);
|
||||
const c=document.createElement('canvas');c.width=px;c.height=Math.round(px*0.6);
|
||||
const ctx=c.getContext('2d');ctx.fillStyle='white';ctx.fillRect(0,0,c.width,c.height);
|
||||
ctx.fillStyle='#222';ctx.fillRect(40,40,px/4,px/4);
|
||||
const blob=await new Promise(r=>c.toBlob(r,'image/png'));
|
||||
const dt=new DataTransfer();dt.items.add(new File([blob],'folha-montada.png',{type:'image/png'}));
|
||||
$('inp').files=dt.files;$('inp').dispatchEvent(new Event('change',{bubbles:true}));
|
||||
};
|
||||
window.pickTipo=t=>document.querySelector('#tipoEnvio .cam[data-tipo="'+t+'"]').click();
|
||||
})()`);
|
||||
// Reproduce the reported entry path, with no navigation workaround.
|
||||
// Navigation links must reach the chooser, never preselect a product.
|
||||
assert.equal(await evaluate(`document.querySelectorAll('[data-modo-cta]').length`),0);
|
||||
await click('[data-modo="file"]');
|
||||
// By-metre opens declaring a mounted sheet; switching is explicit and priced.
|
||||
assert.deepEqual(await evaluate(`({shown:!$('tipoEnvio').hidden,
|
||||
on:[...document.querySelectorAll('#tipoEnvio .cam.on')].map(b=>b.dataset.tipo)})`),
|
||||
{shown:true,on:['folha']});
|
||||
await evaluate(`pickTipo('avulsa')`);
|
||||
assert.equal(await evaluate('modo'),'avulsa');
|
||||
await evaluate('sendImage()');
|
||||
await waitFor(()=>evaluate(`artes.length===1 && !!artes[0].src`),'JPG artwork model');
|
||||
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});
|
||||
await fill('[data-cm]',20);await fill('[data-q]',6);
|
||||
let layout=await packed(6);
|
||||
assert.equal(layout.heading,'Montagem ao vivo');assert.equal(layout.height,121);
|
||||
assert.equal(layout.billed,1.3);
|
||||
assert.deepEqual(layout.pos.map(p=>[p.x,p.y]),[[0,0],[20.5,0],[0,40.5],[20.5,40.5],[0,81],[20.5,81]]);
|
||||
assert.ok(layout.pos.every(p=>p.h/p.w===2 && p.x+p.w<=57));
|
||||
await screenshot('artwork-packed');
|
||||
await fill('[data-cm]',15);
|
||||
await waitFor(()=>evaluate('lastLayout?.altura===60.5 && metros===0.605'),'width updates without blur');
|
||||
await fill('[data-q]',9);layout=await packed(9);assert.equal(layout.height,91);
|
||||
await click('[data-giro]');
|
||||
await waitFor(()=>evaluate('lastLayout?.altura===23.5 && metros===0.235'),'rotation updates packing');
|
||||
const beforeMirror=await evaluate(`$('vArea').querySelector('canvas').toDataURL()`);
|
||||
await click('[data-esp]');
|
||||
await waitFor(()=>evaluate(`$('vArea').querySelector('canvas').toDataURL()!==${JSON.stringify(beforeMirror)}`),'mirror updates pixels');
|
||||
assert.equal(await evaluate('metros'),0.235);
|
||||
// A transparent asymmetric image exposes masks that ignore user transforms.
|
||||
// Its top-left quarter becomes bottom-right after a mirror and 90° turn.
|
||||
assert.equal(await evaluate(`(()=>{
|
||||
const image=document.createElement('canvas');image.width=100;image.height=200;
|
||||
image.getContext('2d').fillRect(0,0,50,100);
|
||||
const result=encaixar([{w:20,h:10,img:image,a:{giro:90,esp:true},travado:true}],57);
|
||||
const m=result.pos[0].m;
|
||||
return !m.bits[2*m.cw+2] && !m.bits[2*m.cw+30] && !!m.bits[15*m.cw+30];
|
||||
})()`),true);
|
||||
// A real finished sheet keeps its own dimensions and by-metre price.
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
await evaluate('sendSheet()');
|
||||
await waitFor(()=>evaluate('folhas.length===1 && !!folhas[0].previewSrc && metros===0.342'),'ready-sheet image');
|
||||
assert.deepEqual(await evaluate(`({mode:modo,art:artes.length,title:$('montcabTit').textContent,rate:precoBase(100)})`),{mode:'file',art:0,title:'Sua folha',rate:14.9});
|
||||
assert.equal(await evaluate(`document.querySelectorAll('[data-cm]').length`),0);
|
||||
// With a sheet loaded the declaration is locked; it cannot flip under the customer.
|
||||
assert.equal(await evaluate(`document.querySelector('#tipoEnvio .cam[data-tipo="avulsa"]').disabled`),true);
|
||||
await evaluate(`(()=>{const el=$('lista').querySelector('[data-repf]');el.value=2;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
await waitFor(()=>evaluate('metros===0.684'),'ready-sheet repetitions');
|
||||
assert.equal(await evaluate(`document.querySelectorAll('.folhaPrevia').length`),1);
|
||||
// An image too small to span the film is refused, never silently repriced.
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
await evaluate('sendImage()');
|
||||
await waitFor(()=>evaluate(`!!document.getElementById('usarAvulsa')`),'ready-sheet refusal');
|
||||
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,art:artes.length})`),{mode:'file',sheets:0,art:0});
|
||||
await click('#usarAvulsa');
|
||||
await waitFor(()=>evaluate(`modo==='avulsa' && artes.length===1`),'one-click recovery into loose artwork');
|
||||
// Keep manual ready-sheet uploads intact.
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
await evaluate(`sel([new File(['sheet'],'sheet.cdr')])`);
|
||||
await waitFor(()=>evaluate(`!!$('lista').querySelector('[data-comp]')`),'manual ready sheet');
|
||||
await evaluate(`(()=>{const el=$('lista').querySelector('[data-comp]');el.value=1.01;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
await waitFor(()=>evaluate('itemAtual?.total===21.89'),'unchanged manual pricing');
|
||||
// PNG drag/drop follows exactly the same artwork path; UV stays UV. Reaching it
|
||||
// from a by-metre product is one declared click, and it is reversible.
|
||||
for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv'],['uv','uv']]){
|
||||
await click('#bVoltar');await click('[data-modo="'+mode+'"]');
|
||||
if(await evaluate(`ehFolha()`))await evaluate(`pickTipo('avulsa')`);
|
||||
await evaluate(`sendImage('image/png',true)`);
|
||||
await waitFor(()=>evaluate('artes.length===1 && !!artes[0].src'),'PNG drop');
|
||||
assert.equal(await evaluate('modo'),expected);
|
||||
await fill('[data-cm]',10);await fill('[data-q]',4);await packed(4);
|
||||
}
|
||||
// An older image load must not overwrite a newer edit, even before debounce.
|
||||
await evaluate(`(()=>{window.realLoad=carregarImagem;window.delayed=[];
|
||||
carregarImagem=f=>new Promise(resolve=>delayed.push(()=>realLoad(f).then(resolve)));
|
||||
})()`);
|
||||
await fill('[data-q]',5);
|
||||
await waitFor(()=>evaluate('delayed.length===1'),'delayed preview');
|
||||
await fill('[data-q]',7);
|
||||
await evaluate(`carregarImagem=realLoad;delayed[0]()`);
|
||||
await packed(7);
|
||||
// Inspect the supplied local artwork, when requested, using the real file input.
|
||||
if(process.env.ARTWORK_FILE){
|
||||
await click('#bVoltar');await click('[data-modo="file"]');
|
||||
const doc=await call('DOM.getDocument');
|
||||
const input=await call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#inp'});
|
||||
await call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve(process.env.ARTWORK_FILE)]});
|
||||
await waitFor(()=>evaluate('artes.length===1 && !!artes[0].src'),'supplied artwork');
|
||||
await fill('[data-cm]',20);await fill('[data-q]',6);layout=await packed(6);
|
||||
assert.ok(new Set(layout.pos.map(p=>p.x)).size>1);
|
||||
assert.ok(new Set(layout.pos.map(p=>p.y)).size>1);
|
||||
await screenshot('artwork-supplied');
|
||||
console.log('Supplied image:',JSON.stringify(layout));
|
||||
}
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: JPG/PNG upload and drop, packed copies, 5 mm gaps, aspect ratio, live width/quantity/rotation/mirror, metres, ready-sheet pricing/repeats, UV, stale renders, strict inline CSP.');
|
||||
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1200));process.exitCode=1;}
|
||||
finally{ws?.close();chrome.kill();server.closeAllConnections();await new Promise(r=>server.close(r));}
|
||||
140
tests/browser_test.mjs
Normal file
140
tests/browser_test.mjs
Normal file
@@ -0,0 +1,140 @@
|
||||
// Dependency-free Chrome DevTools smoke test. Node 22+ and Chrome required.
|
||||
import {spawn} from 'node:child_process';
|
||||
import {mkdtemp,readFile,writeFile,mkdir} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {resolve} from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
try {
|
||||
for(const line of (await readFile('.env','utf8')).split('\n')) {
|
||||
if(!line.startsWith('#') && line.includes('=')) {
|
||||
const i=line.indexOf('=');process.env[line.slice(0,i)]??=line.slice(i+1);
|
||||
}
|
||||
}
|
||||
}catch(e){if(e.code!=='ENOENT')throw e;}
|
||||
const profile=await mkdtemp(tmpdir()+'/dtf-browser-');
|
||||
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
|
||||
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
|
||||
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
|
||||
],{stdio:['ignore','ignore','pipe']});
|
||||
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
let stderr='';chrome.stderr.on('data',data=>stderr+=data.toString());
|
||||
const clients=[];
|
||||
async function waitFor(fn,description,timeout=30000){const end=Date.now()+timeout;while(Date.now()<end){if(await fn())return;await pause(200);}throw new Error('Timeout: '+description);}
|
||||
class Page {
|
||||
constructor(ws){this.ws=ws;this.next=0;this.pending=new Map();this.errors=[];
|
||||
ws.onmessage=event=>{const d=JSON.parse(event.data);if(d.id){const p=this.pending.get(d.id);if(p){this.pending.delete(d.id);d.error?p.reject(d.error):p.resolve(d.result);}}else if(d.method==='Runtime.exceptionThrown')this.errors.push(d.params.exceptionDetails);};
|
||||
}
|
||||
call(method,params={}){return new Promise((resolve,reject)=>{const id=++this.next;this.pending.set(id,{resolve,reject});this.ws.send(JSON.stringify({id,method,params}));});}
|
||||
async eval(expression){const result=await this.call('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(result.exceptionDetails)throw new Error(JSON.stringify(result.exceptionDetails));return result.result.value;}
|
||||
async click(selector){await this.eval(`document.querySelector(${JSON.stringify(selector)}).click()`);}
|
||||
async fill(selector,value,event='input'){await this.eval(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(value)};el.dispatchEvent(new Event(${JSON.stringify(event)},{bubbles:true}));})()`);}
|
||||
async text(){return this.eval('document.body.innerText');}
|
||||
async screenshot(path){const result=await this.call('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});await writeFile(path,Buffer.from(result.data,'base64'));}
|
||||
}
|
||||
try{
|
||||
let port;
|
||||
await waitFor(async()=>{try{port=Number((await readFile(profile+'/DevToolsActivePort','utf8')).split('\n')[0]);return true;}catch{return false;}},'Chrome startup');
|
||||
async function page(url){
|
||||
const tab=await (await fetch('http://localhost:'+port+'/json/new?'+encodeURIComponent(url),{method:'PUT'})).json();
|
||||
const ws=new WebSocket(tab.webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;});
|
||||
const p=new Page(ws);clients.push(p);await p.call('Runtime.enable');await p.call('Page.enable');
|
||||
await p.call('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
|
||||
await waitFor(()=>p.eval('document.readyState === "complete"'),'page load');return p;
|
||||
}
|
||||
const site=await page('http://localhost:'+(process.env.SITE_PORT||8080));
|
||||
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'checkout bridge');
|
||||
// Prove escaping itself, independently of the CSP's second line of defense.
|
||||
await site.call('Page.setBypassCSP',{enabled:true});
|
||||
await site.eval(`(()=>{window.xssProbe=0;abrir('file');sel([new File(['test'],'<img src=x onerror=window.xssProbe=1>.cdr')]);})()`);
|
||||
await pause(500);
|
||||
assert.equal(await site.eval('window.xssProbe'),0);
|
||||
assert.equal(await site.eval('document.querySelectorAll("#lista img[onerror]").length'),0);
|
||||
assert.ok((await site.text()).includes('<img src=x onerror=window.xssProbe=1>.cdr'));
|
||||
await site.eval(`(()=>{recusa([new File(['test'],'bad.<img src=x onerror=window.xssProbe=1>')]);})()`);
|
||||
await pause(200);assert.equal(await site.eval('window.xssProbe'),0);
|
||||
await site.call('Page.setBypassCSP',{enabled:false});
|
||||
await site.call('Page.reload');
|
||||
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'reload after security probe');
|
||||
await site.click('[data-modo="file"]');
|
||||
await site.click('[data-cam="tabela"]');
|
||||
const root=await site.call('DOM.getDocument');
|
||||
const input=await site.call('DOM.querySelector',{nodeId:root.root.nodeId,selector:'#inp'});
|
||||
await site.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]});
|
||||
await waitFor(()=>site.eval('!!document.querySelector("[data-comp]")'),'manual length field');
|
||||
await site.fill('[data-comp]','1.01','change');
|
||||
await waitFor(()=>site.eval('!!itemAtual'),'cart calculation');
|
||||
assert.equal(await site.eval('itemAtual.total'),21.89);
|
||||
await site.fill('#fCnpj','11222333000181');
|
||||
await site.fill('#fZap','11999999999');
|
||||
await site.fill('#fMail','local-browser@example.test');
|
||||
await pause(800);
|
||||
await site.call('Page.reload');
|
||||
await waitFor(()=>site.eval('typeof pedido!=="undefined" && pedido.length===1'),'persistent cart recovery');
|
||||
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
|
||||
assert.equal(await site.eval('pedido[0].total'),21.89);
|
||||
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
|
||||
await site.click('#bPagar');
|
||||
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
|
||||
const qid=await site.eval('localStorage.getItem("dtf-quote")');
|
||||
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081));
|
||||
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
|
||||
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
|
||||
await kanban.eval('document.getElementById("login").requestSubmit()');
|
||||
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
|
||||
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
|
||||
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
|
||||
await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`);
|
||||
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
|
||||
await site.eval('window.dtfCheckout()');
|
||||
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed');
|
||||
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido de teste").click()');
|
||||
await waitFor(async()=> (await site.text()).includes('Pedido #'),'test payment');
|
||||
await kanban.click('#refresh');
|
||||
await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card');
|
||||
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
|
||||
// Click real transition buttons, including rerender after each move.
|
||||
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
|
||||
if(state==='fil'){
|
||||
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent==='Arquivos de produção').click();})()`);
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-final-item]')`),'final upload controls');
|
||||
const doc=await kanban.call('DOM.getDocument');
|
||||
const input=await kanban.call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:`[data-order="${oid}"] [data-final-item]`});
|
||||
await kanban.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]});
|
||||
await kanban.fill(`[data-order="${oid}"] input[placeholder="Nota da revisão"]`,'Browser test final file');
|
||||
await kanban.eval(`(()=>{const form=document.querySelector('[data-order="${oid}"] form');form.querySelector('[type=checkbox]').click();form.requestSubmit();})()`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').version===2`),'final file approval');
|
||||
}
|
||||
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent===${JSON.stringify('→ '+title)}).click();})()`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state);
|
||||
}
|
||||
// Reload proves the board is persisted on the backend.
|
||||
await kanban.call('Page.reload');
|
||||
await waitFor(()=>kanban.eval(`typeof board!=='undefined' && !!board && board.orders.some(o=>o.id==='${oid}'&&o.state==='fin')`),'persisted board');
|
||||
await mkdir('output/local',{recursive:true});
|
||||
await site.eval('window.scrollTo({top:0,behavior:"instant"})');
|
||||
await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position');
|
||||
await site.screenshot('output/local/site.png');
|
||||
await kanban.screenshot('output/local/kanban.png');
|
||||
const portal=await page('http://localhost:'+(process.env.SITE_PORT||8080)+'/portal.html?order='+oid);
|
||||
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking');
|
||||
await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999');
|
||||
await portal.fill('#register-email','browser-'+Date.now()+'@example.test');
|
||||
await portal.fill('#register-password','local-browser-password-123');
|
||||
await portal.eval('document.getElementById("register").requestSubmit()');
|
||||
await waitFor(()=>portal.eval('document.getElementById("auth").hidden'),'customer registration');
|
||||
assert.ok((await portal.text()).includes('Finalizado'));
|
||||
await portal.screenshot('output/local/portal.png');
|
||||
// A logout must clear draft file blobs and metadata, including other open Site tabs.
|
||||
await portal.eval(`(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onsuccess=()=>resolve(r.result);r.onerror=reject;});await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').put({items:[new File(['private'],'private.cdr')],expires:Date.now()+86400000},'security-fixture');tx.oncomplete=resolve;tx.onerror=reject;});db.close();})()`);
|
||||
await portal.click('#logout');
|
||||
await waitFor(()=>portal.eval('document.getElementById("logout").hidden'),'customer logout');
|
||||
let stored;
|
||||
await waitFor(async()=>{stored=await portal.eval(`(async()=>{try{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});const n=await new Promise((resolve,reject)=>{const r=db.transaction('cart').objectStore('cart').count();r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});db.close();return n;}catch{return -1;}})()`);return stored>=0;},'IndexedDB available after Clear-Site-Data');
|
||||
assert.equal(stored,0);
|
||||
assert.deepEqual(portal.errors,[]);
|
||||
assert.deepEqual(site.errors,[]);assert.deepEqual(kanban.errors,[]);
|
||||
console.log('PASS: browser Site upload → operator quote → local paid order → all main Kanban states → reload persistence. Order '+oid);
|
||||
console.log('Screenshots: output/local/site.png and output/local/kanban.png');
|
||||
console.log('PASS: filename XSS escaping with CSP bypassed, no stored operator password, logout clears browser file blobs.');
|
||||
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1500));process.exitCode=1;}
|
||||
finally{for(const p of clients)p.ws.close();chrome.kill();}
|
||||
4
tests/fixtures/local-test.cdr
vendored
Normal file
4
tests/fixtures/local-test.cdr
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
DTF LOCAL UPLOAD TEST ONLY
|
||||
This is a harmless text fixture with a .cdr extension, not printable artwork.
|
||||
Use the manual-length path: 1.01 metres, grade 0, pickup = BRL 21.89.
|
||||
It intentionally requires no artwork parsing or automatic pre-flight.
|
||||
34
tests/retention_test.py
Normal file
34
tests/retention_test.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Run inside the API container. Creates only synthetic retention fixtures."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
from botocore.exceptions import ClientError
|
||||
from app.adapters import LocalS3Storage
|
||||
from app.core.db import connect
|
||||
from app.worker import cleanup
|
||||
|
||||
def run():
|
||||
storage=LocalS3Storage()
|
||||
ids=[]
|
||||
for expired in (True,False):
|
||||
uid=uuid4();ids.append(uid);key=f'originals/{uid}'
|
||||
upload=storage.begin(key)
|
||||
result=storage.client.upload_part(Bucket=storage.bucket,Key=key,UploadId=upload,PartNumber=1,Body=b'LOCAL RETENTION TEST')
|
||||
storage.complete(key,upload,[{'PartNumber':1,'ETag':result['ETag']}])
|
||||
expiry=datetime.now(timezone.utc)+timedelta(days=-1 if expired else 1)
|
||||
with connect() as c:
|
||||
c.execute('INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,complete,expires_at) VALUES(%s,%s,%s,%s,%s,%s,true,%s)',
|
||||
(uid,uuid4(),'LOCAL-RETENTION-TEST.txt',len(b'LOCAL RETENTION TEST'),key,upload,expiry))
|
||||
cleanup()
|
||||
with connect() as c:
|
||||
assert c.execute('SELECT purged_at FROM dtf_local.uploads WHERE id=%s',(ids[0],)).fetchone()['purged_at']
|
||||
assert not c.execute('SELECT purged_at FROM dtf_local.uploads WHERE id=%s',(ids[1],)).fetchone()['purged_at']
|
||||
try:storage.size(f'originals/{ids[0]}');raise AssertionError('Expired bytes still exist')
|
||||
except ClientError as exc:assert exc.response['ResponseMetadata']['HTTPStatusCode']==404
|
||||
assert storage.size(f'originals/{ids[1]}')==len(b'LOCAL RETENTION TEST')
|
||||
# Clean only the other fixture just created above, leaving metadata intact.
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.uploads SET expires_at=now()-interval '1 second' WHERE id=%s",(ids[1],))
|
||||
cleanup()
|
||||
print('PASS: expired bytes removed, unexpired bytes preserved, upload metadata retained. Synthetic fixture bytes cleaned up.')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
87
tests/runtime_security_test.py
Normal file
87
tests/runtime_security_test.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Run inside API container: permissions, hashing and fail-closed scanner unit checks."""
|
||||
import hashlib
|
||||
from unittest.mock import patch
|
||||
from botocore.exceptions import ClientError
|
||||
from app.core.db import connect
|
||||
from app.adapters import LocalS3Storage
|
||||
from app.core.auth import client_ip, password_hash, password_matches
|
||||
from app.scanning import ClamAV, require_clean
|
||||
from fastapi import HTTPException
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self, headers=None, peer='10.0.0.2'):
|
||||
self.headers=headers or {}
|
||||
self.client=type('C',(),{'host':peer})() if peer else None
|
||||
|
||||
def check_client_ip():
|
||||
"""The gateway hands one address; a direct peer falls back to its own."""
|
||||
# Gateway-set header wins over the connection peer, which is the gateway.
|
||||
assert client_ip(FakeRequest({'x-forwarded-for':'198.51.100.9'}))=='198.51.100.9'
|
||||
# Only the first entry is used, and it is length-capped.
|
||||
assert client_ip(FakeRequest({'x-forwarded-for':'198.51.100.9, 10.0.0.2'}))=='198.51.100.9'
|
||||
assert len(client_ip(FakeRequest({'x-forwarded-for':'a'*500})))<=64
|
||||
# No header: the peer address, never a constant shared by every request.
|
||||
assert client_ip(FakeRequest(peer='192.0.2.5'))=='192.0.2.5'
|
||||
assert client_ip(FakeRequest({'x-forwarded-for':' '},peer='192.0.2.5'))=='192.0.2.5'
|
||||
assert client_ip(FakeRequest(peer=None))=='unknown'
|
||||
print('PASS: client address resolution for rate-limit buckets and audit events')
|
||||
|
||||
def check_operator_accounts():
|
||||
"""Accounts are per person, and disabling one ends its access at once."""
|
||||
from uuid import uuid4
|
||||
from app.core.auth import password_hash, password_matches
|
||||
from app.operators import seed_from_environment, set_active
|
||||
email='runtime-check-'+uuid4().hex[:8]+'@example.test'
|
||||
with connect() as c:
|
||||
c.execute('INSERT INTO dtf_local.operators(id,email,name,password_hash) VALUES(%s,%s,%s,%s)',
|
||||
(uuid4(), email, 'Runtime Check', password_hash('runtime-check-password')))
|
||||
row=c.execute('SELECT * FROM dtf_local.operators WHERE email=%s',(email,)).fetchone()
|
||||
assert row['active'] and password_matches('runtime-check-password', row['password_hash'])
|
||||
assert not password_matches('wrong', row['password_hash'])
|
||||
# Seeding is once-only: a password changed here must survive a redeploy.
|
||||
c.execute("INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)",
|
||||
('runtime-check-'+uuid4().hex, email))
|
||||
set_active(email, False)
|
||||
with connect() as c:
|
||||
row=c.execute('SELECT active FROM dtf_local.operators WHERE email=%s',(email,)).fetchone()
|
||||
sessions=c.execute('SELECT count(*) AS n FROM dtf_local.operator_sessions WHERE username=%s',
|
||||
(email,)).fetchone()['n']
|
||||
assert not row['active'], 'disable did not deactivate'
|
||||
assert sessions==0, 'disable left an open session behind'
|
||||
c.execute('DELETE FROM dtf_local.operators WHERE email=%s',(email,))
|
||||
print('PASS: per-operator accounts, password verification, immediate revocation on disable')
|
||||
|
||||
def run():
|
||||
check_client_ip()
|
||||
check_operator_accounts()
|
||||
with connect() as c:
|
||||
role=c.execute('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user').fetchone()
|
||||
assert not any(role.values()),role
|
||||
assert not c.execute("SELECT has_schema_privilege(current_user,'dtf_local','CREATE') AS allowed").fetchone()['allowed']
|
||||
storage=LocalS3Storage()
|
||||
storage.health()
|
||||
visible={bucket['Name'] for bucket in storage.client.list_buckets()['Buckets']}
|
||||
assert visible=={storage.bucket},visible
|
||||
for call in (lambda:storage.client.get_bucket_policy(Bucket=storage.bucket),
|
||||
lambda:storage.client.get_object(Bucket=storage.bucket,Key='outside-runtime-prefix/test.cdr')):
|
||||
try:call();raise AssertionError('Runtime storage credentials have excessive privilege')
|
||||
except ClientError as error:assert error.response['ResponseMetadata']['HTTPStatusCode']==403
|
||||
password='test-password-for-hash'
|
||||
salt='00'*16
|
||||
old='scrypt$'+salt+'$'+hashlib.scrypt(password.encode(),salt=bytes.fromhex(salt),n=16384,r=8,p=1).hex()
|
||||
assert password_matches(password,old)
|
||||
new=password_hash(password)
|
||||
assert new.startswith('scrypt-v2$') and password_matches(password,new)
|
||||
assert not password_matches('wrong',new)
|
||||
for state in ('pending','error','rejected'):
|
||||
try:require_clean({'complete':True,'scan_state':state});raise AssertionError('Unscanned file released')
|
||||
except HTTPException as error:assert error.status_code==409
|
||||
require_clean({'complete':True,'scan_state':'clean'})
|
||||
assert ClamAV().ping() and ClamAV().version().startswith('ClamAV ')
|
||||
assert ClamAV().scan(None,134217729)[0]=='rejected'
|
||||
with patch('app.scanning.socket.create_connection',side_effect=OSError('offline')):
|
||||
try:ClamAV().scan(None,1);raise AssertionError('Offline scanner returned success')
|
||||
except OSError:pass
|
||||
print('PASS: runtime DB/S3 least privilege, legacy/current password hashes, quarantine states and scanner size/offline behavior')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
17
tests/scanning_test.py
Normal file
17
tests/scanning_test.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Harmless EICAR anti-malware test and blocked download/quote regressions."""
|
||||
from uuid import uuid4
|
||||
from tests.smoke_test import Client, upload_bytes
|
||||
|
||||
def run():
|
||||
customer=Client();customer.call('/session')
|
||||
# Standard antivirus test string, not executable malware.
|
||||
marker=b'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'
|
||||
uid=upload_bytes(customer,marker,name='SECURITY-EICAR.cdr',expected_scan='rejected')
|
||||
customer.call('/operator/uploads/'+uid+'/download',operator=True,expected=409)
|
||||
customer.call('/quotes',{'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'security@example.test'},
|
||||
'items':[{'mode':'file','metres':'1','grade':0,'uploads':[uid]}],'freight':{'service':'pickup'}},expected=409)
|
||||
clean=upload_bytes(customer,b'Harmless local artwork fixture',name='SECURITY-CLEAN.cdr')
|
||||
customer.call('/operator/uploads/'+clean+'/download',operator=True)
|
||||
print('PASS: real ClamAV detects EICAR; rejected artwork cannot be downloaded or quoted; clean artwork is released.')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
84
tests/security_test.py
Normal file
84
tests/security_test.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Non-destructive localhost security regressions. Leaves tiny test upload reservations."""
|
||||
import base64
|
||||
import os
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from uuid import uuid4
|
||||
from tests.smoke_test import Client, BASE, with_host
|
||||
|
||||
def raw(path, expected, headers=None, body=None):
|
||||
request=Request(BASE+path, data=body, headers=with_host(headers))
|
||||
try:
|
||||
with urlopen(request,timeout=10) as response:
|
||||
assert response.status==expected
|
||||
return response.headers
|
||||
except HTTPError as error:
|
||||
assert error.code==expected,(path,error.code,expected)
|
||||
return error.headers
|
||||
|
||||
def run():
|
||||
headers=raw('/',200)
|
||||
policy=headers['Content-Security-Policy']
|
||||
assert "script-src-attr 'none'" in policy and "frame-ancestors 'none'" in policy
|
||||
# The Site has no inline script, so the policy needs no hash allowlist at all.
|
||||
# Assert the property that matters rather than the mechanism: nothing inline
|
||||
# executes, and any hash that does appear was added deliberately at build time.
|
||||
script_src = next(d.strip() for d in policy.split(';') if d.strip().startswith('script-src '))
|
||||
assert "'unsafe-inline'" not in script_src and "'unsafe-eval'" not in script_src, script_src
|
||||
assert script_src == "script-src 'self'", script_src
|
||||
assert "object-src 'none'" in policy
|
||||
assert 'cdnjs' not in policy, 'pdf.js is vendored; no CDN belongs in the policy'
|
||||
raw('/api/health',400,{'Host':'attacker.invalid'})
|
||||
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'https://attacker.invalid'},b'{}')
|
||||
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}')
|
||||
print('PASS: CSP, frame protection, Host and cross-origin rejection')
|
||||
|
||||
operator=Client()
|
||||
credentials={'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')}
|
||||
encoded=base64.b64encode((credentials['email']+':'+credentials['password']).encode()).decode()
|
||||
raw('/api/operator/board',401,{'Authorization':'Basic '+encoded})
|
||||
operator.call('/operator/login',credentials)
|
||||
token=next(c for c in operator.jar if c.name=='dtf_operator')
|
||||
assert token.has_nonstandard_attr('HttpOnly') and token.get_nonstandard_attr('SameSite')=='strict'
|
||||
assert token.path=='/api/operator'
|
||||
operator.call('/operator/board')
|
||||
replay=Client();replay.jar.set_cookie(token)
|
||||
operator.call('/operator/logout',{})
|
||||
replay.call('/operator/board',expected=401)
|
||||
print('PASS: Basic rejected; HttpOnly scoped operator session; server-side logout revocation')
|
||||
|
||||
client=Client();client.call('/session')
|
||||
client.call('/uploads',{'name':'payload.html','size':1},expected=422)
|
||||
uid=client.call('/uploads',{'name':'SECURITY-PART.cdr','size':3})['id']
|
||||
url=client.call('/uploads/'+uid+'/parts/1',{})['url']
|
||||
assert 'content-length' in parse_qs(urlparse(url).query)['X-Amz-SignedHeaders'][0]
|
||||
try:
|
||||
urlopen(Request(url,data=b'toolong',method='PUT'),timeout=10)
|
||||
raise AssertionError('Signed part accepted wrong length')
|
||||
except HTTPError as error:assert error.code==403,error.code
|
||||
with urlopen(Request(url,data=b'abc',method='PUT'),timeout=10) as response:assert response.status==200
|
||||
client.call('/uploads/'+uid+'/complete',{})
|
||||
count=int(os.environ.get('MAX_PENDING_UPLOADS','10'))
|
||||
for i in range(count):client.call('/uploads',{'name':'SECURITY-PENDING.cdr','size':1})
|
||||
client.call('/uploads',{'name':'SECURITY-OVER-LIMIT.cdr','size':1},expected=429)
|
||||
print('PASS: extension allowlist, exact multipart Content-Length signature, pending upload quota')
|
||||
|
||||
# Unique identity avoids locking out the real local operator.
|
||||
attacker=Client();email='test-'+uuid4().hex+'@example.test'
|
||||
for _ in range(10):attacker.call('/operator/login',{'email':email,'password':'invalid'},expected=401)
|
||||
attacker.call('/operator/login',{'email':email,'password':'invalid'},expected=429)
|
||||
print('PASS: operator login throttling (only synthetic account bucket exhausted)')
|
||||
|
||||
# Guest sessions are limited per source, not once for the whole deployment.
|
||||
# Keyed on the environment name this was a single global bucket of 120 per
|
||||
# 15 minutes, which the suites above would already have eaten into.
|
||||
for _ in range(25):
|
||||
Client().call('/session')
|
||||
# A forged forwarded address must not let a client pick another bucket: the
|
||||
# gateway overwrites the header, so these count against the real source too.
|
||||
for _ in range(5):
|
||||
raw('/api/session',200,{'X-Forwarded-For':'203.0.113.7'})
|
||||
print('PASS: guest sessions limited per source, forwarded address not client-controlled')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
159
tests/smoke_test.py
Normal file
159
tests/smoke_test.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Local stack integration checks; creates and retains clearly named test orders.
|
||||
|
||||
Run: python3 -m tests.smoke_test. Standard library only. Honors .env/environment.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import hashlib
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import build_opener, HTTPCookieProcessor, Request, urlopen
|
||||
from uuid import uuid4
|
||||
|
||||
if Path('.env').exists():
|
||||
for line in Path('.env').read_text().splitlines():
|
||||
if line.strip() and not line.startswith('#') and '=' in line:
|
||||
key,value=line.split('=',1)
|
||||
os.environ.setdefault(key,value)
|
||||
# CI runs these from a container on the stack's own network, because a runner
|
||||
# container cannot reach ports published on the host's loopback. SITE_BASE_URL
|
||||
# points at the gateway by service name; SITE_HOST_HEADER keeps the Host the
|
||||
# gateway and TrustedHostMiddleware expect, so the security configuration under
|
||||
# test stays identical to a developer's localhost run.
|
||||
BASE=os.environ.get('SITE_BASE_URL') or 'http://localhost:'+os.environ.get('SITE_PORT','8080')
|
||||
HOST_HEADER=os.environ.get('SITE_HOST_HEADER')
|
||||
|
||||
def with_host(headers=None):
|
||||
headers=dict(headers or {})
|
||||
if HOST_HEADER and not any(k.lower()=='host' for k in headers):
|
||||
headers['Host']=HOST_HEADER
|
||||
return headers
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.jar=http.cookiejar.CookieJar()
|
||||
self.opener=build_opener(HTTPCookieProcessor(self.jar))
|
||||
self.operator_client=None
|
||||
def call(self,path,body=None,operator=False,expected=200):
|
||||
headers=with_host({'Content-Type':'application/json'})
|
||||
if operator:
|
||||
if self.operator_client is None:
|
||||
self.operator_client=Client()
|
||||
self.operator_client.call('/operator/login',{'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')})
|
||||
return self.operator_client.call(path,body,expected=expected)
|
||||
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),headers=headers)
|
||||
try:
|
||||
with self.opener.open(request,timeout=30) as response:
|
||||
assert response.status==expected,(path,response.status,expected)
|
||||
return json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code!=expected:raise AssertionError((path,exc.code,exc.read().decode())) from exc
|
||||
return json.load(exc)
|
||||
|
||||
def wait_scan(client, uid, operator=False, expected='clean'):
|
||||
prefix='/operator/uploads' if operator else '/uploads'
|
||||
deadline=time.monotonic()+150
|
||||
while time.monotonic()<deadline:
|
||||
state=client.call(prefix+'/'+uid,operator=operator)
|
||||
if state['scan_state'] in ('clean','rejected','error'):
|
||||
assert state['scan_state']==expected,state
|
||||
return state
|
||||
time.sleep(0.5)
|
||||
raise AssertionError('Malware scan did not finish')
|
||||
|
||||
def upload_bytes(client, content, name='LOCAL-TEST.cdr', order_id=None, expected_scan='clean'):
|
||||
operator=order_id is not None
|
||||
prefix='/operator/uploads' if operator else '/uploads'
|
||||
start='/operator/orders/'+order_id+'/uploads' if operator else prefix
|
||||
data=client.call(start,{'name':name,'size':len(content)},operator=operator)
|
||||
uid=data['id'];size=data['part_bytes']
|
||||
for offset in range(0,len(content),size):
|
||||
url=client.call(prefix+'/'+uid+'/parts/'+str(offset//size+1),{},operator=operator)['url']
|
||||
with urlopen(Request(url,data=content[offset:offset+size],method='PUT'),timeout=30) as response:
|
||||
assert response.status==200
|
||||
client.call(prefix+'/'+uid+'/complete',{},operator=operator)
|
||||
wait_scan(client,uid,operator,expected_scan)
|
||||
return uid
|
||||
|
||||
def run():
|
||||
client=Client();other=Client()
|
||||
config=client.call('/session');other.call('/session')
|
||||
assert client.call('/health')['integrations']=='fake'
|
||||
client.call('/operator/board',expected=401)
|
||||
block=config['part_bytes'];content=b'DTF local multipart test\n'+b'x'*block
|
||||
uid=client.call('/uploads',{'name':'LOCAL-SMOKE-ONLY.cdr','size':len(content)})['id']
|
||||
other.call('/uploads/'+uid,expected=404)
|
||||
other.call('/uploads/'+uid+'/parts/1',{},expected=404)
|
||||
signed=client.call('/uploads/'+uid+'/parts/1',{})['url']
|
||||
with urlopen(Request(signed,data=content[:block],method='PUT'),timeout=30) as response:assert response.status==200
|
||||
assert client.call('/uploads/'+uid)['parts']==[1]
|
||||
client.call('/uploads/'+uid+'/complete',{},expected=409)
|
||||
signed=client.call('/uploads/'+uid+'/parts/2',{})['url']
|
||||
with urlopen(Request(signed,data=content[block:],method='PUT'),timeout=30) as response:assert response.status==200
|
||||
client.call('/uploads/'+uid+'/complete',{})
|
||||
client.call('/uploads/'+uid+'/complete',{})
|
||||
wait_scan(client,uid)
|
||||
client.call('/uploads/'+uid+'/parts/1',{},expected=409)
|
||||
download=client.call('/operator/uploads/'+uid+'/download',operator=True)
|
||||
with urlopen(download['url'],timeout=30) as response:assert hashlib.sha256(response.read()).digest()==hashlib.sha256(content).digest()
|
||||
unsigned=download['url'].split('?')[0]
|
||||
try:urlopen(unsigned,timeout=10);raise AssertionError('Bucket must be private')
|
||||
except HTTPError as exc:assert exc.code==403
|
||||
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
|
||||
|
||||
items=[{'mode':m,'metres':'2.75','grade':90,'uploads':[uid]} for m in ('file','avulsa','uvfile','uv')]
|
||||
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
|
||||
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'}}
|
||||
client.call('/quotes',{**draft,'total_cents':1},expected=422)
|
||||
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
|
||||
quote=client.call('/quotes',draft)
|
||||
assert client.call('/quotes',draft)['id']==quote['id']
|
||||
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=409)
|
||||
qid=quote['id']
|
||||
other.call('/quotes/'+qid,expected=404)
|
||||
client.call('/orders/dev-paid',{'quote_id':qid},expected=409)
|
||||
client.call('/operator/quotes/'+qid+'/approve',{'items':items},expected=401)
|
||||
# Reviewer corrects a browser-supplied grade and length. Browser values are proposals.
|
||||
corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]]
|
||||
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
|
||||
assert approved['items'][0]['total_cents']==2189
|
||||
assert approved['total_cents']==2189+6972+19572+23492+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
|
||||
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
|
||||
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
|
||||
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
|
||||
# Concurrent retries must produce precisely one payment/order/outbox pair.
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
paid=list(executor.map(lambda _:client.call('/orders/dev-paid',{'quote_id':qid}),range(4)))
|
||||
assert len({p['id'] for p in paid})==1
|
||||
order=paid[0];oid=order['id']
|
||||
assert order['payment']['status']=='paid' and order['snapshot']==approved
|
||||
board=client.call('/operator/board',operator=True)
|
||||
assert len([o for o in board['orders'] if o['quote_id']==qid])==1
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':0},operator=True,expected=409)
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'cor','version':0},operator=True,expected=422)
|
||||
version=0
|
||||
for state in ('cor','rec','tra','fil','imp','fin'):
|
||||
if state=='fil':
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
|
||||
files=[{'item_index':i,'upload_id':upload_bytes(client,b'LOCAL FINAL FIXTURE '+str(i).encode(),order_id=oid)} for i in range(4)]
|
||||
result=client.call('/operator/orders/'+oid+'/final-files',{'version':version,'files':files,'note':'Local test manual final-file approval'},operator=True)
|
||||
version=result['version']
|
||||
moved=client.call('/operator/orders/'+oid+'/move',{'state':state,'version':version,'reason':'Local test correction' if state=='cor' else ''},operator=True)
|
||||
version+=1;assert moved['version']==version
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409)
|
||||
assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6
|
||||
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
|
||||
deadline=time.monotonic()+30
|
||||
while time.monotonic()<deadline:
|
||||
events=[e for e in client.call('/operator/board',operator=True)['events'] if e['payload']['order_id']==oid]
|
||||
if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break
|
||||
time.sleep(1)
|
||||
else:raise AssertionError('Mock outbox did not drain')
|
||||
assert len({e['event_key'] for e in events})==8
|
||||
print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.")
|
||||
return oid
|
||||
|
||||
if __name__=='__main__':run()
|
||||
46
tests/test_dependency_lock.py
Normal file
46
tests/test_dependency_lock.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NAME_VERSION = re.compile(r'^([A-Za-z0-9_.-]+)==([^\s\\]+)', re.MULTILINE)
|
||||
|
||||
|
||||
def normalized(name):
|
||||
return re.sub(r'[-_.]+', '-', name).lower()
|
||||
|
||||
|
||||
class DependencyLockTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.direct_text = (ROOT / 'infra/requirements.txt').read_text()
|
||||
cls.lock_text = (ROOT / 'infra/requirements.lock').read_text()
|
||||
|
||||
def test_every_direct_pin_matches_lock(self):
|
||||
direct = {normalized(name): version for name, version in
|
||||
NAME_VERSION.findall(self.direct_text)}
|
||||
locked = {normalized(name): version for name, version in
|
||||
NAME_VERSION.findall(self.lock_text)}
|
||||
self.assertTrue(direct)
|
||||
self.assertEqual({name: locked.get(name) for name in direct}, direct)
|
||||
|
||||
def test_every_locked_package_has_sha256_hash(self):
|
||||
matches = list(NAME_VERSION.finditer(self.lock_text))
|
||||
self.assertTrue(matches)
|
||||
for index, match in enumerate(matches):
|
||||
end = matches[index + 1].start() if index + 1 < len(matches) else len(self.lock_text)
|
||||
block = self.lock_text[match.start():end]
|
||||
hashes = re.findall(r'--hash=sha256:([0-9a-f]{64})(?:\s|\\)', block)
|
||||
self.assertTrue(hashes, f'{match.group(1)} has no SHA-256 artifact hash')
|
||||
|
||||
def test_image_build_requires_the_lock_and_hashes(self):
|
||||
dockerfile = (ROOT / 'infra/Dockerfile').read_text()
|
||||
self.assertIn('requirements.lock', dockerfile)
|
||||
self.assertIn('--require-hashes -r infra/requirements.lock', dockerfile)
|
||||
|
||||
def test_reproducible_generator_is_recorded(self):
|
||||
self.assertIn('./local/lock_dependencies.sh', self.lock_text[:300])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
42
tests/test_pricing.py
Normal file
42
tests/test_pricing.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Run from repository root: python3 -m unittest tests.test_pricing -v."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import unittest
|
||||
from app.core.pricing import price, TIERS
|
||||
|
||||
class PricingTests(unittest.TestCase):
|
||||
def test_every_grade_against_actual_site_javascript(self):
|
||||
# The ladders live with the rest of the Site's behaviour; this test exists
|
||||
# to prove the server agrees with whatever the customer is shown.
|
||||
source = Path('web/site-config.js').read_text()
|
||||
tiers = source.split('const FAIXAS=')[1].split('\n};',1)[0]+'\n}'
|
||||
calculator = source.split('const cobrar=')[1].split(';',1)[0]
|
||||
js = f'const FAIXAS={tiers};const MINIMO_M=1;const cobrar={calculator};'
|
||||
js += '''const results=[];for(const mode of Object.keys(FAIXAS))for(let grade=0;grade<=100;grade++)
|
||||
for(const metres of [0.01,0.99,1,1.01,1.1,2.75,2.8,2.8000000000000003,19.99,60,12000]){
|
||||
const unit=FAIXAS[mode].find(x=>grade>=x[0])[1];
|
||||
results.push([mode,String(metres),grade,cobrar(metres),Math.round(unit*100),Math.round(cobrar(metres)*unit*100)]);
|
||||
}process.stdout.write(JSON.stringify(results));'''
|
||||
cases = json.loads(subprocess.check_output(['node','-e',js],text=True))
|
||||
for mode,metres,grade,billed,unit,total in cases:
|
||||
with self.subTest(mode=mode,metres=metres,grade=grade):
|
||||
result=price(mode,metres,grade)
|
||||
self.assertEqual(float(result['billed_metres']),billed)
|
||||
self.assertEqual(result['unit_cents'],unit)
|
||||
self.assertEqual(result['total_cents'],total)
|
||||
|
||||
def test_assembly_is_included_at_every_tier(self):
|
||||
for grade in range(101):
|
||||
self.assertEqual(price('avulsa','1',grade)['total_cents']-price('file','1',grade)['total_cents'],1000)
|
||||
self.assertEqual(price('uv','1',grade)['total_cents']-price('uvfile','1',grade)['total_cents'],1400)
|
||||
|
||||
def test_invalid_inputs(self):
|
||||
for value in ('0','-1','NaN','Infinity','12001'):
|
||||
with self.assertRaises(ValueError):price('file',value,90)
|
||||
for grade in (-1,101,True,89.5):
|
||||
with self.assertRaises(ValueError):price('file','1',grade)
|
||||
with self.assertRaises(ValueError):price('other','1',90)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
93
tests/test_secrets.py
Normal file
93
tests/test_secrets.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Docker secret-file resolution. Standard library only; no stack required."""
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.secrets import SECRET_FILE_SETTINGS, load, read_secret
|
||||
|
||||
|
||||
class SecretFileTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.dir.cleanup)
|
||||
|
||||
def secret(self, content, name='secret'):
|
||||
path = Path(self.dir.name) / name
|
||||
path.write_text(content, encoding='utf-8')
|
||||
return str(path)
|
||||
|
||||
def test_resolves_file_into_plain_setting(self):
|
||||
env = {'DATABASE_URL_FILE': self.secret('postgresql://u:p@db:5432/dtf\n')}
|
||||
self.assertEqual(load(env), ['DATABASE_URL'])
|
||||
self.assertEqual(env['DATABASE_URL'], 'postgresql://u:p@db:5432/dtf')
|
||||
|
||||
def test_strips_only_one_trailing_newline(self):
|
||||
# A generated password may legitimately end in whitespace, so only the
|
||||
# newline `docker secret` or an editor appends may be removed.
|
||||
self.assertEqual(read_secret(self.secret('p@ss \n')), 'p@ss ')
|
||||
self.assertEqual(read_secret(self.secret('p@ss\n\n')), 'p@ss\n')
|
||||
self.assertEqual(read_secret(self.secret('p@ss\r\n')), 'p@ss')
|
||||
self.assertEqual(read_secret(self.secret('p@ss')), 'p@ss')
|
||||
|
||||
def test_preserves_characters_that_would_break_a_url(self):
|
||||
value = 'p@ss:w/rd?#[]&=+$ ,%'
|
||||
env = {'OPERATOR_PASSWORD_FILE': self.secret(value + '\n')}
|
||||
load(env)
|
||||
self.assertEqual(env['OPERATOR_PASSWORD'], value)
|
||||
|
||||
def test_resolves_every_documented_production_setting(self):
|
||||
env = {f'{name}_FILE': self.secret(f'value-for-{name}', name)
|
||||
for name in SECRET_FILE_SETTINGS}
|
||||
load(env)
|
||||
for name in SECRET_FILE_SETTINGS:
|
||||
self.assertEqual(env[name], f'value-for-{name}')
|
||||
|
||||
def test_missing_file_fails_closed(self):
|
||||
env = {'DATABASE_URL_FILE': str(Path(self.dir.name) / 'absent')}
|
||||
with self.assertRaises(RuntimeError) as caught:
|
||||
load(env)
|
||||
self.assertIn('DATABASE_URL_FILE', str(caught.exception))
|
||||
self.assertNotIn('DATABASE_URL', env)
|
||||
|
||||
def test_empty_secret_fails_closed(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
load({'OPERATOR_PASSWORD_FILE': self.secret('\n')})
|
||||
|
||||
def test_empty_path_fails_closed(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
load({'OPERATOR_PASSWORD_FILE': ' '})
|
||||
|
||||
def test_value_and_file_together_is_ambiguous(self):
|
||||
env = {'OPERATOR_PASSWORD': 'inline',
|
||||
'OPERATOR_PASSWORD_FILE': self.secret('from-file')}
|
||||
with self.assertRaises(RuntimeError):
|
||||
load(env)
|
||||
self.assertEqual(env['OPERATOR_PASSWORD'], 'inline')
|
||||
|
||||
def test_never_puts_a_secret_in_the_error_text(self):
|
||||
value = 'super-secret-value'
|
||||
env = {'TINY_TOKEN': value, 'TINY_TOKEN_FILE': self.secret(value)}
|
||||
with self.assertRaises(RuntimeError) as caught:
|
||||
load(env)
|
||||
self.assertNotIn(value, str(caught.exception))
|
||||
|
||||
def test_ignores_a_bare_suffix_and_leaves_other_settings_alone(self):
|
||||
env = {'_FILE': '/nowhere', 'APP_ENV': 'production'}
|
||||
self.assertEqual(load(env), [])
|
||||
self.assertEqual(env['APP_ENV'], 'production')
|
||||
|
||||
def test_documented_list_matches_the_production_stack(self):
|
||||
stack = Path(__file__).resolve().parent.parent / 'deploy' / 'stack.yaml'
|
||||
if not stack.exists():
|
||||
self.skipTest('production stack definition not present')
|
||||
text = stack.read_text()
|
||||
# POSTGRES_PASSWORD_FILE is consumed by the database image, not by us.
|
||||
used = {line.split(':')[0].strip() for line in text.splitlines()
|
||||
if '_FILE:' in line and 'POSTGRES_PASSWORD_FILE' not in line}
|
||||
for key in used:
|
||||
self.assertIn(key[:-len('_FILE')], SECRET_FILE_SETTINGS,
|
||||
f'{key} is passed by the stack but undocumented in secrets.py')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
56
tests/test_staging_readiness.py
Normal file
56
tests/test_staging_readiness.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from ops.staging_readiness import read_config, validate
|
||||
|
||||
|
||||
VALID = '''
|
||||
APP_ENV=staging
|
||||
STAGING_APPROVED_BY=business-and-technical-owners
|
||||
STAGING_PUBLIC_ORIGIN=https://staging.example.invalid
|
||||
STAGING_S3_ENDPOINT=https://example.r2.cloudflarestorage.com
|
||||
STAGING_S3_BUCKET=dtf-staging-artwork
|
||||
STAGING_DATABASE_MODE=dedicated-container
|
||||
STAGING_SECRET_SOURCE=portainer-secrets
|
||||
STAGING_BACKUP_DESTINATION=separate-encrypted-r2-bucket
|
||||
STAGING_FREIGHT_PROVIDER=provider-sandbox
|
||||
STAGING_PAYMENT_PROVIDER=mercado-pago-sandbox
|
||||
STAGING_ERP_PROVIDER=tiny-olist-sandbox
|
||||
STAGING_WHATSAPP_PROVIDER=provider-sandbox
|
||||
STAGING_ALERT_OWNER=operations-team
|
||||
STAGING_ROLLBACK_OWNER=technical-team
|
||||
'''
|
||||
|
||||
|
||||
class StagingReadinessTests(unittest.TestCase):
|
||||
def parse(self, text):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'staging.env'
|
||||
path.write_text(text)
|
||||
return read_config(path)
|
||||
|
||||
def test_complete_non_secret_metadata_passes(self):
|
||||
self.assertEqual(validate(self.parse(VALID)), [])
|
||||
|
||||
def test_local_endpoint_and_fake_provider_are_blocked(self):
|
||||
values = self.parse(VALID.replace(
|
||||
'https://example.r2.cloudflarestorage.com', 'http://localhost:9000'
|
||||
).replace('provider-sandbox', 'fake', 1))
|
||||
errors = validate(values)
|
||||
self.assertTrue(any('STAGING_S3_ENDPOINT' in error for error in errors))
|
||||
self.assertTrue(any('STAGING_FREIGHT_PROVIDER' in error for error in errors))
|
||||
|
||||
def test_secret_named_setting_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, 'secrets must not be stored'):
|
||||
self.parse(VALID + 'MERCADO_PAGO_ACCESS_TOKEN=do-not-store-this\n')
|
||||
|
||||
def test_undecided_values_are_blocked(self):
|
||||
errors = validate(self.parse(VALID.replace(
|
||||
'STAGING_APPROVED_BY=business-and-technical-owners',
|
||||
'STAGING_APPROVED_BY=TBD')))
|
||||
self.assertIn('STAGING_APPROVED_BY is not decided', errors)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
73
tests/workflow_test.py
Normal file
73
tests/workflow_test.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Customer identity, correction and final-file trust boundaries against local stack."""
|
||||
from uuid import uuid4
|
||||
from urllib.request import urlopen
|
||||
from tests.smoke_test import Client, upload_bytes
|
||||
|
||||
def run():
|
||||
customer=Client();other=Client();customer.call('/session');other.call('/session')
|
||||
uid=upload_bytes(customer,b'LOCAL ORIGINAL ONLY')
|
||||
item={'mode':'file','metres':'1.01','grade':0,'uploads':[uid]}
|
||||
profile={'cnpj':'11222333000181','zap':'11999999999','mail':'workflow-'+uuid4().hex[:8]+'@example.test'}
|
||||
q=customer.call('/quotes',{'request_key':str(uuid4()),'customer':profile,'items':[item],'freight':{'service':'pickup'}})
|
||||
customer.call('/operator/quotes/'+q['id']+'/approve',{'items':[item]},operator=True)
|
||||
order=customer.call('/orders/dev-paid',{'quote_id':q['id']});oid=order['id']
|
||||
before=list(customer.jar)[0].value
|
||||
password='local-test-password-'+uuid4().hex
|
||||
customer.call('/account/register',{'customer':profile,'password':password})
|
||||
assert customer.call('/account/me')['customer']['mail']==profile['mail']
|
||||
assert customer.call('/customer/orders')['orders'][0]['id']==oid
|
||||
# Email/CNPJ do not grant ownership; only current guest session is migrated.
|
||||
other.call('/customer/orders/'+oid,expected=404)
|
||||
other.call('/account/login',{'email':profile['mail'],'password':'wrong-password'},expected=401)
|
||||
revoked=Client()
|
||||
import http.cookiejar
|
||||
cookie=http.cookiejar.Cookie(0,'dtf_session',before,None,False,'localhost.local',False,False,'/',True,False,None,True,None,None,{},False)
|
||||
revoked.jar.set_cookie(cookie)
|
||||
revoked.call('/customer/orders',expected=401)
|
||||
account_scope=customer.call('/session')['cart_scope']
|
||||
cookie.value=account_scope;revoked.jar.set_cookie(cookie)
|
||||
revoked.call('/customer/orders',expected=401)
|
||||
other.call('/account/login',{'email':profile['mail'],'password':password})
|
||||
assert other.call('/customer/orders/'+oid)['id']==oid
|
||||
print('PASS: registration claims only current guest records, cross-session account login, revoked sessions, owner UUID is not a credential')
|
||||
|
||||
def move(state,version):
|
||||
return customer.call('/operator/orders/'+oid+'/move',{'state':state,'version':version,'reason':'Please replace the artwork' if state=='cor' else ''},operator=True)['version']
|
||||
version=move('tra',0)
|
||||
customer.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
|
||||
final_id=upload_bytes(customer,b'LOCAL FINAL VERSION ONE',order_id=oid)
|
||||
customer.call('/uploads/'+final_id,expected=404)
|
||||
body={'version':version,'files':[{'item_index':0,'upload_id':final_id}],'note':'Manually checked final'}
|
||||
customer.call('/operator/orders/'+oid+'/final-files',body,expected=401)
|
||||
version=customer.call('/operator/orders/'+oid+'/final-files',body,operator=True)['version']
|
||||
detail=customer.call('/customer/orders/'+oid)
|
||||
final=detail['files'][0]
|
||||
link=customer.call('/customer/orders/'+oid+'/files/'+final['id']+'/download')
|
||||
assert urlopen(link['url']).read()==b'LOCAL FINAL VERSION ONE'
|
||||
guest=Client();guest.call('/session');guest.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
|
||||
version=move('fil',version);version=move('imp',version);version=move('cor',version)
|
||||
customer.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
|
||||
correction_id=upload_bytes(customer,b'LOCAL CORRECTED ORIGINAL')
|
||||
payload={'version':version,'files':[{'item_index':0,'upload_id':correction_id}],'note':'Replaced the artwork as requested'}
|
||||
guest.call('/customer/orders/'+oid+'/corrections',payload,expected=404)
|
||||
customer.call('/customer/orders/'+oid+'/corrections',{**payload,'version':0},expected=409)
|
||||
version=customer.call('/customer/orders/'+oid+'/corrections',payload)['version']
|
||||
files=customer.call('/operator/orders/'+oid+'/files',operator=True)
|
||||
assert any(f['kind']=='correction' and f['active'] and f['upload_id']==correction_id for f in files)
|
||||
version=move('tra',version)
|
||||
customer.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
|
||||
new_final=upload_bytes(customer,b'LOCAL FINAL VERSION TWO',order_id=oid)
|
||||
version=customer.call('/operator/orders/'+oid+'/final-files',{'version':version,'files':[{'item_index':0,'upload_id':new_final}],'note':'Checked corrected final'},operator=True)['version']
|
||||
for state in ('fil','imp','fin'):version=move(state,version)
|
||||
detail=other.call('/customer/orders/'+oid)
|
||||
assert detail['state']=='fin'
|
||||
assert sum(f['active'] and f['kind']=='final' for f in detail['files'])==1
|
||||
assert any(h['reason']=='Please replace the artwork' for h in detail['history'])
|
||||
print('PASS: final-file gate, final revisions, secure customer downloads, correction history/uploads, old final invalidation and reapproval')
|
||||
old_cookie=list(other.jar)[0].value
|
||||
other.call('/account/logout',{})
|
||||
cookie.value=old_cookie;revoked.jar.set_cookie(cookie);revoked.call('/customer/orders',expected=401)
|
||||
other.call('/session');assert other.call('/customer/orders')['orders']==[]
|
||||
print('PASS: logout revokes server session and signed-out visitors cannot see account orders')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
Reference in New Issue
Block a user