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/.
358 lines
18 KiB
TypeScript
358 lines
18 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { join } from "node:path";
|
|
import { Pool, type PoolClient } from "pg";
|
|
|
|
/**
|
|
* WP3 analytics snapshots against a live PostgreSQL, as sp_app (FORCE RLS
|
|
* binds). Uses its OWN database (ANALYTICS_IT_DATABASE_URL, plus
|
|
* ANALYTICS_IT_SUPER_DATABASE_URL for migrations/seeding) for the same reason
|
|
* as groups.integration.test.ts: tenancy.integration.test.ts wipes every
|
|
* workspace in the shared DB and vitest runs files in parallel.
|
|
* Skipped cleanly when either env var is unset.
|
|
*/
|
|
const appUrl = process.env.ANALYTICS_IT_DATABASE_URL;
|
|
const ownerUrl = process.env.ANALYTICS_IT_SUPER_DATABASE_URL;
|
|
|
|
type Modules = {
|
|
service: typeof import("./snapshot.service.js");
|
|
controller: typeof import("./analytics.controller.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)("analytics snapshots (live DB)", () => {
|
|
let m: Modules;
|
|
let ownerPool: Pool;
|
|
const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
|
const currentMonth = new Date().toISOString().slice(0, 7) + "-01";
|
|
const prevDate = new Date(`${currentMonth}T00:00:00Z`);
|
|
prevDate.setUTCMonth(prevDate.getUTCMonth() - 1);
|
|
const previousMonth = prevDate.toISOString().slice(0, 10);
|
|
let wsA: string;
|
|
let wsB: string;
|
|
let user: 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();
|
|
}
|
|
}
|
|
|
|
/** Seed tenant rows honestly: FORCE RLS applies to the owner too. */
|
|
async function inTenant(client: PoolClient, wsId: string, fn: () => Promise<void>): Promise<void> {
|
|
await client.query("BEGIN");
|
|
await client.query("SELECT set_config('app.workspace_id', $1, true)", [wsId]);
|
|
await fn();
|
|
await client.query("COMMIT");
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
// The shared pool reads DATABASE_URL at import time: point it at this
|
|
// isolated DB first, then import the modules under test.
|
|
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 = {
|
|
service: await import("./snapshot.service.js"),
|
|
controller: await import("./analytics.controller.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 });
|
|
|
|
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("analytics-a");
|
|
wsB = await ws("analytics-b");
|
|
user = (await c.query(
|
|
"INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id",
|
|
[`analytics:owner:${suffix}`, `analytics-owner-${suffix}`, `owner-${suffix}@example.com`, "owner"],
|
|
)).rows[0].id as string;
|
|
|
|
const seed = async (wsId: string, archivedCount: number): Promise<void> => {
|
|
await inTenant(c, wsId, async () => {
|
|
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [wsId, user]);
|
|
const tag = wsId.slice(0, 8);
|
|
// 3 connections (mastodon, bluesky, mastodon) + one draft each.
|
|
const drafts: string[] = [];
|
|
for (const [i, provider] of ["mastodon", "bluesky", "mastodon"].entries()) {
|
|
const conn = (await c.query(
|
|
"INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, $2, $3) RETURNING id",
|
|
[wsId, provider, `ch-${tag}-${i}`],
|
|
)).rows[0].id as string;
|
|
const draft = (await c.query(
|
|
"INSERT INTO drafts (workspace_id, author_user_id, post) VALUES ($1, $2, $3::jsonb) RETURNING id",
|
|
[wsId, user, JSON.stringify({ body: "x" })],
|
|
)).rows[0].id as string;
|
|
drafts.push(draft);
|
|
await c.query(
|
|
`INSERT INTO publications (workspace_id, draft_id, connection_id, idempotency_key, remote_id, published_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6::date + $7::int)`,
|
|
[wsId, draft, conn, `pub-${tag}-${i}`, `r-${tag}-${i}`, currentMonth, i],
|
|
);
|
|
}
|
|
// One scheduled publication (draft_id is UNIQUE there) carrying
|
|
// three attempts on the same draft: attempt_no 1..3.
|
|
const schedule = (await c.query(
|
|
`INSERT INTO scheduled_publications (workspace_id, draft_id, connection_id, publish_at, idempotency_key)
|
|
VALUES ($1, $2, (SELECT id FROM connections WHERE workspace_id = $1 LIMIT 1), $3::date, $4) RETURNING id`,
|
|
[wsId, drafts[0]!, currentMonth, `sch-${tag}`],
|
|
)).rows[0].id as string;
|
|
const credential = (await c.query(
|
|
"INSERT INTO credentials (workspace_id, kind, ciphertext) VALUES ($1, 'test', gen_random_bytes(16)) RETURNING id",
|
|
[wsId],
|
|
)).rows[0].id as string;
|
|
for (const n of [1, 2, 3]) {
|
|
await c.query(
|
|
`INSERT INTO publishing_attempts
|
|
(workspace_id, schedule_id, draft_id, connection_id, attempt_no, idempotency_key, schedule_generation,
|
|
post_snapshot, post_hash, scheduled_by_user_id, required_permission, approved_revision,
|
|
reviewer_user_id, credential_id, credential_generation, authorized_epoch, status)
|
|
VALUES ($1, $2, $3, (SELECT id FROM connections WHERE workspace_id = $1 LIMIT 1), $4, $5, 1,
|
|
'{}'::jsonb, $6, $7, 'publication.publish', 1, $7, $8, 1, 0, $9)`,
|
|
[wsId, schedule, drafts[0]!, n, `att-${tag}-${n}`, `hash-${tag}-${n}`, user, credential, n === 3 ? "completed" : "failed"],
|
|
);
|
|
}
|
|
// Archived feed items: `archivedCount` in the current month, one in
|
|
// the previous month, one in the far past (outside both windows).
|
|
// remote_ref must equal channel_id || ':' || message_id.
|
|
// remote_ref must match (@handle|numeric):message_id AND equal
|
|
// channel_id || ':' || message_id; content_hash is sha256 hex (64).
|
|
for (let i = 0; i < archivedCount; i += 1) {
|
|
await c.query(
|
|
`INSERT INTO archived_feed_items (workspace_id, remote_ref, channel_id, message_id, content_hash, posted_at)
|
|
VALUES ($1, '@ch' || $2::text || ':' || $3::bigint::text, '@ch' || $2::text, $3::bigint,
|
|
encode(sha256($4::bytea), 'hex'), $5::date + ($6::int * interval '1 hour'))`,
|
|
[wsId, wsId.replace(/-/g, "").slice(0, 9), 1000 + i, `${tag}-${i}`, currentMonth, i],
|
|
);
|
|
}
|
|
await c.query(
|
|
`INSERT INTO archived_feed_items (workspace_id, remote_ref, channel_id, message_id, content_hash, posted_at)
|
|
VALUES ($1, '@ch' || $2::text || ':9001', '@ch' || $2::text, 9001,
|
|
encode(sha256($3::bytea), 'hex'), $4::date)`,
|
|
[wsId, wsId.replace(/-/g, "").slice(0, 9), `${tag}-prev`, previousMonth],
|
|
);
|
|
await c.query(
|
|
`INSERT INTO archived_feed_items (workspace_id, remote_ref, channel_id, message_id, content_hash, posted_at)
|
|
VALUES ($1, '@ch' || $2::text || ':9002', '@ch' || $2::text, 9002,
|
|
encode(sha256($3::bytea), 'hex'), '2020-01-01')`,
|
|
[wsId, wsId.replace(/-/g, "").slice(0, 9), `${tag}-old`],
|
|
);
|
|
});
|
|
};
|
|
|
|
await seed(wsA, 5);
|
|
await seed(wsB, 10);
|
|
});
|
|
});
|
|
|
|
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", [`analytics:%:${suffix}`]);
|
|
});
|
|
await ownerPool.end();
|
|
}
|
|
await m?.db.pool.end();
|
|
});
|
|
|
|
it("computes per-workspace monthly metrics from fixtures (set-based, no row loops)", async () => {
|
|
const service = new m.service.SnapshotService();
|
|
const a = await service.refresh(wsA, currentMonth);
|
|
expect(a.month).toBe(currentMonth.slice(0, 7));
|
|
expect(a.publicationsTotal).toBe(3);
|
|
expect(a.publicationsByProvider).toEqual({ mastodon: 2, bluesky: 1 });
|
|
expect(a.postsArchivedTotal).toBe(5);
|
|
expect(a.activeConnections).toBe(3);
|
|
expect(a.draftsCreatedTotal).toBe(3);
|
|
expect(a.publishingAttemptsTotal).toBe(3);
|
|
expect(a.publishFailuresTotal).toBe(2);
|
|
|
|
const b = await service.refresh(wsB, currentMonth);
|
|
expect(b.postsArchivedTotal).toBe(10);
|
|
expect(b.activeConnections).toBe(3);
|
|
// Disconnect wsB's connections as sp_app so RLS scopes the update to wsB
|
|
// (the owner pool is a superuser and bypasses RLS entirely).
|
|
await m.db.withTenant(wsB, (client) => client.query("UPDATE connections SET disconnected_at = now()"));
|
|
const bAfter = await service.refresh(wsB, currentMonth);
|
|
expect(bAfter.activeConnections).toBe(0);
|
|
});
|
|
|
|
it("is idempotent: running twice leaves exactly one row, same id and metrics", async () => {
|
|
const service = new m.service.SnapshotService();
|
|
const first = await service.refresh(wsA, currentMonth);
|
|
const second = await service.refresh(wsA, currentMonth);
|
|
expect(second.id).toBe(first.id);
|
|
// Metrics are identical; only the snapshot timestamps move.
|
|
const stripTimes = (r: typeof first): Record<string, unknown> => {
|
|
const { snapshotStartedAt, snapshotCompletedAt, ...metrics } = r;
|
|
void snapshotStartedAt; void snapshotCompletedAt;
|
|
return metrics;
|
|
};
|
|
expect(stripTimes(second)).toEqual(stripTimes(first));
|
|
expect(second.snapshotCompletedAt >= first.snapshotCompletedAt).toBe(true);
|
|
// Count through the app pool so FORCE RLS scopes the check to wsA (the
|
|
// owner pool is a superuser and would see every workspace's rows).
|
|
const rows = await m.db
|
|
.withTenant(wsA, (client) => client.query("SELECT id, publications_total FROM analytics_snapshots WHERE month = $1::date", [currentMonth]))
|
|
.then((r) => r.rows);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({ id: first.id, publications_total: "3" });
|
|
});
|
|
|
|
it("partition pruning: a single-month predicate plans a scan of exactly one leaf", async () => {
|
|
// Plan-time pruning needs a literal (a $n param defers pruning to run
|
|
// time); the value is a validated YYYY-MM-01 so inlining is safe.
|
|
if (!/^\d{4}-\d{2}-01$/.test(currentMonth)) throw new Error("bad month literal");
|
|
const plan = await asOwner(async (c) => {
|
|
await c.query("BEGIN");
|
|
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsA]);
|
|
const explained = await c.query<{ "QUERY PLAN": string }>(
|
|
`EXPLAIN (COSTS OFF) SELECT count(*) FROM analytics_snapshots WHERE month = DATE '${currentMonth}'`,
|
|
);
|
|
await c.query("COMMIT");
|
|
return explained.rows.map((r) => r["QUERY PLAN"]).join("\n");
|
|
});
|
|
const scanned = [...plan.matchAll(/Scan on (analytics_snapshots\S*)/g)].map((match) => match[1]!);
|
|
expect(scanned.filter((r) => /^analytics_snapshots_[0-9]{6}$/.test(r)).length).toBe(1);
|
|
for (const relation of scanned) expect(relation).not.toContain("default");
|
|
});
|
|
|
|
it("cross-tenant denial: RLS hides other workspaces' snapshots (parent and every leaf)", async () => {
|
|
const service = new m.service.SnapshotService();
|
|
await service.refresh(wsB, currentMonth);
|
|
const aList = await service.list(wsA, { limit: 24 });
|
|
expect(aList.items.length).toBeGreaterThan(0);
|
|
expect(aList.items.every((item) => item.workspaceId === wsA)).toBe(true);
|
|
|
|
// A raw read as the runtime role under B's context sees only B's rows.
|
|
const leaked = await m.db.withTenant(wsB, async (client) =>
|
|
client.query("SELECT workspace_id FROM analytics_snapshots"));
|
|
expect(leaked.rows.length).toBeGreaterThan(0);
|
|
expect(new Set(leaked.rows.map((r) => r.workspace_id))).toEqual(new Set([wsB]));
|
|
|
|
// Direct leaf access (parent RLS does not cascade to leaves): zero of
|
|
// A's rows visible from B's context in every partition.
|
|
const partitions = await asOwner((c) =>
|
|
c.query<{ relname: string }>(
|
|
"SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid WHERE i.inhparent = 'analytics_snapshots'::regclass",
|
|
));
|
|
expect(partitions.rows.length).toBeGreaterThan(0);
|
|
for (const row of partitions.rows) {
|
|
if (!/^analytics_snapshots_(default|[0-9]{6})$/.test(row.relname)) throw new Error(`unexpected partition ${row.relname}`);
|
|
const seen = await m.db.withTenant(wsB, (client) => client.query(`SELECT workspace_id FROM ${row.relname}`));
|
|
expect(seen.rows.every((r) => r.workspace_id === wsB)).toBe(true);
|
|
}
|
|
|
|
// And a write attempt for another workspace is refused by WITH CHECK.
|
|
await expect(
|
|
m.db.withTenant(wsA, async (client) =>
|
|
client.query(
|
|
`INSERT INTO analytics_snapshots (workspace_id, month) VALUES ($1::uuid, DATE '${currentMonth}')`,
|
|
[wsB],
|
|
)),
|
|
).rejects.toThrow(/row-level security/i);
|
|
});
|
|
|
|
it("keyset pagination: newest first, cursor continues without overlap", async () => {
|
|
const service = new m.service.SnapshotService();
|
|
await service.refresh(wsA, previousMonth);
|
|
const page1 = await service.list(wsA, { limit: 1 });
|
|
expect(page1.items.map((i) => i.month)).toEqual([currentMonth.slice(0, 7)]);
|
|
expect(page1.nextCursor).not.toBeNull();
|
|
const page2 = await service.list(wsA, { cursor: page1.nextCursor!, limit: 1 });
|
|
expect(page2.items.map((i) => i.month)).toEqual([previousMonth.slice(0, 7)]);
|
|
expect(page2.nextCursor).toBeNull();
|
|
await expect(service.list(wsA, { cursor: "!!!not-a-cursor" })).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
|
|
it("summary: current month + previous month with correct deltas", async () => {
|
|
const service = new m.service.SnapshotService();
|
|
await service.refresh(wsB, previousMonth);
|
|
const summary = await service.summary(wsB);
|
|
expect(summary.currentMonth).toBe(currentMonth.slice(0, 7));
|
|
expect(summary.previousMonth).toBe(previousMonth.slice(0, 7));
|
|
expect(summary.previous).not.toBeNull();
|
|
// previous month: only the one archived item; connections already
|
|
// disconnected when that snapshot was taken.
|
|
expect(summary.previous!.postsArchivedTotal).toBe(1);
|
|
expect(summary.previous!.publicationsTotal).toBe(0);
|
|
expect(summary.current!.postsArchivedTotal).toBe(10);
|
|
expect(summary.deltas).toMatchObject({
|
|
publicationsTotal: 3,
|
|
postsArchivedTotal: 9,
|
|
activeConnections: 0,
|
|
publishFailuresTotal: 2,
|
|
});
|
|
});
|
|
|
|
it("invalid month input is rejected before any query", async () => {
|
|
await expect(m.service.writeMonthlySnapshot({} as never, "2025-13")).rejects.toMatchObject({ status: 400 });
|
|
await expect(new m.service.SnapshotService().refresh(wsA, "2025-1")).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
|
|
it("controller + RBAC guard chain: analytics.view required, wrong-workspace token is refused", async () => {
|
|
const controller = new m.controller.AnalyticsController(new m.service.SnapshotService());
|
|
const user = (await asOwner((c) => c.query<{ id: string }>("SELECT id FROM users WHERE external_id = $1", [`analytics:owner:${suffix}`]))).rows[0]!.id;
|
|
|
|
// Permission gate: a role without analytics.view is forbidden by the
|
|
// guard. The guard resolves the role from the MEMBERSHIP, not the token,
|
|
// so an Editor member of wsA is the honest deny-path fixture.
|
|
const editor = (await asOwner(async (c) => {
|
|
const id = (await c.query(
|
|
"INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id",
|
|
[`analytics:editor:${suffix}`, `analytics-editor-${suffix}`, `editor-${suffix}@example.com`, "editor"],
|
|
)).rows[0].id as string;
|
|
await c.query("BEGIN");
|
|
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsA]);
|
|
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Editor')", [wsA, id]);
|
|
await c.query("COMMIT");
|
|
return id;
|
|
}));
|
|
// The membership insert above bumps authz_epoch (continuous authz), so
|
|
// tokens are minted AFTER it — mirroring a fresh login per epoch.
|
|
const epochNow = await asOwner(async (c) =>
|
|
Number((await c.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [wsA])).rows[0]?.authz_epoch));
|
|
const token = (userId: string, workspaceId: string, role: string): string =>
|
|
m.session.issueSession({ userId, workspaceId, role, authzEpoch: epochNow });
|
|
const request = (t: string): { headers: Record<string, string>; session?: unknown } => ({
|
|
headers: { authorization: `Bearer ${t}` },
|
|
// In production RbacGuard.canActivate attaches this before the
|
|
// controller runs; do the same here after verifying the token.
|
|
session: m.session.verifySession(t) ?? undefined,
|
|
});
|
|
|
|
// Owner of wsA refreshes wsA's snapshot through the real controller.
|
|
const refreshed = await controller.snapshot(request(token(user, wsA, "Owner")) as never, {});
|
|
expect(refreshed.workspaceId).toBe(wsA);
|
|
expect(refreshed.publicationsTotal).toBe(3);
|
|
|
|
// A valid token for wsB does not leak wsA: wsB's refresh writes wsB's row.
|
|
const forB = await controller.snapshot(request(token(user, wsB, "Owner")) as never, {});
|
|
expect(forB.workspaceId).toBe(wsB);
|
|
|
|
const { Reflector } = await import("@nestjs/core");
|
|
const guard = new m.guard.RbacGuard(new Reflector());
|
|
const handler = (): void => undefined;
|
|
Reflect.defineMetadata(m.guard.PERMISSION_KEY, "analytics.view", handler);
|
|
const ctx = {
|
|
getHandler: () => handler,
|
|
switchToHttp: () => ({ getRequest: () => ({ headers: { authorization: `Bearer ${token(editor, wsA, "Editor")}` }, params: {} }) }),
|
|
} as unknown as import("@nestjs/common").ExecutionContext;
|
|
await expect(guard.canActivate(ctx)).rejects.toMatchObject({ status: 403 });
|
|
// ...while the Owner's token passes the same gate.
|
|
const ownerCtx = {
|
|
getHandler: () => handler,
|
|
switchToHttp: () => ({ getRequest: () => ({ headers: { authorization: `Bearer ${token(user, wsA, "Owner")}` }, params: {} }) }),
|
|
} as unknown as import("@nestjs/common").ExecutionContext;
|
|
await expect(guard.canActivate(ownerCtx)).resolves.toBe(true);
|
|
});
|
|
});
|