Mitarbeiter-Zugang: Review-Punkte (fail closed, Kunden-Konto bleibt, Probelauf robuster)

- removeEmployeeEntry: nur 404 heisst "kein Konto", jeder andere Fehler bricht ab
- Mitarbeiter loeschen (Admin-Panel): Konto ohne Mitarbeiter-Label (Kunde mit
  altem employees-Eintrag) bleibt bestehen, nur der Eintrag geht
- Mitarbeiter bearbeiten gibt nie automatisch das Label (der Eintrag kann von
  einem Kunden stammen); nur Neuanlage bekommt "mitarbeiter"
- --rechte: --offen-lassen <ids>, unlesbare Listen als Fehler statt Abbruch
  (--anwenden verweigert dann), --sicherung <datei> + docker-cp-Hinweis,
  Bucket-Einstellung transformations wird mitgeschickt
- getEmployeeByEmail entfernt (unbenutzt)
- Tests: write/users-unverified, zweiter Lauf idempotent, >100 Dokumente,
  Loeschen mit Kunden- und Mitarbeiter-Konto

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-03 11:00:48 +00:00
1 parent 2659385205
commit 10b393687c
6 files changed
+139 -40

No files matched your search

+73 -10
View File
@@ -17,7 +17,7 @@ const {
staffOnlyPermissions,
} = await import('../server/services/staffAccessAdmin.js')
const { isStaffAccount, isAdminAccount } = await import('../server/services/staffLabels.js')
const { createEmployeeWithLogin, updateEmployeeWithLogin } = await import('../server/services/employeeAdmin.js')
const { createEmployeeWithLogin, deleteEmployeeWithLogin, updateEmployeeWithLogin } = await import('../server/services/employeeAdmin.js')
after(() => Promise.all([aw.close(), st.close()]))
const STAFF = ['label:mitarbeiter', 'label:admin']
@@ -44,6 +44,15 @@ test('staffOnlyPermissions: any/guests/users -> Mitarbeiter-Labels, andere Rolle
)
assert.deepEqual(staffOnlyPermissions(['read("label:mitarbeiter")', 'read("team:x")']), ['read("label:mitarbeiter")', 'read("team:x")'])
assert.deepEqual(staffOnlyPermissions([]), [])
assert.deepEqual(staffOnlyPermissions(['write("users")', 'read("users/unverified")']), [
...STAFF.map((r) => `write("${r}")`),
...STAFF.map((r) => `read("${r}")`),
])
})
test('staffOnlyPermissions: zweiter Lauf aendert nichts mehr', () => {
const once = staffOnlyPermissions(['read("users")', 'create("any")', 'delete("user:u1")'])
assert.deepEqual(staffOnlyPermissions(once), once)
})
// Nachbau der Appwrite-Verwaltungs-API fuer Rechte (Datenbanken, Collections, Dokumente, Buckets, Dateien)
@@ -62,7 +71,7 @@ function fakeAdminApi() {
allowedFileExtensions: ['pdf'], compression: 'none', encryption: true, antivirus: true, $permissions: ['read("users")', 'create("users")'],
}
const writes = []
const request = async (path, { method = 'GET', body } = {}) => {
const request = async (path, { method = 'GET', body, queries = [] } = {}) => {
if (method !== 'GET') {
if (path.endsWith('/collections/workorders') && body.name === undefined) throw new Error('name fehlt')
if (path.startsWith('/storage/') && method === 'PUT' && path.endsWith('/broken')) throw new Error('missing scope (buckets.write)')
@@ -71,16 +80,22 @@ function fakeAdminApi() {
}
if (path === '/databases') return { databases: [{ $id: 'woms-database' }] }
if (path === '/databases/woms-database/collections') return { collections: Object.values(collections) }
if (path === '/databases/woms-database/collections/notes/documents') return { documents }
if (path === '/databases/woms-database/collections/notes/documents') {
// Seiten zu 100 per cursorAfter wie Appwrite
const cursor = queries.map((q) => (typeof q === 'string' ? JSON.parse(q) : q)).find((q) => q.method === 'cursorAfter')?.values[0]
const start = cursor ? documents.findIndex((d) => d.$id === cursor) + 1 : 0
return { documents: documents.slice(start, start + 100) }
}
if (path === '/storage/buckets') return { buckets: [bucket, { ...bucket, $id: 'broken', name: 'Kaputt' }] }
throw new Error(`unerwartet ${path}`)
}
return { request, writes }
return { request, writes, documents }
}
test('planPermissionChanges: offene Collections, Dokumente und Buckets, geschlossene nicht', async () => {
const api = fakeAdminApi()
const changes = await planPermissionChanges(api.request)
const { changes, errors } = await planPermissionChanges(api.request)
assert.deepEqual(errors, [])
assert.deepEqual(changes.map((c) => `${c.kind}:${c.id}`), ['collection:workorders', 'document:n2', 'bucket:legal-files', 'bucket:broken'])
const tickets = changes[0]
assert.deepEqual(tickets.before, ['read("users")', 'update("users")'])
@@ -88,9 +103,28 @@ test('planPermissionChanges: offene Collections, Dokumente und Buckets, geschlos
assert.deepEqual(tickets.settings, { name: 'Work Orders', documentSecurity: false, enabled: true })
})
test('planPermissionChanges: liest ueber 100 Dokumente seitenweise, offen-lassen und unlesbare Listen', async () => {
const api = fakeAdminApi()
const many = Array.from({ length: 230 }, (_, i) => ({ $id: `d${String(i).padStart(3, '0')}`, $permissions: ['read("users")'] }))
api.documents.splice(0, api.documents.length, ...many)
const all = await planPermissionChanges(api.request)
assert.equal(all.changes.filter((c) => c.kind === 'document').length, 230)
const kept = await planPermissionChanges(api.request, { keep: ['notes', 'legal-files'] })
assert.deepEqual(kept.changes.map((c) => `${c.kind}:${c.id}`), ['collection:workorders', 'bucket:broken'])
const failing = async (path, options) => {
if (path === '/storage/buckets') throw new Error('missing scope (buckets.read)')
return api.request(path, options)
}
const partial = await planPermissionChanges(failing)
assert.deepEqual(partial.errors, [{ path: '/storage/buckets', error: 'missing scope (buckets.read)' }])
assert.ok(partial.changes.some((c) => c.id === 'workorders'))
})
test('applyPermissionChanges: schreibt mit allen Einstellungen, meldet Fehler je Stelle, Ruecknahme schreibt before', async () => {
const api = fakeAdminApi()
const changes = await planPermissionChanges(api.request)
const { changes } = await planPermissionChanges(api.request)
const results = await applyPermissionChanges(changes, { request: api.request })
assert.deepEqual(results.map((r) => r.ok), [true, true, true, false])
assert.match(results[3].error, /buckets\.write/)
@@ -155,6 +189,33 @@ test('removeEmployeeEntry: loescht nur den Eintrag eines Kunden, nie das Konto;
assert.equal(aw.docs('employees').has('e_m2'), true)
})
test('removeEmployeeEntry: Konto-Abruf gestoert -> Abbruch, Eintrag bleibt (fail closed)', async () => {
aw.seed('employees', { $id: 'e_m3', userId: 'u_m2', displayName: 'M2' })
aw.failUserLookups(true)
try {
await assert.rejects(removeEmployeeEntry('e_m3'))
} finally {
aw.failUserLookups(false)
}
assert.equal(aw.docs('employees').has('e_m3'), true)
})
test('Mitarbeiter loeschen (Admin-Panel): Eintrag eines Kunden weg, sein Konto bleibt; Mitarbeiter-Konto wird geloescht', async () => {
aw.addUser({ $id: 'u_k3', email: 'k3@example.invalid', name: 'K3' })
aw.seed('employees', { $id: 'e_k3', userId: 'u_k3', displayName: 'K3' })
const res = await deleteEmployeeWithLogin('e_k3', 'u_kenso')
assert.equal(res.accountKept, true)
assert.equal(aw.docs('employees').has('e_k3'), false)
assert.ok(aw.user('u_k3'))
assert.ok(!aw.userCalls().includes('delete:u_k3'))
aw.addUser({ $id: 'u_m4', email: 'm4@webklar.com', name: 'M4', labels: ['mitarbeiter'] })
aw.seed('employees', { $id: 'e_m4', userId: 'u_m4', displayName: 'M4' })
await deleteEmployeeWithLogin('e_m4', 'u_kenso')
assert.equal(aw.user('u_m4'), null)
assert.ok(aw.userCalls().includes('delete:u_m4'))
})
test('Mitarbeiter anlegen: Label mitarbeiter (+ admin), Bearbeiten ergaenzt es und laesst admin ohne Angabe stehen', async () => {
const { employee } = await createEmployeeWithLogin({ displayName: 'Neu', email: 'neu@webklar.com', password: 'neu-neu-neu', isAdmin: false })
assert.deepEqual(aw.user(employee.userId).labels, ['mitarbeiter'])
@@ -163,11 +224,13 @@ test('Mitarbeiter anlegen: Label mitarbeiter (+ admin), Bearbeiten ergaenzt es u
assert.deepEqual(aw.user(chef.userId).labels.sort(), ['admin', 'mitarbeiter'])
assert.equal(chef.isAdmin, true)
// Altbestand ohne Label: jede Bearbeitung holt es nach, admin bleibt ohne Angabe unveraendert
aw.addUser({ $id: 'u_alt', email: 'alt@webklar.com', name: 'Alt', labels: ['admin'] })
// Bearbeiten gibt nie automatisch Zugang (der Eintrag kann von einem Kunden stammen); admin nur auf Angabe
aw.addUser({ $id: 'u_alt', email: 'alt@webklar.com', name: 'Alt', labels: [] })
aw.seed('employees', { $id: 'e_alt', userId: 'u_alt', displayName: 'Alt', email: 'alt@webklar.com' })
await updateEmployeeWithLogin('e_alt', { shortcode: 'ALT' }, 'u_kenso')
assert.deepEqual(aw.user('u_alt').labels.sort(), ['admin', 'mitarbeiter'])
assert.deepEqual(aw.user('u_alt').labels, [])
await updateEmployeeWithLogin('e_alt', { isAdmin: true }, 'u_kenso')
assert.deepEqual(aw.user('u_alt').labels, ['admin'])
await updateEmployeeWithLogin('e_alt', { isAdmin: false }, 'u_kenso')
assert.deepEqual(aw.user('u_alt').labels, ['mitarbeiter'])
assert.deepEqual(aw.user('u_alt').labels, [])
})
+6
View File
@@ -236,6 +236,12 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
userCalls.push(`create:${userId}`)
return [201, publicUser(users.get(userId))]
}
const deleteUserPath = url.pathname.match(/^\/v1\/users\/([^/]+)$/)
if (deleteUserPath && req.method === 'DELETE') {
if (!users.delete(deleteUserPath[1])) return [404, { message: 'User not found', code: 404, type: 'user_not_found' }]
userCalls.push(`delete:${deleteUserPath[1]}`)
return [204, null]
}
const labelsPath = url.pathname.match(/^\/v1\/users\/([^/]+)\/labels$/)
if (labelsPath && req.method === 'PUT') {
const user = users.get(labelsPath[1])