This commit is contained in:
Ludwig Lehnert
2026-10-03 13:58:22 +00:00
parent 3075d951ba
commit 98b08f6e57
431 changed files with 210848 additions and 60 deletions
+9 -9
View File
@@ -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();
+79
View File
@@ -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();}
+162
View File
@@ -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();}
+23
View File
@@ -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")
+407
View File
@@ -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
View File
@@ -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)