Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
161
apps/api/src/groups/groups.controller.ts
Normal file
161
apps/api/src/groups/groups.controller.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Patch, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js";
|
||||
import {
|
||||
GroupsService,
|
||||
hasWorkspaceWideAccess,
|
||||
type GroupActor,
|
||||
type GroupDetail,
|
||||
type GroupMembershipResult,
|
||||
type GroupSummary,
|
||||
type VisibleConnections,
|
||||
} from "./groups.service.js";
|
||||
|
||||
type AuthedRequest = Request & AuthenticatedRequest;
|
||||
|
||||
function actorOf(request: AuthedRequest): GroupActor {
|
||||
const session = requireSession(request);
|
||||
return { userId: session.userId, role: session.role };
|
||||
}
|
||||
|
||||
function optionalDescription(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "string") throw new BadRequestException("description must be a string");
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Member add/remove and group delete bump the workspace authz epoch, which
|
||||
* invalidates every live session; those responses carry `token` — the caller's
|
||||
* replacement session (same contract as /team).
|
||||
*/
|
||||
@Controller("groups")
|
||||
@UseGuards(RbacGuard)
|
||||
export class GroupsController {
|
||||
constructor(@Inject(GroupsService) private readonly groups: GroupsService) {}
|
||||
|
||||
/**
|
||||
* The connections the caller may publish through (Compose calls this).
|
||||
* Declared before `:id` so the literal path wins route matching.
|
||||
* session.role is current: role changes bump the epoch and the guard rejects stale sessions.
|
||||
*/
|
||||
@Get("visible-connections")
|
||||
@RequirePermission("workspace.view")
|
||||
async visibleConnections(@Req() request: AuthedRequest): Promise<VisibleConnections> {
|
||||
const session = requireSession(request);
|
||||
return this.groups.getVisibleConnections(session.workspaceId, session.userId, hasWorkspaceWideAccess(session.role));
|
||||
}
|
||||
|
||||
@Get("mine")
|
||||
@RequirePermission("workspace.view")
|
||||
async mine(@Req() request: AuthedRequest): Promise<GroupSummary[]> {
|
||||
const session = requireSession(request);
|
||||
return this.groups.getUserGroups(session.workspaceId, session.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission("workspace.members.manage")
|
||||
async create(@Req() request: AuthedRequest, @Body() body: unknown): Promise<GroupSummary> {
|
||||
const dto = requireObject(body, "body");
|
||||
return this.groups.createGroup(
|
||||
requireSession(request).workspaceId,
|
||||
requireString(dto.name, "name"),
|
||||
optionalDescription(dto.description) ?? "",
|
||||
actorOf(request),
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission("workspace.view")
|
||||
async list(@Req() request: AuthedRequest): Promise<GroupSummary[]> {
|
||||
return this.groups.listGroups(requireSession(request).workspaceId);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission("workspace.view")
|
||||
async detail(@Req() request: AuthedRequest, @Param("id") id: string): Promise<GroupDetail> {
|
||||
return this.groups.getGroupDetail(requireSession(request).workspaceId, parseId(id));
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequirePermission("workspace.members.manage")
|
||||
async rename(@Req() request: AuthedRequest, @Param("id") id: string, @Body() body: unknown): Promise<GroupSummary> {
|
||||
const dto = requireObject(body, "body");
|
||||
return this.groups.renameGroup(
|
||||
requireSession(request).workspaceId,
|
||||
parseId(id),
|
||||
{ name: optionalString(dto.name, "name"), description: optionalDescription(dto.description) },
|
||||
actorOf(request),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequirePermission("workspace.members.manage")
|
||||
async remove(@Req() request: AuthedRequest, @Param("id") id: string): Promise<GroupMembershipResult> {
|
||||
return this.groups.deleteGroup(requireSession(request).workspaceId, parseId(id), actorOf(request));
|
||||
}
|
||||
|
||||
@Post(":id/members")
|
||||
@RequirePermission("workspace.members.manage")
|
||||
async addMember(@Req() request: AuthedRequest, @Param("id") id: string, @Body() body: unknown): Promise<GroupMembershipResult> {
|
||||
const dto = requireObject(body, "body");
|
||||
return this.groups.addMember(
|
||||
requireSession(request).workspaceId,
|
||||
parseId(id),
|
||||
parseId(requireString(dto.userId, "userId"), "userId"),
|
||||
actorOf(request),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":id/members/:userId")
|
||||
@RequirePermission("workspace.members.manage")
|
||||
async removeMember(
|
||||
@Req() request: AuthedRequest,
|
||||
@Param("id") id: string,
|
||||
@Param("userId") userId: string,
|
||||
): Promise<GroupMembershipResult> {
|
||||
return this.groups.removeMember(requireSession(request).workspaceId, parseId(id), parseId(userId, "userId"), actorOf(request));
|
||||
}
|
||||
|
||||
@Post(":id/connections")
|
||||
@RequirePermission("connection.manage")
|
||||
async assignConnection(@Req() request: AuthedRequest, @Param("id") id: string, @Body() body: unknown): Promise<GroupSummary & GroupMembershipResult> {
|
||||
const dto = requireObject(body, "body");
|
||||
return this.groups.assignConnection(
|
||||
requireSession(request).workspaceId,
|
||||
parseId(id),
|
||||
parseId(requireString(dto.connectionId, "connectionId"), "connectionId"),
|
||||
actorOf(request),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":id/connections/:connectionId")
|
||||
@RequirePermission("connection.manage")
|
||||
async unassignConnection(
|
||||
@Req() request: AuthedRequest,
|
||||
@Param("id") id: string,
|
||||
@Param("connectionId") connectionId: string,
|
||||
): Promise<GroupSummary & GroupMembershipResult> {
|
||||
return this.groups.unassignConnection(
|
||||
requireSession(request).workspaceId,
|
||||
parseId(id),
|
||||
parseId(connectionId, "connectionId"),
|
||||
actorOf(request),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/assets")
|
||||
@RequirePermission("media.manage")
|
||||
async assignAsset(@Req() request: AuthedRequest, @Param("id") id: string, @Body() body: unknown): Promise<GroupSummary> {
|
||||
const dto = requireObject(body, "body");
|
||||
return this.groups.assignAsset(requireSession(request).workspaceId, parseId(id), requireString(dto.assetId, "assetId"), actorOf(request));
|
||||
}
|
||||
|
||||
/** Asset ids are storage keys and may contain `/`: pass them URL-encoded. */
|
||||
@Delete(":id/assets/:assetId")
|
||||
@RequirePermission("media.manage")
|
||||
async unassignAsset(@Req() request: AuthedRequest, @Param("id") id: string, @Param("assetId") assetId: string): Promise<GroupSummary> {
|
||||
return this.groups.unassignAsset(requireSession(request).workspaceId, parseId(id), assetId, actorOf(request));
|
||||
}
|
||||
}
|
||||
251
apps/api/src/groups/groups.integration.test.ts
Normal file
251
apps/api/src/groups/groups.integration.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import type { ExecutionContext } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
|
||||
/**
|
||||
* Groups against a live PostgreSQL, as sp_app (FORCE RLS binds).
|
||||
* Uses its OWN database (GROUPS_IT_DATABASE_URL, plus GROUPS_IT_SUPER_DATABASE_URL
|
||||
* for migrations/seeding) for the same reason as auth.integration.test.ts:
|
||||
* tenancy.integration.test.ts wipes every workspace in the shared DB and vitest
|
||||
* runs files in parallel. Skipped when unset.
|
||||
*/
|
||||
const appUrl = process.env.GROUPS_IT_DATABASE_URL;
|
||||
const ownerUrl = process.env.GROUPS_IT_SUPER_DATABASE_URL;
|
||||
|
||||
type Modules = {
|
||||
groups: typeof import("./groups.service.js");
|
||||
guard: typeof import("../rbac/rbac.guard.js");
|
||||
session: typeof import("../auth/session.js");
|
||||
db: typeof import("../db/client.js");
|
||||
};
|
||||
|
||||
describe.skipIf(appUrl === undefined || ownerUrl === undefined)("groups (live DB)", () => {
|
||||
let m: Modules;
|
||||
let ownerPool: Pool;
|
||||
const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
||||
let wsA: string;
|
||||
let wsB: string;
|
||||
let owner: string;
|
||||
let admin: string;
|
||||
let member: string;
|
||||
let outsider: string;
|
||||
let connShared: string;
|
||||
let connPrivate: string;
|
||||
let connB: string;
|
||||
|
||||
async function asOwner<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await ownerPool.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function epoch(ws: string): Promise<number> {
|
||||
return asOwner(async (c) => Number((await c.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [ws])).rows[0]?.authz_epoch));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// The pool reads DATABASE_URL at import time: point it at the isolated DB first.
|
||||
process.env.DATABASE_URL = appUrl;
|
||||
process.env.SUPER_DATABASE_URL = ownerUrl;
|
||||
const { runMigrations } = await import("../db/migrate.js");
|
||||
await runMigrations(join(process.cwd(), "migrations"));
|
||||
m = {
|
||||
groups: await import("./groups.service.js"),
|
||||
guard: await import("../rbac/rbac.guard.js"),
|
||||
session: await import("../auth/session.js"),
|
||||
db: await import("../db/client.js"),
|
||||
};
|
||||
ownerPool = new Pool({ connectionString: ownerUrl });
|
||||
|
||||
// Seed via the owner pool (FORCE RLS still applies to the owner, so tenant
|
||||
// rows are written inside a tenant context).
|
||||
await asOwner(async (c) => {
|
||||
const ws = async (name: string): Promise<string> =>
|
||||
(await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", [name, `${name}-${suffix}`])).rows[0].id as string;
|
||||
wsA = await ws("grp-a");
|
||||
wsB = await ws("grp-b");
|
||||
const user = async (tag: string): Promise<string> =>
|
||||
(
|
||||
await c.query("INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id", [
|
||||
`grp:${tag}:${suffix}`,
|
||||
`test-${tag}-${suffix}`,
|
||||
`${tag}-${suffix}@example.com`,
|
||||
tag,
|
||||
])
|
||||
).rows[0].id as string;
|
||||
owner = await user("owner");
|
||||
admin = await user("admin");
|
||||
member = await user("member");
|
||||
outsider = await user("outsider");
|
||||
|
||||
const inTenant = async (wsId: string, fn: () => Promise<void>): Promise<void> => {
|
||||
await c.query("BEGIN");
|
||||
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsId]);
|
||||
await fn();
|
||||
await c.query("COMMIT");
|
||||
};
|
||||
await inTenant(wsA, async () => {
|
||||
for (const [u, role] of [
|
||||
[owner, "Owner"],
|
||||
[admin, "Admin"],
|
||||
[member, "Publisher"],
|
||||
[outsider, "Publisher"],
|
||||
] as const) {
|
||||
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, $3)", [wsA, u, role]);
|
||||
}
|
||||
const conn = async (ch: string): Promise<string> =>
|
||||
(await c.query("INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', $2) RETURNING id", [wsA, ch]))
|
||||
.rows[0].id as string;
|
||||
connShared = await conn("shared");
|
||||
connPrivate = await conn("private");
|
||||
});
|
||||
await inTenant(wsB, async () => {
|
||||
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [wsB, owner]);
|
||||
connB = (await c.query("INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', 'b') RETURNING id", [wsB]))
|
||||
.rows[0].id as string;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (ownerPool !== undefined) {
|
||||
await asOwner(async (c) => {
|
||||
await c.query("DELETE FROM workspaces WHERE id = ANY($1::uuid[])", [[wsA, wsB]]);
|
||||
await c.query("DELETE FROM users WHERE external_id LIKE $1", [`grp:%:${suffix}`]);
|
||||
});
|
||||
await ownerPool.end();
|
||||
}
|
||||
await m?.db.pool.end();
|
||||
});
|
||||
|
||||
let groupId: string;
|
||||
|
||||
it("create group -> assign connection -> add member: the member sees exactly that connection", async () => {
|
||||
const svc = new m.groups.GroupsService();
|
||||
const actor = { userId: admin, role: "Admin" };
|
||||
const group = await svc.createGroup(wsA, "Newsroom", "front desk", actor);
|
||||
groupId = group.id;
|
||||
await expect(svc.createGroup(wsA, "Newsroom", "", actor)).rejects.toMatchObject({ status: 409 });
|
||||
|
||||
await svc.assignConnection(wsA, groupId, connShared, actor);
|
||||
const before = await epoch(wsA);
|
||||
const added = await svc.addMember(wsA, groupId, member, actor);
|
||||
expect(await epoch(wsA)).toBeGreaterThan(before);
|
||||
// The replacement token carries the post-bump epoch.
|
||||
expect(m.session.verifySession(added.token)?.authzEpoch).toBe(await epoch(wsA));
|
||||
|
||||
expect(await svc.getVisibleConnections(wsA, member, false)).toEqual({ scope: "groups", connectionIds: [connShared] });
|
||||
|
||||
const detail = await svc.getGroupDetail(wsA, groupId);
|
||||
expect(detail.members.map((x) => x.userId)).toEqual([member]);
|
||||
expect(detail.connections.map((x) => x.connectionId)).toEqual([connShared]);
|
||||
expect((await svc.listGroups(wsA)).find((g) => g.id === groupId)).toMatchObject({ memberCount: 1, connectionCount: 1 });
|
||||
expect((await svc.getUserGroups(wsA, member)).map((g) => g.id)).toEqual([groupId]);
|
||||
|
||||
const actions = await asOwner(async (c) => {
|
||||
await c.query("BEGIN");
|
||||
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsA]);
|
||||
const rows = (await c.query("SELECT action FROM audit_log WHERE target_id = $1 ORDER BY seq", [groupId])).rows as Array<{ action: string }>;
|
||||
await c.query("COMMIT");
|
||||
return rows.map((r) => r.action);
|
||||
});
|
||||
expect(actions).toEqual(["group.created", "group.connection_assigned", "group.member_added"]);
|
||||
});
|
||||
|
||||
it("a non-member does NOT see the connection", async () => {
|
||||
const svc = new m.groups.GroupsService();
|
||||
expect(await svc.getVisibleConnections(wsA, outsider, false)).toEqual({ scope: "groups", connectionIds: [] });
|
||||
});
|
||||
|
||||
it("Owner/Admin see ALL workspace connections regardless of group membership", async () => {
|
||||
const svc = new m.groups.GroupsService();
|
||||
for (const u of [owner, admin]) {
|
||||
const visible = await svc.getVisibleConnections(wsA, u, true);
|
||||
expect(visible.scope).toBe("workspace");
|
||||
expect(new Set(visible.connectionIds)).toEqual(new Set([connShared, connPrivate]));
|
||||
}
|
||||
});
|
||||
|
||||
it("@RequireGroupAccess: member passes for the shared connection, outsider and private connection are forbidden, Owner passes", async () => {
|
||||
const epochNow = await epoch(wsA);
|
||||
const tokenFor = (userId: string, role: string): string =>
|
||||
m.session.issueSession({ userId, workspaceId: wsA, role, authzEpoch: epochNow });
|
||||
const check = async (token: string, connectionId: string): Promise<boolean | string> => {
|
||||
const handler = (): void => undefined;
|
||||
Reflect.defineMetadata(m.guard.PERMISSION_KEY, "publication.publish", handler);
|
||||
Reflect.defineMetadata(m.guard.GROUP_ACCESS_KEY, true, handler);
|
||||
const req = { headers: { authorization: `Bearer ${token}` }, params: { connectionId } };
|
||||
const ctx = { getHandler: () => handler, switchToHttp: () => ({ getRequest: () => req }) } as unknown as ExecutionContext;
|
||||
try {
|
||||
return await new m.guard.RbacGuard(new Reflector()).canActivate(ctx);
|
||||
} catch (err) {
|
||||
return (err as Error).constructor.name;
|
||||
}
|
||||
};
|
||||
expect(await check(tokenFor(member, "Publisher"), connShared)).toBe(true);
|
||||
expect(await check(tokenFor(member, "Publisher"), connPrivate)).toBe("ForbiddenException");
|
||||
expect(await check(tokenFor(outsider, "Publisher"), connShared)).toBe("ForbiddenException");
|
||||
expect(await check(tokenFor(owner, "Owner"), connPrivate)).toBe(true);
|
||||
});
|
||||
|
||||
it("cross-tenant scoping: a connection or user from another workspace cannot be attached", async () => {
|
||||
const svc = new m.groups.GroupsService();
|
||||
const actor = { userId: admin, role: "Admin" };
|
||||
await expect(svc.assignConnection(wsA, groupId, connB, actor)).rejects.toMatchObject({ status: 404 });
|
||||
// owner IS a member of B, but this checks a user with no wsA membership is refused:
|
||||
const stranger = await asOwner(
|
||||
async (c) =>
|
||||
(await c.query(
|
||||
"INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, 'S') RETURNING id",
|
||||
[`grp:stranger:${suffix}`, `test-stranger-${suffix}`, `s-${suffix}@x.io`],
|
||||
))
|
||||
.rows[0].id as string,
|
||||
);
|
||||
await expect(svc.addMember(wsA, groupId, stranger, actor)).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it("remove member -> authz_epoch bumps -> connection no longer visible", async () => {
|
||||
const svc = new m.groups.GroupsService();
|
||||
const before = await epoch(wsA);
|
||||
await svc.removeMember(wsA, groupId, member, { userId: admin, role: "Admin" });
|
||||
expect(await epoch(wsA)).toBeGreaterThan(before);
|
||||
expect(await svc.getVisibleConnections(wsA, member, false)).toEqual({ scope: "groups", connectionIds: [] });
|
||||
await expect(svc.removeMember(wsA, groupId, member, { userId: admin, role: "Admin" })).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it("deleting a group with members bumps the epoch via the cascade", async () => {
|
||||
const svc = new m.groups.GroupsService();
|
||||
const actor = { userId: admin, role: "Admin" };
|
||||
const g = await svc.createGroup(wsA, "Ephemeral", "", actor);
|
||||
await svc.addMember(wsA, g.id, member, actor);
|
||||
const before = await epoch(wsA);
|
||||
await svc.deleteGroup(wsA, g.id, actor);
|
||||
expect(await epoch(wsA)).toBeGreaterThan(before);
|
||||
await expect(svc.getGroupDetail(wsA, g.id)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it("RLS: workspace A's group rows are invisible from workspace B's context and from no context", async () => {
|
||||
const { withTenant, withSystem } = m.db;
|
||||
const count = async (client: PoolClient, table: string): Promise<number> =>
|
||||
Number((await client.query(`SELECT count(*)::int AS n FROM ${table}`)).rows[0]?.n);
|
||||
await new m.groups.GroupsService().assignAsset(wsA, groupId, "media/abc.png", { userId: admin, role: "Admin" });
|
||||
|
||||
const fromA = await withTenant(wsA, async (c) => ({ groups: await count(c, "groups"), assets: await count(c, "group_assets") }));
|
||||
expect(fromA.groups).toBeGreaterThan(0);
|
||||
expect(fromA.assets).toBe(1);
|
||||
for (const table of ["groups", "group_members", "group_connections", "group_assets"]) {
|
||||
expect(await withTenant(wsB, (c) => count(c, table))).toBe(0);
|
||||
expect(await withSystem((c) => count(c, table))).toBe(0);
|
||||
}
|
||||
await expect(new m.groups.GroupsService().getGroupDetail(wsB, groupId)).rejects.toMatchObject({ status: 404 });
|
||||
// WITH CHECK: a B-context write cannot target A.
|
||||
await expect(
|
||||
withTenant(wsB, (c) => c.query("INSERT INTO groups (workspace_id, name) VALUES ($1, 'smuggled')", [wsA])),
|
||||
).rejects.toThrow(/row-level security/);
|
||||
});
|
||||
});
|
||||
472
apps/api/src/groups/groups.service.ts
Normal file
472
apps/api/src/groups/groups.service.ts
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import type { PoolClient } from "pg";
|
||||
import { scrub, type AuditEvent } from "../audit/audit.service.js";
|
||||
import { issueSessionInTenant } from "../auth/auth.service.js";
|
||||
import { withTenant } from "../db/client.js";
|
||||
|
||||
/**
|
||||
* Groups: sub-workspace sharing scopes. Owner/Admin publish through every
|
||||
* connection; everyone else publishes only through connections assigned to a
|
||||
* group they belong to.
|
||||
*
|
||||
* Every mutation writes its audit row on the SAME transaction (audit_append on
|
||||
* the tenant client), so a change can never land without its audit entry.
|
||||
*
|
||||
* Group-membership changes bump the workspace authz epoch (008 trigger), which
|
||||
* invalidates all live sessions — including the caller's. Those mutations return
|
||||
* a fresh token for the caller, minted inside the same transaction (same
|
||||
* contract as TeamService).
|
||||
*/
|
||||
|
||||
export const WORKSPACE_WIDE_ROLES: readonly string[] = ["Owner", "Admin"];
|
||||
|
||||
export function hasWorkspaceWideAccess(role: string): boolean {
|
||||
return WORKSPACE_WIDE_ROLES.includes(role);
|
||||
}
|
||||
|
||||
export interface GroupActor {
|
||||
userId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface GroupSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
memberCount: number;
|
||||
connectionCount: number;
|
||||
assetCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
userId: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
addedBy: string | null;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface GroupConnection {
|
||||
connectionId: string;
|
||||
provider: string;
|
||||
channelId: string;
|
||||
assignedBy: string | null;
|
||||
assignedAt: string;
|
||||
}
|
||||
|
||||
export interface GroupAsset {
|
||||
assetId: string;
|
||||
assignedBy: string | null;
|
||||
assignedAt: string;
|
||||
}
|
||||
|
||||
export interface GroupDetail extends GroupSummary {
|
||||
members: GroupMember[];
|
||||
connections: GroupConnection[];
|
||||
assets: GroupAsset[];
|
||||
}
|
||||
|
||||
/** Result of a mutation that bumps the authz epoch: the caller's replacement session. */
|
||||
export interface GroupMembershipResult {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface VisibleConnections {
|
||||
/** "workspace": Owner/Admin — every connection. "groups": union of the user's groups. */
|
||||
scope: "workspace" | "groups";
|
||||
connectionIds: string[];
|
||||
}
|
||||
|
||||
interface GroupRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_at: Date;
|
||||
member_count: string | number;
|
||||
connection_count: string | number;
|
||||
asset_count: string | number;
|
||||
}
|
||||
|
||||
const GROUP_SELECT = `SELECT g.id, g.name, g.description, g.created_at,
|
||||
(SELECT count(*) FROM group_members gm WHERE gm.group_id = g.id) AS member_count,
|
||||
(SELECT count(*) FROM group_connections gc WHERE gc.group_id = g.id) AS connection_count,
|
||||
(SELECT count(*) FROM group_assets ga WHERE ga.group_id = g.id) AS asset_count
|
||||
FROM groups g`;
|
||||
|
||||
function summaryFromRow(row: GroupRow): GroupSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
memberCount: Number(row.member_count),
|
||||
connectionCount: Number(row.connection_count),
|
||||
assetCount: Number(row.asset_count),
|
||||
createdAt: new Date(row.created_at).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function iso(value: Date): string {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
/** node-postgres surfaces SQLSTATE on `code`. */
|
||||
function pgCode(err: unknown): string | undefined {
|
||||
if (typeof err !== "object" || err === null || !("code" in err)) return undefined;
|
||||
const code = (err as { code: unknown }).code;
|
||||
return typeof code === "string" ? code : undefined;
|
||||
}
|
||||
|
||||
const UNIQUE_VIOLATION = "23505";
|
||||
const FK_VIOLATION = "23503";
|
||||
|
||||
async function audit(client: PoolClient, workspaceId: string, event: AuditEvent): Promise<void> {
|
||||
await client.query("SELECT audit_append($1, $2, $3, $4, $5, $6, $7)", [
|
||||
workspaceId,
|
||||
event.actorUserId,
|
||||
event.actorRole,
|
||||
event.action,
|
||||
event.targetType,
|
||||
event.targetId,
|
||||
scrub(event.diff ?? null),
|
||||
]);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GroupsService {
|
||||
async createGroup(workspaceId: string, name: string, description: string, actor: GroupActor): Promise<GroupSummary> {
|
||||
const cleanName = name.trim();
|
||||
if (cleanName === "") throw new BadRequestException("name must be a non-empty string");
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
let id: string;
|
||||
try {
|
||||
const res = await client.query(
|
||||
"INSERT INTO groups (workspace_id, name, description) VALUES ($1, $2, $3) RETURNING id",
|
||||
[workspaceId, cleanName, description],
|
||||
);
|
||||
id = res.rows[0]?.id as string;
|
||||
} catch (err) {
|
||||
if (pgCode(err) === UNIQUE_VIOLATION) throw new ConflictException(`a group named "${cleanName}" already exists`);
|
||||
throw err;
|
||||
}
|
||||
await audit(client, workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: "group.created",
|
||||
targetType: "group",
|
||||
targetId: id,
|
||||
diff: { name: cleanName, description },
|
||||
});
|
||||
return this.summary(client, id);
|
||||
});
|
||||
}
|
||||
|
||||
async renameGroup(
|
||||
workspaceId: string,
|
||||
groupId: string,
|
||||
changes: { name?: string; description?: string },
|
||||
actor: GroupActor,
|
||||
): Promise<GroupSummary> {
|
||||
const name = changes.name?.trim();
|
||||
if (name === "") throw new BadRequestException("name must be a non-empty string");
|
||||
if (name === undefined && changes.description === undefined) {
|
||||
throw new BadRequestException("nothing to update: pass name and/or description");
|
||||
}
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const before = await this.summary(client, groupId, true);
|
||||
try {
|
||||
await client.query(
|
||||
"UPDATE groups SET name = COALESCE($2, name), description = COALESCE($3, description) WHERE id = $1",
|
||||
[groupId, name ?? null, changes.description ?? null],
|
||||
);
|
||||
} catch (err) {
|
||||
if (pgCode(err) === UNIQUE_VIOLATION) throw new ConflictException(`a group named "${name ?? ""}" already exists`);
|
||||
throw err;
|
||||
}
|
||||
const after = await this.summary(client, groupId);
|
||||
const diff: Record<string, unknown> = {};
|
||||
if (after.name !== before.name) diff.name = [before.name, after.name];
|
||||
if (after.description !== before.description) diff.description = [before.description, after.description];
|
||||
if (Object.keys(diff).length > 0) {
|
||||
await audit(client, workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: "group.renamed",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
diff,
|
||||
});
|
||||
}
|
||||
return after;
|
||||
});
|
||||
}
|
||||
|
||||
/** Cascades to members/connections/assets; member rows going away bump the epoch. */
|
||||
async deleteGroup(workspaceId: string, groupId: string, actor: GroupActor): Promise<GroupMembershipResult> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||
const before = await this.summary(client, groupId, true);
|
||||
await client.query("DELETE FROM groups WHERE id = $1", [groupId]);
|
||||
await audit(client, workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: "group.deleted",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
diff: {
|
||||
name: before.name,
|
||||
memberCount: before.memberCount,
|
||||
connectionCount: before.connectionCount,
|
||||
assetCount: before.assetCount,
|
||||
},
|
||||
});
|
||||
return { token: await issueSessionInTenant(client, workspaceId, actor.userId) };
|
||||
});
|
||||
}
|
||||
|
||||
async addMember(workspaceId: string, groupId: string, userId: string, actor: GroupActor): Promise<GroupMembershipResult> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||
await this.summary(client, groupId);
|
||||
let inserted: number;
|
||||
try {
|
||||
const res = await client.query(
|
||||
`INSERT INTO group_members (workspace_id, group_id, user_id, added_by) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (group_id, user_id) DO NOTHING`,
|
||||
[workspaceId, groupId, userId, actor.userId],
|
||||
);
|
||||
inserted = res.rowCount ?? 0;
|
||||
} catch (err) {
|
||||
// Composite FK to memberships: the user is not a member of this workspace.
|
||||
if (pgCode(err) === FK_VIOLATION) throw new BadRequestException("user is not a member of this workspace");
|
||||
throw err;
|
||||
}
|
||||
if (inserted === 0) throw new ConflictException("user is already a member of this group");
|
||||
await audit(client, workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: "group.member_added",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
diff: { userId },
|
||||
});
|
||||
return { token: await issueSessionInTenant(client, workspaceId, actor.userId) };
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(workspaceId: string, groupId: string, userId: string, actor: GroupActor): Promise<GroupMembershipResult> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||
const res = await client.query("DELETE FROM group_members WHERE group_id = $1 AND user_id = $2", [groupId, userId]);
|
||||
if ((res.rowCount ?? 0) === 0) throw new NotFoundException("user is not a member of this group");
|
||||
await audit(client, workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: "group.member_removed",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
diff: { userId },
|
||||
});
|
||||
return { token: await issueSessionInTenant(client, workspaceId, actor.userId) };
|
||||
});
|
||||
}
|
||||
|
||||
async assignConnection(workspaceId: string, groupId: string, connectionId: string, actor: GroupActor): Promise<GroupSummary & GroupMembershipResult> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||
await this.summary(client, groupId);
|
||||
let inserted: number;
|
||||
try {
|
||||
const res = await client.query(
|
||||
`INSERT INTO group_connections (workspace_id, group_id, connection_id, assigned_by) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (group_id, connection_id) DO NOTHING`,
|
||||
[workspaceId, groupId, connectionId, actor.userId],
|
||||
);
|
||||
inserted = res.rowCount ?? 0;
|
||||
} catch (err) {
|
||||
if (pgCode(err) === FK_VIOLATION) throw new NotFoundException(`connection ${connectionId} not found`);
|
||||
throw err;
|
||||
}
|
||||
if (inserted === 0) throw new ConflictException("connection is already assigned to this group");
|
||||
await audit(client, workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: "group.connection_assigned",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
diff: { connectionId },
|
||||
});
|
||||
return { ...await this.summary(client, groupId), token: await issueSessionInTenant(client, workspaceId, actor.userId) };
|
||||
});
|
||||
}
|
||||
|
||||
async unassignConnection(workspaceId: string, groupId: string, connectionId: string, actor: GroupActor): Promise<GroupSummary & GroupMembershipResult> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||
const res = await client.query("DELETE FROM group_connections WHERE group_id = $1 AND connection_id = $2", [
|
||||
groupId,
|
||||
connectionId,
|
||||
]);
|
||||
if ((res.rowCount ?? 0) === 0) throw new NotFoundException("connection is not assigned to this group");
|
||||
await audit(client, workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: "group.connection_unassigned",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
diff: { connectionId },
|
||||
});
|
||||
return { ...await this.summary(client, groupId), token: await issueSessionInTenant(client, workspaceId, actor.userId) };
|
||||
});
|
||||
}
|
||||
|
||||
async assignAsset(workspaceId: string, groupId: string, assetId: string, actor: GroupActor): Promise<GroupSummary> {
|
||||
const cleanAssetId = assetId.trim();
|
||||
if (cleanAssetId === "") throw new BadRequestException("assetId must be a non-empty string");
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
await this.summary(client, groupId);
|
||||
const res = await client.query(
|
||||
`INSERT INTO group_assets (workspace_id, group_id, asset_id, assigned_by) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (group_id, asset_id) DO NOTHING`,
|
||||
[workspaceId, groupId, cleanAssetId, actor.userId],
|
||||
);
|
||||
if ((res.rowCount ?? 0) === 0) throw new ConflictException("asset is already assigned to this group");
|
||||
await audit(client, workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: "group.asset_assigned",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
diff: { assetId: cleanAssetId },
|
||||
});
|
||||
return this.summary(client, groupId);
|
||||
});
|
||||
}
|
||||
|
||||
async unassignAsset(workspaceId: string, groupId: string, assetId: string, actor: GroupActor): Promise<GroupSummary> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const res = await client.query("DELETE FROM group_assets WHERE group_id = $1 AND asset_id = $2", [groupId, assetId]);
|
||||
if ((res.rowCount ?? 0) === 0) throw new NotFoundException("asset is not assigned to this group");
|
||||
await audit(client, workspaceId, {
|
||||
actorUserId: actor.userId,
|
||||
actorRole: actor.role,
|
||||
action: "group.asset_unassigned",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
diff: { assetId },
|
||||
});
|
||||
return this.summary(client, groupId);
|
||||
});
|
||||
}
|
||||
|
||||
async listGroups(workspaceId: string): Promise<GroupSummary[]> {
|
||||
const res = await withTenant(workspaceId, (client) => client.query(`${GROUP_SELECT} ORDER BY g.name ASC`));
|
||||
return (res.rows as GroupRow[]).map(summaryFromRow);
|
||||
}
|
||||
|
||||
async getGroupDetail(workspaceId: string, groupId: string): Promise<GroupDetail> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const summary = await this.summary(client, groupId);
|
||||
const members = await client.query(
|
||||
`SELECT gm.user_id, u.email, u.display_name, gm.added_by, gm.created_at
|
||||
FROM group_members gm JOIN users u ON u.id = gm.user_id
|
||||
WHERE gm.group_id = $1 ORDER BY gm.created_at ASC`,
|
||||
[groupId],
|
||||
);
|
||||
const connections = await client.query(
|
||||
`SELECT gc.connection_id, c.provider, c.channel_id, gc.assigned_by, gc.created_at
|
||||
FROM group_connections gc JOIN connections c ON c.id = gc.connection_id
|
||||
WHERE gc.group_id = $1 ORDER BY gc.created_at ASC`,
|
||||
[groupId],
|
||||
);
|
||||
const assets = await client.query(
|
||||
"SELECT asset_id, assigned_by, created_at FROM group_assets WHERE group_id = $1 ORDER BY created_at ASC",
|
||||
[groupId],
|
||||
);
|
||||
return {
|
||||
...summary,
|
||||
members: (
|
||||
members.rows as Array<{ user_id: string; email: string; display_name: string; added_by: string | null; created_at: Date }>
|
||||
).map((r) => ({ userId: r.user_id, email: r.email, displayName: r.display_name, addedBy: r.added_by, addedAt: iso(r.created_at) })),
|
||||
connections: (
|
||||
connections.rows as Array<{ connection_id: string; provider: string; channel_id: string; assigned_by: string | null; created_at: Date }>
|
||||
).map((r) => ({
|
||||
connectionId: r.connection_id,
|
||||
provider: r.provider,
|
||||
channelId: r.channel_id,
|
||||
assignedBy: r.assigned_by,
|
||||
assignedAt: iso(r.created_at),
|
||||
})),
|
||||
assets: (assets.rows as Array<{ asset_id: string; assigned_by: string | null; created_at: Date }>).map((r) => ({
|
||||
assetId: r.asset_id,
|
||||
assignedBy: r.assigned_by,
|
||||
assignedAt: iso(r.created_at),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getUserGroups(workspaceId: string, userId: string): Promise<GroupSummary[]> {
|
||||
const res = await withTenant(workspaceId, (client) =>
|
||||
client.query(
|
||||
`${GROUP_SELECT} WHERE EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id = $1)
|
||||
ORDER BY g.name ASC`,
|
||||
[userId],
|
||||
),
|
||||
);
|
||||
return (res.rows as GroupRow[]).map(summaryFromRow);
|
||||
}
|
||||
|
||||
async getVisibleConnections(workspaceId: string, userId: string, isOwnerOrAdmin: boolean): Promise<VisibleConnections> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
if (isOwnerOrAdmin) {
|
||||
const res = await client.query("SELECT id FROM connections ORDER BY created_at ASC");
|
||||
return { scope: "workspace", connectionIds: (res.rows as Array<{ id: string }>).map((r) => r.id) };
|
||||
}
|
||||
const res = await client.query(
|
||||
`SELECT c.id FROM connections c
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM group_connections gc
|
||||
JOIN group_members gm ON gm.group_id = gc.group_id
|
||||
WHERE gc.connection_id = c.id AND gm.user_id = $1
|
||||
)
|
||||
ORDER BY c.created_at ASC`,
|
||||
[userId],
|
||||
);
|
||||
return { scope: "groups", connectionIds: (res.rows as Array<{ id: string }>).map((r) => r.id) };
|
||||
});
|
||||
}
|
||||
|
||||
private async summary(client: PoolClient, groupId: string, lock = false): Promise<GroupSummary> {
|
||||
const res = await client.query(`${GROUP_SELECT} WHERE g.id = $1${lock ? " FOR UPDATE OF g" : ""}`, [groupId]);
|
||||
const row = res.rows[0] as GroupRow | undefined;
|
||||
if (row === undefined) throw new NotFoundException(`group ${groupId} not found`);
|
||||
return summaryFromRow(row);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard-side check (same tenant client): can this user publish through this
|
||||
* connection? Role is read from the DB, not the token, so it is the live value.
|
||||
*/
|
||||
export async function userCanUseConnection(
|
||||
client: PoolClient,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
): Promise<boolean> {
|
||||
const res = await client.query(
|
||||
`SELECT 1 FROM memberships m
|
||||
WHERE m.workspace_id = $1 AND m.user_id = $2 AND m.status = 'active'
|
||||
AND (
|
||||
m.role = ANY($4::text[])
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM group_connections gc
|
||||
JOIN group_members gm ON gm.group_id = gc.group_id
|
||||
WHERE gc.connection_id = $3 AND gm.user_id = m.user_id
|
||||
)
|
||||
)`,
|
||||
[workspaceId, userId, connectionId, WORKSPACE_WIDE_ROLES],
|
||||
);
|
||||
return (res.rowCount ?? 0) > 0;
|
||||
}
|
||||
336
apps/api/src/groups/groups.test.ts
Normal file
336
apps/api/src/groups/groups.test.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException, type ExecutionContext } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import type { QueryResult } from "pg";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
// vi.mock calls below are hoisted above these imports.
|
||||
import { issueSession } from "../auth/session.js";
|
||||
import { GROUP_ACCESS_KEY, PERMISSION_KEY, RbacGuard } from "../rbac/rbac.guard.js";
|
||||
import { GroupsService, hasWorkspaceWideAccess } from "./groups.service.js";
|
||||
|
||||
/**
|
||||
* Unit tests: the tenant client is faked so we can assert on the exact SQL
|
||||
* parameters and the in-transaction audit_append call. Live-DB behaviour
|
||||
* (RLS, epoch trigger, FK scoping) is in groups.integration.test.ts — that
|
||||
* file cannot share this one because vi.mock is file-wide.
|
||||
*/
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
interface Call {
|
||||
sql: string;
|
||||
values: readonly unknown[];
|
||||
}
|
||||
|
||||
const fake = vi.hoisted(() => ({
|
||||
calls: [] as Array<{ sql: string; values: readonly unknown[] }>,
|
||||
responders: [] as Array<(sql: string, values: readonly unknown[]) => Array<Record<string, unknown>> | Error | undefined>,
|
||||
tenants: [] as string[],
|
||||
epoch: 7,
|
||||
}));
|
||||
|
||||
function result(rows: Row[]): QueryResult<Row> {
|
||||
return { command: "", rowCount: rows.length, oid: 0, fields: [], rows };
|
||||
}
|
||||
|
||||
const client = {
|
||||
async query(sql: string, values: readonly unknown[] = []): Promise<QueryResult<Row>> {
|
||||
fake.calls.push({ sql, values });
|
||||
for (const respond of fake.responders) {
|
||||
const out = respond(sql, values);
|
||||
if (out instanceof Error) throw out;
|
||||
if (out !== undefined) return result(out);
|
||||
}
|
||||
return result([]);
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
pool: {
|
||||
query: async (sql: string, values: readonly unknown[] = []) => client.query(sql, values),
|
||||
},
|
||||
withTenant: async <T>(workspaceId: string, fn: (c: typeof client) => Promise<T>): Promise<T> => {
|
||||
fake.tenants.push(workspaceId);
|
||||
return fn(client);
|
||||
},
|
||||
withSystem: async <T>(fn: (c: typeof client) => Promise<T>): Promise<T> => fn(client),
|
||||
}));
|
||||
|
||||
vi.mock("../auth/auth.service.js", () => ({
|
||||
issueSessionInTenant: async (_c: unknown, workspaceId: string, userId: string) => `token:${workspaceId}:${userId}:${fake.epoch}`,
|
||||
}));
|
||||
|
||||
|
||||
const ws = "10000000-0000-4000-8000-000000000001";
|
||||
const groupId = "20000000-0000-4000-8000-000000000002";
|
||||
const userId = "30000000-0000-4000-8000-000000000003";
|
||||
const connectionId = "40000000-0000-4000-8000-000000000004";
|
||||
const actor = { userId: "50000000-0000-4000-8000-000000000005", role: "Admin" };
|
||||
|
||||
const groupRow = (over: Row = {}): Row => ({
|
||||
id: groupId,
|
||||
name: "Newsroom",
|
||||
description: "",
|
||||
created_at: new Date("2026-10-06T10:00:00.000Z"),
|
||||
member_count: "1",
|
||||
connection_count: "2",
|
||||
asset_count: "0",
|
||||
...over,
|
||||
});
|
||||
|
||||
function pgError(code: string): Error {
|
||||
return Object.assign(new Error(`pg ${code}`), { code });
|
||||
}
|
||||
|
||||
function respond(match: RegExp, out: Row[] | Error | ((values: readonly unknown[]) => Row[] | Error)): void {
|
||||
fake.responders.push((sql, values) => (match.test(sql) ? (typeof out === "function" ? out(values) : out) : undefined));
|
||||
}
|
||||
|
||||
function auditCalls(): Call[] {
|
||||
return fake.calls.filter((c) => c.sql.includes("audit_append"));
|
||||
}
|
||||
|
||||
function insertCall(table: string): Call {
|
||||
const call = fake.calls.find((c) => c.sql.includes(`INSERT INTO ${table}`));
|
||||
if (call === undefined) throw new Error(`no INSERT INTO ${table}`);
|
||||
return call;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fake.calls.length = 0;
|
||||
fake.responders.length = 0;
|
||||
fake.tenants.length = 0;
|
||||
});
|
||||
|
||||
describe("GroupsService (unit)", () => {
|
||||
const service = new GroupsService();
|
||||
|
||||
it("createGroup inserts under the tenant, trims the name, audits group.created on the same client", async () => {
|
||||
respond(/INSERT INTO groups/, [{ id: groupId }]);
|
||||
respond(/FROM groups g WHERE g.id/, [groupRow({ member_count: 0, connection_count: 0 })]);
|
||||
const created = await service.createGroup(ws, " Newsroom ", "desk", actor);
|
||||
|
||||
expect(created).toMatchObject({ id: groupId, name: "Newsroom", memberCount: 0, connectionCount: 0 });
|
||||
expect(fake.tenants).toEqual([ws]);
|
||||
expect(insertCall("groups").values).toEqual([ws, "Newsroom", "desk"]);
|
||||
expect(auditCalls()).toHaveLength(1);
|
||||
expect(auditCalls()[0]?.values).toEqual([ws, actor.userId, "Admin", "group.created", "group", groupId, { name: "Newsroom", description: "desk" }]);
|
||||
});
|
||||
|
||||
it("createGroup maps a duplicate name to 409 and writes no audit", async () => {
|
||||
respond(/INSERT INTO groups/, pgError("23505"));
|
||||
await expect(service.createGroup(ws, "Newsroom", "", actor)).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(auditCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("createGroup rejects a blank name before touching the DB", async () => {
|
||||
await expect(service.createGroup(ws, " ", "", actor)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(fake.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renameGroup audits only the fields that changed", async () => {
|
||||
let renamed = false;
|
||||
respond(/UPDATE groups SET/, () => {
|
||||
renamed = true;
|
||||
return [];
|
||||
});
|
||||
respond(/FROM groups g WHERE g.id/, () => [groupRow(renamed ? { name: "Desk" } : {})]);
|
||||
const after = await service.renameGroup(ws, groupId, { name: "Desk" }, actor);
|
||||
|
||||
expect(after.name).toBe("Desk");
|
||||
const update = fake.calls.find((c) => c.sql.includes("UPDATE groups SET"));
|
||||
expect(update?.values).toEqual([groupId, "Desk", null]);
|
||||
expect(auditCalls()[0]?.values.slice(3)).toEqual(["group.renamed", "group", groupId, { name: ["Newsroom", "Desk"] }]);
|
||||
});
|
||||
|
||||
it("renameGroup with nothing to change is a 400", async () => {
|
||||
await expect(service.renameGroup(ws, groupId, {}, actor)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("deleteGroup audits the cascade counts and returns a replacement token", async () => {
|
||||
respond(/FROM groups g WHERE g.id/, [groupRow()]);
|
||||
const out = await service.deleteGroup(ws, groupId, actor);
|
||||
expect(out.token).toBe(`token:${ws}:${actor.userId}:7`);
|
||||
expect(fake.calls.some((c) => c.sql.includes("FOR UPDATE OF g"))).toBe(true);
|
||||
expect(fake.calls.find((c) => c.sql.startsWith("DELETE FROM groups"))?.values).toEqual([groupId]);
|
||||
expect(auditCalls()[0]?.values.slice(3)).toEqual([
|
||||
"group.deleted",
|
||||
"group",
|
||||
groupId,
|
||||
{ name: "Newsroom", memberCount: 1, connectionCount: 2, assetCount: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("deleteGroup on an unknown/foreign group is a 404 (RLS hides it)", async () => {
|
||||
await expect(service.deleteGroup(ws, groupId, actor)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(auditCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("addMember inserts with workspace + actor, audits group.member_added, returns a fresh token", async () => {
|
||||
respond(/FROM groups g WHERE g.id/, [groupRow()]);
|
||||
respond(/INSERT INTO group_members/, [{}]);
|
||||
const out = await service.addMember(ws, groupId, userId, actor);
|
||||
|
||||
expect(insertCall("group_members").values).toEqual([ws, groupId, userId, actor.userId]);
|
||||
expect(auditCalls()[0]?.values).toEqual([ws, actor.userId, "Admin", "group.member_added", "group", groupId, { userId }]);
|
||||
expect(out.token).toContain(actor.userId);
|
||||
});
|
||||
|
||||
it("addMember: non-workspace user (composite FK) is a 400; duplicate is a 409", async () => {
|
||||
respond(/FROM groups g WHERE g.id/, [groupRow()]);
|
||||
respond(/INSERT INTO group_members/, pgError("23503"));
|
||||
await expect(service.addMember(ws, groupId, userId, actor)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
fake.responders.length = 0;
|
||||
respond(/FROM groups g WHERE g.id/, [groupRow()]);
|
||||
respond(/INSERT INTO group_members/, []);
|
||||
await expect(service.addMember(ws, groupId, userId, actor)).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(auditCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removeMember deletes by (group, user) and audits group.member_removed", async () => {
|
||||
respond(/DELETE FROM group_members/, [{}]);
|
||||
const out = await service.removeMember(ws, groupId, userId, actor);
|
||||
expect(fake.calls.find((c) => c.sql.includes("DELETE FROM group_members"))?.values).toEqual([groupId, userId]);
|
||||
expect(auditCalls()[0]?.values.slice(3)).toEqual(["group.member_removed", "group", groupId, { userId }]);
|
||||
expect(out.token).toBeTruthy();
|
||||
});
|
||||
|
||||
it("removeMember of a non-member is a 404 with no audit", async () => {
|
||||
await expect(service.removeMember(ws, groupId, userId, actor)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(auditCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("assignConnection / unassignConnection pass the right params and audit", async () => {
|
||||
respond(/FROM groups g WHERE g.id/, [groupRow()]);
|
||||
respond(/INSERT INTO group_connections/, [{}]);
|
||||
respond(/DELETE FROM group_connections/, [{}]);
|
||||
await service.assignConnection(ws, groupId, connectionId, actor);
|
||||
await service.unassignConnection(ws, groupId, connectionId, actor);
|
||||
|
||||
expect(insertCall("group_connections").values).toEqual([ws, groupId, connectionId, actor.userId]);
|
||||
expect(fake.calls.find((c) => c.sql.includes("DELETE FROM group_connections"))?.values).toEqual([groupId, connectionId]);
|
||||
expect(auditCalls().map((c) => c.values[3])).toEqual(["group.connection_assigned", "group.connection_unassigned"]);
|
||||
expect(auditCalls().map((c) => c.values[6])).toEqual([{ connectionId }, { connectionId }]);
|
||||
});
|
||||
|
||||
it("assignConnection of a connection outside the workspace (composite FK) is a 404", async () => {
|
||||
respond(/FROM groups g WHERE g.id/, [groupRow()]);
|
||||
respond(/INSERT INTO group_connections/, pgError("23503"));
|
||||
await expect(service.assignConnection(ws, groupId, connectionId, actor)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it("assignAsset / unassignAsset audit with the asset id", async () => {
|
||||
respond(/FROM groups g WHERE g.id/, [groupRow()]);
|
||||
respond(/INSERT INTO group_assets/, [{}]);
|
||||
respond(/DELETE FROM group_assets/, [{}]);
|
||||
await service.assignAsset(ws, groupId, " media/ab12.png ", actor);
|
||||
await service.unassignAsset(ws, groupId, "media/ab12.png", actor);
|
||||
|
||||
expect(insertCall("group_assets").values).toEqual([ws, groupId, "media/ab12.png", actor.userId]);
|
||||
expect(auditCalls().map((c) => [c.values[3], c.values[6]])).toEqual([
|
||||
["group.asset_assigned", { assetId: "media/ab12.png" }],
|
||||
["group.asset_unassigned", { assetId: "media/ab12.png" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("listGroups maps counts to numbers", async () => {
|
||||
respond(/FROM groups g ORDER BY/, [groupRow(), groupRow({ id: "x", name: "Ops", member_count: "0" })]);
|
||||
const list = await service.listGroups(ws);
|
||||
expect(list.map((g) => [g.name, g.memberCount, g.connectionCount])).toEqual([
|
||||
["Newsroom", 1, 2],
|
||||
["Ops", 0, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it("getUserGroups filters by the user's membership", async () => {
|
||||
respond(/FROM groups g WHERE EXISTS/, [groupRow()]);
|
||||
const mine = await service.getUserGroups(ws, userId);
|
||||
expect(mine).toHaveLength(1);
|
||||
expect(fake.calls[0]?.values).toEqual([userId]);
|
||||
});
|
||||
|
||||
it("getGroupDetail returns members, connections and assets", async () => {
|
||||
const at = new Date("2026-10-06T11:00:00.000Z");
|
||||
respond(/FROM groups g WHERE g.id/, [groupRow()]);
|
||||
respond(/FROM group_members gm JOIN users/, [{ user_id: userId, email: "a@x.io", display_name: "A", added_by: actor.userId, created_at: at }]);
|
||||
respond(/FROM group_connections gc JOIN connections/, [
|
||||
{ connection_id: connectionId, provider: "mock-social", channel_id: "ch", assigned_by: null, created_at: at },
|
||||
]);
|
||||
respond(/FROM group_assets/, [{ asset_id: "media/1", assigned_by: null, created_at: at }]);
|
||||
const detail = await service.getGroupDetail(ws, groupId);
|
||||
expect(detail.members).toEqual([{ userId, email: "a@x.io", displayName: "A", addedBy: actor.userId, addedAt: at.toISOString() }]);
|
||||
expect(detail.connections[0]).toMatchObject({ connectionId, provider: "mock-social", channelId: "ch" });
|
||||
expect(detail.assets[0]?.assetId).toBe("media/1");
|
||||
});
|
||||
|
||||
it("getVisibleConnections: Owner/Admin get the whole workspace; others get the group-scoped query", async () => {
|
||||
respond(/SELECT id FROM connections/, [{ id: "c1" }, { id: "c2" }]);
|
||||
respond(/JOIN group_members gm/, [{ id: "c1" }]);
|
||||
|
||||
expect(await service.getVisibleConnections(ws, userId, true)).toEqual({ scope: "workspace", connectionIds: ["c1", "c2"] });
|
||||
expect(await service.getVisibleConnections(ws, userId, false)).toEqual({ scope: "groups", connectionIds: ["c1"] });
|
||||
expect(fake.calls[1]?.values).toEqual([userId]);
|
||||
});
|
||||
|
||||
it("hasWorkspaceWideAccess is Owner/Admin only", () => {
|
||||
expect(["Owner", "Admin", "Publisher", "Editor"].map(hasWorkspaceWideAccess)).toEqual([true, true, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RbacGuard @RequireGroupAccess (unit)", () => {
|
||||
const token = issueSession({ userId, workspaceId: ws, role: "Publisher", authzEpoch: 3 });
|
||||
|
||||
function context(opts: { permission?: string; groupAccess?: boolean; params?: Record<string, string> }): {
|
||||
ctx: ExecutionContext;
|
||||
reflector: Reflector;
|
||||
} {
|
||||
const handler = (): void => undefined;
|
||||
if (opts.permission !== undefined) Reflect.defineMetadata(PERMISSION_KEY, opts.permission, handler);
|
||||
if (opts.groupAccess === true) Reflect.defineMetadata(GROUP_ACCESS_KEY, true, handler);
|
||||
const req = { headers: { authorization: `Bearer ${token}` }, params: opts.params ?? {} };
|
||||
const ctx = { getHandler: () => handler, switchToHttp: () => ({ getRequest: () => req }) } as unknown as ExecutionContext;
|
||||
return { ctx, reflector: new Reflector() };
|
||||
}
|
||||
|
||||
async function run(opts: Parameters<typeof context>[0]): Promise<true | string> {
|
||||
const { ctx, reflector } = context(opts);
|
||||
try {
|
||||
return (await new RbacGuard(reflector).canActivate(ctx)) as true;
|
||||
} catch (err) {
|
||||
return (err as Error).constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
respond(/SELECT authz_epoch FROM workspaces/, [{ authz_epoch: "3" }]);
|
||||
respond(/FROM role_permissions rp/, [{ "?column?": 1 }]);
|
||||
});
|
||||
|
||||
it("routes without the decorator are unchanged: no group query runs", async () => {
|
||||
expect(await run({ permission: "publication.publish", params: { connectionId } })).toBe(true);
|
||||
expect(fake.calls.some((c) => c.sql.includes("group_connections"))).toBe(false);
|
||||
});
|
||||
|
||||
it("passes when the membership/group query matches, with (workspace, user, connection, roles) params", async () => {
|
||||
respond(/group_connections gc/, [{ "?column?": 1 }]);
|
||||
expect(await run({ permission: "publication.publish", groupAccess: true, params: { connectionId } })).toBe(true);
|
||||
const check = fake.calls.find((c) => c.sql.includes("group_connections gc"));
|
||||
expect(check?.values).toEqual([ws, userId, connectionId, ["Owner", "Admin"]]);
|
||||
});
|
||||
|
||||
it("forbids when the user is neither Owner/Admin nor in a group holding the connection", async () => {
|
||||
expect(await run({ permission: "publication.publish", groupAccess: true, params: { connectionId } })).toBe(ForbiddenException.name);
|
||||
});
|
||||
|
||||
it("fails closed when the route has no :connectionId, and 400s a non-UUID", async () => {
|
||||
expect(await run({ groupAccess: true })).toBe(ForbiddenException.name);
|
||||
expect(await run({ groupAccess: true, params: { connectionId: "nope" } })).toBe(BadRequestException.name);
|
||||
});
|
||||
|
||||
it("still enforces the permission before the group check", async () => {
|
||||
fake.responders.length = 0;
|
||||
respond(/SELECT authz_epoch FROM workspaces/, [{ authz_epoch: "3" }]);
|
||||
respond(/group_connections gc/, [{ "?column?": 1 }]);
|
||||
expect(await run({ permission: "publication.publish", groupAccess: true, params: { connectionId } })).toBe(ForbiddenException.name);
|
||||
expect(fake.calls.some((c) => c.sql.includes("group_connections"))).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue