feat(backend): local accounts + invites for Gemeinde Teamer
Per the updated plan, Gemeinde Teamer are no longer Authentik-backed; they
are local accounts a Gemeinde Verantwortliche/r provisions per KC.
Schema:
- User.authentikSub now nullable; add passwordHash + kcId (cascade from Kc)
so one User model covers Authentik members and local Teamer.
- new TeamerInvite model: shareable group link (email null, maxUses null)
or personal invite (email pinned, single use), with expiry + revoke.
- sync log now also replicates User / Membership / TeamerInvite.
Auth:
- TeamAuthService: bcrypt password login (POST /auth/team-login) and invite
redemption (POST /auth/teamer/register) issuing a JWT signed with
TEAM_JWT_SECRET, payload typ:"team".
- TeamJwtStrategy (AuthGuard('team')) resolves it to the same
AuthenticatedUser shape as AuthentikStrategy.
- TokenVerificationService.verifyEither() also accepts team tokens (WS).
- files + chat read endpoints accept 'team' tokens; Teamer see non-Konfi
files and can use chat / start DMs.
Teamer admin (teamer/ module, under /gemeinde/:gemeindeId):
- POST/GET teamer, DELETE teamer/:userId
- POST/GET teamer-invites, DELETE teamer-invites/:inviteId
- LT may manage any Gemeinde; a Verantwortliche/r only their own
(checked in TeamerService, since RolesGuard only scopes by kcId).
Tests: TeamAuthService + TeamerService specs added (Prisma/Sync mocked),
npm test green at 32. Docs (plan + backend README) updated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { TeamerService } from './teamer.service';
|
||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||
|
||||
/// Focus: the Gemeinde-scope check (assertCanManage) and the create/invite
|
||||
/// branching. Prisma + SyncService faked in memory.
|
||||
|
||||
const GEMEINDE = { id: 'gem-1', name: 'Nord', kcId: 'kc-1', createdAt: new Date() };
|
||||
|
||||
function caller(memberships: AuthenticatedUser['memberships']): AuthenticatedUser {
|
||||
return { userId: 'caller-1', authentikSub: 'sub-1', email: 'c@example.org', memberships };
|
||||
}
|
||||
const LT = caller([{ kcId: 'kc-1', gemeindeId: null, role: Role.LEITUNGSTEAM }]);
|
||||
const VERANTW_GEM1 = caller([
|
||||
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
|
||||
]);
|
||||
const VERANTW_GEM2 = caller([
|
||||
{ kcId: 'kc-1', gemeindeId: 'gem-2', role: Role.GEMEINDE_VERANTWORTLICHER },
|
||||
]);
|
||||
|
||||
function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?: string[] } = {}) {
|
||||
const gemeinde = opts.gemeinde === undefined ? GEMEINDE : opts.gemeinde;
|
||||
const emails = new Set(opts.existingEmails ?? []);
|
||||
const created: Record<string, unknown> = {};
|
||||
|
||||
const prisma = {
|
||||
gemeinde: { findUnique: jest.fn().mockResolvedValue(gemeinde) },
|
||||
user: {
|
||||
findUnique: jest.fn(({ where }: { where: { email: string } }) =>
|
||||
Promise.resolve(emails.has(where.email) ? { id: 'dup', email: where.email } : null),
|
||||
),
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
|
||||
created.user = { id: 'u-1', createdAt: new Date(), ...data };
|
||||
return Promise.resolve(created.user);
|
||||
}),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'u-1' }),
|
||||
},
|
||||
membership: {
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
|
||||
created.membership = { id: 'm-1', ...data };
|
||||
return Promise.resolve(created.membership);
|
||||
}),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
teamerInvite: {
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({ id: 'inv-1', usedCount: 0, revokedAt: null, ...data }),
|
||||
),
|
||||
},
|
||||
};
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new TeamerService(prisma as never, sync as never);
|
||||
return { service, prisma, sync, created };
|
||||
}
|
||||
|
||||
describe('TeamerService scope check', () => {
|
||||
it('404s when the Gemeinde does not exist', async () => {
|
||||
const { service } = makeService({ gemeinde: null });
|
||||
await expect(service.listTeamer(LT, 'gem-x')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('lets the Leitungsteam manage any Gemeinde', async () => {
|
||||
const { service, prisma } = makeService();
|
||||
await expect(service.listTeamer(LT, 'gem-1')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('lets a Verantwortliche/r manage their own Gemeinde', async () => {
|
||||
const { service, prisma } = makeService();
|
||||
await expect(service.listTeamer(VERANTW_GEM1, 'gem-1')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('forbids a Verantwortliche/r from managing a different Gemeinde', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(service.listTeamer(VERANTW_GEM2, 'gem-1')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamerService.createTeamer', () => {
|
||||
it('rejects a duplicate email', async () => {
|
||||
const { service } = makeService({ existingEmails: ['dup@example.org'] });
|
||||
await expect(
|
||||
service.createTeamer(VERANTW_GEM1, 'gem-1', {
|
||||
firstName: 'A',
|
||||
lastName: 'B',
|
||||
email: 'dup@example.org',
|
||||
password: 'password1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('creates a hashed local account + GEMEINDE_TEAMER membership and hides the hash', async () => {
|
||||
const { service, created, sync } = makeService();
|
||||
const res = await service.createTeamer(VERANTW_GEM1, 'gem-1', {
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lo',
|
||||
email: 'Ada@Example.org',
|
||||
password: 'password1',
|
||||
});
|
||||
|
||||
expect(res).not.toHaveProperty('passwordHash');
|
||||
expect(res.email).toBe('ada@example.org');
|
||||
expect((created.user as { kcId: string }).kcId).toBe('kc-1');
|
||||
expect(
|
||||
await bcrypt.compare('password1', (created.user as { passwordHash: string }).passwordHash),
|
||||
).toBe(true);
|
||||
expect((created.membership as { role: Role }).role).toBe(Role.GEMEINDE_TEAMER);
|
||||
expect((created.membership as { gemeindeId: string }).gemeindeId).toBe('gem-1');
|
||||
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-1', expect.anything());
|
||||
expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', 'm-1', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamerService.createInvite', () => {
|
||||
it('defaults a group link to unlimited uses and no expiry', async () => {
|
||||
const { service } = makeService();
|
||||
const inv = await service.createInvite(LT, 'gem-1', {});
|
||||
expect(inv.email).toBeNull();
|
||||
expect(inv.maxUses).toBeNull();
|
||||
expect(inv.expiresAt).toBeNull();
|
||||
expect(inv.token).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('defaults a personal invite to a single use and lowercases the email', async () => {
|
||||
const { service } = makeService();
|
||||
const inv = await service.createInvite(LT, 'gem-1', { email: 'New@Example.org' });
|
||||
expect(inv.email).toBe('new@example.org');
|
||||
expect(inv.maxUses).toBe(1);
|
||||
});
|
||||
|
||||
it('turns expiresInHours into a concrete expiry', async () => {
|
||||
const { service } = makeService();
|
||||
const before = Date.now();
|
||||
const inv = await service.createInvite(LT, 'gem-1', { expiresInHours: 48 });
|
||||
const ms = (inv.expiresAt as Date).getTime() - before;
|
||||
expect(ms).toBeGreaterThan(47 * 3600_000);
|
||||
expect(ms).toBeLessThan(49 * 3600_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamerService.removeTeamer', () => {
|
||||
it('404s when the user is not a local Teamer of that Gemeinde', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(service.removeTeamer(LT, 'gem-1', 'u-9')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the account and captures a User DELETE', async () => {
|
||||
const { service, prisma, sync } = makeService();
|
||||
prisma.membership.findFirst = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ userId: 'u-1', gemeindeId: 'gem-1', user: { passwordHash: 'h' } });
|
||||
const res = await service.removeTeamer(LT, 'gem-1', 'u-1');
|
||||
expect(res).toEqual({ id: 'u-1' });
|
||||
expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 'u-1' } });
|
||||
expect(sync.capture).toHaveBeenCalledWith('User', 'DELETE', 'u-1', { id: 'u-1' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user