dorfteich/apps/api/src/admin/user-admin.e2e.db.test.ts
Claude Opus 4.8 42e97b9df2
Some checks failed
CD / Build and push images (push) Successful in 3m12s
CI / Lint, typecheck, test (push) Failing after 2m29s
CI / Auth e2e pack (push) Successful in 3m32s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
Add Site-Admin user management (#59)
Instance operators get basic user administration for support, abuse handling,
and GDPR groundwork (security.md §Privacy).

- api `admin/`: Site-Admin-gated `/admin/users` — a searchable, paginated list
  (username, e-mail, status, role, pond count, last login) plus lifecycle
  actions: disable/enable (a disabled user is logged out everywhere and login
  is refused with the distinct `account_disabled`), resend verification, delete,
  and grant/revoke Site Admin. Guards: you cannot act on your own account
  (`cannot_modify_self`) and the last Site Admin cannot be dropped
  (`last_site_admin`). Every action is audit-logged with the actor.
- `PseudonymizationService`: account deletion scrubs the PII, removes all login
  identities + sessions, and trashes the personal pond — the kept row is what
  authorship references, so shared content the user authored shows as "Deleted
  user" (no orphaned/cascaded content).
- web: the Admin area gains a 'Users' surface — search, pagination, and the
  actions (destructive ones behind an inline two-step confirm; self-actions
  hidden). New `users` i18n namespace (de+en).
- tests: `user-admin.e2e.db.test.ts` (disable → logout + login blocked; delete
  → pseudonymized authorship + personal pond trashed + credentials gone; last
  Site Admin and self protected; Site-Admin gating); a non-destructive browser
  `admin-users` pack proving disable-in-UI blocks login and enable restores it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 00:31:41 +02:00

139 lines
5.3 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import { AdminUserListView } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Site-Admin user management end to end (issue #59): disable logs a user out
* and blocks login; delete pseudonymizes authorship and trashes the personal
* pond; the last Site Admin and one's own account are protected.
*/
describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'nutzerverwaltung ist ernst 1';
const ids: Record<string, string> = {};
const cookies: Record<string, string> = {};
const api = () => request(app.getHttpServer());
async function makeUser(handle: string, siteAdmin: boolean): Promise<void> {
const users = app.get(UsersService);
const username = `ua-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `UA ${handle}`,
password,
locale: 'en',
});
await users.markEmailVerified(user.id);
if (siteAdmin)
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
ids[handle] = user.id;
cookies[handle] = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
await makeUser('admin1', true);
await makeUser('admin2', true);
await makeUser('bob', false);
// Bob gets a personal pond (trashed on delete).
await app
.get(PondsService)
.ensurePersonalPond(await prisma.user.findUniqueOrThrow({ where: { id: ids.bob! } }));
});
afterAll(async () => {
const all = Object.values(ids);
await prisma.session.deleteMany({ where: { userId: { in: all } } });
await prisma.pond.deleteMany({ where: { ownerId: { in: all } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
await prisma.user.deleteMany({ where: { id: { in: all } } });
await prisma.$disconnect();
await app.close();
});
it('lists and searches users (Site-Admin only)', async () => {
const res = await api()
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)
.set('Cookie', cookies.admin1!)
.expect(200);
const body = res.body as AdminUserListView;
expect(body.users.map((u) => u.id)).toContain(ids.bob);
await api().get('/api/v1/admin/users').set('Cookie', cookies.bob!).expect(403);
});
it('disabling logs the user out everywhere and blocks login', async () => {
await api()
.patch(`/api/v1/admin/users/${ids.bob}/disabled`)
.set('Cookie', cookies.admin1!)
.send({ disabled: true })
.expect(200);
// Existing session is gone…
await api().get('/api/v1/auth/me').set('Cookie', cookies.bob!).expect(401);
// …and login is refused with a distinct code.
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: `ua-bob-${suffix}`, password })
.expect(403)
.expect((r) => expect((r.body as { code: string }).code).toBe('account_disabled'));
await api()
.patch(`/api/v1/admin/users/${ids.bob}/disabled`)
.set('Cookie', cookies.admin1!)
.send({ disabled: false })
.expect(200);
});
it('deleting pseudonymizes authorship and trashes the personal pond', async () => {
await api().delete(`/api/v1/admin/users/${ids.bob}`).set('Cookie', cookies.admin1!).expect(204);
const user = await prisma.user.findUniqueOrThrow({ where: { id: ids.bob! } });
expect(user.displayName).toBe('Deleted user');
expect(user.username).toBe(`deleted-${ids.bob}`);
expect(user.status).toBe('DISABLED');
const personal = await prisma.pond.findFirstOrThrow({
where: { ownerId: ids.bob!, type: 'PERSONAL' },
});
expect(personal.deletedAt).not.toBeNull();
// Credentials are gone → login impossible even with the old password.
expect(await prisma.userIdentity.count({ where: { userId: ids.bob! } })).toBe(0);
});
it("protects the last Site Admin and one's own account", async () => {
// Revoke admin2 → fine (admin1 remains).
await api()
.patch(`/api/v1/admin/users/${ids.admin2}/site-admin`)
.set('Cookie', cookies.admin1!)
.send({ isSiteAdmin: false })
.expect(200);
// Now admin1 is the last → cannot revoke self, and self-action is blocked anyway.
await api()
.patch(`/api/v1/admin/users/${ids.admin1}/site-admin`)
.set('Cookie', cookies.admin1!)
.send({ isSiteAdmin: false })
.expect(400)
.expect((r) => expect((r.body as { code: string }).code).toBe('cannot_modify_self'));
await api()
.delete(`/api/v1/admin/users/${ids.admin1}`)
.set('Cookie', cookies.admin1!)
.expect(400)
.expect((r) => expect((r.body as { code: string }).code).toBe('cannot_modify_self'));
});
});