Opening a pond landed on whatever sorted first in the sidebar — stable, but a rule nobody could see, and one whose target moved as soon as someone added a page ahead of it. New ponds landed on the empty-pond hint instead of anything useful. - `startPageId` joins the pond settings. No migration: `Pond.settings` is already jsonb. It stores an id, not a slug, so renaming or moving the page keeps it working. - `PondHomePage` prefers it, but only when the page is in this user's page list. That list already holds just what they may see, so a start page hidden by a page-scoped grant — or trashed — falls back silently instead of landing them on a 404, and it costs no extra request. - Both creation paths give the pond a start page, titled from the creator's stored locale. It happens after the creating transaction commits: the owner's grant is written inside it and permissions cache per pond, so creating the page any earlier would ask about rights the grant has not published yet. A failure is logged, not fatal — a pond without a start page still works. `PagesModule` imported `PondsModule` without using it. Removing that vestigial edge let PondsModule depend on PagesModule in the honest direction instead of tying the two together with forwardRef. Every pond created through the api now owns a page, which broke eight suites whose teardown deleted ponds directly — `Page.pond` deliberately has no cascade, because a real purge removes contents explicitly and audits it. A shared `deletePondsWhere` helper deletes pages first. Two tests that counted pages now account for the start page rather than pretending the pond began empty.
139 lines
5.4 KiB
TypeScript
139 lines
5.4 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, deletePondsWhere, 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 deletePondsWhere(prisma, { 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'));
|
|
});
|
|
});
|