Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion testplanit/app/api/admin/elasticsearch/reindex/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { prisma } from "@/lib/prisma";
import { getElasticsearchReindexQueue } from "@/lib/queues";
import { NextRequest, NextResponse } from "next/server";
import { authenticateApiToken } from "~/lib/api-token-auth";
import { enqueueWithAuditContext } from "~/lib/auditContextEnqueue";
import {
enqueueWithAuditContext,
enrichFromApiAuth,
withAuditContext,
} from "~/lib/auditContextWrappers";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Prisma } from "@prisma/client";
import { getServerSession } from "next-auth/next";
import { NextRequest, NextResponse } from "next/server";
import {
enqueueWithAuditContext,
withAuditContext,
} from "~/lib/auditContextWrappers";
import { enqueueWithAuditContext } from "~/lib/auditContextEnqueue";
import { withAuditContext } from "~/lib/auditContextWrappers";
import { getCurrentTenantId } from "~/lib/multiTenantPrisma";
import { getTestmoImportQueue, TESTMO_IMPORT_QUEUE_NAME } from "~/lib/queues";
import { captureAuditEvent } from "~/lib/services/auditLog";
Expand Down
6 changes: 2 additions & 4 deletions testplanit/app/api/imports/testmo/jobs/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { getServerSession } from "next-auth/next";
import { NextRequest, NextResponse } from "next/server";
import {
enqueueWithAuditContext,
withAuditContext,
} from "~/lib/auditContextWrappers";
import { enqueueWithAuditContext } from "~/lib/auditContextEnqueue";
import { withAuditContext } from "~/lib/auditContextWrappers";
import { getCurrentTenantId } from "~/lib/multiTenantPrisma";
import { getTestmoImportQueue, TESTMO_IMPORT_QUEUE_NAME } from "~/lib/queues";
import { authOptions } from "~/server/auth";
Expand Down
6 changes: 2 additions & 4 deletions testplanit/app/api/repository/copy-move/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ import { getCurrentTenantId } from "@/lib/multiTenantPrisma";
import { enhance } from "@zenstackhq/runtime";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import {
enqueueWithAuditContext,
withAuditContext,
} from "~/lib/auditContextWrappers";
import { enqueueWithAuditContext } from "~/lib/auditContextEnqueue";
import { withAuditContext } from "~/lib/auditContextWrappers";
import { prisma } from "~/lib/prisma";
import { getCopyMoveQueue } from "~/lib/queues";
import { authOptions } from "~/server/auth";
Expand Down
207 changes: 207 additions & 0 deletions testplanit/lib/auditContextEnqueue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getAuditContext,
runWithAuditContext,
type AuditContext,
SYSTEM_ACTOR_ID,
} from "./auditContext";
import { enqueueWithAuditContext } from "./auditContextEnqueue";

// Hand-rolled Queue mock — no real BullMQ or Valkey connection.
type QueueMock = {
add: ReturnType<typeof vi.fn>;
};
function makeQueueMock(): QueueMock {
return { add: vi.fn(async () => ({ id: "job-mock" })) };
}

// This suite intentionally does NOT mock next/headers. The enqueue module
// is runtime-agnostic and must load cleanly in environments where Next.js
// is not installed (e.g. the workers Docker image).
describe("enqueueWithAuditContext", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("merges ALS context into actorContext when ALS is present", async () => {
const queue = makeQueueMock();
const ctx: AuditContext = {
userId: "u-42",
userEmail: "u42@example.com",
userName: "User 42",
ipAddress: "198.51.100.2",
userAgent: "UA/enqueue",
requestId: "req_fixed_42",
};

await runWithAuditContext(ctx, async () => {
await enqueueWithAuditContext(
queue as unknown as Parameters<typeof enqueueWithAuditContext>[0],
"job-alpha",
{ foo: "bar" }
);
});

expect(queue.add).toHaveBeenCalledTimes(1);
const [jobName, payload] = queue.add.mock.calls[0];
expect(jobName).toBe("job-alpha");
expect(payload).toMatchObject({
foo: "bar",
actorContext: {
userId: "u-42",
userEmail: "u42@example.com",
userName: "User 42",
ipAddress: "198.51.100.2",
userAgent: "UA/enqueue",
requestId: "req_fixed_42",
},
});
// No root-level systemReason mirror when not system-stamped
expect((payload as Record<string, unknown>).systemReason).toBeUndefined();
});

it("throws when ALS is empty and no systemReason is provided", async () => {
const queue = makeQueueMock();

// Call OUTSIDE any runWithAuditContext frame — ALS is empty.
await expect(
enqueueWithAuditContext(
queue as unknown as Parameters<typeof enqueueWithAuditContext>[0],
"job-beta",
{ foo: "bar" }
)
).rejects.toThrow(/no audit context present/);

expect(queue.add).not.toHaveBeenCalled();
});

it("throws when ALS contains only empty-string fields and no systemReason (WR-01 hardening)", async () => {
const queue = makeQueueMock();
const emptyCtx: AuditContext = {
userId: "",
userEmail: "",
userName: "",
ipAddress: "",
userAgent: "",
requestId: "",
};

await expect(
runWithAuditContext(emptyCtx, async () => {
await enqueueWithAuditContext(
queue as unknown as Parameters<typeof enqueueWithAuditContext>[0],
"job-empty-strings",
{ foo: "bar" }
);
})
).rejects.toThrow(/no audit context present/);

expect(queue.add).not.toHaveBeenCalled();
});

it("does NOT misattribute when ALS has a populated userId but empty ipAddress (WR-01 future-refactor guard)", async () => {
const queue = makeQueueMock();
const ctx: AuditContext = {
userId: "u-real",
userEmail: "real@example.com",
userName: "Real User",
ipAddress: "", // future-refactor scenario: extractIpAddress defaulted to ""
userAgent: "UA/real",
requestId: "req_real_1",
};

await runWithAuditContext(ctx, async () => {
await enqueueWithAuditContext(
queue as unknown as Parameters<typeof enqueueWithAuditContext>[0],
"job-mixed",
{ foo: "bar" }
);
});

expect(queue.add).toHaveBeenCalledTimes(1);
const [, payload] = queue.add.mock.calls[0];
// The user is real, so user-attribution wins (the empty ipAddress is
// faithfully carried through — we do NOT rewrite it, we just do not
// let it alone flip the branch). The job payload reflects the
// actual ALS state; consumers / expectAuditRowComplete catch the
// incomplete ipAddress downstream via WR-03.
expect((payload as Record<string, unknown>).actorContext).toMatchObject({
userId: "u-real",
ipAddress: "",
});
});

it("stamps __system__ with systemReason embedded in actorContext when ALS is empty", async () => {
const queue = makeQueueMock();

await enqueueWithAuditContext(
queue as unknown as Parameters<typeof enqueueWithAuditContext>[0],
"job-gamma",
{ foo: "bar" },
{ systemReason: "scheduled:test-rollup" }
);

expect(queue.add).toHaveBeenCalledTimes(1);
const [jobName, payload] = queue.add.mock.calls[0];
expect(jobName).toBe("job-gamma");
expect(payload).toMatchObject({
foo: "bar",
actorContext: {
userId: SYSTEM_ACTOR_ID,
systemReason: "scheduled:test-rollup",
},
});
});

it("sets root-level systemReason mirror on the job payload", async () => {
const queue = makeQueueMock();

await enqueueWithAuditContext(
queue as unknown as Parameters<typeof enqueueWithAuditContext>[0],
"job-delta",
{ foo: "bar" },
{ systemReason: "scheduled:mirror-test" }
);

const payload = queue.add.mock.calls[0][1] as Record<string, unknown>;
expect(payload.systemReason).toBe("scheduled:mirror-test");
});

it("prefers ALS context over systemReason when both present", async () => {
const queue = makeQueueMock();
const ctx: AuditContext = {
userId: "u-user-wins",
userEmail: "user@example.com",
userName: "Real User",
requestId: "req_user_wins",
};

await runWithAuditContext(ctx, async () => {
await enqueueWithAuditContext(
queue as unknown as Parameters<typeof enqueueWithAuditContext>[0],
"job-epsilon",
{ foo: "bar" },
{ systemReason: "scheduled:should-be-ignored" }
);
});

const payload = queue.add.mock.calls[0][1] as Record<string, unknown>;
expect((payload.actorContext as AuditContext).userId).toBe("u-user-wins");
// No __system__ anywhere in actorContext
expect((payload.actorContext as AuditContext).userId).not.toBe(
SYSTEM_ACTOR_ID
);
// No systemReason leaks in on the user-attributed branch
expect(payload.systemReason).toBeUndefined();
expect((payload.actorContext as AuditContext).systemReason).toBeUndefined();
});

it("sanity: getAuditContext is the same module both tests import", async () => {
// Guard against a stealth duplicate-module issue where the enqueue
// module's ALS store diverges from the test's. If this ever fails,
// the tsconfig path alias or vitest resolver has drifted.
await runWithAuditContext({ userId: "sanity" }, async () => {
expect(getAuditContext()?.userId).toBe("sanity");
});
});
});
132 changes: 132 additions & 0 deletions testplanit/lib/auditContextEnqueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { Job, JobsOptions, Queue } from "bullmq";
import {
type AuditContext,
SYSTEM_ACTOR_ID,
getAuditContext,
} from "~/lib/auditContext";

/**
* Runtime-agnostic BullMQ audit-context helpers.
*
* This module is intentionally free of any `next/*` imports so that it can
* be loaded by BullMQ workers, which run against a Docker image that
* strips Next.js from node_modules to save ~900MB. The Next.js-specific
* HOFs (withAuditContext, withActionAuditContext, enrichFromApiAuth) live
* in `./auditContextWrappers.ts` and MUST NOT be imported from here.
*/

/**
* System-actor stamping options. Callers that legitimately enqueue jobs
* from outside any user request (scheduled jobs, worker-to-worker where
* no upstream context exists, infrastructure tasks) MUST pass a
* `systemReason` so the audit log records WHY the event has no actor.
*
* Convention: `scope:identifier` e.g. `"scheduled:daily-report-rollup"`,
* `"scheduled:budget-alert-check"`, `"fixture:test-fixture"`. Free-form
* is acceptable — it is stored as-is on the ALS frame and merged into
* event.metadata by captureAuditEvent.
*
* Per Phase 64 D-14 / D-15 / W5.
*/
export interface EnqueueSystemOptions {
systemReason: string;
}

export type ActorContextJobData<T> = T & {
actorContext: AuditContext;
/** Mirror of actorContext.systemReason for consumers that read job root directly. */
systemReason?: string;
};

/**
* Enqueue a job with actor context stamped from the current ALS frame,
* OR explicitly stamp as `__system__` when no ALS context is available
* and a `systemReason` is provided.
*
* Throws at enqueue-time when no ALS context is present AND no
* systemReason is provided — this is intentional: callers must decide
* consciously whether a job is user-attributed or system-initiated.
*
* When stamping `__system__`, the systemReason is embedded INSIDE
* actorContext (so `runWithAuditContext(job.data.actorContext, ...)` in
* the worker re-populates ALS with systemReason, and captureAuditEvent
* merges it into event.metadata — see W5 Option A). A root-level mirror
* of systemReason is preserved for consumers that read the job data
* directly.
*
* Per Phase 64 D-08 / D-14 / W5.
*/
export function enqueueWithAuditContext<T extends object>(
queue: Queue,
name: string,
data: T,
opts?: JobsOptions
): Promise<Job<ActorContextJobData<T>>>;
export function enqueueWithAuditContext<T extends object>(
queue: Queue,
name: string,
data: T,
systemOpts: EnqueueSystemOptions,
opts?: JobsOptions
): Promise<Job<ActorContextJobData<T>>>;
export async function enqueueWithAuditContext<T extends object>(
queue: Queue,
name: string,
data: T,
optsOrSystem?: JobsOptions | EnqueueSystemOptions,
maybeOpts?: JobsOptions
): Promise<Job<ActorContextJobData<T>>> {
const isSystemOpts =
typeof optsOrSystem === "object" &&
optsOrSystem !== null &&
"systemReason" in optsOrSystem &&
typeof (optsOrSystem as EnqueueSystemOptions).systemReason === "string";

const jobOpts: JobsOptions | undefined = isSystemOpts
? maybeOpts
: (optsOrSystem as JobsOptions | undefined);

const alsContext = getAuditContext();
// WR-01: empty strings are "present but empty", which is just as
// invalid as absent. Compare explicitly against null/undefined AND ""
// so a future refactor that defaults a field to "" does not silently
// flip a user-attributed event into the system branch.
const isPresent = (value: unknown): boolean =>
typeof value === "string" && value.length > 0;
const hasAlsIdentity = Boolean(
alsContext &&
(isPresent(alsContext.userId) ||
isPresent(alsContext.userEmail) ||
isPresent(alsContext.userName) ||
isPresent(alsContext.ipAddress) ||
isPresent(alsContext.userAgent) ||
isPresent(alsContext.requestId))
);

let payload: ActorContextJobData<T>;

if (hasAlsIdentity && alsContext) {
payload = {
...data,
actorContext: { ...alsContext },
};
} else if (isSystemOpts) {
const systemReason = (optsOrSystem as EnqueueSystemOptions).systemReason;
payload = {
...data,
actorContext: { userId: SYSTEM_ACTOR_ID, systemReason },
systemReason,
};
} else {
throw new Error(
"enqueueWithAuditContext: no audit context present; pass { systemReason } to stamp __system__"
);
}

return queue.add(
name,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload as any,
jobOpts
) as Promise<Job<ActorContextJobData<T>>>;
}
Loading
Loading