feat(chat): free-form GRUPPE channels with mutable participants
CybeDefend Security Scan / cybedefend_scan (push) Failing after 19s
CybeDefend Security Scan / cybedefend_scan (pull_request) Failing after 1s

- ChatChannelType.GRUPPE: created by Leitungsteam (any KC) or a Gemeinde
  Verantwortliche/r (own KC), mixing team users and guests/Konfis as
  explicit ChatParticipant rows (unlike GEMEINDE_GRUPPE, membership is
  not derived from Gemeinde)
- POST /chat/:kcId/gruppen to create, GET participant-candidates, and
  POST/DELETE /chat/gruppen/:channelId/participants to manage membership
  (creator, LT, or Verantwortliche/r of that KC)
- ChatGateway broadcasts chat:participants-changed on membership change
- PushService updated for nullable ChatParticipant.userId + new
  guestAccountId column
- SyncService now replicates ChatParticipant
- Prisma migration + 14 new unit tests (75/75 passing), tsc clean
- CI: add .gitea/workflows/cybedefend-scan.yml + .cybedefend project config
This commit is contained in:
2026-09-12 13:25:48 +02:00
parent 1467c8bdf6
commit 288628f20e
13 changed files with 790 additions and 54 deletions
+280
View File
@@ -0,0 +1,280 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { ChatChannelType, Role } from '@prisma/client';
import { ChatService } from './chat.service';
import { AuthenticatedUser } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
/// Focus: GRUPPE channel creation + participant management authorization
/// (creator / Leitungsteam / Verantwortliche/r of that KC), and read/write
/// access for team users and guests. Prisma + Sync + Push faked in memory.
function userCaller(userId: string, memberships: AuthenticatedUser['memberships']) {
return {
kind: 'user' as const,
user: { userId, authentikSub: `sub-${userId}`, email: `${userId}@example.org`, memberships },
};
}
function guestCaller(guestId: string, kcId: string, gemeindeId: string | null = null) {
const guest: GuestJwtPayload = { guestId, kcId, gemeindeId };
return { kind: 'guest' as const, guest };
}
const LT = userCaller('lt-1', [{ kcId: 'kc-1', gemeindeId: null, role: Role.LEITUNGSTEAM }]);
const VERANTW = userCaller('ver-1', [
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
]);
const TEAMER = userCaller('teamer-1', [
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_TEAMER },
]);
function makeService(
opts: {
channels?: Record<string, any>;
memberships?: { kcId: string; userId: string }[];
guests?: { id: string; kcId: string }[];
} = {},
) {
const channels: Record<string, any> = opts.channels ?? {};
const memberships = opts.memberships ?? [];
const guests = opts.guests ?? [];
const participants: any[] = [];
let participantSeq = 0;
const prisma = {
chatChannel: {
create: jest.fn(({ data, include }: any) => {
const id = `chan-${Object.keys(channels).length + 1}`;
const created = { id, ...data, participants: [] };
if (data.participants?.create) {
for (const p of data.participants.create) {
const row = { id: `part-${++participantSeq}`, channelId: id, userId: null, guestAccountId: null, ...p };
participants.push(row);
created.participants.push(row);
}
}
channels[id] = created;
return Promise.resolve(include ? created : { id, ...data });
}),
findUnique: jest.fn(({ where, include }: any) => {
const channel = channels[where.id];
if (!channel) return Promise.resolve(null);
if (include?.participants) {
const seeded = participants.filter((p) => p.channelId === channel.id);
const fallback = Array.isArray(channel.participants) ? channel.participants : [];
return Promise.resolve({
...channel,
participants: seeded.length ? seeded : fallback,
});
}
return Promise.resolve(channel);
}),
findMany: jest.fn().mockResolvedValue([]),
},
membership: {
findMany: jest.fn(({ where }: any) => {
const ids: string[] = where.userId.in;
const rows = memberships.filter((m) => m.kcId === where.kcId && ids.includes(m.userId));
const seen = new Set<string>();
const distinct = rows.filter((r) => (seen.has(r.userId) ? false : (seen.add(r.userId), true)));
return Promise.resolve(distinct);
}),
findFirst: jest.fn(({ where }: any) =>
Promise.resolve(memberships.find((m) => m.kcId === where.kcId && m.userId === where.userId) ?? null),
),
count: jest.fn().mockResolvedValue(0),
},
guestAccount: {
count: jest.fn(({ where }: any) =>
Promise.resolve(guests.filter((g) => where.id.in.includes(g.id) && g.kcId === where.kcId).length),
),
findFirst: jest.fn(({ where }: any) =>
Promise.resolve(guests.find((g) => g.id === where.id && g.kcId === where.kcId) ?? null),
),
},
chatParticipant: {
upsert: jest.fn(({ create }: any) => {
const existing = participants.find(
(p) =>
p.channelId === create.channelId &&
p.userId === (create.userId ?? null) &&
p.guestAccountId === (create.guestAccountId ?? null),
);
if (existing) return Promise.resolve(existing);
const row = { id: `part-${++participantSeq}`, userId: null, guestAccountId: null, ...create };
participants.push(row);
return Promise.resolve(row);
}),
findFirst: jest.fn(({ where }: any) =>
Promise.resolve(
participants.find(
(p) =>
p.channelId === where.channelId &&
(where.userId === undefined || p.userId === where.userId) &&
(where.guestAccountId === undefined || p.guestAccountId === where.guestAccountId),
) ?? null,
),
),
delete: jest.fn(({ where }: any) => {
const idx = participants.findIndex((p) => p.id === where.id);
const [removed] = participants.splice(idx, 1);
return Promise.resolve(removed);
}),
},
chatMessage: { create: jest.fn(), findMany: jest.fn() },
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const push = { notifyChannel: jest.fn().mockResolvedValue(undefined) };
const service = new ChatService(prisma as never, sync as never, push as never);
return { service, prisma, sync, push, channels, participants };
}
describe('ChatService.createGruppe', () => {
it('creates a GRUPPE channel with the creator plus given team/guest participants', async () => {
const { service, sync } = makeService({
memberships: [{ kcId: 'kc-1', userId: 'ver-1' }, { kcId: 'kc-1', userId: 'teamer-1' }],
guests: [{ id: 'guest-1', kcId: 'kc-1' }],
});
const channel = await service.createGruppe('kc-1', 'Ausflugsplanung', 'ver-1', {
userIds: ['teamer-1'],
guestIds: ['guest-1'],
});
expect(channel.type).toBe(ChatChannelType.GRUPPE);
expect(channel.createdByUserId).toBe('ver-1');
const userIds = channel.participants.map((p: any) => p.userId).filter(Boolean);
const guestIds = channel.participants.map((p: any) => p.guestAccountId).filter(Boolean);
expect(userIds.sort()).toEqual(['teamer-1', 'ver-1']);
expect(guestIds).toEqual(['guest-1']);
expect(sync.capture).toHaveBeenCalledWith('ChatChannel', 'CREATE', channel.id, expect.anything());
});
it('does not duplicate the creator if already listed as a participant', async () => {
const { service } = makeService({ memberships: [{ kcId: 'kc-1', userId: 'ver-1' }] });
const channel = await service.createGruppe('kc-1', 'X', 'ver-1', { userIds: ['ver-1'] });
const userIds = channel.participants.map((p: any) => p.userId).filter(Boolean);
expect(userIds).toEqual(['ver-1']);
});
it('rejects a participant who is not a member of the KC', async () => {
const { service } = makeService({ memberships: [] });
await expect(
service.createGruppe('kc-1', 'X', 'ver-1', { userIds: ['ghost'] }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a guest who is not part of the KC', async () => {
const { service } = makeService({ guests: [{ id: 'guest-1', kcId: 'kc-2' }] });
await expect(
service.createGruppe('kc-1', 'X', 'ver-1', { guestIds: ['guest-1'] }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('ChatService participant management', () => {
function seedGruppe() {
const channels = {
'chan-1': { id: 'chan-1', kcId: 'kc-1', type: ChatChannelType.GRUPPE, createdByUserId: 'ver-1', gemeindeId: null },
};
return channels;
}
it('lets the creator add a team user', async () => {
const { service } = makeService({
channels: seedGruppe(),
memberships: [{ kcId: 'kc-1', userId: 'teamer-1' }],
});
const p = await service.addParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
expect(p.userId).toBe('teamer-1');
});
it('lets a Leitungsteam member add a guest even if not the creator', async () => {
const { service } = makeService({
channels: seedGruppe(),
guests: [{ id: 'guest-1', kcId: 'kc-1' }],
});
const p = await service.addParticipant('chan-1', LT, { guestId: 'guest-1' });
expect(p.guestAccountId).toBe('guest-1');
});
it('forbids a plain Teamer (not creator, not LT, not Verantwortliche/r) from managing participants', async () => {
const { service } = makeService({ channels: seedGruppe() });
await expect(
service.addParticipant('chan-1', TEAMER, { userId: 'teamer-1' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('forbids guests from managing participants', async () => {
const { service } = makeService({ channels: seedGruppe() });
await expect(
service.addParticipant('chan-1', guestCaller('g-1', 'kc-1') as never, { userId: 'x' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('404s for a non-GRUPPE channel', async () => {
const channels = {
'chan-2': { id: 'chan-2', kcId: 'kc-1', type: ChatChannelType.GEMEINDE_GRUPPE, createdByUserId: null },
};
const { service } = makeService({ channels });
await expect(
service.addParticipant('chan-2', LT, { userId: 'teamer-1' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects adding a user not in the KC', async () => {
const { service } = makeService({ channels: seedGruppe(), memberships: [] });
await expect(
service.addParticipant('chan-1', LT, { userId: 'ghost' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('removes a participant and is a no-op if already absent', async () => {
const { service } = makeService({
channels: seedGruppe(),
memberships: [{ kcId: 'kc-1', userId: 'teamer-1' }],
});
await service.addParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
const res = await service.removeParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
expect(res).toEqual({ ok: true });
const res2 = await service.removeParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
expect(res2).toEqual({ ok: true });
});
});
describe('ChatService GRUPPE read/write access', () => {
function seedGruppeWithParticipants(participants: any[]) {
return {
'chan-1': {
id: 'chan-1',
kcId: 'kc-1',
type: ChatChannelType.GRUPPE,
createdByUserId: 'ver-1',
gemeindeId: null,
participants,
},
};
}
it('lets a listed guest read messages', async () => {
const { service } = makeService({
channels: seedGruppeWithParticipants([{ userId: null, guestAccountId: 'guest-1' }]),
});
await expect(
service.assertCanRead('chan-1', guestCaller('guest-1', 'kc-1') as never),
).resolves.toBeDefined();
});
it('forbids a guest not in the participant list', async () => {
const { service } = makeService({
channels: seedGruppeWithParticipants([{ userId: null, guestAccountId: 'guest-1' }]),
});
await expect(
service.assertCanRead('chan-1', guestCaller('guest-2', 'kc-1') as never),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('forbids a team user not in the participant list', async () => {
const { service } = makeService({
channels: seedGruppeWithParticipants([{ userId: 'someone-else', guestAccountId: null }]),
});
await expect(service.assertCanRead('chan-1', TEAMER)).rejects.toBeInstanceOf(ForbiddenException);
});
});