fts
This commit is contained in:
@@ -31,8 +31,8 @@ let nextId=0, failSave=false; const requests=[],errors=[],restores=[];
|
||||
const page=await browser.newPage({viewport:{width:1500,height:1050}});
|
||||
page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('http://adfs.test/**',async route=>{
|
||||
const url=new URL(route.request().url());let body,type='application/json',status=200;
|
||||
if(url.pathname==='/api/session')body={user:'EXAMPLE\\admin',expiresAt:9999999999};
|
||||
const url=new URL(route.request().url());url.pathname=url.pathname.replace(/^\/admin(?=\/)/,'');let body,type='application/json',status=200;
|
||||
if(url.pathname==='/api/session')body={user:'EXAMPLE\\admin',expiresAt:9999999999,role:'domain-admin'};
|
||||
else if(url.pathname==='/api/access'){
|
||||
if(route.request().method()==='POST'){
|
||||
const change=route.request().postDataJSON();requests.push(change);
|
||||
@@ -88,7 +88,7 @@ let nextId=0, failSave=false; const requests=[],errors=[],restores=[];
|
||||
assert.deepEqual(differences,[],'Access controls differ from the established admin styles');
|
||||
};
|
||||
await page.goto('http://adfs.test/shares');await page.locator('[data-user-rule]').first().waitFor();
|
||||
assert.equal(new URL(page.url()).pathname,'/access');
|
||||
assert.equal(new URL(page.url()).pathname,'/admin/access');
|
||||
assert.equal(await page.locator('nav a[href="/shares"]').count(),0);
|
||||
assert.equal(await page.locator('[data-tab=groups], [data-group-rule]').count(),0);
|
||||
assert.doesNotMatch(await page.locator('#content').innerText(),/Gruppe/);
|
||||
@@ -151,7 +151,7 @@ let nextId=0, failSave=false; const requests=[],errors=[],restores=[];
|
||||
assert.equal(await page.locator('#folder-status').inputValue(),'active');
|
||||
assert.equal(dialogs,2);
|
||||
await page.locator('a[data-route="storage-data"]').click();
|
||||
assert.equal(new URL(page.url()).pathname,'/access');
|
||||
assert.equal(new URL(page.url()).pathname,'/admin/access');
|
||||
assert.equal(dialogs,3);
|
||||
failSave=true;await page.locator('#access-save').click();
|
||||
await page.locator('.access-message.failure').waitFor();
|
||||
@@ -237,7 +237,7 @@ let nextId=0, failSave=false; const requests=[],errors=[],restores=[];
|
||||
{sid:'unassigned',sam:'unzugeordnet',name:'Nicht zugeordnet',available:false});
|
||||
data.folders.find(f=>f.id==='invoices').permissions.push({kind:'user',principalId:'departed',level:1});
|
||||
await page.setViewportSize({width:1500,height:1050});
|
||||
await page.goto('http://adfs.test/access');
|
||||
await page.goto('http://adfs.test/admin/access');
|
||||
await page.locator('[data-user-rule=departed]').first().waitFor();
|
||||
assert.equal(await page.locator('[data-user-rule=unassigned]').count(),0);
|
||||
await page.locator('#access-user-search').fill('ehemalig');
|
||||
@@ -247,7 +247,7 @@ let nextId=0, failSave=false; const requests=[],errors=[],restores=[];
|
||||
assert.equal(await page.locator('[data-user-rule=departed]').count(),0);
|
||||
// An installation with only archived folders opens the matching list.
|
||||
data.folders.forEach(f=>f.active=false);
|
||||
await page.goto('http://adfs.test/access');
|
||||
await page.goto('http://adfs.test/admin/access');
|
||||
await page.locator('#access-archive').waitFor();
|
||||
assert.equal(await page.locator('#folder-status').inputValue(),'archived');
|
||||
assert.equal(await page.locator('[data-select].active').count(),1);
|
||||
@@ -255,12 +255,12 @@ let nextId=0, failSave=false; const requests=[],errors=[],restores=[];
|
||||
assert.equal(await page.locator('#access-save').isDisabled(),true);
|
||||
data.folders.length=0;
|
||||
await page.setViewportSize({width:1500,height:1050});
|
||||
await page.goto('http://adfs.test/access');
|
||||
await page.goto('http://adfs.test/admin/access');
|
||||
await page.locator('#access-create').waitFor();
|
||||
assert.match(await page.locator('#access-detail').innerText(),/Ersten Ordner anlegen/);
|
||||
await shot('07-desktop-leere-installation');
|
||||
// Icon controls keep accessible names and activate their original actions.
|
||||
await page.goto('http://adfs.test/trash');
|
||||
await page.goto('http://adfs.test/admin/trash');
|
||||
await page.locator('#trash-rows .row-actions').first().waitFor();
|
||||
for(const [role,name] of [['link','Herunterladen'],['button','Wiederherstellen']]){
|
||||
const control=page.getByRole(role,{name,exact:true}).first();
|
||||
@@ -272,7 +272,7 @@ let nextId=0, failSave=false; const requests=[],errors=[],restores=[];
|
||||
const downloadStarted=page.waitForEvent('download');
|
||||
await page.getByRole('link',{name:'Herunterladen',exact:true}).first().locator('svg').click();
|
||||
const download=await downloadStarted;
|
||||
assert.equal(download.url(),'http://adfs.test/api/trash/download?id=data%2Freport');
|
||||
assert.equal(download.url(),'http://adfs.test/admin/api/trash/download?id=data%2Freport');
|
||||
// Downloads leave page interception; verify the destination and cancel the mocked transfer.
|
||||
await download.cancel();
|
||||
await page.getByRole('button',{name:'Wiederherstellen',exact:true}).first().locator('svg').click();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Called by the disposable AD/Samba E2E stack with its local test credentials.
|
||||
import fs from "node:fs";
|
||||
import {createRequire} from "node:module";
|
||||
import assert from "node:assert/strict";
|
||||
const require=createRequire(import.meta.url);
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE||"playwright");
|
||||
const out=process.env.SCREENSHOT_DIR||'/tmp/adfs-documents-live-review';fs.mkdirSync(out,{recursive:true});
|
||||
const host=process.env.PREVIEW_HTTPS_HOST;
|
||||
const origin=`https://${host}:${process.env.PREVIEW_HTTPS_PORT}`;
|
||||
const browser=await chromium.launch({headless:true,args:[`--host-resolver-rules=MAP ${host} 127.0.0.1`,"--ignore-certificate-errors"]});
|
||||
try{
|
||||
const context=await browser.newContext({ignoreHTTPSErrors:true,viewport:{width:1500,height:1050}});
|
||||
const page=await context.newPage();const errors=[];page.on('pageerror',error=>errors.push(error.message));
|
||||
await page.goto(origin+'/');
|
||||
await page.locator('#login-view').waitFor({state:'visible'});
|
||||
await page.locator('input[name=username]').fill('alice');await page.locator('input[name=password]').fill(process.env.PREVIEW_USER_PASSWORD);
|
||||
await page.locator('#login-form button').click();await page.locator('.document-card').first().waitFor();
|
||||
assert.equal(await page.locator('#admin-link').isHidden(),true);
|
||||
await page.screenshot({path:out+'/01-live-user-files.png',fullPage:true});
|
||||
await page.locator('#search-query').fill('SCANNEDUNIQUE742');
|
||||
await page.waitForFunction(()=>document.querySelectorAll('.document-card').length===1);
|
||||
await page.locator('[data-document]').click();await page.locator('#document-dialog').waitFor({state:'visible'});
|
||||
await page.frameLocator('#document-pdf-viewer').locator('.page canvas').first().waitFor();
|
||||
assert.equal(await page.locator('.document-text, #document-content').count(),0);
|
||||
assert.deepEqual(await page.locator('#document-dialog').boundingBox(),{x:0,y:0,width:1500,height:1050});
|
||||
await page.screenshot({path:out+'/02-live-scan-ocr-preview.png',fullPage:false});
|
||||
await page.locator('#document-close').click();
|
||||
const downloadStarted=page.waitForEvent('download');
|
||||
await page.locator('.document-card a[download]').click();
|
||||
const download=await downloadStarted;assert.equal(await download.failure(),null);assert.equal(download.suggestedFilename(),'scanned-invoice.pdf');
|
||||
const original=fs.readFileSync(await download.path());assert.equal(original.subarray(0,5).toString(),'%PDF-');
|
||||
await page.locator('#search-query').fill('digital-invoice.pdf');
|
||||
await page.locator('.document-card').filter({hasText:'digital-invoice.pdf'}).waitFor();
|
||||
await page.locator('[data-document]').click();
|
||||
const viewer=page.frameLocator('#document-pdf-viewer');
|
||||
await viewer.locator('.page canvas').first().waitFor();
|
||||
assert.equal(await page.evaluate(()=>document.querySelector('#document-pdf-viewer').contentWindow.PDFViewerApplication.pagesCount),3);
|
||||
await viewer.locator('#pageNumber').fill('2');await viewer.locator('#pageNumber').press('Enter');
|
||||
await page.waitForFunction(()=>document.querySelector('#document-pdf-viewer').contentWindow.PDFViewerApplication.page===2);
|
||||
await page.waitForTimeout(2500);assert.equal(await viewer.locator('#pageNumber').inputValue(),'2');
|
||||
await viewer.locator('#zoomInButton').click();
|
||||
await viewer.locator('#viewFindButton').click();await viewer.locator('#findInput').fill('NATIVEINVOICE742');
|
||||
await viewer.locator('#findResultsCount').filter({hasText:/1/}).waitFor();
|
||||
await page.screenshot({path:out+'/02b-live-interactive-pdf-viewer.png',fullPage:false});
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
assert.deepEqual(await page.locator('#document-dialog').boundingBox(),{x:0,y:0,width:390,height:844});
|
||||
await page.screenshot({path:out+'/02c-live-mobile-pdf-viewer.png',fullPage:false});
|
||||
await page.locator('#document-close').click();await page.locator('#reset-filters').click();
|
||||
await page.waitForFunction(()=>document.querySelectorAll('.document-card').length>1);
|
||||
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
|
||||
await page.screenshot({path:out+'/03-live-mobile-files.png',fullPage:true});
|
||||
await page.goto(origin+'/admin/access');await page.waitForURL(origin+'/');await page.locator('.document-card').first().waitFor();
|
||||
assert.equal(await page.locator('#admin-link').isHidden(),true);
|
||||
const adminContext=await browser.newContext({ignoreHTTPSErrors:true,viewport:{width:1500,height:1050}});
|
||||
const admin=await adminContext.newPage();await admin.goto(origin+'/admin/access');
|
||||
await admin.locator('#login-view').waitFor({state:'visible'});
|
||||
await admin.locator('input[name=username]').fill(process.env.PREVIEW_ADMIN_USER);
|
||||
await admin.locator('input[name=password]').fill(process.env.PREVIEW_ADMIN_PASSWORD);
|
||||
await admin.locator('#login-form button').click();await admin.locator('[data-user-rule]').first().waitFor();
|
||||
assert.equal(new URL(admin.url()).pathname,'/admin/access');
|
||||
assert.equal(await admin.locator('nav a[href="/admin/access"]').count(),1);
|
||||
await admin.screenshot({path:out+'/04-live-admin-access.png',fullPage:true});
|
||||
await admin.locator('a[href="/admin/documents"]').click();
|
||||
await admin.locator('#document-worker-control').waitFor();
|
||||
await admin.waitForFunction(()=>!document.querySelector('#document-worker-control').disabled);
|
||||
await admin.screenshot({path:out+'/05-live-document-index.png',fullPage:true});
|
||||
await admin.locator('#document-worker-control').click();
|
||||
await admin.waitForFunction(()=>document.querySelector('#document-worker-control')?.textContent==='Fortsetzen' && document.querySelector('#document-worker-state')?.textContent==='Angehalten');
|
||||
await admin.screenshot({path:out+'/06-live-document-index-paused.png',fullPage:true});
|
||||
await admin.locator('#document-worker-control').click();
|
||||
await admin.waitForFunction(()=>document.querySelector('#document-worker-control')?.textContent==='Anhalten');
|
||||
await admin.setViewportSize({width:390,height:844});
|
||||
await admin.screenshot({path:out+'/07-live-document-index-mobile.png',fullPage:true});
|
||||
await admin.goto(origin+'/');await admin.locator('.document-card').first().waitFor();
|
||||
assert.equal(await admin.locator('#admin-link').isVisible(),true);
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: live user/admin login, OCR search/preview, original PDF download, mobile layout, /admin boundary, index monitoring and pause/resume.');
|
||||
console.log('Screenshots: '+out);
|
||||
}finally{await browser.close();}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Exercise the real portal frontend with deterministic permission-filtered responses.
|
||||
import fs from "node:fs";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {createRequire} from "node:module";
|
||||
import {fileURLToPath} from "node:url";
|
||||
const require = createRequire(import.meta.url);
|
||||
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || "playwright");
|
||||
const root = fileURLToPath(new URL("../app/web/", import.meta.url));
|
||||
const out = process.env.SCREENSHOT_DIR || fs.mkdtempSync(path.join(os.tmpdir(), "adfs-portal-review-"));
|
||||
fs.mkdirSync(out,{recursive:true});
|
||||
const sources=[{id:'data:finance',label:'Finanzen',kind:'data'},{id:'data:projects',label:'Projekte',kind:'data'},{id:'private:alice',label:'Private',kind:'private'}];
|
||||
const fixtures=[
|
||||
['Rechnung Oktober 2026.pdf','pdf','data:finance','Rechnung für Büroausstattung\nReferenz FINANZ742\nGesamtbetrag 1.500,00 EUR',true],
|
||||
['Projektplan für die Einführung des neuen Dokumentenportals und die Schulung aller Mitarbeiter.docx','docx','data:projects','Projektplan mit Schulung und Zeitplan. Start im Oktober.',false],
|
||||
['Meine Notizen.txt','txt','private:alice','Meine persönlichen Notizen. Termin zur Budgetplanung am Mittwoch.',false],
|
||||
['Umsatzübersicht.csv','csv','data:finance','Quartal,Umsatz\nQ1,120000\nQ2,135000',false],
|
||||
['Gescanntes Protokoll.pdf','pdf','data:projects','',true]
|
||||
].map(([name,extension,sourceId,text,hasPreview],index)=>({id:(index+1).toString(16).padStart(32,'0'),name,extension,sourceId,text,path:'Dokumente/'+name,source:sources.find(source=>source.id===sourceId).label,kind:sources.find(source=>source.id===sourceId).kind,size:143360+index*1024,modified:1791023400-index*86400,state:index===4?'ocr':'ready',pages:extension==='pdf'?3:0,hasPreview,version:'aaaaaaaaaaaaaaaa',snippet:text}));
|
||||
// A small native three-page PDF exercises the real viewer, including its find bar.
|
||||
function viewerPdf() {
|
||||
const objects=['<< /Type /Catalog /Pages 2 0 R >>','<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R] /Count 3 >>'];
|
||||
for(let page=1;page<=3;page++){
|
||||
const content=`BT /F1 24 Tf 72 720 Td (PDFVIEWER742 - Page ${page}) Tj ET`;
|
||||
objects.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 9 0 R >> >> /Contents ${page*2+2} 0 R >>`);
|
||||
objects.push(`<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`);
|
||||
}
|
||||
objects.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>');
|
||||
let pdf='%PDF-1.7\n', offsets=[0];
|
||||
objects.forEach((object,index)=>{offsets.push(Buffer.byteLength(pdf));pdf+=`${index+1} 0 obj\n${object}\nendobj\n`;});
|
||||
const offset=Buffer.byteLength(pdf);pdf+=`xref\n0 10\n0000000000 65535 f \n`;
|
||||
offsets.slice(1).forEach(value=>{pdf+=`${String(value).padStart(10,'0')} 00000 n \n`;});
|
||||
return Buffer.from(pdf+`trailer\n<< /Size 10 /Root 1 0 R >>\nstartxref\n${offset}\n%%EOF\n`);
|
||||
}
|
||||
const pdf=viewerPdf();
|
||||
let role='user', loggedIn=true, deniedId='', pendingRequests=[], errors=[];
|
||||
const browser=await chromium.launch({headless:true});
|
||||
try {
|
||||
const page=await browser.newPage({viewport:{width:1500,height:1050}});
|
||||
page.on('pageerror',error=>errors.push(error.message));
|
||||
await page.route('http://adfs.test/**',async route=>{
|
||||
const url=new URL(route.request().url()); let body,type='application/json',status=200;
|
||||
if(url.pathname==='/api/session'){
|
||||
if(!loggedIn){status=401;body={error:'Anmeldung erforderlich'};}else body={user:'EXAMPLE\\alice',role,expiresAt:9999999999};
|
||||
}else if(url.pathname==='/api/login'){loggedIn=true;body={user:'EXAMPLE\\alice',role,expiresAt:9999999999};}
|
||||
else if(url.pathname==='/api/logout'){loggedIn=false;body={ok:true};}
|
||||
else if(url.pathname==='/api/documents'){
|
||||
pendingRequests.push(url.searchParams.get('q'));
|
||||
const query=(url.searchParams.get('q')||'').toLowerCase(),scope=url.searchParams.get('scope');
|
||||
let rows=fixtures.filter(row=>row.id!==deniedId);
|
||||
if(query)rows=rows.filter(row=>(scope==='name'?row.name:scope==='content'?row.text:row.name+' '+row.text).toLowerCase().includes(query));
|
||||
if(url.searchParams.get('source'))rows=rows.filter(row=>row.sourceId===url.searchParams.get('source'));
|
||||
if(url.searchParams.get('type'))rows=rows.filter(row=>row.extension===url.searchParams.get('type'));
|
||||
body={items:rows,total:rows.length,hasMore:false,sources,types:['pdf','docx','txt','csv'],pending:rows.filter(row=>row.state==='ocr').length};
|
||||
}else if(url.pathname.startsWith('/api/documents/')){
|
||||
const parts=url.pathname.split('/'),row=fixtures.find(row=>row.id===parts[3]);
|
||||
if(!row||row.id===deniedId){status=404;body={error:'Datei nicht verfügbar'};}
|
||||
else if(parts[4]==='preview'){
|
||||
type='image/svg+xml';body=Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="700" height="940" viewBox="0 0 700 940"><rect width="700" height="940" fill="white"/><text x="70" y="100" font-family="Arial" font-size="30">RECHNUNG</text><text x="70" y="160" font-family="Arial" font-size="18">Oktober 2026 · FINANZ742</text><path d="M70 200h560M70 500h560" stroke="#aaa"/><text x="70" y="275" font-family="Arial" font-size="20">Büroausstattung</text><text x="70" y="350" font-family="Arial" font-size="18">3 × Bildschirm</text><text x="480" y="350" font-family="Arial" font-size="18">1.500,00 EUR</text><text x="70" y="560" font-family="Arial" font-size="20">Gesamtbetrag: 1.500,00 EUR</text></svg>`);
|
||||
}else if(parts[4]==='content'){type='application/pdf';body=pdf;}else body=row;
|
||||
}else if(url.pathname.startsWith('/assets/vendor/pdfjs/6.3.289-app1/')){
|
||||
const relative=url.pathname.slice('/assets/vendor/pdfjs/6.3.289-app1/'.length);
|
||||
const filename=root+'vendor/pdfjs/6.3.289-app1/'+relative;
|
||||
type=({'.html':'text/html','.mjs':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.ftl':'text/plain','.json':'application/json','.wasm':'application/wasm','.ttf':'font/ttf'})[path.extname(filename)]||'application/octet-stream';
|
||||
body=fs.readFileSync(filename);
|
||||
}else if(url.pathname==='/assets/pdf-viewer.js'){type='text/javascript';body=fs.readFileSync(root+'pdf-viewer.js');}
|
||||
else if(url.pathname==='/assets/pdf-viewer.css'){type='text/css';body=fs.readFileSync(root+'pdf-viewer.css');}
|
||||
else if(url.pathname==='/assets/portal.js'){type='text/javascript';body=fs.readFileSync(root+'portal.js');}
|
||||
else if(url.pathname==='/assets/styles.css'){type='text/css';body=fs.readFileSync(root+'styles.css');}
|
||||
else {type='text/html';body=fs.readFileSync(root+'portal.html');}
|
||||
await route.fulfill({status,contentType:type,body:Buffer.isBuffer(body)?body:JSON.stringify(body)});
|
||||
});
|
||||
const shot=async name=>{await page.waitForFunction(()=>Array.from(document.images).filter(img=>img.loading!=='lazy'||img.getBoundingClientRect().top<innerHeight).every(img=>img.complete));await page.screenshot({path:path.join(out,name+'.png'),fullPage:!await page.locator('#document-dialog').isVisible()});};
|
||||
const all=()=>page.waitForFunction(()=>document.querySelectorAll('.document-card').length===5);
|
||||
await page.goto('http://adfs.test/');await all();
|
||||
assert.equal(await page.locator('#admin-link').isHidden(),true);
|
||||
assert.equal(await page.locator('nav a[href^="/admin"]').count(),0);
|
||||
assert.equal(await page.locator('#source-filter').textContent().then(text=>text.includes('Mein Private-Ordner')),true);
|
||||
assert.match(await page.locator('#index-progress').innerText(),/1 Datei/);
|
||||
await shot('01-portal-desktop');
|
||||
await page.locator('#search-query').fill('FINANZ742');
|
||||
await page.waitForFunction(()=>document.querySelectorAll('.document-card').length===1);
|
||||
assert.match(await page.locator('.document-card').innerText(),/Rechnung Oktober/);
|
||||
await page.locator('#search-scope').selectOption('content');
|
||||
await page.locator('[data-document]').click();
|
||||
await page.locator('#document-dialog').waitFor({state:'visible'});
|
||||
assert.equal(await page.locator('.document-text, #document-content').count(),0);
|
||||
const viewer=page.frameLocator('#document-pdf-viewer');
|
||||
await viewer.locator('.page canvas').first().waitFor();
|
||||
assert.deepEqual(await page.locator('#document-dialog').boundingBox(),{x:0,y:0,width:1500,height:1050});
|
||||
await viewer.locator('#pageNumber').fill('2');await viewer.locator('#pageNumber').press('Enter');
|
||||
await page.waitForFunction(()=>document.querySelector('#document-pdf-viewer').contentWindow.PDFViewerApplication.page===2);
|
||||
await page.waitForTimeout(2500);
|
||||
assert.equal(await viewer.locator('#pageNumber').inputValue(),'2','Detail polling must preserve the selected page');
|
||||
const scale=await page.evaluate(()=>document.querySelector('#document-pdf-viewer').contentWindow.PDFViewerApplication.pdfViewer.currentScale);
|
||||
await viewer.locator('#zoomInButton').click();
|
||||
await page.waitForFunction(old=>document.querySelector('#document-pdf-viewer').contentWindow.PDFViewerApplication.pdfViewer.currentScale>old,scale);
|
||||
await viewer.locator('#viewFindButton').click();await viewer.locator('#findInput').fill('PDFVIEWER742');
|
||||
await viewer.locator('#findResultsCount').filter({hasText:/3/}).waitFor();
|
||||
assert.equal(await page.locator('#document-download').getAttribute('href'),'/api/documents/'+fixtures[0].id+'/download');
|
||||
await shot('02-portal-document-preview');
|
||||
await page.locator('#document-close').click();
|
||||
await page.locator('#reset-filters').click();await all();
|
||||
await page.locator(`[data-document="${fixtures[1].id}"]`).click();
|
||||
await page.locator('#document-dialog').waitFor({state:'visible'});
|
||||
assert.equal(await page.locator('.document-text, #document-content, #document-pdf-viewer').count(),0);
|
||||
assert.equal(await page.locator('#document-dialog').innerText().then(text=>text.includes('Projektplan mit Schulung')),false);
|
||||
assert.ok((await page.locator('#document-dialog').boundingBox()).height<400,'Files without a visual preview should have a compact dialog');
|
||||
await shot('02b-office-without-document-text');
|
||||
for(const extension of ['md','odt']) {
|
||||
await page.locator('#document-close').click();fixtures[1].extension=extension;
|
||||
await page.locator(`[data-document="${fixtures[1].id}"]`).click();
|
||||
await page.locator('#document-dialog').waitFor({state:'visible'});
|
||||
assert.equal(await page.locator('.document-text, #document-content, #document-pdf-viewer').count(),0);
|
||||
assert.equal(await page.locator('#document-dialog').innerText().then(text=>text.includes('Projektplan mit Schulung')),false);
|
||||
}
|
||||
fixtures[1].extension='docx';
|
||||
await page.locator('#document-close').click();
|
||||
await page.locator('#source-filter').selectOption('private:alice');
|
||||
await page.waitForFunction(()=>document.querySelectorAll('.document-card').length===1);
|
||||
assert.match(await page.locator('.document-card').innerText(),/Meine Notizen/);
|
||||
await page.locator('#reset-filters').click();await all();
|
||||
await page.locator('#view-list').click();
|
||||
assert.equal(await page.locator('#documents').evaluate(element=>element.classList.contains('document-list')),true);
|
||||
await shot('03-portal-list');
|
||||
// A background OCR completion becomes searchable without reloading.
|
||||
fixtures[4].text='Erkanntes Protokoll mit dem Suchwort SCANLIVE742';fixtures[4].snippet=fixtures[4].text;fixtures[4].state='ready';
|
||||
await page.locator('#search-query').fill('SCANLIVE742');
|
||||
await page.waitForFunction(()=>document.querySelectorAll('.document-card').length===1);
|
||||
await page.locator('[data-document]').click();
|
||||
await page.locator('#document-dialog').waitFor({state:'visible'});
|
||||
assert.equal(await page.locator('.document-text, #document-content').count(),0);
|
||||
deniedId=fixtures[4].id;
|
||||
await page.locator('#document-dialog').waitFor({state:'hidden',timeout:6000});
|
||||
assert.match(await page.locator('#toast').innerText(),/nicht verfügbar/);
|
||||
deniedId=''; await page.locator('#reset-filters').click();await all();
|
||||
await page.locator('#view-grid').click();
|
||||
for(const width of [900,390]){
|
||||
await page.setViewportSize({width,height:width===390?844:1000});
|
||||
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'Portal overflows at '+width);
|
||||
await shot('04-portal-'+width);
|
||||
}
|
||||
await page.locator('[data-document]').first().click();
|
||||
await page.locator('#document-dialog').waitFor({state:'visible'});
|
||||
assert.equal(await page.evaluate(()=>document.querySelector('#document-dialog').scrollWidth>document.querySelector('#document-dialog').clientWidth),false);
|
||||
await page.frameLocator('#document-pdf-viewer').locator('.page canvas').first().waitFor();
|
||||
assert.deepEqual(await page.locator('#document-dialog').boundingBox(),{x:0,y:0,width:390,height:844});
|
||||
await shot('05-portal-mobile-document');await page.locator('#document-close').click();
|
||||
await page.locator('#logout').click();await page.locator('#login-view').waitFor({state:'visible'});
|
||||
await page.locator('#login-form input[name=username]').fill('alice');
|
||||
await page.locator('#login-form input[name=password]').fill('password');
|
||||
await page.locator('#login-form button').click();await all();
|
||||
assert.equal(await page.locator('#admin-link').isHidden(),true);
|
||||
role='domain-admin';await page.goto('http://adfs.test/');await all();
|
||||
assert.equal(await page.locator('#admin-link').getAttribute('href'),'/admin/overview');
|
||||
assert.equal(await page.locator('#admin-link').isVisible(),true);
|
||||
assert.ok(pendingRequests.includes('FINANZ742')&&pendingRequests.includes('SCANLIVE742'));
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: user login, admin separation, live content search, own-private filter, preview/download links, OCR refresh, revocation, grid/list, desktop/mobile.');
|
||||
console.log('Screenshots: '+out);
|
||||
} finally {await browser.close();}
|
||||
@@ -1,5 +1,6 @@
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
@@ -346,6 +347,28 @@ class GroupArchiveTests(unittest.TestCase):
|
||||
|
||||
|
||||
class StateSnapshotTests(unittest.TestCase):
|
||||
def test_document_cache_is_omitted_without_changing_originals_or_access_state(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
state = Path(tmpdir) / "state"
|
||||
cache = state / "documents"
|
||||
cache.mkdir(parents=True)
|
||||
(cache / "search.db").write_bytes(b"derived index")
|
||||
original = Path(tmpdir) / "original.pdf"
|
||||
original.write_bytes(b"original PDF bytes")
|
||||
database = state / "shares.db"
|
||||
sqlite3.connect(database).close()
|
||||
(state / "data-xattrs.tdb").write_bytes(b"access records")
|
||||
with mock.patch.dict(os.environ, {"DOCUMENT_STATE_ROOT": str(cache)}):
|
||||
snapshot_root, staged = backup.prepare_state_snapshot(str(state), str(database))
|
||||
try:
|
||||
self.assertFalse((Path(staged) / "documents").exists())
|
||||
self.assertEqual((cache / "search.db").read_bytes(), b"derived index")
|
||||
self.assertEqual(original.read_bytes(), b"original PDF bytes")
|
||||
self.assertEqual((Path(staged) / "data-xattrs.tdb").read_bytes(), b"access records")
|
||||
self.assertTrue((Path(staged) / "shares.db").exists())
|
||||
finally:
|
||||
shutil.rmtree(snapshot_root)
|
||||
|
||||
def test_online_snapshot_includes_wal_commits_and_other_state(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
state_root = os.path.join(tmpdir, "state")
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import contextlib
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from app import access_control as access, document_index, documents, reconcile_shares as directory, web_ui
|
||||
|
||||
ALICE = 'S-1-5-21-1-2-3-1100'
|
||||
BOB = 'S-1-5-21-1-2-3-1101'
|
||||
ADMIN = 'S-1-5-21-1-2-3-1102'
|
||||
IDENTITY = {'sub': 'EXAMPLE\\alice', 'sid': ALICE, 'uid': os.getuid(), 'role': 'user'}
|
||||
|
||||
|
||||
class DocumentFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
root = Path(self.temp.name)
|
||||
self.data = root / 'data'
|
||||
self.private = root / 'private'
|
||||
self.archive = root / 'archive'
|
||||
self.state = root / 'documents'
|
||||
for path in (self.data, self.private, self.archive):
|
||||
path.mkdir()
|
||||
for target, field, value in [(directory,'DB_PATH',str(root/'state.db')),
|
||||
(directory,'GROUP_ROOT',str(self.data)),
|
||||
(directory,'GROUP_ARCHIVE_ROOT',str(self.archive)),
|
||||
(directory,'PRIVATE_ROOT',str(self.private)),
|
||||
(documents,'SEARCH_ROOT',str(self.state)),
|
||||
(documents,'SEARCH_DB',str(self.state/'search.db'))]:
|
||||
patch = mock.patch.object(target,field,value)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
self.policy = directory.open_db()
|
||||
self.addCleanup(self.policy.close)
|
||||
access.ensure_schema(self.policy)
|
||||
access.cache_users(self.policy, {ALICE:{'sam':'alice','name':'Alice'},BOB:{'sam':'bob','name':'Bob'},ADMIN:{'sam':'admin','name':'Admin'}})
|
||||
self.folder_ids = {}
|
||||
for name in ('Finance','Engineering'):
|
||||
path = self.data/name
|
||||
path.mkdir()
|
||||
self.folder_ids[name] = access.create_folder_record(self.policy,name,str(path))
|
||||
self.policy.executemany('INSERT INTO folder_permissions VALUES(?,?,?,?)',[
|
||||
(self.folder_ids['Finance'],'user',ALICE,1),(self.folder_ids['Engineering'],'user',BOB,3)])
|
||||
self.policy.commit()
|
||||
for name in ('alice','bob'):
|
||||
(self.private/name).mkdir()
|
||||
self.write(self.data/'Finance'/'forecast.txt','Forecast apple Umsatz München')
|
||||
self.write(self.data/'Engineering'/'secret.hidden','Classified secret ROBOT42')
|
||||
self.write(self.private/'alice'/'own.txt','Private personal ALICEONLY')
|
||||
self.write(self.private/'bob'/'other.txt','Private personal BOBONLY')
|
||||
self.conn = documents.connect()
|
||||
self.addCleanup(self.conn.close)
|
||||
documents.ensure_schema(self.conn)
|
||||
self.scan()
|
||||
# Native text extraction is independent of parsers; real parsers are covered by E2E.
|
||||
for row in self.conn.execute('SELECT * FROM documents').fetchall():
|
||||
source = self.conn.execute('SELECT * FROM document_sources WHERE id=?',(row['source_id'],)).fetchone()
|
||||
with documents.open_file(source['root'], row['path']) as (handle,_):
|
||||
body = handle.read().decode()
|
||||
self.conn.execute("UPDATE documents SET body=?,state='ready' WHERE id=?",(body,row['id']))
|
||||
self.conn.commit()
|
||||
|
||||
def write(self,path,text):
|
||||
path.parent.mkdir(parents=True,exist_ok=True)
|
||||
path.write_text(text)
|
||||
|
||||
def scan(self):
|
||||
sources,_ = document_index.update_sources(self.conn)
|
||||
for source in sources.values():
|
||||
documents.walk_source(self.conn,source)
|
||||
|
||||
def find(self,params=None,identity=IDENTITY):
|
||||
return documents.search(self.conn,identity,params or {})
|
||||
|
||||
def document(self,name):
|
||||
return dict(self.conn.execute('SELECT * FROM documents WHERE name=?',(name,)).fetchone())
|
||||
|
||||
|
||||
class DocumentTests(DocumentFixture):
|
||||
def test_search_facets_counts_and_snippets_never_expose_other_users_or_hidden_folders(self):
|
||||
value = self.find()
|
||||
self.assertEqual(value['total'],2)
|
||||
self.assertEqual({row['name'] for row in value['items']},{'forecast.txt','own.txt'})
|
||||
self.assertEqual(value['types'],['txt'])
|
||||
self.assertEqual({source['label'] for source in value['sources']},{'Finance','Private'})
|
||||
self.assertEqual(self.find({'q':['BOBONLY']})['total'],0)
|
||||
self.assertEqual(self.find({'q':['secret']})['total'],0)
|
||||
self.assertEqual(self.find({'source':['data:'+self.folder_ids['Engineering']]})['items'],[])
|
||||
|
||||
def test_filename_content_unicode_prefix_and_safe_fts_queries(self):
|
||||
self.assertEqual(self.find({'q':['fore'],'scope':['name']})['total'],1)
|
||||
result = self.find({'q':['Umsatz Munchen'],'scope':['content']})
|
||||
self.assertEqual(result['total'],1)
|
||||
self.assertIn('\x01',result['items'][0]['snippet'])
|
||||
self.assertEqual(self.find({'q':['forecast'],'scope':['content']})['total'],1)
|
||||
for query in ('"', '*', ':', '" OR NOT ()', 'x\x00y'):
|
||||
self.find({'q':[query]})
|
||||
|
||||
def test_preview_download_and_detail_reject_inaccessible_guessed_ids(self):
|
||||
for name in ('secret.hidden','other.txt'):
|
||||
row = self.document(name)
|
||||
for operation in (documents.detail,):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
operation(self.conn,IDENTITY,row['id'])
|
||||
for operation in (documents.download,documents.preview):
|
||||
with self.assertRaises(FileNotFoundError), operation(self.conn,IDENTITY,row['id']):
|
||||
pass
|
||||
|
||||
def test_revoke_and_archive_apply_to_all_endpoints_without_reindexing(self):
|
||||
row = self.document('forecast.txt')
|
||||
with documents.download(self.conn,IDENTITY,row['id']) as (handle,_):
|
||||
self.assertIn(b'Umsatz',handle.read())
|
||||
self.policy.execute('UPDATE folder_permissions SET level=0 WHERE principalId=?',(ALICE,))
|
||||
self.policy.commit()
|
||||
self.assertEqual(self.find()['total'],1)
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
documents.detail(self.conn,IDENTITY,row['id'])
|
||||
self.policy.execute('UPDATE folder_permissions SET level=3 WHERE principalId=?',(ALICE,))
|
||||
self.policy.execute('UPDATE shares SET isActive=0 WHERE objectGUID=?',(self.folder_ids['Finance'],))
|
||||
self.policy.commit()
|
||||
self.assertEqual(self.find()['total'],1)
|
||||
with self.assertRaises(FileNotFoundError), documents.download(self.conn,IDENTITY,row['id']):
|
||||
pass
|
||||
|
||||
def test_admin_sees_all_data_but_only_own_private_and_excluded_accounts_see_nothing(self):
|
||||
admin = {**IDENTITY,'sub':'EXAMPLE\\admin','sid':ADMIN,'role':'domain-admin'}
|
||||
self.assertEqual({row['name'] for row in self.find(identity=admin)['items']},{'forecast.txt','secret.hidden'})
|
||||
for username in ('MSOL_sync','krbtgt'):
|
||||
self.assertEqual(self.find(identity={**IDENTITY,'sub':username})['total'],0)
|
||||
access.cache_users(self.policy,{ALICE:{'sam':'MSOL_sync','name':'Sync'}})
|
||||
self.policy.commit()
|
||||
self.assertEqual(self.find()['total'],0)
|
||||
|
||||
def test_private_owner_must_match_current_unix_identity(self):
|
||||
identity = {**IDENTITY,'uid':os.getuid()+10000}
|
||||
self.assertEqual({row['name'] for row in self.find(identity=identity)['items']},{'forecast.txt'})
|
||||
|
||||
def test_private_read_permissions_filter_counts_types_and_all_file_endpoints(self):
|
||||
row = self.document('own.txt')
|
||||
(self.private/'alice'/'own.txt').chmod(0)
|
||||
self.assertEqual(self.find()['total'],1)
|
||||
self.assertEqual(self.find({'q':['ALICEONLY']})['total'],0)
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
documents.detail(self.conn,IDENTITY,row['id'])
|
||||
for operation in (documents.download,documents.preview):
|
||||
with self.assertRaises(FileNotFoundError), operation(self.conn,IDENTITY,row['id']):
|
||||
pass
|
||||
|
||||
def test_changed_and_deleted_files_cannot_serve_stale_text_preview_or_download(self):
|
||||
row = self.document('forecast.txt')
|
||||
self.write(self.data/'Finance'/'forecast.txt','Completely new revision')
|
||||
self.assertNotIn('forecast.txt',{item['name'] for item in self.find()['items']})
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
documents.detail(self.conn,IDENTITY,row['id'])
|
||||
self.scan()
|
||||
current = self.document('forecast.txt')
|
||||
self.assertEqual(current['id'],row['id'])
|
||||
self.assertEqual(current['body'],'')
|
||||
self.assertEqual(current['state'],'pending')
|
||||
self.assertEqual(self.find({'q':['Umsatz']})['total'],0)
|
||||
(self.data/'Finance'/'forecast.txt').unlink()
|
||||
self.scan()
|
||||
self.assertEqual(self.find()['total'],1)
|
||||
|
||||
def test_symlinks_trash_fifo_and_path_traversal_are_not_indexed_or_opened(self):
|
||||
folder = self.data/'Finance'
|
||||
os.symlink(self.private/'bob'/'other.txt',folder/'linked.txt')
|
||||
os.symlink(self.private/'bob',folder/'linked-directory')
|
||||
os.mkfifo(folder/'fifo')
|
||||
self.write(folder/'.trash'/'deleted.txt','deleted contents')
|
||||
self.scan()
|
||||
self.assertEqual(self.find()['total'],2)
|
||||
for path in ('../alice/own.txt','.trash/deleted.txt','linked.txt','linked-directory/other.txt','fifo','/forecast.txt'):
|
||||
with self.assertRaises((OSError,FileNotFoundError)), documents.open_file(str(folder),path):
|
||||
pass
|
||||
|
||||
def test_queue_phases_retries_and_restart_keep_ocr_work_durable(self):
|
||||
row = self.document('forecast.txt')
|
||||
self.conn.execute("UPDATE documents SET extension='.pdf',state='pending' WHERE id=?",(row['id'],))
|
||||
self.conn.commit()
|
||||
with mock.patch.object(document_index,'run_job',return_value={'body':'native footer','pages':1,'needsOcr':True,'preview':''}):
|
||||
self.assertTrue(document_index.process_next(self.conn))
|
||||
self.assertEqual(self.document('forecast.txt')['state'],'ocr')
|
||||
with mock.patch.object(document_index,'run_job',side_effect=RuntimeError('OCR busy')):
|
||||
self.assertTrue(document_index.process_next(self.conn))
|
||||
queued = self.document('forecast.txt')
|
||||
self.assertEqual(queued['state'],'ocr')
|
||||
self.assertEqual(queued['body'],'native footer')
|
||||
self.assertGreater(queued['retry_at'],time.time())
|
||||
self.conn.execute('UPDATE documents SET retry_at=0 WHERE id=?',(row['id'],))
|
||||
self.conn.commit()
|
||||
with mock.patch.object(document_index,'run_job',return_value={'body':'Recognized invoice OCR742','pages':1,'needsOcr':False,'preview':''}) as job:
|
||||
document_index.process_next(self.conn)
|
||||
self.assertEqual(job.call_args.args[2],'ocr')
|
||||
self.assertEqual(self.find({'q':['OCR742']})['total'],1)
|
||||
|
||||
def test_stale_extraction_result_cannot_overwrite_a_new_version(self):
|
||||
row = self.document('forecast.txt')
|
||||
self.conn.execute("UPDATE documents SET state='pending' WHERE id=?",(row['id'],))
|
||||
self.conn.commit()
|
||||
def concurrent_change(*args):
|
||||
self.write(self.data/'Finance'/'forecast.txt','New content')
|
||||
self.scan()
|
||||
return {'body':'obsolete confidential text','pages':0,'needsOcr':False,'preview':''}
|
||||
with mock.patch.object(document_index,'run_job',side_effect=concurrent_change):
|
||||
document_index.process_next(self.conn)
|
||||
self.assertEqual(self.document('forecast.txt')['body'],'')
|
||||
self.assertEqual(self.document('forecast.txt')['state'],'pending')
|
||||
|
||||
def test_copy_rejects_growth_before_starting_a_parser(self):
|
||||
row = self.document('forecast.txt')
|
||||
source = self.conn.execute('SELECT * FROM document_sources WHERE id=?',(row['source_id'],)).fetchone()
|
||||
original = self.data/'Finance'/'forecast.txt'
|
||||
before = original.read_bytes()
|
||||
@contextlib.contextmanager
|
||||
def growing_file(*args):
|
||||
yield io.BytesIO(before+b'concurrent growth'),original.stat()
|
||||
user = SimpleNamespace(pw_uid=os.getuid(),pw_gid=os.getgid())
|
||||
with mock.patch.object(document_index.pwd,'getpwnam',return_value=user), \
|
||||
mock.patch.object(documents,'open_file',side_effect=growing_file), \
|
||||
mock.patch.object(document_index.subprocess,'Popen') as parser:
|
||||
self.assertIsNone(document_index.run_job(row,source,'text'))
|
||||
parser.assert_not_called()
|
||||
self.assertEqual(original.read_bytes(),before)
|
||||
|
||||
def test_pause_survives_restart_and_resume_preserves_queue_and_search(self):
|
||||
row = self.document('forecast.txt')
|
||||
self.conn.execute("UPDATE documents SET state='ocr' WHERE id=?",(row['id'],))
|
||||
self.conn.commit()
|
||||
value = documents.control_worker(self.conn,'pause','EXAMPLE\\admin')
|
||||
self.assertTrue(value['paused'])
|
||||
with contextlib.closing(documents.connect()) as reopened:
|
||||
self.assertTrue(documents.worker_paused(reopened))
|
||||
with mock.patch.object(document_index,'run_job') as job:
|
||||
self.assertFalse(document_index.process_next(self.conn))
|
||||
job.assert_not_called()
|
||||
self.assertEqual(self.document('forecast.txt')['state'],'ocr')
|
||||
self.assertEqual(self.find({'q':['Umsatz']})['total'],1)
|
||||
self.conn.commit()
|
||||
documents.control_worker(self.conn,'resume','EXAMPLE\\admin')
|
||||
with mock.patch.object(document_index,'run_job',return_value={'body':'Resumed OCR742','pages':1,'needsOcr':False,'preview':''}):
|
||||
self.assertTrue(document_index.process_next(self.conn))
|
||||
self.assertEqual(self.document('forecast.txt')['state'],'ready')
|
||||
self.assertEqual(self.find({'q':['OCR742']})['total'],1)
|
||||
status = documents.worker_snapshot(self.conn)
|
||||
self.assertEqual(status['counts']['complete'],4)
|
||||
self.assertEqual(status['processed'],1)
|
||||
self.assertEqual({event['action'] for event in status['activity']},{'pause','resume','ocr','complete'})
|
||||
with self.assertRaises(ValueError):
|
||||
documents.control_worker(self.conn,'delete','admin')
|
||||
|
||||
def test_interrupting_active_job_keeps_ocr_phase_and_attempt_count(self):
|
||||
row = self.document('forecast.txt')
|
||||
self.conn.execute("UPDATE documents SET state='ocr' WHERE id=?",(row['id'],))
|
||||
self.conn.commit()
|
||||
def pause(*args):
|
||||
documents.control_worker(self.conn,'pause','admin')
|
||||
raise document_index.JobPaused()
|
||||
with mock.patch.object(document_index,'run_job',side_effect=pause):
|
||||
self.assertFalse(document_index.process_next(self.conn))
|
||||
queued = self.document('forecast.txt')
|
||||
self.assertEqual((queued['state'],queued['attempts'],queued['retry_at']),('ocr',0,0))
|
||||
self.assertEqual(documents.worker_snapshot(self.conn)['current'],None)
|
||||
|
||||
def test_interrupted_catalog_scan_never_prunes_existing_records(self):
|
||||
source = self.conn.execute('SELECT * FROM document_sources WHERE id=?',('data:'+self.folder_ids['Finance'],)).fetchone()
|
||||
before = self.document('forecast.txt')
|
||||
self.assertFalse(documents.walk_source(self.conn,source,should_stop=lambda:True))
|
||||
self.assertEqual(self.document('forecast.txt'),before)
|
||||
|
||||
def test_linux_file_events_detect_atomic_replacement_and_delete(self):
|
||||
watcher = document_index.FileEvents()
|
||||
self.addCleanup(watcher.close)
|
||||
watcher.add(str(self.data/'Finance'),'finance')
|
||||
self.write(self.data/'Finance'/'incoming.txt','incoming document')
|
||||
os.rename(self.data/'Finance'/'incoming.txt',self.data/'Finance'/'final.txt')
|
||||
(self.data/'Finance'/'final.txt').unlink()
|
||||
events = watcher.read(timeout=1)
|
||||
self.assertIn(('finance','incoming.txt',False),events)
|
||||
self.assertIn(('finance','final.txt',False),events)
|
||||
|
||||
|
||||
class DocumentHttpTests(DocumentFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.tokens = web_ui.TokenManager('s'*48,600)
|
||||
app = mock.Mock(tokens=self.tokens)
|
||||
app.overview.return_value = {'administrator':True}
|
||||
patches = [mock.patch.object(web_ui,'APP',app),mock.patch.object(web_ui,'STATIC_ROOT',str(Path(__file__).resolve().parents[1]/'app'/'web'))]
|
||||
for patch in patches:
|
||||
patch.start(); self.addCleanup(patch.stop)
|
||||
self.server = web_ui.ReusableHTTPServer(('127.0.0.1',0),web_ui.Handler)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever,daemon=True)
|
||||
self.thread.start()
|
||||
self.addCleanup(self.close_server)
|
||||
|
||||
def close_server(self):
|
||||
self.server.shutdown(); self.server.server_close(); self.thread.join()
|
||||
|
||||
def request(self,path,identity=IDENTITY,method='GET',body=None,extra_headers=None):
|
||||
headers = dict(extra_headers or {})
|
||||
if identity:
|
||||
token,_ = self.tokens.issue(identity['sub'],identity['sid'],identity['role'],identity.get('uid'))
|
||||
headers['Authorization'] = 'Bearer '+token
|
||||
if body is not None:
|
||||
body=json.dumps(body);headers['Content-Type']='application/json'
|
||||
conn=http.client.HTTPConnection(*self.server.server_address)
|
||||
try:
|
||||
conn.request(method,path,body=body,headers=headers)
|
||||
response=conn.getresponse()
|
||||
return response.status,dict(response.headers),response.read()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_http_admin_boundary_covers_all_read_and_mutation_endpoints(self):
|
||||
for path in ('overview','access','trash','storage','report','reconciliation','activity','system','documents/status'):
|
||||
self.assertEqual(self.request('/admin/api/'+path)[0],403)
|
||||
self.assertEqual(self.request('/admin/api/'+path,identity=None)[0],401)
|
||||
for path in ('access','trash/restore','actions/backup','actions/reconciliation','documents/control'):
|
||||
self.assertEqual(self.request('/admin/api/'+path,method='POST',body={})[0],403)
|
||||
self.assertEqual(self.request('/api/'+path,method='POST',body={})[0],404)
|
||||
self.assertEqual(self.request('/api/storage')[0],404)
|
||||
admin = {**IDENTITY,'role':'domain-admin'}
|
||||
self.assertEqual(self.request('/admin/api/overview',identity=admin)[0],200)
|
||||
|
||||
def test_http_admin_worker_status_pause_and_resume(self):
|
||||
admin = {**IDENTITY,'role':'domain-admin'}
|
||||
self.assertEqual(self.request('/admin/api/documents/status',identity=admin)[0],200)
|
||||
status,_,body = self.request('/admin/api/documents/control',identity=admin,method='POST',body={'action':'pause'})
|
||||
self.assertEqual(status,200)
|
||||
self.assertTrue(json.loads(body)['paused'])
|
||||
self.assertEqual(self.request('/api/documents/control',identity=admin,method='POST',body={'action':'resume'})[0],404)
|
||||
self.assertTrue(documents.worker_paused(self.conn))
|
||||
self.assertEqual(self.request('/admin/api/documents/control',identity=admin,method='POST',body={'action':'resume'})[0],200)
|
||||
self.assertFalse(documents.worker_paused(self.conn))
|
||||
|
||||
def test_http_serves_user_shell_admin_shell_and_legacy_redirects(self):
|
||||
self.assertIn(b'portal.js',self.request('/',identity=None)[2])
|
||||
self.assertIn(b'app.js',self.request('/admin/access',identity=None)[2])
|
||||
self.assertEqual(self.request('/access')[1]['Location'],'/admin/access')
|
||||
self.assertEqual(self.request('/shares')[1]['Location'],'/admin/access')
|
||||
self.assertIn(b'"role":"user"',self.request('/api/session')[2])
|
||||
|
||||
def test_http_user_login_succeeds(self):
|
||||
with mock.patch.object(web_ui,'authenticate_user',return_value=IDENTITY):
|
||||
status,headers,body=self.request('/api/login',identity=None,method='POST',body={'username':'alice','password':'good'})
|
||||
self.assertEqual(status,200)
|
||||
self.assertEqual(json.loads(body)['role'],'user')
|
||||
self.assertIn('HttpOnly',headers['Set-Cookie'])
|
||||
|
||||
def test_http_download_original_detail_and_guessed_private_id(self):
|
||||
row=self.document('forecast.txt')
|
||||
status,headers,body=self.request('/api/documents/'+row['id']+'/download')
|
||||
self.assertEqual(status,200)
|
||||
self.assertIn(b'Umsatz',body)
|
||||
self.assertIn('attachment;',headers['Content-Disposition'])
|
||||
self.assertEqual(headers['Content-Type'],'application/octet-stream')
|
||||
for suffix in ('','/download','/preview','/content'):
|
||||
self.assertEqual(self.request('/api/documents/'+self.document('other.txt')['id']+suffix)[0],404)
|
||||
self.assertEqual(self.request('/api/documents',identity=None)[0],401)
|
||||
|
||||
def test_pdf_content_range_requests_recheck_access_and_keep_original_bytes(self):
|
||||
original = self.data/'Finance'/'sample.pdf'
|
||||
data = b'%PDF-1.7\n' + b'original PDF bytes\n'*200
|
||||
original.write_bytes(data)
|
||||
self.scan()
|
||||
row = self.document('sample.pdf')
|
||||
path = '/api/documents/'+row['id']+'/content'
|
||||
status,headers,body = self.request(path)
|
||||
self.assertEqual((status,body),(200,data))
|
||||
self.assertEqual(headers['Content-Type'],'application/pdf')
|
||||
self.assertEqual(headers['Accept-Ranges'],'bytes')
|
||||
self.assertIn('inline;',headers['Content-Disposition'])
|
||||
for value,expected in [('bytes=0-4',data[:5]),('bytes=-9',data[-9:]),('bytes=10-',data[10:]),('bytes=0-999999',data)]:
|
||||
status,headers,body = self.request(path,extra_headers={'Range':value})
|
||||
self.assertEqual(status,206)
|
||||
self.assertEqual(body,expected)
|
||||
self.assertEqual(int(headers['Content-Length']),len(expected))
|
||||
self.assertTrue(headers['Content-Range'].endswith('/'+str(len(data))))
|
||||
for value in ('bytes=999999-','bytes=4-1','bytes=-0','bytes=-','bytes=0-1,3-4','anything'):
|
||||
self.assertEqual(self.request(path,extra_headers={'Range':value})[0],416)
|
||||
self.assertEqual(self.request('/api/documents/'+self.document('forecast.txt')['id']+'/content')[0],404)
|
||||
self.policy.execute('UPDATE folder_permissions SET level=0 WHERE principalId=?',(ALICE,))
|
||||
self.policy.commit()
|
||||
self.assertEqual(self.request(path,extra_headers={'Range':'bytes=0-4'})[0],404)
|
||||
self.assertEqual(original.read_bytes(),data)
|
||||
|
||||
def test_pdfjs_assets_are_local_and_paths_cannot_escape_the_vendor_directory(self):
|
||||
prefix='/assets/vendor/pdfjs/6.3.289-app1/'
|
||||
status,headers,body = self.request(prefix+'web/viewer.html',identity=None)
|
||||
self.assertEqual(status,200)
|
||||
self.assertIn(b'/assets/pdf-viewer.js',body)
|
||||
self.assertIn("frame-ancestors 'self'",headers['Content-Security-Policy'])
|
||||
self.assertNotIn("'unsafe-eval'",headers['Content-Security-Policy'])
|
||||
self.assertIn("frame-ancestors 'none'",self.request('/')[1]['Content-Security-Policy'])
|
||||
self.assertEqual(self.request(prefix+'build/pdf.worker.mjs')[1]['Content-Type'],'text/javascript; charset=utf-8')
|
||||
for path in ('../LICENSE','%2e%2e/LICENSE','web/%2e%2e/../index.html','web/not-present.js'):
|
||||
self.assertEqual(self.request(prefix+path)[0],404)
|
||||
+52
-6
@@ -17,7 +17,7 @@ class TokenManagerTests(unittest.TestCase):
|
||||
def test_issues_and_verifies_short_lived_admin_jwt(self):
|
||||
manager = web_ui.TokenManager("s" * 48, 600)
|
||||
|
||||
token, expires = manager.issue("EXAMPLE\\alice")
|
||||
token, expires = manager.issue("EXAMPLE\\alice", "S-1-5-21-1-2-3-1100", "domain-admin")
|
||||
payload = manager.verify(token)
|
||||
|
||||
self.assertEqual(payload["sub"], "EXAMPLE\\alice")
|
||||
@@ -26,7 +26,7 @@ class TokenManagerTests(unittest.TestCase):
|
||||
|
||||
def test_rejects_tampered_jwt(self):
|
||||
manager = web_ui.TokenManager("s" * 48, 600)
|
||||
token, _ = manager.issue("EXAMPLE\\alice")
|
||||
token, _ = manager.issue("EXAMPLE\\alice", "S-1-5-21-1-2-3-1100", "domain-admin")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Invalid or expired"):
|
||||
manager.verify(f"{token[:-1]}x")
|
||||
@@ -290,6 +290,8 @@ class DomainAuthenticationTests(unittest.TestCase):
|
||||
mock.Mock(returncode=0, stdout=""),
|
||||
mock.Mock(returncode=0, stdout="S-1-5-21-1-2-3-1100 SID_USER (1)\n"),
|
||||
mock.Mock(returncode=0, stdout="S-1-5-21-1-2-3-1100\nS-1-5-21-1-2-3-512\n"),
|
||||
mock.Mock(returncode=0, stdout="EXAMPLE\\alice 1\n"),
|
||||
mock.Mock(returncode=0, stdout="11100\n"),
|
||||
]
|
||||
|
||||
result = web_ui.authenticate_domain_admin("alice", "p@ss word")
|
||||
@@ -298,6 +300,50 @@ class DomainAuthenticationTests(unittest.TestCase):
|
||||
self.assertEqual(run.call_args_list[0].args[0], ["kinit", "alice@EXAMPLE.COM"])
|
||||
self.assertNotIn("p@ss word", run.call_args_list[0].args[0])
|
||||
|
||||
@mock.patch.dict(os.environ, {'WORKGROUP':'EXAMPLE','REALM':'EXAMPLE.COM','DOMAIN_ADMINS_SID':'S-1-5-21-1-2-3-512'})
|
||||
@mock.patch('app.web_ui.subprocess.run')
|
||||
def test_all_human_domain_users_can_authenticate_without_admin_membership(self, run):
|
||||
run.side_effect = [mock.Mock(returncode=0,stdout=''),
|
||||
mock.Mock(returncode=0,stdout='S-1-5-21-1-2-3-1100 SID_USER (1)'),
|
||||
mock.Mock(returncode=0,stdout='S-1-5-21-1-2-3-513'),
|
||||
mock.Mock(returncode=0,stdout='EXAMPLE\\alice 1'),
|
||||
mock.Mock(returncode=0,stdout='11100')]
|
||||
result = web_ui.authenticate_user('alice','password')
|
||||
self.assertEqual(result,{'sub':'EXAMPLE\\alice','sid':'S-1-5-21-1-2-3-1100','uid':11100,'role':'user'})
|
||||
|
||||
@mock.patch.dict(os.environ, {'WORKGROUP':'EXAMPLE','REALM':'EXAMPLE.COM','DOMAIN':'example.com','DOMAIN_ADMINS_SID':'S-1-5-21-1-2-3-512'})
|
||||
@mock.patch('app.web_ui.subprocess.run')
|
||||
def test_login_formats_resolve_the_same_identity_and_role(self, run):
|
||||
for name in ('alice','EXAMPLE\\alice','example\\alice','alice@example.com','alice@EXAMPLE.COM','alice@EXAMPLE'):
|
||||
with self.subTest(name=name):
|
||||
run.reset_mock()
|
||||
run.side_effect = [mock.Mock(returncode=0,stdout=''),
|
||||
mock.Mock(returncode=0,stdout='S-1-5-21-1-2-3-1100 SID_USER (1)'),
|
||||
mock.Mock(returncode=0,stdout='S-1-5-21-1-2-3-513'),
|
||||
mock.Mock(returncode=0,stdout='EXAMPLE\\alice 1'),
|
||||
mock.Mock(returncode=0,stdout='11100')]
|
||||
value = web_ui.authenticate_user(name,'password')
|
||||
self.assertEqual(value['sub'],'EXAMPLE\\alice')
|
||||
self.assertEqual(value['role'],'user')
|
||||
self.assertEqual(run.call_args_list[0].args[0],['kinit','alice@EXAMPLE.COM'])
|
||||
self.assertEqual(run.call_args_list[1].args[0],['wbinfo','--name-to-sid','EXAMPLE\\alice'])
|
||||
run.reset_mock()
|
||||
for name in ('alice@other.example','OTHER\\alice','MSOL_sync@example.com','krbtgt@example.com'):
|
||||
self.assertIsNone(web_ui.authenticate_user(name,'password'))
|
||||
run.assert_not_called()
|
||||
|
||||
@mock.patch.dict(os.environ, {'WORKGROUP':'EXAMPLE','REALM':'EXAMPLE.COM','DOMAIN_ADMINS_SID':'S-1-5-21-1-2-3-512'})
|
||||
@mock.patch('app.web_ui.subprocess.run')
|
||||
def test_system_accounts_rejected_even_when_resolved_from_an_alias(self, run):
|
||||
for username in ('MSOL_sync','EXAMPLE\\krbtgt'):
|
||||
self.assertIsNone(web_ui.authenticate_user(username,'password'))
|
||||
run.assert_not_called()
|
||||
run.side_effect = [mock.Mock(returncode=0,stdout=''),
|
||||
mock.Mock(returncode=0,stdout='S-1-5-21-1-2-3-1100 SID_USER (1)'),
|
||||
mock.Mock(returncode=0,stdout='S-1-5-21-1-2-3-513'),
|
||||
mock.Mock(returncode=0,stdout='EXAMPLE\\MSOL_sync 1')]
|
||||
self.assertIsNone(web_ui.authenticate_user('other-name','password'))
|
||||
|
||||
|
||||
class DirectoryManagedFolderTests(unittest.TestCase):
|
||||
def test_tree_reads_individual_assignments_without_ad_group_queries(self):
|
||||
@@ -1202,8 +1248,8 @@ class WebPresentationTests(unittest.TestCase):
|
||||
self.assertNotIn("nav-group", html)
|
||||
self.assertIn('>Datenbelegung</a>', html)
|
||||
self.assertIn('>Benutzerbelegung</a>', html)
|
||||
self.assertIn('href="/reconciliation" data-route="reconciliation">Freigabenabgleich</a>', html)
|
||||
self.assertIn('href="/report" data-route="report">PDF-Bericht</a>', html)
|
||||
self.assertIn('href="/admin/reconciliation" data-route="reconciliation">Freigabenabgleich</a>', html)
|
||||
self.assertIn('href="/admin/report" data-route="report">PDF-Bericht</a>', html)
|
||||
self.assertNotIn("brand-mark", html)
|
||||
self.assertNotIn("eyebrow", html + script)
|
||||
self.assertIn("getUTCHours()", script)
|
||||
@@ -1218,9 +1264,9 @@ class WebPresentationTests(unittest.TestCase):
|
||||
self.assertIn("/activity/fslogix", html)
|
||||
self.assertIn("/api/fslogix-activity", script)
|
||||
self.assertIn('renderActivity("fslogix")', script)
|
||||
self.assertIn('href="/trash" data-route="trash">Papierkorb</a>', html)
|
||||
self.assertIn('href="/admin/trash" data-route="trash">Papierkorb</a>', html)
|
||||
self.assertIn("/api/trash?", script)
|
||||
self.assertIn("/api/trash/download?id=", script)
|
||||
self.assertIn("/admin/api/trash/download?id=", script)
|
||||
self.assertIn('api("/api/trash/restore"', script)
|
||||
self.assertIn("Herunterladen", script)
|
||||
self.assertIn("Wiederherstellen", script)
|
||||
|
||||
Reference in New Issue
Block a user