diff --git a/apps/api/src/modules/app.module.ts b/apps/api/src/modules/app.module.ts index 3ea3b36..0079046 100644 --- a/apps/api/src/modules/app.module.ts +++ b/apps/api/src/modules/app.module.ts @@ -1,7 +1,9 @@ +import { join } from "node:path"; import { AuthModule } from "@cv/auth"; import { AIResolutionModule, AuthorizationModule, + AuthorizedInterceptor, BaseModule, DatabaseModule, EventsModule, @@ -17,13 +19,12 @@ import { RequestMethod, } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; -import { APP_GUARD } from "@nestjs/core"; +import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core"; import { GraphQLModule } from "@nestjs/graphql"; import { JwtModule } from "@nestjs/jwt"; import { ThrottlerModule } from "@nestjs/throttler"; import { SentryModule } from "@sentry/nestjs/setup"; import { Request, Response } from "express"; -import { join } from "node:path"; import { AuthContextMiddleware } from "@/config/auth-context.middleware"; import { AppConfigModule } from "@/config/config.module"; import { validate } from "@/config/env.validation"; @@ -37,8 +38,8 @@ import { SeedModule } from "@/seed/seed.module"; import { AdminModule } from "./admin/admin.module"; import { AppModule as AppModuleComponent } from "./app/app.module"; import { ApplicationModule } from "./application/application.module"; -import { AsyncJobGraphQLModule } from "./async-job/async-job-graphql.module"; import { ApplicationStatusModule } from "./application/application-status/application-status.module"; +import { AsyncJobGraphQLModule } from "./async-job/async-job-graphql.module"; import { AuthenticationModule } from "./authentication/authentication.module"; import { CurrentUserModule } from "./current-user/current-user.module"; import { CVParserModule } from "./cv-parser/cv-parser.module"; @@ -142,6 +143,7 @@ import { VacancyModule } from "./vacancies/vacancy.module"; ], providers: [ { provide: APP_GUARD, useClass: RoleAwareThrottlerGuard }, + { provide: APP_INTERCEPTOR, useClass: AuthorizedInterceptor }, GraphQLComplexityPlugin, ], }) diff --git a/apps/api/src/modules/base/__tests__/named-entity-resolver.factory.spec.ts b/apps/api/src/modules/base/__tests__/named-entity-resolver.factory.spec.ts new file mode 100644 index 0000000..dff5f8a --- /dev/null +++ b/apps/api/src/modules/base/__tests__/named-entity-resolver.factory.spec.ts @@ -0,0 +1,186 @@ +import "reflect-metadata"; +import { + ClockService, + type Factory, + NamedEntity, + type NamedEntityService, + type PaginationResult, + PaginationService, +} from "@cv/core"; +import { ArgsType } from "@nestjs/graphql"; +import { describe, expect, it } from "vitest"; +import { createNamedEntityResolver } from "../named-entity-resolver.factory"; +import { createNamedGraphQLType } from "../named-graphql-type.factory"; + +// Each test uses a distinct entity class so their @ObjectType-derived +// schema names don't collide in @nestjs/graphql's global type metadata +// storage. createNamedGraphQLType registers a singleton per name. +class FakeAlpha extends NamedEntity {} +class FakeBeta extends NamedEntity {} +class FakeCategory extends NamedEntity {} + +const Alpha = createNamedGraphQLType("FakeAlpha", FakeAlpha); +const Beta = createNamedGraphQLType("FakeBeta", FakeBeta); +const Category = createNamedGraphQLType( + "FakeCategory", + FakeCategory, +); + +@ArgsType() +class CustomConnectionArgs {} + +class FakeService { + findMany = async (): Promise => []; + count = async (): Promise => 0; + findByIdOrFail = async (id: string): Promise => + new FakeAlpha(id, "n", new Date(), new Date()); + save = async (entity: NamedEntity): Promise => entity; + destroy = async (_entity: NamedEntity): Promise => undefined; +} + +class FakeFactory implements Factory { + create(data: { name: string }): NamedEntity { + return new FakeAlpha("new-id", data.name, new Date(), new Date()); + } +} + +const RESOLVER_NAME = "graphql:resolver_name"; +const RESOLVER_TYPE = "graphql:resolver_type"; + +const collectResolverMethods = (cls: { + prototype: Record; +}): Record => { + const proto = cls.prototype; + const out: Record = {}; + for (const key of Object.getOwnPropertyNames(proto)) { + if (key === "constructor") { + continue; + } + const fn = proto[key]; + if (typeof fn !== "function") { + continue; + } + const type = + Reflect.getMetadata(RESOLVER_TYPE, fn) ?? + Reflect.getMetadata(RESOLVER_TYPE, proto, key); + const schemaName = + Reflect.getMetadata(RESOLVER_NAME, fn) ?? + Reflect.getMetadata(RESOLVER_NAME, proto, key); + if (typeof type === "string") { + out[key] = { type, schemaName }; + } + } + return out; +}; + +describe("createNamedEntityResolver", () => { + it("derives field names from `domainEntity.name` when `names` is omitted", () => { + const ResolverClass = createNamedEntityResolver({ + domainEntity: FakeAlpha, + gqlType: Alpha.Type, + gqlConnection: Alpha.Connection, + serviceToken: + FakeService as unknown as new () => NamedEntityService, + factoryToken: FakeFactory as unknown as new () => Factory< + FakeAlpha, + { name: string; description?: string } + >, + }) as unknown as { prototype: Record }; + + const methods = collectResolverMethods(ResolverClass); + + expect(methods).toMatchObject({ + list: { type: "Query", schemaName: "fakeAlphas" }, + byId: { type: "Query", schemaName: "fakeAlpha" }, + create: { type: "Mutation", schemaName: "createFakeAlpha" }, + update: { type: "Mutation", schemaName: "updateFakeAlpha" }, + delete: { type: "Mutation", schemaName: "deleteFakeAlpha" }, + }); + }); + + it("pluralises `y` endings as `ies` (FakeCategory => fakeCategories)", () => { + const ResolverClass = createNamedEntityResolver({ + domainEntity: FakeCategory, + gqlType: Category.Type, + gqlConnection: Category.Connection, + serviceToken: + FakeService as unknown as new () => NamedEntityService, + factoryToken: FakeFactory as unknown as new () => Factory< + FakeCategory, + { name: string; description?: string } + >, + }) as unknown as { prototype: Record }; + + const methods = collectResolverMethods(ResolverClass); + + expect(methods.list?.schemaName).toBe("fakeCategories"); + expect(methods.byId?.schemaName).toBe("fakeCategory"); + }); + + it("accepts per-field overrides when GraphQL name diverges from the domain class", () => { + const ResolverClass = createNamedEntityResolver({ + domainEntity: FakeBeta, + gqlType: Beta.Type, + gqlConnection: Beta.Connection, + gqlConnectionArgs: CustomConnectionArgs, + names: { + plural: "betaList", + }, + serviceToken: + FakeService as unknown as new () => NamedEntityService, + factoryToken: FakeFactory as unknown as new () => Factory< + FakeBeta, + { name: string; description?: string } + >, + }) as unknown as { prototype: Record }; + + const methods = collectResolverMethods(ResolverClass); + + expect(methods.list?.schemaName).toBe("betaList"); + expect(methods.byId?.schemaName).toBe("fakeBeta"); + expect(methods.create?.schemaName).toBe("createFakeBeta"); + }); + + it("instantiates and runs the generated list method against stubs", async () => { + const ResolverClass = createNamedEntityResolver({ + domainEntity: FakeAlpha, + gqlType: Alpha.Type, + gqlConnection: Alpha.Connection, + serviceToken: + FakeService as unknown as new () => NamedEntityService, + factoryToken: FakeFactory as unknown as new () => Factory< + FakeAlpha, + { name: string; description?: string } + >, + }); + + const buildResult: PaginationResult = { + edges: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + totalCount: 0, + }; + const paginationService = { + parsePaginationArgs: () => ({}), + buildPaginationResult: () => buildResult, + } as unknown as PaginationService; + const clock = { + now: () => new Date("2026-05-14T00:00:00Z"), + } as ClockService; + const service = new FakeService(); + const factory = new FakeFactory(); + + const instance = new ( + ResolverClass as new ( + ...args: unknown[] + ) => Record unknown> + )(service, factory, paginationService, clock); + + const result = await instance.list({}); + expect(result).toBeDefined(); + }); +}); diff --git a/apps/api/src/modules/base/dataloaded-field.factory.ts b/apps/api/src/modules/base/dataloaded-field.factory.ts new file mode 100644 index 0000000..ce40f15 --- /dev/null +++ b/apps/api/src/modules/base/dataloaded-field.factory.ts @@ -0,0 +1,72 @@ +import { type BaseDataLoaderService, type BaseEntity, raise } from "@cv/core"; +import type { Type } from "@nestjs/common"; + +/** + * Options for `dataLoadedField`. The function is intentionally + * thin - the @ResolveField + @Parent decorators stay at the call site so + * NestJS metadata reflection sees the canonical method signatures. + */ +export interface DataLoadedFieldOptions< + TParent, + TLoaded extends BaseEntity, + TGql, +> { + /** GQL output type with `fromDomain` mapper. */ + GqlType: Type & { fromDomain: (domain: TLoaded) => TGql }; + /** Reaches the per-request DataLoader off the resolver instance. */ + loader: (self: unknown) => BaseDataLoaderService; + /** Foreign key on the parent GraphQL object. */ + foreignKey: keyof TParent; + /** When `true`, missing FK returns null instead of throwing. */ + nullable?: boolean; +} + +/** + * Returns a function suitable for invocation from a `@ResolveField` resolver + * method body. Replaces the `dataLoader.load(parent.fkId)` + null-check + + * `GqlType.fromDomain(loaded)` chain that every field-resolver duplicates. + * + * Use via `.call(this, parent)` from the resolver body so `this` carries the + * dataloader instance: + * + * @example + * @ResolveField(() => Company) + * async company(@Parent() v: Vacancy): Promise { + * return dataLoadedField({ + * GqlType: Company, + * loader: (self) => (self as VacancyResolver).companyDataLoader, + * foreignKey: "companyId", + * }).call(this, v); + * } + */ +export function dataLoadedField( + opts: DataLoadedFieldOptions, +): (this: unknown, parent: TParent) => Promise { + return async function dataLoadedFieldImpl( + this: unknown, + parent: TParent, + ): Promise { + const fk = parent[opts.foreignKey]; + if (fk == null) { + if (opts.nullable) { + return null; + } + raise( + `dataLoadedField: missing foreign key '${String(opts.foreignKey)}' on parent`, + ); + } + if (typeof fk !== "string") { + raise( + `dataLoadedField: foreign key '${String(opts.foreignKey)}' is not a string`, + ); + } + const loaded = await opts.loader(this).load(fk); + if (!loaded) { + if (opts.nullable) { + return null; + } + raise(`dataLoadedField: ${opts.GqlType.name} with id ${fk} not found`); + } + return opts.GqlType.fromDomain(loaded); + }; +} diff --git a/apps/api/src/modules/base/named-entity-resolver.factory.ts b/apps/api/src/modules/base/named-entity-resolver.factory.ts new file mode 100644 index 0000000..0834225 --- /dev/null +++ b/apps/api/src/modules/base/named-entity-resolver.factory.ts @@ -0,0 +1,256 @@ +import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; +import { + Authorized, + AuthorizedResource, + type BaseEntity, + ClockService, + type User as DomainUser, + type Factory, + type NamedEntity, + type NamedEntityService, + type PaginationResult, + PaginationService, + SearchablePaginationArgs, +} from "@cv/core"; +import { Inject, type Type, UseGuards } from "@nestjs/common"; +import { Args, ArgsType, Mutation, Query, Resolver } from "@nestjs/graphql"; +import { CurrentUser } from "@/modules/current-user/current-user.decorator"; + +type NamedDto = { name: string; description?: string }; +type NamedUpdateDto = { name?: string; description?: string }; + +/** + * Default args shape for the factory's list query: `first`/`last`/`after`/ + * `before` from `BasePaginationArgs` plus `searchTerm` from + * `SearchablePaginationArgs`. Used when the caller doesn't pass a custom + * `gqlConnectionArgs`. Single shared concrete `@ArgsType` so the schema + * doesn't grow a distinct args type per `createNamedEntityResolver` call. + */ +@ArgsType() +export class DefaultNamedEntityConnectionArgs extends SearchablePaginationArgs {} + +/** + * Optional override for the update reconstruction step. Receives the loaded + * entity, the partial update DTO, and the clock so caller can produce a + * fresh domain instance. The default below handles the standard NamedEntity + * (id, name, createdAt, updatedAt, description). + */ +export type ReconstructFn = ( + current: TDomain, + dto: NamedUpdateDto, + clock: ClockService, +) => TDomain; + +/** + * GraphQL field names for the 5 generated methods. Every field is optional + * because the factory derives sensible defaults from `domainEntity.name`: + * + * - `domainEntity: Skill` => + * singular = "skill" + * plural = "skills" + * create* = "createSkill" + * update* = "updateSkill" + * delete* = "deleteSkill" + * + * Override per-field only when the GraphQL surface diverges from the domain + * class name. The repo convention is to keep them aligned, so the common + * call site omits `names` entirely. + */ +export interface NamedEntityNamesOverride { + singular?: string; + plural?: string; + createMutation?: string; + updateMutation?: string; + deleteMutation?: string; +} + +export interface NamedEntityResolverOptions< + TDomain extends NamedEntity, + TGqlType, + TGqlConnection, +> { + domainEntity: Type; + gqlType: Type & { fromDomain: (domain: TDomain) => TGqlType }; + gqlConnection: Type & { + fromPaginationResult: (result: PaginationResult) => TGqlConnection; + }; + serviceToken: Type>; + factoryToken: Type>; + /** Optional - defaults to `DefaultNamedEntityConnectionArgs`. */ + gqlConnectionArgs?: Type; + /** Optional - each field defaults to a camelCased `domainEntity.name`. */ + names?: NamedEntityNamesOverride; + reconstruct?: ReconstructFn; +} + +interface ResolvedNames { + singular: string; + plural: string; + createMutation: string; + updateMutation: string; + deleteMutation: string; +} + +const camelCase = (s: string): string => + s.length === 0 ? s : `${s[0]?.toLowerCase() ?? ""}${s.slice(1)}`; + +const pluralise = (s: string): string => + s.endsWith("y") ? `${s.slice(0, -1)}ies` : `${s}s`; + +const resolveNames = ( + entity: { name: string }, + overrides?: NamedEntityNamesOverride, +): ResolvedNames => { + const singularBase = camelCase(entity.name); + const pluralBase = pluralise(singularBase); + return { + singular: overrides?.singular ?? singularBase, + plural: overrides?.plural ?? pluralBase, + createMutation: overrides?.createMutation ?? `create${entity.name}`, + updateMutation: overrides?.updateMutation ?? `update${entity.name}`, + deleteMutation: overrides?.deleteMutation ?? `delete${entity.name}`, + }; +}; + +const defaultReconstruct = ( + DomainCtor: Type, +): ReconstructFn => { + return (current, dto, clock) => { + const next = new ( + DomainCtor as new ( + id: string, + name: string, + createdAt: Date, + updatedAt: Date, + description?: string, + ) => TDomain + )( + current.id, + dto.name ?? current.name, + current.createdAt, + clock.now(), + dto.description !== undefined ? dto.description : current.description, + ); + return next; + }; +}; + +/** + * Builds a `@Resolver(() => GqlType)`-decorated CRUD resolver for a + * `NamedEntity`. Replaces the 5-method boilerplate (list / byId+view / + * create / update / delete) shared by Skill / Role / Level / Company with a + * single factory call. + * + * The factory mirrors `createNamedGraphQLType` + `createConnection`: + * decorators are applied inside the factory body at definition time, so the + * resulting class participates in Nest's DI + GraphQL schema generation + * exactly like a hand-written resolver. Mutation / query field *names* are + * derived from `domainEntity.name` (camelCased / pluralised) so grep on the + * field name still hits the entity's home module. + * + * Auth uses the `@Authorized(...)` decorator path (interceptor-driven) for + * `view` / `update` / `delete` / `create`. Modules consuming the factory + * must register an `@AuthorizedEntityLoader(domainEntity)` provider. + */ +export function createNamedEntityResolver< + TDomain extends NamedEntity, + TGqlType, + TGqlConnection, +>( + opts: NamedEntityResolverOptions, +): Type { + const { domainEntity, gqlType, gqlConnection, serviceToken, factoryToken } = + opts; + const gqlConnectionArgs = + opts.gqlConnectionArgs ?? DefaultNamedEntityConnectionArgs; + const names = resolveNames(domainEntity, opts.names); + const reconstruct = opts.reconstruct ?? defaultReconstruct(domainEntity); + + @Resolver(() => gqlType) + @UseGuards(JwtAuthGuard, VerifiedScopeGuard) + class GeneratedNamedEntityResolver { + constructor( + @Inject(serviceToken) + readonly service: NamedEntityService, + @Inject(factoryToken) + readonly factory: Factory, + readonly paginationService: PaginationService, + readonly clock: ClockService, + ) {} + + @Query(() => gqlConnection, { name: names.plural }) + async list( + @Args({ type: () => gqlConnectionArgs }) + args: { searchTerm?: string | null } & Record = {}, + ): Promise { + const options = this.paginationService.parsePaginationArgs(args); + const filters = { searchTerm: args.searchTerm || undefined }; + const [items, totalCount] = await Promise.all([ + this.service.findMany(filters), + this.service.count(filters), + ]); + const result = this.paginationService.buildPaginationResult( + items, + totalCount, + options, + ); + return gqlConnection.fromPaginationResult(result); + } + + @Query(() => gqlType, { name: names.singular }) + @Authorized({ action: "view", entity: domainEntity }) + async byId(@AuthorizedResource() resource: TDomain): Promise { + return gqlType.fromDomain(resource); + } + + @Mutation(() => gqlType, { name: names.createMutation }) + @Authorized({ action: "create", entity: domainEntity }) + async create( + @Args("name") name: string, + @Args("description", { nullable: true }) description?: string, + ): Promise { + const dto: NamedDto = { name }; + if (description !== undefined) { + dto.description = description; + } + const entity = this.factory.create(dto); + const saved = await this.service.save(entity); + return gqlType.fromDomain(saved); + } + + @Mutation(() => gqlType, { name: names.updateMutation }) + @Authorized({ action: "update", entity: domainEntity }) + async update( + @AuthorizedResource() current: TDomain, + @Args("id") _id: string, + @Args("name", { nullable: true }) name?: string, + @Args("description", { nullable: true }) description?: string, + ): Promise { + const dto: NamedUpdateDto = {}; + if (name !== undefined) { + dto.name = name; + } + if (description !== undefined) { + dto.description = description; + } + const updated = reconstruct(current, dto, this.clock); + const saved = await this.service.save(updated); + return gqlType.fromDomain(saved); + } + + @Mutation(() => Boolean, { name: names.deleteMutation }) + @Authorized({ action: "delete", entity: domainEntity }) + async delete( + @AuthorizedResource() resource: TDomain, + @Args("id") _id: string, + @CurrentUser() _user: DomainUser, + ): Promise { + await this.service.destroy(resource); + return true; + } + } + + return GeneratedNamedEntityResolver as Type; +} + +export type { BaseEntity, NamedDto, NamedUpdateDto }; diff --git a/apps/api/src/modules/base/paginated-owned-field.factory.ts b/apps/api/src/modules/base/paginated-owned-field.factory.ts new file mode 100644 index 0000000..6555a7f --- /dev/null +++ b/apps/api/src/modules/base/paginated-owned-field.factory.ts @@ -0,0 +1,98 @@ +import { + type BaseEntity, + type PaginationArgs, + type PaginationOptions, + type PaginationResult, + type PaginationService, +} from "@cv/core"; +import type { Type } from "@nestjs/common"; + +/** + * Options for `paginatedOwnedField`. The factory targets the User/Profile + * owned-entity field pattern - `@ResolveField` methods that fetch a + * paginated connection of items owned by the parent. + */ +export interface PaginatedOwnedFieldOptions< + TParent, + TItem extends BaseEntity, + TConnection, + TFilter, +> { + /** Connection type with `fromPaginationResult` mapper. */ + GqlConnection: Type & { + fromPaginationResult: (result: PaginationResult) => TConnection; + }; + /** + * Fetch implementation. Receives the resolver instance so closures can + * lazily reach injected services (avoids passing every dependency through + * the factory options). + */ + fetch: ( + self: unknown, + ownerId: string, + filters: TFilter | undefined, + options: PaginationOptions, + ) => Promise<{ items: TItem[]; totalCount: number }>; + /** Property on the parent GraphQL object that contains the owner id. */ + ownerKey: keyof TParent; +} + +/** + * Returns a function suitable for invocation from a `@ResolveField` resolver + * method body. Replaces the parsePaginationArgs / fetch / buildPaginationResult + * boilerplate shared by User/Profile-owned entity field resolvers. + * + * `this` must carry the resolver's `paginationService` (the runtime is the + * resolver instance), so calls go through `.call(this, parent, args)` from + * the resolver body. + * + * @example + * @ResolveField(() => EducationConnection, { nullable: true }) + * async educationHistory( + * @Parent() user: User, + * @Args() args: PaginationArgs = {}, + * ): Promise { + * return paginatedOwnedField({ + * GqlConnection: EducationConnection, + * ownerKey: "id", + * fetch: (self, ownerId, _filter, options) => + * (self as EducationUserFieldResolver).educationService.findManyForOwner(ownerId, options) + * .then((r) => ({ items: r.edges.map((e) => e.node), totalCount: r.totalCount })), + * }).call(this, user, args); + * } + */ +export function paginatedOwnedField< + TParent, + TItem extends BaseEntity, + TConnection, + TFilter = undefined, +>( + opts: PaginatedOwnedFieldOptions, +): ( + this: { paginationService: PaginationService }, + parent: TParent, + paginationArgs: PaginationArgs, + filter?: TFilter, +) => Promise { + return async function paginatedOwnedFieldImpl( + this: { paginationService: PaginationService }, + parent: TParent, + paginationArgs: PaginationArgs, + filter?: TFilter, + ): Promise { + const ownerId = parent[opts.ownerKey] as unknown as string; + const options = this.paginationService.parsePaginationArgs(paginationArgs); + const { items, totalCount } = await opts.fetch( + this, + ownerId, + filter, + options, + ); + const result = this.paginationService.buildPaginationResult( + items, + totalCount, + options, + ); + return opts.GqlConnection.fromPaginationResult(result); + }; +} diff --git a/packages/core/package.json b/packages/core/package.json index fbc10d8..949893c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -87,6 +87,11 @@ "require": "./dist/shared/index.js", "import": "./dist/shared/index.js" }, + "./shared/testing": { + "types": "./dist/shared/testing/index.d.ts", + "require": "./dist/shared/testing/index.js", + "import": "./dist/shared/testing/index.js" + }, "./modules/auth": { "types": "./dist/modules/auth/index.d.ts", "require": "./dist/modules/auth/index.js", diff --git a/packages/core/src/modules/async-job/__tests__/async-job.store.spec.ts b/packages/core/src/modules/async-job/__tests__/async-job.store.spec.ts index 32a5b63..3cad3bb 100644 --- a/packages/core/src/modules/async-job/__tests__/async-job.store.spec.ts +++ b/packages/core/src/modules/async-job/__tests__/async-job.store.spec.ts @@ -1,7 +1,7 @@ import { AsyncJobKind, Prisma } from "@prisma/client"; -import { describe, expect, it, vi } from "vitest"; import type { Mock } from "vitest"; -import type { ClockService } from "../../../shared/clock.service"; +import { describe, expect, it, vi } from "vitest"; +import { frozenClockAt } from "../../../shared/testing"; import type { PrismaService } from "../../database/prisma.service"; import { AsyncJobEntity } from "../async-job.entity"; import { AsyncJobMapper } from "../async-job.mapper"; @@ -12,7 +12,7 @@ describe("AsyncJobStore", () => { let prisma: { asyncJob: { findUnique: Mock; update: Mock } }; const now = new Date("2026-05-14T13:00:00.000Z"); - const clock: ClockService = { now: () => now }; + const clock = frozenClockAt(now); const completedAt = new Date("2026-05-13T16:00:00.000Z"); const row = { diff --git a/packages/core/src/modules/auth/authorization/__tests__/authorized.interceptor.spec.ts b/packages/core/src/modules/auth/authorization/__tests__/authorized.interceptor.spec.ts new file mode 100644 index 0000000..9a94ec3 --- /dev/null +++ b/packages/core/src/modules/auth/authorization/__tests__/authorized.interceptor.spec.ts @@ -0,0 +1,299 @@ +import { type CallHandler, type ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import * as GraphQLModule from "@nestjs/graphql"; +import type { Mock } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { BaseEntity } from "../../../../shared/base.entity"; +import { CannotViewError } from "../../errors/authorization.error"; +import { type User } from "../../user/user.entity"; +import { type AuthorizationService } from "../authorization.service"; +import { + AUTHORIZED_RESOURCE_KEY, + type AuthorizedOptions, +} from "../authorized.decorator"; +import { AuthorizedInterceptor } from "../authorized.interceptor"; +import { type AuthorizedEntityLoaderRegistry } from "../authorized-entity-loader.registry"; + +const originalCreate = GraphQLModule.GqlExecutionContext.create; + +class FakeEntity extends BaseEntity { + constructor() { + super("entity-1", new Date(), new Date()); + } +} + +const fakeUser = { id: "user-1" } as User; + +const makeReflector = (opts: AuthorizedOptions | undefined): Reflector => + ({ + get: vi.fn().mockReturnValue(opts), + }) as unknown as Reflector; + +interface FakeGqlContext { + req?: { user?: User }; + [AUTHORIZED_RESOURCE_KEY]?: BaseEntity | undefined; +} + +const makeCtx = ( + args: Record, + options?: { user?: User | null }, +): { ctx: ExecutionContext; gqlCtx: FakeGqlContext } => { + const user = options ? options.user : fakeUser; + const gqlCtx: FakeGqlContext = { req: user ? { user } : {} }; + const ctx = { + getHandler: () => () => undefined, + getClass: () => class {}, + } as unknown as ExecutionContext; + + (GraphQLModule.GqlExecutionContext.create as unknown as Mock) = vi + .fn() + .mockReturnValue({ + getContext: () => gqlCtx as unknown as T, + getArgs: () => args as T, + }); + + return { ctx, gqlCtx }; +}; + +const makeHandler = (): { handle: Mock } => ({ + handle: vi.fn().mockReturnValue("ok"), +}); + +const asCallHandler = (h: { handle: Mock }): CallHandler => + ({ handle: h.handle }) as unknown as CallHandler; + +describe("AuthorizedInterceptor", () => { + let authorization: { + canCreate: Mock; + canView: Mock; + canUpdate: Mock; + canDelete: Mock; + }; + let loaders: { resolve: Mock }; + let load: Mock; + + beforeEach(() => { + (GraphQLModule.GqlExecutionContext.create as unknown) = originalCreate; + authorization = { + canCreate: vi.fn().mockResolvedValue(undefined), + canView: vi.fn().mockResolvedValue(undefined), + canUpdate: vi.fn().mockResolvedValue(undefined), + canDelete: vi.fn().mockResolvedValue(undefined), + }; + load = vi.fn().mockResolvedValue(new FakeEntity()); + loaders = { resolve: vi.fn().mockReturnValue({ load }) }; + }); + + const buildInterceptor = ( + opts: AuthorizedOptions | undefined, + ): AuthorizedInterceptor => + new AuthorizedInterceptor( + authorization as unknown as AuthorizationService, + loaders as unknown as AuthorizedEntityLoaderRegistry, + makeReflector(opts), + ); + + it("passes through when no @Authorized metadata is present", async () => { + const interceptor = buildInterceptor(undefined); + const handler = makeHandler(); + const { ctx } = makeCtx({}); + + const result = await interceptor.intercept(ctx, asCallHandler(handler)); + + expect(result).toBe("ok"); + expect(handler.handle).toHaveBeenCalledOnce(); + expect(loaders.resolve).not.toHaveBeenCalled(); + expect(authorization.canView).not.toHaveBeenCalled(); + }); + + it("create branch calls canCreate without loading a resource", async () => { + const interceptor = buildInterceptor({ + action: "create", + entity: FakeEntity, + }); + const handler = makeHandler(); + const { ctx } = makeCtx({ input: { name: "x" } }); + + await interceptor.intercept(ctx, asCallHandler(handler)); + + expect(loaders.resolve).not.toHaveBeenCalled(); + expect(authorization.canCreate).toHaveBeenCalledWith( + fakeUser, + FakeEntity, + undefined, + ); + expect(handler.handle).toHaveBeenCalledOnce(); + }); + + it("create branch forwards payloadArg via dot-path", async () => { + const interceptor = buildInterceptor({ + action: "create", + entity: FakeEntity, + payloadArg: "input.profileId", + }); + const handler = makeHandler(); + const { ctx } = makeCtx({ input: { profileId: "p1" } }); + + await interceptor.intercept(ctx, asCallHandler(handler)); + + expect(authorization.canCreate).toHaveBeenCalledWith( + fakeUser, + FakeEntity, + "p1", + ); + }); + + it.each([ + ["view", "canView"], + ["delete", "canDelete"], + ] as const)("%s branch loads resource by id and calls %s without payload", async (action, method) => { + const interceptor = buildInterceptor({ action, entity: FakeEntity }); + const handler = makeHandler(); + const { ctx, gqlCtx } = makeCtx({ id: "entity-1" }); + const loaded = new FakeEntity(); + load.mockResolvedValueOnce(loaded); + + await interceptor.intercept(ctx, asCallHandler(handler)); + + expect(loaders.resolve).toHaveBeenCalledWith(FakeEntity); + expect(load).toHaveBeenCalledWith("entity-1"); + const fn = authorization[method as keyof typeof authorization] as Mock; + expect(fn).toHaveBeenCalledWith(fakeUser, loaded, FakeEntity); + expect(handler.handle).toHaveBeenCalledOnce(); + expect(gqlCtx[AUTHORIZED_RESOURCE_KEY]).toBe(loaded); + }); + + it("update branch loads resource and calls canUpdate with undefined payload by default", async () => { + const interceptor = buildInterceptor({ + action: "update", + entity: FakeEntity, + }); + const handler = makeHandler(); + const { ctx, gqlCtx } = makeCtx({ id: "entity-1" }); + const loaded = new FakeEntity(); + load.mockResolvedValueOnce(loaded); + + await interceptor.intercept(ctx, asCallHandler(handler)); + + expect(authorization.canUpdate).toHaveBeenCalledWith( + fakeUser, + loaded, + FakeEntity, + undefined, + ); + expect(gqlCtx[AUTHORIZED_RESOURCE_KEY]).toBe(loaded); + }); + + it("forwards payloadArg via dot-path for update", async () => { + const interceptor = buildInterceptor({ + action: "update", + entity: FakeEntity, + payloadArg: "input", + }); + const handler = makeHandler(); + const payload = { foo: "bar" }; + const { ctx } = makeCtx({ id: "entity-1", input: payload }); + + await interceptor.intercept(ctx, asCallHandler(handler)); + + expect(authorization.canUpdate).toHaveBeenCalledWith( + fakeUser, + expect.any(FakeEntity), + FakeEntity, + payload, + ); + }); + + it("respects a custom idArg", async () => { + const interceptor = buildInterceptor({ + action: "view", + entity: FakeEntity, + idArg: "skillId", + }); + const handler = makeHandler(); + const { ctx } = makeCtx({ skillId: "skill-42" }); + + await interceptor.intercept(ctx, asCallHandler(handler)); + + expect(load).toHaveBeenCalledWith("skill-42"); + }); + + it("resolves dot-path idArg (e.g. nested input.id)", async () => { + const interceptor = buildInterceptor({ + action: "update", + entity: FakeEntity, + idArg: "input.id", + }); + const handler = makeHandler(); + const { ctx } = makeCtx({ + input: { id: "entity-99", patch: { name: "x" } }, + }); + + await interceptor.intercept(ctx, asCallHandler(handler)); + + expect(load).toHaveBeenCalledWith("entity-99"); + }); + + it("throws if req.user is missing", async () => { + const interceptor = buildInterceptor({ + action: "view", + entity: FakeEntity, + }); + const handler = makeHandler(); + const { ctx } = makeCtx({ id: "entity-1" }, { user: null }); + + await expect( + interceptor.intercept(ctx, asCallHandler(handler)), + ).rejects.toThrow(/req\.user missing/); + expect(handler.handle).not.toHaveBeenCalled(); + }); + + it("throws if the id arg is missing for loaded actions", async () => { + const interceptor = buildInterceptor({ + action: "view", + entity: FakeEntity, + }); + const handler = makeHandler(); + const { ctx } = makeCtx({}); + + await expect( + interceptor.intercept(ctx, asCallHandler(handler)), + ).rejects.toThrow(/missing\/invalid 'id'/); + expect(handler.handle).not.toHaveBeenCalled(); + expect(load).not.toHaveBeenCalled(); + }); + + it("propagates loader errors and never calls the policy or handler", async () => { + const interceptor = buildInterceptor({ + action: "view", + entity: FakeEntity, + }); + const handler = makeHandler(); + const { ctx } = makeCtx({ id: "ghost" }); + load.mockRejectedValueOnce(new Error("entity not found")); + + await expect( + interceptor.intercept(ctx, asCallHandler(handler)), + ).rejects.toThrow(/entity not found/); + expect(authorization.canView).not.toHaveBeenCalled(); + expect(handler.handle).not.toHaveBeenCalled(); + }); + + it("propagates CannotViewError and never calls the handler", async () => { + authorization.canView.mockRejectedValueOnce( + new CannotViewError("FakeEntity"), + ); + const interceptor = buildInterceptor({ + action: "view", + entity: FakeEntity, + }); + const handler = makeHandler(); + const { ctx, gqlCtx } = makeCtx({ id: "entity-1" }); + + await expect( + interceptor.intercept(ctx, asCallHandler(handler)), + ).rejects.toBeInstanceOf(CannotViewError); + expect(handler.handle).not.toHaveBeenCalled(); + expect(gqlCtx[AUTHORIZED_RESOURCE_KEY]).toBeUndefined(); + }); +}); diff --git a/packages/core/src/modules/auth/authorization/authorization.module.ts b/packages/core/src/modules/auth/authorization/authorization.module.ts index 7bf54df..be9bb81 100644 --- a/packages/core/src/modules/auth/authorization/authorization.module.ts +++ b/packages/core/src/modules/auth/authorization/authorization.module.ts @@ -1,11 +1,23 @@ import { Module } from "@nestjs/common"; import { ServiceLocatorModule } from "@riotbyte-com/nest-service-locator"; import { AuthorizationService } from "./authorization.service"; +import { AuthorizedInterceptor } from "./authorized.interceptor"; +import { AuthorizedEntityLoaderRegistry } from "./authorized-entity-loader.registry"; import { PolicyRegistry } from "./policy-registry.service"; @Module({ imports: [ServiceLocatorModule], - providers: [PolicyRegistry, AuthorizationService], - exports: [AuthorizationService, PolicyRegistry], + providers: [ + PolicyRegistry, + AuthorizationService, + AuthorizedEntityLoaderRegistry, + AuthorizedInterceptor, + ], + exports: [ + AuthorizationService, + PolicyRegistry, + AuthorizedEntityLoaderRegistry, + AuthorizedInterceptor, + ], }) export class AuthorizationModule {} diff --git a/packages/core/src/modules/auth/authorization/authorization.service.ts b/packages/core/src/modules/auth/authorization/authorization.service.ts index 8d7080d..a6a4b7e 100644 --- a/packages/core/src/modules/auth/authorization/authorization.service.ts +++ b/packages/core/src/modules/auth/authorization/authorization.service.ts @@ -22,15 +22,16 @@ export interface Authorizer { resource: TResource, resourceType?: Type, ): Promise; - canCreate( + canCreate( userContext: User, resourceType: Type, - resource?: Partial, + payload?: Partial, ): Promise; - canUpdate( + canUpdate( userContext: User, resource: TResource, resourceType?: Type, + payload?: Partial, ): Promise; canDelete( userContext: User, @@ -57,26 +58,29 @@ export class AuthorizationService implements Authorizer { } } - async canCreate( + async canCreate( userContext: User, resourceType: Type, - resource?: Partial, + payload?: Partial, ): Promise { - const policy = this.policyRegistry.getPolicy(resourceType); - const allowed = await policy.create(userContext, resource); + const policy = this.policyRegistry.getPolicy( + resourceType, + ); + const allowed = await policy.create(userContext, payload); if (!allowed) { throw new CannotCreateError(resourceType.name); } } - async canUpdate( + async canUpdate( userContext: User, resource: TResource, resourceType?: Type, + payload?: Partial, ): Promise { const type = resourceType ?? (resource.constructor as Type); - const policy = this.policyRegistry.getPolicy(type); - const allowed = await policy.update(userContext, resource); + const policy = this.policyRegistry.getPolicy(type); + const allowed = await policy.update(userContext, resource, payload); if (!allowed) { throw new CannotUpdateError(type.name); } diff --git a/packages/core/src/modules/auth/authorization/authorized-entity-loader.decorator.ts b/packages/core/src/modules/auth/authorization/authorized-entity-loader.decorator.ts new file mode 100644 index 0000000..6a31772 --- /dev/null +++ b/packages/core/src/modules/auth/authorization/authorized-entity-loader.decorator.ts @@ -0,0 +1,36 @@ +import { applyDecorators, Injectable, type Type } from "@nestjs/common"; +import type { BaseEntity } from "../../../shared"; +import { AuthorizedEntityLoaderTag } from "./authorized-entity-loader.tag"; + +/** + * Marks a class as the `AuthorizedEntityLoader` for `entity`. The class is + * picked up by `AuthorizedEntityLoaderRegistry` at request time and used by + * `AuthorizedInterceptor` to materialise the target resource for `view` / + * `update` / `delete` decorations. + * + * Bundles `@Injectable()` so each registration is a single decorator at the + * call site - the loader is a normal Nest provider that the entity's home + * module registers (typically alongside the entity's service). + * + * @example + * @AuthorizedEntityLoader(Skill) + * export class SkillAuthorizedEntityLoader + * implements AuthorizedEntityLoader + * { + * constructor(private readonly skills: SkillService) {} + * load(id: string) { + * return this.skills.findByIdOrFail(id); + * } + * } + */ +export const AuthorizedEntityLoader = ( + entity: Type, +): ClassDecorator => + applyDecorators( + Injectable(), + AuthorizedEntityLoaderTag.decorator({ + entity: entity as Type, + }) as ClassDecorator, + ); + +export type { AuthorizedEntityLoader as IAuthorizedEntityLoader } from "./authorized-entity-loader.tag"; diff --git a/packages/core/src/modules/auth/authorization/authorized-entity-loader.registry.ts b/packages/core/src/modules/auth/authorization/authorized-entity-loader.registry.ts new file mode 100644 index 0000000..d21a3b3 --- /dev/null +++ b/packages/core/src/modules/auth/authorization/authorized-entity-loader.registry.ts @@ -0,0 +1,35 @@ +import { Injectable, type Type } from "@nestjs/common"; +import { ServiceLocator } from "@riotbyte-com/nest-service-locator"; +import { type BaseEntity, raise } from "../../../shared"; +import { + type AuthorizedEntityLoader, + AuthorizedEntityLoaderTag, +} from "./authorized-entity-loader.tag"; + +/** + * Routes a `BaseEntity` subclass to its registered loader. Mirrors + * `PolicyRegistry` - tagged providers are discovered at request time, so + * modules register loaders the same way they register policies. + */ +@Injectable() +export class AuthorizedEntityLoaderRegistry { + constructor(private readonly locator: ServiceLocator) {} + + resolve( + entity: Type, + ): AuthorizedEntityLoader { + const found = this.locator + .tagged(AuthorizedEntityLoaderTag) + .find((tagged) => tagged.metadata.entity === entity); + return ( + (found?.service as AuthorizedEntityLoader | undefined) ?? + raise(`No AuthorizedEntityLoader registered for entity: ${entity.name}`) + ); + } + + has(entity: Type): boolean { + return this.locator + .tagged(AuthorizedEntityLoaderTag) + .some((tagged) => tagged.metadata.entity === entity); + } +} diff --git a/packages/core/src/modules/auth/authorization/authorized-entity-loader.tag.ts b/packages/core/src/modules/auth/authorization/authorized-entity-loader.tag.ts new file mode 100644 index 0000000..b4eb12f --- /dev/null +++ b/packages/core/src/modules/auth/authorization/authorized-entity-loader.tag.ts @@ -0,0 +1,21 @@ +import { type Type } from "@nestjs/common"; +import { defineTag } from "@riotbyte-com/nest-service-locator"; +import type { BaseEntity } from "../../../shared"; + +/** + * Contract for a per-entity loader registered with + * `@AuthorizedEntityLoader(EntityType)`. The class wraps whatever service is + * authoritative for fetching the entity by id (typically `*.findByIdOrFail`). + */ +export interface AuthorizedEntityLoader { + load(id: string): Promise; +} + +/** + * Tag for `AuthorizedEntityLoader` providers. Mirrors `PolicyTag` so the + * locator can route loaded-entity requests by resource type at request time. + */ +export const AuthorizedEntityLoaderTag = defineTag< + { entity: Type }, + AuthorizedEntityLoader +>("authorized-entity-loader"); diff --git a/packages/core/src/modules/auth/authorization/authorized-resource.decorator.ts b/packages/core/src/modules/auth/authorization/authorized-resource.decorator.ts new file mode 100644 index 0000000..f7375aa --- /dev/null +++ b/packages/core/src/modules/auth/authorization/authorized-resource.decorator.ts @@ -0,0 +1,34 @@ +import { createParamDecorator, type ExecutionContext } from "@nestjs/common"; +import { GqlExecutionContext } from "@nestjs/graphql"; +import { type BaseEntity, raise } from "../../../shared"; +import { AUTHORIZED_RESOURCE_KEY } from "./authorized.decorator"; + +/** + * Param decorator that returns the resource stashed by `AuthorizedInterceptor` + * during a `view` / `update` / `delete` decoration. Lets the resolver body + * skip a second fetch. + * + * Throws if no resource is on the context - that means the method was not + * gated by `@Authorized({ action: "view" | "update" | "delete", ... })`, + * which is a wiring bug, not a runtime condition. + * + * @example + * @Authorized({ action: "view", entity: Skill }) + * async skill(@AuthorizedResource() skill: Skill): Promise { + * return Skill.fromDomain(skill); + * } + */ +export const AuthorizedResource = createParamDecorator( + (_data: unknown, context: ExecutionContext): BaseEntity => { + const gqlCtx = + GqlExecutionContext.create(context).getContext< + Record + >(); + return ( + gqlCtx[AUTHORIZED_RESOURCE_KEY] ?? + raise( + "AuthorizedResource: no resource on context. Did you forget @Authorized({ action: 'view' | 'update' | 'delete', ... })?", + ) + ); + }, +); diff --git a/packages/core/src/modules/auth/authorization/authorized.decorator.ts b/packages/core/src/modules/auth/authorization/authorized.decorator.ts new file mode 100644 index 0000000..88dac8e --- /dev/null +++ b/packages/core/src/modules/auth/authorization/authorized.decorator.ts @@ -0,0 +1,61 @@ +import { SetMetadata, type Type } from "@nestjs/common"; +import type { BaseEntity } from "../../../shared"; + +/** + * Reflector metadata key carrying `AuthorizedOptions` for resolver methods + * decorated with `@Authorized(...)`. Read at request time by + * `AuthorizedInterceptor`. + */ +export const AUTHORIZED_META = "cv/authorized"; + +/** + * Context key under which the interceptor stashes a successfully loaded + * entity (for `view` / `update` / `delete`). `@AuthorizedResource()` reads + * the value back so resolver bodies don't need a second DB round-trip. + */ +export const AUTHORIZED_RESOURCE_KEY = Symbol("cv/authorized:resource"); + +/** + * Options accepted by `@Authorized(...)`. Discriminated on `action`: + * + * - `create`: no resource load. The optional `payloadArg` is a dot-path + * into the resolver args (e.g. `"input"` or `"input.profileId"`) that + * is forwarded to the policy as the partial would-be resource. + * - `view`: load `entity` by `idArg` (defaults to `"id"`) and call + * `canView`. No payload - reads are pure. + * - `update`: load `entity` by `idArg`, optionally extract `payloadArg`, + * call `canUpdate(user, resource, entity, payload?)`. + * - `delete`: load `entity` by `idArg` and call `canDelete`. No payload - + * if your delete needs to authorize on an input body, model the gate as + * `update` instead. + * + * `idArg` accepts dot-paths (`"input.id"`) for ids nested inside an input + * wrapper, resolved the same way as `payloadArg`. + * + * Each action's option shape carries only the fields it actually uses, so + * a `view` decoration spelling `payloadArg` fails at compile time. + */ +export type AuthorizedOptions = + | { action: "create"; entity: Type; payloadArg?: string } + | { action: "view"; entity: Type; idArg?: string } + | { action: "update"; entity: Type; idArg?: string; payloadArg?: string } + | { action: "delete"; entity: Type; idArg?: string }; + +/** + * Marks a resolver method as requiring authorization. The + * `AuthorizedInterceptor` reads the metadata at request time and short- + * circuits with a `CannotXError` if the relevant `Policy` denies access. + * + * Requires `JwtAuthGuard` on the resolver (so `req.user` is populated). For + * loaded actions, the target entity must be registered in + * `AuthorizedEntityLoaderRegistry` via `@AuthorizedEntityLoader(...)`. + * + * @example + * @Authorized({ action: "view", entity: Skill }) + * async skill(@AuthorizedResource() skill: Skill): Promise { ... } + * + * @Authorized({ action: "create", entity: Education, payloadArg: "input" }) + * async createEducation(@Args("input") input: CreateEducationInput) { ... } + */ +export const Authorized = (opts: AuthorizedOptions) => + SetMetadata(AUTHORIZED_META, opts); diff --git a/packages/core/src/modules/auth/authorization/authorized.interceptor.ts b/packages/core/src/modules/auth/authorization/authorized.interceptor.ts new file mode 100644 index 0000000..b96008f --- /dev/null +++ b/packages/core/src/modules/auth/authorization/authorized.interceptor.ts @@ -0,0 +1,151 @@ +import { + type CallHandler, + type ExecutionContext, + Injectable, + type NestInterceptor, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { GqlExecutionContext } from "@nestjs/graphql"; +import type { Observable } from "rxjs"; +import { type BaseEntity, raise } from "../../../shared"; +import { type User } from "../user/user.entity"; +import { AuthorizationService } from "./authorization.service"; +import { + AUTHORIZED_META, + AUTHORIZED_RESOURCE_KEY, + type AuthorizedOptions, +} from "./authorized.decorator"; +import { AuthorizedEntityLoaderRegistry } from "./authorized-entity-loader.registry"; + +/** + * Routes `@Authorized(opts)`-decorated resolvers through + * `AuthorizationService`. Reads the metadata, materialises the target + * resource (for non-create actions) via `AuthorizedEntityLoaderRegistry`, + * threads `payloadArg` through (only for `create` and `update`), and + * stashes the loaded resource on the GQL context so `@AuthorizedResource()` + * can read it without re-fetching. + * + * Registered as `APP_INTERCEPTOR` so coverage is opt-in per method via the + * decorator presence check. + */ +@Injectable() +export class AuthorizedInterceptor implements NestInterceptor { + constructor( + private readonly authorization: AuthorizationService, + private readonly loaders: AuthorizedEntityLoaderRegistry, + private readonly reflector: Reflector, + ) {} + + async intercept( + ctx: ExecutionContext, + next: CallHandler, + ): Promise> { + const opts = this.reflector.get( + AUTHORIZED_META, + ctx.getHandler(), + ); + + if (!opts) { + return next.handle(); + } + + const gql = GqlExecutionContext.create(ctx); + const user = + (gql.getContext().req?.user as User | undefined) ?? + raise( + "AuthorizedInterceptor: req.user missing; resolver must apply JwtAuthGuard", + ); + const args = gql.getArgs>(); + + const resource = + opts.action === "create" ? null : await this.loadResource(opts, args); + + await this.runAuthCheck(opts, user, resource, args); + + if (resource !== null) { + const gqlCtx = gql.getContext>(); + gqlCtx[AUTHORIZED_RESOURCE_KEY] = resource; + } + + return next.handle(); + } + + /** + * Resolves the target entity for `view` / `update` / `delete` via the + * registered loader. Raises on missing/invalid id arg up front so the + * loader sees a known-good input. + */ + private async loadResource( + opts: Extract, + args: Record, + ): Promise { + const idArg = opts.idArg ?? "id"; + const idValue = this.getByPath(args, idArg); + const id = + typeof idValue === "string" + ? idValue + : raise( + `AuthorizedInterceptor: missing/invalid '${idArg}' arg for ${opts.action} on ${opts.entity.name}`, + ); + return this.loaders.resolve(opts.entity).load(id); + } + + /** + * Dispatches to the right `AuthorizationService.canX` call for the + * action. `create` and `update` thread `payloadArg` through; `view` and + * `delete` ignore payloads by construction (see `AuthorizedOptions`). + */ + private async runAuthCheck( + opts: AuthorizedOptions, + user: User, + resource: BaseEntity | null, + args: Record, + ): Promise { + const payload = + "payloadArg" in opts && opts.payloadArg !== undefined + ? this.getByPath(args, opts.payloadArg) + : undefined; + + const dispatchers: Record< + AuthorizedOptions["action"], + () => Promise + > = { + create: () => + this.authorization.canCreate( + user, + opts.entity, + payload as Partial | undefined, + ), + view: () => + this.authorization.canView(user, resource as BaseEntity, opts.entity), + update: () => + this.authorization.canUpdate( + user, + resource as BaseEntity, + opts.entity, + payload as Partial | undefined, + ), + delete: () => + this.authorization.canDelete(user, resource as BaseEntity, opts.entity), + }; + + await dispatchers[opts.action](); + } + + /** + * Extracts a value from `args` by dot-path. Supports `"input"` (top-level + * arg) and `"input.profileId"` (nested). Returns `undefined` if any + * segment is missing - the caller decides whether absence is an error. + */ + private getByPath(args: Record, path: string): unknown { + const segments = path.split("."); + let current: unknown = args; + for (const segment of segments) { + if (current == null || typeof current !== "object") { + return undefined; + } + current = (current as Record)[segment]; + } + return current; + } +} diff --git a/packages/core/src/modules/auth/authorization/index.ts b/packages/core/src/modules/auth/authorization/index.ts index bbb389a..ea2cd6f 100644 --- a/packages/core/src/modules/auth/authorization/index.ts +++ b/packages/core/src/modules/auth/authorization/index.ts @@ -1,5 +1,14 @@ export * from "./authorization.module"; export * from "./authorization.service"; +export * from "./authorized.decorator"; +export * from "./authorized.interceptor"; +export { + AuthorizedEntityLoader, + type IAuthorizedEntityLoader, +} from "./authorized-entity-loader.decorator"; +export * from "./authorized-entity-loader.registry"; +export { AuthorizedEntityLoaderTag } from "./authorized-entity-loader.tag"; +export * from "./authorized-resource.decorator"; export * from "./owner-owned-resource.policy"; export { POLICY_RESOURCE_KEY, Policy } from "./policy.decorator"; export type { Policy as IPolicy } from "./policy.interface"; diff --git a/packages/core/src/modules/auth/authorization/policy-registry.service.ts b/packages/core/src/modules/auth/authorization/policy-registry.service.ts index 903b63c..aea8b16 100644 --- a/packages/core/src/modules/auth/authorization/policy-registry.service.ts +++ b/packages/core/src/modules/auth/authorization/policy-registry.service.ts @@ -8,12 +8,14 @@ import { PolicyTag } from "./policy-tag"; export class PolicyRegistry { constructor(private readonly locator: ServiceLocator) {} - getPolicy(resourceType: Type): Policy { + getPolicy( + resourceType: Type, + ): Policy { const found = this.locator .tagged(PolicyTag) .find((tagged) => tagged.metadata.resourceType === resourceType); return ( - (found?.service as Policy | undefined) ?? + (found?.service as Policy | undefined) ?? raise(`No policy found for resource type: ${resourceType.name}`) ); } diff --git a/packages/core/src/modules/auth/authorization/policy.interface.ts b/packages/core/src/modules/auth/authorization/policy.interface.ts index e8f9c04..f1e9b1f 100644 --- a/packages/core/src/modules/auth/authorization/policy.interface.ts +++ b/packages/core/src/modules/auth/authorization/policy.interface.ts @@ -1,11 +1,30 @@ import { User } from "../user/user.entity"; -export interface Policy { +/** + * Per-resource authorization policy. Implementations are tagged with + * `@Policy(ResourceType)` and discovered by `PolicyRegistry`. + * + * `TPayload` defaults to `TResource`, which matches the dominant case: the + * create / update input shape is just a partial of the entity itself + * (e.g. `Partial` for `SkillPolicy`). Sub-resource policies whose + * input shape diverges from the resource (e.g. `CreateEducationInput` + * carries `profileId` but `Education` doesn't expose it) declare both + * generics explicitly: `Policy`. + * + * `view` and `delete` are read-shaped: they get the loaded resource only, + * no payload. If your delete needs to authorize on input fields, model + * the gate as `update` instead. + */ +export interface Policy { view(user: User, resource: TResource): boolean | Promise; - create(user: User, resource?: Partial): boolean | Promise; + create(user: User, payload?: Partial): boolean | Promise; - update(user: User, resource: TResource): boolean | Promise; + update( + user: User, + resource: TResource, + payload?: Partial, + ): boolean | Promise; delete(user: User, resource: TResource): boolean | Promise; } diff --git a/packages/core/src/modules/cv-template/__tests__/cv-data-assembler.service.spec.ts b/packages/core/src/modules/cv-template/__tests__/cv-data-assembler.service.spec.ts index 212c983..1da2873 100644 --- a/packages/core/src/modules/cv-template/__tests__/cv-data-assembler.service.spec.ts +++ b/packages/core/src/modules/cv-template/__tests__/cv-data-assembler.service.spec.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { frozenClockAt } from "../../../shared/testing"; import { CVDataAssemblerService } from "../cv-data-assembler.service"; const buildMockPrisma = () => ({ @@ -27,6 +28,7 @@ describe("CVDataAssemblerService", () => { service = new CVDataAssemblerService( prisma as never, profileService as never, + frozenClockAt("2026-05-14T12:00:00.000Z"), ); }); diff --git a/packages/core/src/shared/testing/__tests__/frozen-clock.service.spec.ts b/packages/core/src/shared/testing/__tests__/frozen-clock.service.spec.ts new file mode 100644 index 0000000..37ded80 --- /dev/null +++ b/packages/core/src/shared/testing/__tests__/frozen-clock.service.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { FrozenClockService, frozenClockAt } from "../frozen-clock.service"; + +describe("FrozenClockService", () => { + it("returns the same instant on repeated now() calls", () => { + const clock = new FrozenClockService(new Date("2026-05-14T13:00:00.000Z")); + const a = clock.now(); + const b = clock.now(); + expect(a.getTime()).toBe(b.getTime()); + expect(a.toISOString()).toBe("2026-05-14T13:00:00.000Z"); + }); + + it("returns a defensive copy so callers cannot mutate the backing date", () => { + const clock = new FrozenClockService(new Date("2026-05-14T13:00:00.000Z")); + const first = clock.now(); + first.setUTCFullYear(1970); + const second = clock.now(); + expect(second.toISOString()).toBe("2026-05-14T13:00:00.000Z"); + }); + + it("advance(ms) moves the frozen instant forward", () => { + const clock = frozenClockAt("2026-05-14T13:00:00.000Z"); + clock.advance(60_000); + expect(clock.now().toISOString()).toBe("2026-05-14T13:01:00.000Z"); + }); + + it("advance(ms) accepts negative deltas", () => { + const clock = frozenClockAt("2026-05-14T13:00:00.000Z"); + clock.advance(-1_000); + expect(clock.now().toISOString()).toBe("2026-05-14T12:59:59.000Z"); + }); + + it("set(when) replaces the frozen instant", () => { + const clock = frozenClockAt("2026-05-14T13:00:00.000Z"); + clock.set(new Date("2030-01-01T00:00:00.000Z")); + expect(clock.now().toISOString()).toBe("2030-01-01T00:00:00.000Z"); + }); + + it("frozenClockAt accepts string / number / Date inputs", () => { + const fromString = frozenClockAt("2026-05-14T13:00:00.000Z"); + const fromMillis = frozenClockAt(fromString.now().getTime()); + const fromDate = frozenClockAt(new Date(fromString.now())); + expect(fromString.now().getTime()).toBe(fromMillis.now().getTime()); + expect(fromString.now().getTime()).toBe(fromDate.now().getTime()); + }); +}); diff --git a/packages/core/src/shared/testing/frozen-clock.service.ts b/packages/core/src/shared/testing/frozen-clock.service.ts new file mode 100644 index 0000000..44a74bc --- /dev/null +++ b/packages/core/src/shared/testing/frozen-clock.service.ts @@ -0,0 +1,52 @@ +import type { ClockService } from "../clock.service"; + +/** + * `ClockService`-shaped implementation that returns a fixed instant on + * every call to `now()` until told otherwise. Replaces ad-hoc inline + * stubs in tests so the advance / set semantics are consistent across + * the codebase. + * + * Structurally implements the same `now(): Date` contract as + * `ClockService` without extending it: the parent is a concrete + * `@Injectable` with its own real-clock implementation that we'd just be + * overriding, and Nest DI keys on the token (`provide: ClockService, + * useValue: frozenClockAt(...)`) not the prototype chain. + * + * Defensive copy on every `now()` and `set()` so callers can't mutate the + * backing `Date` by reference and accidentally affect later assertions. + */ +export class FrozenClockService implements ClockService { + constructor(private current: Date) {} + + now(): Date { + return new Date(this.current); + } + + /** + * Replace the frozen instant. Useful when one test exercises multiple + * time-sensitive branches without rebuilding the whole module under test. + */ + set(when: Date): void { + this.current = new Date(when); + } + + /** + * Move the frozen instant forward (or backward, with a negative argument) + * by `ms` milliseconds. Avoids manual `new Date(clock.now().getTime() + ...)` + * arithmetic at call sites. + */ + advance(ms: number): void { + this.current = new Date(this.current.getTime() + ms); + } +} + +/** + * Convenience constructor: accepts whatever the `Date` constructor accepts + * (Date / ISO string / epoch ms) so test setup reads naturally. + * + * @example + * const clock = frozenClockAt("2026-05-14T13:00:00.000Z"); + */ +export const frozenClockAt = ( + when: Date | string | number, +): FrozenClockService => new FrozenClockService(new Date(when)); diff --git a/packages/core/src/shared/testing/index.ts b/packages/core/src/shared/testing/index.ts new file mode 100644 index 0000000..3381d68 --- /dev/null +++ b/packages/core/src/shared/testing/index.ts @@ -0,0 +1 @@ +export * from "./frozen-clock.service";