- 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
281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|