Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.21",
"@prisma/adapter-pg": "7.8.0",
"db": "workspace:*",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pg": "^8",
"rxjs": "^7.8.2",
"zod": "^4.4.3"
},
"devDependencies": {
Expand All @@ -44,6 +47,7 @@
"@types/passport": "^1.0.17",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.23.1",
"@types/supertest": "^7.2.0",
"eslint": "^9.39.1",
"jest": "^30.4.2",
Expand Down
75 changes: 74 additions & 1 deletion apps/api/src/__mocks__/db-client.mock.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,82 @@
/**
* Jest manual mock for the Prisma generated client (`db/client`).
* Jest manual mock for the Prisma generated client (`db/client`) and for the
* `db` package entrypoint (see `jest.config.js` — both specifiers map here).
*
* Used in all unit and integration tests so that module resolution doesn't
* attempt to load the ESM-only generated Prisma files.
*
* `runWithOrgContext`/`runWithoutOrgScope`/`getOrgContext` below are a
* deliberate, self-contained duplicate of `db`'s `org-scope/context.ts` (not
* a re-export): because this file's specifier intercepts every `db` import
* within this test run (including from application code like
* `OrgContextInterceptor`), it cannot itself `import ... from "db"` without
* resolving back to itself. The real implementation has no Prisma
* dependency and is exercised directly by `packages/db`'s own unit tests
* (`org-scope/context.spec.ts`); keep this copy in sync if that file's
* behavior changes.
*/
import { AsyncLocalStorage } from "node:async_hooks";

interface OrgContext {
readonly organizationId: string;
}
interface UnscopedContext {
readonly unscoped: true;
}
type OrgContextStore = OrgContext | UnscopedContext;

const orgContextStorage = new AsyncLocalStorage<OrgContextStore>();

export async function runWithOrgContext<T>(
organizationId: string,
fn: () => T | PromiseLike<T>,
): Promise<T> {
return orgContextStorage.run({ organizationId }, async () => await fn());
}

export async function runWithoutOrgScope<T>(
fn: () => T | PromiseLike<T>,
): Promise<T> {
return orgContextStorage.run({ unscoped: true }, async () => await fn());
}

export function getOrgContext(): OrgContextStore | undefined {
return orgContextStorage.getStore();
}

export function isUnscopedContext(
context: OrgContextStore,
): context is UnscopedContext {
return "unscoped" in context && context.unscoped === true;
}

export class MissingOrgContextError extends Error {
constructor(model: string, operation: string) {
super(
`Org-scoped query blocked: no organization context for ${model}.${operation}()`,
);
this.name = "MissingOrgContextError";
}
}

export class OrgScopeViolationError extends Error {
constructor(model: string, operation: string) {
super(
`Org-scoped query blocked: ${model}.${operation}() crossed a tenant boundary`,
);
this.name = "OrgScopeViolationError";
}
}

/**
* Tests override `PrismaService` entirely (see every `*.integration.spec.ts`
* and `*.spec.ts`), so the real `createOrgScopedClient` extension never runs
* in this suite. This stub exists only so application code can import it
* without a resolution error; it is intentionally never exercised.
*/
export function createOrgScopedClient<T>(baseClient: T): T {
return baseClient;
}

export const mockPrismaClient = {
$connect: jest.fn().mockResolvedValue(undefined),
Expand Down
28 changes: 3 additions & 25 deletions apps/api/src/admin/organizations/organization.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const mockOrg = {

const mockPrisma = {
organization: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
Expand All @@ -37,27 +36,6 @@ describe("OrganizationService", () => {
service = module.get<OrganizationService>(OrganizationService);
});

describe("findAll", () => {
it("returns all organizations ordered by createdAt desc", async () => {
mockPrisma.organization.findMany.mockResolvedValue([mockOrg]);

const result = await service.findAll();

expect(result).toEqual([mockOrg]);
expect(mockPrisma.organization.findMany).toHaveBeenCalledWith({
orderBy: { createdAt: "desc" },
});
});

it("returns an empty array when no organizations exist", async () => {
mockPrisma.organization.findMany.mockResolvedValue([]);

const result = await service.findAll();

expect(result).toEqual([]);
});
});

describe("findOne", () => {
it("returns the organization when found", async () => {
mockPrisma.organization.findUnique.mockResolvedValue(mockOrg);
Expand Down Expand Up @@ -175,9 +153,9 @@ describe("OrganizationService", () => {
mockPrisma.organization.findUnique.mockResolvedValue(mockOrg);
mockPrisma.organization.update.mockRejectedValue(p2025);

await expect(service.update("org-1", { name: "new.com" })).rejects.toThrow(
NotFoundException,
);
await expect(
service.update("org-1", { name: "new.com" }),
).rejects.toThrow(NotFoundException);
});

it("applies no data update when dto is empty", async () => {
Expand Down
6 changes: 0 additions & 6 deletions apps/api/src/admin/organizations/organization.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,6 @@ import { isPrismaUniqueConstraintError } from "../../common/prisma-errors";
export class OrganizationService {
constructor(private readonly prisma: PrismaService) {}

async findAll(): Promise<Organization[]> {
return this.prisma.organization.findMany({
orderBy: { createdAt: "desc" },
});
}

async findOne(id: string): Promise<Organization> {
const org = await this.prisma.organization.findUnique({ where: { id } });
if (!org) {
Expand Down
37 changes: 33 additions & 4 deletions apps/api/src/admin/organizations/organizations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,26 @@ import {
Patch,
Param,
Body,
Req,
UseGuards,
HttpCode,
HttpStatus,
BadRequestException,
} from "@nestjs/common";
import { AdminRoleGuard } from "../guards/admin-role.guard";
import { assertAdminOrganizationAccess } from "../guards/assert-admin-organization";
import { OrganizationService } from "./organization.service";
import type {
CreateOrganizationDto,
OrganizationResponseDto,
UpdateOrganizationDto,
} from "./organization.dto";
import type { Organization } from "db/client";
import type { AuthenticatedUser } from "../../auth/auth.types";

interface RequestWithUser {
user: AuthenticatedUser;
}

function toResponseDto(org: Organization): OrganizationResponseDto {
return {
Expand All @@ -28,21 +35,41 @@ function toResponseDto(org: Organization): OrganizationResponseDto {
};
}

/**
* Organization membership is the tenant boundary itself, so every route
* here is scoped to the authenticated admin's own organization —
* `assertAdminOrganizationAccess` blocks any attempt to read or modify a
* different organization with 403 Forbidden, mirroring the same convention
* used by `DepartmentsController` and `AdminUsersController`. Creating a new
* organization is the one exception: it doesn't read or modify existing
* tenant data, so it isn't scoped to an existing org.
*/
@Controller("api/admin/organizations")
@UseGuards(AdminRoleGuard)
export class OrganizationsController {
constructor(private readonly organizationService: OrganizationService) {}

/**
* Returns the caller's own organization as a single-item list. There is
* no cross-tenant "list all organizations" view — an admin can only ever
* see the organization they belong to.
*/
@Get()
@HttpCode(HttpStatus.OK)
async findAll(): Promise<OrganizationResponseDto[]> {
const orgs = await this.organizationService.findAll();
return orgs.map(toResponseDto);
async findAll(
@Req() req: RequestWithUser,
): Promise<OrganizationResponseDto[]> {
const org = await this.organizationService.findOne(req.user.organizationId);
return [toResponseDto(org)];
}

@Get(":id")
@HttpCode(HttpStatus.OK)
async findOne(@Param("id") id: string): Promise<OrganizationResponseDto> {
async findOne(
@Req() req: RequestWithUser,
@Param("id") id: string,
): Promise<OrganizationResponseDto> {
assertAdminOrganizationAccess(req.user.organizationId, id);
const org = await this.organizationService.findOne(id);
return toResponseDto(org);
}
Expand All @@ -64,9 +91,11 @@ export class OrganizationsController {
@Patch(":id")
@HttpCode(HttpStatus.OK)
async update(
@Req() req: RequestWithUser,
@Param("id") id: string,
@Body() body: UpdateOrganizationDto,
): Promise<OrganizationResponseDto> {
assertAdminOrganizationAccess(req.user.organizationId, id);
if (
body.name !== undefined &&
(typeof body.name !== "string" || !body.name.trim())
Expand Down
Loading
Loading