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
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
139 lines
5.3 KiB
TypeScript
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'));
|
|
});
|
|
});
|