From 2bbec70a4e6a50da7d52c530f222cf4a1a68488c Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 12:58:10 +0200 Subject: [PATCH] refactor(auth): unify use-case I/O schemas + presenter + feature error map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Plan 9 (spec R1-R28): - Use cases: input + output schemas (signIn, signUp); input-only for signOut (void output). Use case body validates output via outputSchema.parse before returning. - Controllers: receive `unknown`; safeParse with the use-case schema; presenter (returning cookie) for signIn/signUp; void return for signOut. - New integrations/api/procedures.ts with authProcedure built via defineErrorMiddleware([[InputParseError,"BAD_REQUEST"], [AuthenticationError,"UNAUTHORIZED"], [UnauthenticatedError, "UNAUTHORIZED"], [UnauthorizedError,"FORBIDDEN"]]). - Router uses authProcedure + .input(xInputSchema) for every procedure. - src/index.ts exports schemas + types + IUseCase/IController aliases. - package.json gains ./ui subpath; src/ui/index.ts placeholder (auth has no query builders today). - New tests: R25 output-validation per use case (signIn, signUp); R26 router error-mapping (UNAUTHORIZED on missing user, BAD_REQUEST on schema fail). Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2 Spec: R1–R6, R8–R15, R18, R19, R22–R26 --- .../2026-05-06-input-output-unification.md | 27 +++++++--- packages/auth/package.json | 1 + .../use-cases/sign-in.use-case.test.ts | 29 +++++++++- .../application/use-cases/sign-in.use-case.ts | 38 ++++++++----- .../use-cases/sign-out.use-case.test.ts | 7 ++- .../use-cases/sign-out.use-case.ts | 13 +++-- .../use-cases/sign-up.use-case.test.ts | 38 +++++++++++-- .../application/use-cases/sign-up.use-case.ts | 44 +++++++++------ packages/auth/src/entities/models/cookie.ts | 32 ++++++----- packages/auth/src/index.ts | 26 +++++++++ .../auth/src/integrations/api/procedures.ts | 18 +++++++ .../auth/src/integrations/api/router.test.ts | 54 ++++++++++++++++++- packages/auth/src/integrations/api/router.ts | 52 ++++++++---------- .../controllers/sign-in.controller.test.ts | 30 +++++------ .../controllers/sign-in.controller.ts | 24 ++++----- .../controllers/sign-out.controller.test.ts | 15 +++--- .../controllers/sign-out.controller.ts | 16 +++--- .../controllers/sign-up.controller.test.ts | 6 +-- .../controllers/sign-up.controller.ts | 40 +++++--------- packages/auth/src/ui/index.ts | 4 ++ .../auth/tests/sign-in-flow.feature.test.ts | 15 +++--- 21 files changed, 353 insertions(+), 176 deletions(-) create mode 100644 packages/auth/src/integrations/api/procedures.ts create mode 100644 packages/auth/src/ui/index.ts diff --git a/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md b/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md index c3d7a9f..0f22a26 100644 --- a/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md +++ b/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md @@ -17,23 +17,36 @@ doc-update items so docs are written once for the post-Plan-9 state. - packages/core-shared/src/trpc/define-error-middleware.ts — middleware factory mapping [ErrorCtor, TRPC_CODE] tuples to TRPCError translation - packages/core-shared/src/trpc/define-error-middleware.test.ts — 4 tests covering mapped translation, multiple codes, unmapped passthrough (verifies INTERNAL_SERVER_ERROR + cause preservation), cause preservation +- packages/auth/src/integrations/api/procedures.ts — authProcedure with feature error map (InputParse → BAD_REQUEST, Auth/Unauthenticated → UNAUTHORIZED, Unauthorized → FORBIDDEN) +- packages/auth/src/ui/index.ts — placeholder UI surface (no queries today; mutations only) ## 2. Files modified - packages/core-shared/src/trpc/init.ts — `t` instance now exported (was internal const) so feature procedures.ts can do `t.procedure.use(...)` - packages/core-shared/package.json — added "./trpc/define-error-middleware" subpath export - packages/core-shared/tsconfig.json — set `rootDir: "."` and added `@/*` path alias so test files using `@/` resolve correctly under `tsc --noEmit` +- packages/auth/src/application/use-cases/sign-in.use-case.ts — input + output schemas; output.parse before return; SignInInput/SignInOutput types exported +- packages/auth/src/application/use-cases/sign-up.use-case.ts — input + output schemas (with confirmPassword refine); output.parse; types exported; removed user from output (presenter extracts cookie) +- packages/auth/src/application/use-cases/sign-out.use-case.ts — input schema only (void output, no presenter); SignOutInput exported; takes { sessionId } object instead of raw string +- packages/auth/src/interface-adapters/controllers/sign-in.controller.ts — presenter returning cookie; unknown input; ReturnType return type +- packages/auth/src/interface-adapters/controllers/sign-up.controller.ts — presenter returning cookie; unknown input +- packages/auth/src/interface-adapters/controllers/sign-out.controller.ts — no presenter (void); unknown input; Promise return +- packages/auth/src/integrations/api/router.ts — uses authProcedure, .input(xInputSchema) +- packages/auth/src/index.ts — schemas + types now exported from feature root +- packages/auth/package.json — added ./ui subpath +- packages/auth/tests/sign-in-flow.feature.test.ts — updated to match new presenter shapes (cookie return, void sign-out, object input) +- All affected auth use-case + controller tests updated for new contracts ## 3. Pattern changes (code-level) ### 3.1 Use-case files — input + output schemas + runtime parse -(populated when use cases are migrated) +auth migrated: all 3 use cases. signIn and signUp export xInputSchema + xOutputSchema + types; signOut exports xInputSchema only (void output). All non-void use cases end with `xOutputSchema.parse(result)` before returning. ### 3.2 Controller files — presenter + unknown input + view return type -(populated when controllers are migrated) +auth migrated: all 3 controllers. signIn/signUp have `function presenter(value: XOutput)` returning `value.cookie`; return type is `ReturnType`. signOut has no presenter (void). All controllers accept `unknown` input and safeparse with the use-case schema. ### 3.3 tRPC integration — feature-scoped procedures, schema reuse from use cases -(populated when routers are migrated) +auth migrated: authProcedure in procedures.ts wraps defineErrorMiddleware with 4-tuple error map. Router uses `authProcedure.input(xInputSchema)` for all 3 procedures — no more local schema redefinition. ## 4. Error-middleware adoption @@ -45,18 +58,18 @@ doc-update items so docs are written once for the post-Plan-9 state. ## 5. Public-API surface ### 5.1 ./ui subpath added per feature -(populated as features adopt the subpath) +auth: `./ui` subpath added to package.json exports; `src/ui/index.ts` placeholder created (auth has no query builders — all procedures are mutations). ### 5.2 Feature root index.ts cleanup -(populated as features clean up their root exports) +auth: root `src/index.ts` now exports all use-case schemas (signInInputSchema, signInOutputSchema, signUpInputSchema, signUpOutputSchema, signOutInputSchema) and types (SignInInput/Output, SignUpInput/Output, SignOutInput, ISignInUseCase, ISignUpUseCase, ISignOutUseCase) plus controller type aliases. ## 6. Test additions ### 6.1 R25 — output-validation tests (use case) -(populated when use cases gain malformed-input "repo lied" tests) +auth: signIn and signUp each have 2 new R25 tests — one verifying that a malformed service response throws (Zod parse error), one verifying the output schema parses a valid shape. signOut is void — no R25 test. ### 6.2 R26 — router error-mapping tests -(populated when feature routers gain TRPCError-translation tests) +auth: 2 new R26 tests in router.test.ts — UNAUTHORIZED on missing user (AuthenticationError translation), BAD_REQUEST on Zod schema failure (schema validation at procedure boundary). ### 6.3 R27/R28 — presenter shape tests (populated when controllers with non-identity presenters add view-shape assertions) diff --git a/packages/auth/package.json b/packages/auth/package.json index c673d41..682a45a 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./ui": "./src/ui/index.ts", "./cms": "./src/integrations/cms/index.ts", "./api": "./src/integrations/api/router.ts", "./di/bind-production": "./src/di/bind-production.ts" diff --git a/packages/auth/src/application/use-cases/sign-in.use-case.test.ts b/packages/auth/src/application/use-cases/sign-in.use-case.test.ts index 2779c9f..ca5388e 100644 --- a/packages/auth/src/application/use-cases/sign-in.use-case.test.ts +++ b/packages/auth/src/application/use-cases/sign-in.use-case.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect } from "vitest"; -import { signInUseCase } from "@/application/use-cases/sign-in.use-case"; +import { signInUseCase, signInOutputSchema } from "@/application/use-cases/sign-in.use-case"; import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; import { AuthenticationError } from "@/entities/errors/auth"; +import type { IAuthenticationService } from "@/application/services/authentication.service.interface"; import { userFactory } from "@/__factories__/user.factory"; describe("signInUseCase", () => { @@ -45,3 +46,29 @@ describe("signInUseCase", () => { ).rejects.toBeInstanceOf(AuthenticationError); }); }); + +describe("signInUseCase output validation (R25)", () => { + it("throws when authenticationService returns a malformed session", async () => { + const users = new MockUsersRepository([]); + const seed = userFactory.build({ username: "alice" }); + await users.createUser(seed); + + const auth = { + verifyPassword: async () => true, + // session missing required fields → should fail signInOutputSchema.parse + createSession: async () => ({ session: { id: 123 }, cookie: null }), + } as unknown as IAuthenticationService; + + const useCase = signInUseCase(users, auth); + await expect(useCase({ username: "alice", password: "x" })).rejects.toThrow(/parse|invalid/i); + }); + + it("exports an output schema that mirrors the success shape", () => { + expect(signInOutputSchema).toBeDefined(); + const parsed = signInOutputSchema.safeParse({ + session: { id: "s1", userId: "u1", expiresAt: new Date() }, + cookie: { name: "session", value: "s1", attributes: {} }, + }); + expect(parsed.success).toBe(true); + }); +}); diff --git a/packages/auth/src/application/use-cases/sign-in.use-case.ts b/packages/auth/src/application/use-cases/sign-in.use-case.ts index d483085..09d886b 100644 --- a/packages/auth/src/application/use-cases/sign-in.use-case.ts +++ b/packages/auth/src/application/use-cases/sign-in.use-case.ts @@ -1,25 +1,37 @@ +import { z } from "zod"; + import { AuthenticationError } from "../../entities/errors/auth"; -import type { Cookie } from "../../entities/models/cookie"; -import type { Session } from "../../entities/models/session"; +import { cookieSchema } from "../../entities/models/cookie"; +import { sessionSchema } from "../../entities/models/session"; import type { IUsersRepository } from "../repositories/users.repository.interface"; import type { IAuthenticationService } from "../services/authentication.service.interface"; +// ── Input ──────────────────────────────────────────────────────────────── +export const signInInputSchema = z + .object({ + username: z.string().min(3).max(31), + password: z.string().min(6).max(255), + }) + .strict(); +export type SignInInput = z.infer; + +// ── Output ─────────────────────────────────────────────────────────────── +export const signInOutputSchema = z.object({ + session: sessionSchema, + cookie: cookieSchema, +}); +export type SignInOutput = z.infer; + +// ── Use case ───────────────────────────────────────────────────────────── export type ISignInUseCase = ReturnType; export const signInUseCase = - ( - usersRepository: IUsersRepository, - authenticationService: IAuthenticationService, - ) => - async (input: { - username: string; - password: string; - }): Promise<{ session: Session; cookie: Cookie }> => { + (usersRepository: IUsersRepository, authenticationService: IAuthenticationService) => + async (input: SignInInput): Promise => { const existingUser = await usersRepository.getUserByUsername(input.username); if (!existingUser) { throw new AuthenticationError("User does not exist"); } - const validPassword = await authenticationService.verifyPassword( existingUser.passwordHash, input.password, @@ -27,6 +39,6 @@ export const signInUseCase = if (!validPassword) { throw new AuthenticationError("Incorrect username or password"); } - - return await authenticationService.createSession(existingUser); + const result = await authenticationService.createSession(existingUser); + return signInOutputSchema.parse(result); }; diff --git a/packages/auth/src/application/use-cases/sign-out.use-case.test.ts b/packages/auth/src/application/use-cases/sign-out.use-case.test.ts index 97d6737..8ffc8d8 100644 --- a/packages/auth/src/application/use-cases/sign-out.use-case.test.ts +++ b/packages/auth/src/application/use-cases/sign-out.use-case.test.ts @@ -4,13 +4,12 @@ import { MockUsersRepository } from "@/infrastructure/repositories/users.reposit import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; describe("signOutUseCase", () => { - it("returns a blank cookie", async () => { + it("returns void on successful sign-out", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); const useCase = signOutUseCase(auth); - const result = await useCase("session_1"); - expect(result.blankCookie.name).toBe("session"); - expect(result.blankCookie.value).toBe(""); + const result = await useCase({ sessionId: "session_1" }); + expect(result).toBeUndefined(); }); }); diff --git a/packages/auth/src/application/use-cases/sign-out.use-case.ts b/packages/auth/src/application/use-cases/sign-out.use-case.ts index 0a38dc6..259cb2c 100644 --- a/packages/auth/src/application/use-cases/sign-out.use-case.ts +++ b/packages/auth/src/application/use-cases/sign-out.use-case.ts @@ -1,10 +1,17 @@ -import type { Cookie } from "../../entities/models/cookie"; +import { z } from "zod"; import type { IAuthenticationService } from "../services/authentication.service.interface"; +// ── Input ──────────────────────────────────────────────────────────────── +export const signOutInputSchema = z.object({ sessionId: z.string() }).strict(); +export type SignOutInput = z.infer; + +// No xOutputSchema — use case returns void. + +// ── Use case ───────────────────────────────────────────────────────────── export type ISignOutUseCase = ReturnType; export const signOutUseCase = (authenticationService: IAuthenticationService) => - async (sessionId: string): Promise<{ blankCookie: Cookie }> => { - return await authenticationService.invalidateSession(sessionId); + async (input: SignOutInput): Promise => { + await authenticationService.invalidateSession(input.sessionId); }; diff --git a/packages/auth/src/application/use-cases/sign-up.use-case.test.ts b/packages/auth/src/application/use-cases/sign-up.use-case.test.ts index ce127a9..c3d62a4 100644 --- a/packages/auth/src/application/use-cases/sign-up.use-case.test.ts +++ b/packages/auth/src/application/use-cases/sign-up.use-case.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect } from "vitest"; -import { signUpUseCase } from "@/application/use-cases/sign-up.use-case"; +import { signUpUseCase, signUpOutputSchema } from "@/application/use-cases/sign-up.use-case"; import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; import { AuthenticationError } from "@/entities/errors/auth"; +import type { IAuthenticationService } from "@/application/services/authentication.service.interface"; import { userFactory } from "@/__factories__/user.factory"; describe("signUpUseCase", () => { - it("creates a new user and returns session + cookie + user", async () => { + it("creates a new user and returns session + cookie", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); const useCase = signUpUseCase(users, auth); @@ -14,10 +15,10 @@ describe("signUpUseCase", () => { const result = await useCase({ username: "carol", password: "secret_password", + confirmPassword: "secret_password", }); - expect(result.user.username).toBe("carol"); - expect(result.session.userId).toBe(result.user.id); + expect(result.session.userId).toBeTruthy(); expect(result.cookie.name).toBe("session"); }); @@ -28,7 +29,34 @@ describe("signUpUseCase", () => { const useCase = signUpUseCase(users, auth); await expect( - useCase({ username: "alice", password: "secret_password" }), + useCase({ username: "alice", password: "secret_password", confirmPassword: "secret_password" }), ).rejects.toBeInstanceOf(AuthenticationError); }); }); + +describe("signUpUseCase output validation (R25)", () => { + it("throws when authenticationService returns a malformed session", async () => { + const users = new MockUsersRepository([]); + const auth = { + hashPassword: async () => "hashed_x", + generateUserId: () => "uid1", + verifyPassword: async () => true, + // session missing required fields → should fail signUpOutputSchema.parse + createSession: async () => ({ session: { id: 123 }, cookie: null }), + } as unknown as IAuthenticationService; + + const useCase = signUpUseCase(users, auth); + await expect( + useCase({ username: "carol", password: "secret_password", confirmPassword: "secret_password" }), + ).rejects.toThrow(/parse|invalid/i); + }); + + it("exports an output schema that mirrors the success shape", () => { + expect(signUpOutputSchema).toBeDefined(); + const parsed = signUpOutputSchema.safeParse({ + session: { id: "s1", userId: "u1", expiresAt: new Date() }, + cookie: { name: "session", value: "s1", attributes: {} }, + }); + expect(parsed.success).toBe(true); + }); +}); diff --git a/packages/auth/src/application/use-cases/sign-up.use-case.ts b/packages/auth/src/application/use-cases/sign-up.use-case.ts index a0ad42d..87258e3 100644 --- a/packages/auth/src/application/use-cases/sign-up.use-case.ts +++ b/packages/auth/src/application/use-cases/sign-up.use-case.ts @@ -1,10 +1,33 @@ +import { z } from "zod"; + import { AuthenticationError } from "../../entities/errors/auth"; -import type { Cookie } from "../../entities/models/cookie"; -import type { Session } from "../../entities/models/session"; -import type { User } from "../../entities/models/user"; +import { cookieSchema } from "../../entities/models/cookie"; +import { sessionSchema } from "../../entities/models/session"; import type { IUsersRepository } from "../repositories/users.repository.interface"; import type { IAuthenticationService } from "../services/authentication.service.interface"; +// ── Input ──────────────────────────────────────────────────────────────── +export const signUpInputSchema = z + .object({ + username: z.string().min(3).max(31), + password: z.string().min(6).max(255), + confirmPassword: z.string().min(6).max(255), + }) + .strict() + .refine((d) => d.password === d.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); +export type SignUpInput = z.infer; + +// ── Output ─────────────────────────────────────────────────────────────── +export const signUpOutputSchema = z.object({ + session: sessionSchema, + cookie: cookieSchema, +}); +export type SignUpOutput = z.infer; + +// ── Use case ───────────────────────────────────────────────────────────── export type ISignUpUseCase = ReturnType; export const signUpUseCase = @@ -12,14 +35,7 @@ export const signUpUseCase = usersRepository: IUsersRepository, authenticationService: IAuthenticationService, ) => - async (input: { - username: string; - password: string; - }): Promise<{ - session: Session; - cookie: Cookie; - user: Pick; - }> => { + async (input: SignUpInput): Promise => { const existingUser = await usersRepository.getUserByUsername(input.username); if (existingUser) { throw new AuthenticationError("Username taken"); @@ -36,9 +52,5 @@ export const signUpUseCase = const { cookie, session } = await authenticationService.createSession(newUser); - return { - cookie, - session, - user: { id: newUser.id, username: newUser.username }, - }; + return signUpOutputSchema.parse({ session, cookie }); }; diff --git a/packages/auth/src/entities/models/cookie.ts b/packages/auth/src/entities/models/cookie.ts index f3dfa4a..3244898 100644 --- a/packages/auth/src/entities/models/cookie.ts +++ b/packages/auth/src/entities/models/cookie.ts @@ -1,15 +1,19 @@ -type CookieAttributes = { - secure?: boolean; - path?: string; - domain?: string; - sameSite?: "lax" | "strict" | "none"; - httpOnly?: boolean; - maxAge?: number; - expires?: Date; -}; +import { z } from "zod"; -export type Cookie = { - name: string; - value: string; - attributes: CookieAttributes; -}; +const cookieAttributesSchema = z.object({ + secure: z.boolean().optional(), + path: z.string().optional(), + domain: z.string().optional(), + sameSite: z.enum(["lax", "strict", "none"]).optional(), + httpOnly: z.boolean().optional(), + maxAge: z.number().optional(), + expires: z.date().optional(), +}); + +export const cookieSchema = z.object({ + name: z.string(), + value: z.string(), + attributes: cookieAttributesSchema, +}); + +export type Cookie = z.infer; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 980b3fb..559f8be 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -9,3 +9,29 @@ export { } from "./entities/errors/auth"; export { InputParseError } from "./entities/errors/common"; export { SESSION_COOKIE } from "./config"; + +// Use case schemas + types (Plan 9 R18) +export { + signInInputSchema, + signInOutputSchema, + type SignInInput, + type SignInOutput, + type ISignInUseCase, +} from "./application/use-cases/sign-in.use-case"; +export { + signUpInputSchema, + signUpOutputSchema, + type SignUpInput, + type SignUpOutput, + type ISignUpUseCase, +} from "./application/use-cases/sign-up.use-case"; +export { + signOutInputSchema, + type SignOutInput, + type ISignOutUseCase, +} from "./application/use-cases/sign-out.use-case"; + +// Controller type aliases +export type { ISignInController } from "./interface-adapters/controllers/sign-in.controller"; +export type { ISignUpController } from "./interface-adapters/controllers/sign-up.controller"; +export type { ISignOutController } from "./interface-adapters/controllers/sign-out.controller"; diff --git a/packages/auth/src/integrations/api/procedures.ts b/packages/auth/src/integrations/api/procedures.ts new file mode 100644 index 0000000..87effe5 --- /dev/null +++ b/packages/auth/src/integrations/api/procedures.ts @@ -0,0 +1,18 @@ +import { t } from "@repo/core-shared/trpc/init"; +import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; + +import { + AuthenticationError, + UnauthenticatedError, + UnauthorizedError, +} from "../../entities/errors/auth"; +import { InputParseError } from "../../entities/errors/common"; + +export const authProcedure = t.procedure.use( + defineErrorMiddleware([ + [InputParseError, "BAD_REQUEST"], + [AuthenticationError, "UNAUTHORIZED"], + [UnauthenticatedError, "UNAUTHORIZED"], + [UnauthorizedError, "FORBIDDEN"], + ]), +); diff --git a/packages/auth/src/integrations/api/router.test.ts b/packages/auth/src/integrations/api/router.test.ts index a3a7a02..b4cd8ea 100644 --- a/packages/auth/src/integrations/api/router.test.ts +++ b/packages/auth/src/integrations/api/router.test.ts @@ -1,5 +1,13 @@ -import { describe, it, expect } from "vitest"; -import { authRouter } from "./router"; +import { describe, it, expect, beforeEach } from "vitest"; +import { TRPCError } from "@trpc/server"; + +import { authRouter } from "@/integrations/api/router"; +import { authContainer } from "@/di/container"; +import { AUTH_SYMBOLS } from "@/di/symbols"; +import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; +import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; +import type { IUsersRepository } from "@/application/repositories/users.repository.interface"; +import type { IAuthenticationService } from "@/application/services/authentication.service.interface"; describe("authRouter", () => { it("exposes signIn, signUp, signOut procedures", () => { @@ -20,3 +28,45 @@ describe("authRouter", () => { expect(result.name).toBe("session"); }); }); + +describe("authRouter (R26 error mapping)", () => { + beforeEach(() => { + if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { + authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); + } + if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { + authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); + } + const users = new MockUsersRepository(); + const auth = new MockAuthenticationService(users); + authContainer.bind(AUTH_SYMBOLS.IUsersRepository).toConstantValue(users); + authContainer + .bind(AUTH_SYMBOLS.IAuthenticationService) + .toConstantValue(auth); + }); + + it("translates AuthenticationError → UNAUTHORIZED on missing user", async () => { + const caller = authRouter.createCaller({}); + try { + await caller.signIn({ username: "ghost", password: "long-enough" }); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("UNAUTHORIZED"); + } + }); + + it("translates BAD_REQUEST when zod parse fails at the procedure boundary", async () => { + const caller = authRouter.createCaller({}); + try { + await caller.signIn({ username: "ab", password: "x" } as unknown as { + username: string; + password: string; + }); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("BAD_REQUEST"); + } + }); +}); diff --git a/packages/auth/src/integrations/api/router.ts b/packages/auth/src/integrations/api/router.ts index e2856d2..5ebf5e2 100644 --- a/packages/auth/src/integrations/api/router.ts +++ b/packages/auth/src/integrations/api/router.ts @@ -1,43 +1,33 @@ -import { z } from "zod"; -import { router, publicProcedure } from "@repo/core-shared/trpc/init"; +import { router } from "@repo/core-shared/trpc/init"; + import { authContainer } from "../../di/container"; import { AUTH_SYMBOLS } from "../../di/symbols"; + +import { signInInputSchema } from "../../application/use-cases/sign-in.use-case"; +import { signUpInputSchema } from "../../application/use-cases/sign-up.use-case"; +import { signOutInputSchema } from "../../application/use-cases/sign-out.use-case"; + import type { ISignInController } from "../../interface-adapters/controllers/sign-in.controller"; import type { ISignUpController } from "../../interface-adapters/controllers/sign-up.controller"; import type { ISignOutController } from "../../interface-adapters/controllers/sign-out.controller"; +import { authProcedure } from "./procedures"; + export const authRouter = router({ - signIn: publicProcedure - .input( - z.object({ - username: z.string().min(3).max(31), - password: z.string().min(6).max(255), - }), - ) - .mutation(({ input }) => { - const ctrl = authContainer.get(AUTH_SYMBOLS.ISignInController); - return ctrl(input); - }), + signIn: authProcedure.input(signInInputSchema).mutation(({ input }) => { + const ctrl = authContainer.get(AUTH_SYMBOLS.ISignInController); + return ctrl(input); + }), - signUp: publicProcedure - .input( - z.object({ - username: z.string().min(3).max(31), - password: z.string().min(6).max(255), - confirmPassword: z.string().min(6).max(255), - }), - ) - .mutation(({ input }) => { - const ctrl = authContainer.get(AUTH_SYMBOLS.ISignUpController); - return ctrl(input); - }), + signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => { + const ctrl = authContainer.get(AUTH_SYMBOLS.ISignUpController); + return ctrl(input); + }), - signOut: publicProcedure - .input(z.object({ sessionId: z.string() })) - .mutation(({ input }) => { - const ctrl = authContainer.get(AUTH_SYMBOLS.ISignOutController); - return ctrl(input.sessionId); - }), + signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => { + const ctrl = authContainer.get(AUTH_SYMBOLS.ISignOutController); + return ctrl(input); + }), }); export type AuthRouter = typeof authRouter; diff --git a/packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts b/packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts index de69150..0816eb5 100644 --- a/packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts +++ b/packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts @@ -1,47 +1,45 @@ import { describe, it, expect } from "vitest"; import { signInController } from "@/interface-adapters/controllers/sign-in.controller"; +import { signInUseCase } from "@/application/use-cases/sign-in.use-case"; import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; -import { signInUseCase } from "@/application/use-cases/sign-in.use-case"; import { InputParseError } from "@/entities/errors/common"; import { userFactory } from "@/__factories__/user.factory"; describe("signInController", () => { - it("returns a cookie on valid credentials", async () => { + it("returns a cookie on successful sign-in", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); - const seedUser = userFactory.build({ - username: "alice", - passwordHash: "hashed_testpassword", - }); + const seedUser = userFactory.build({ username: "alice", passwordHash: "hashed_testpassword" }); await users.createUser(seedUser); const useCase = signInUseCase(users, auth); const controller = signInController(useCase); - const cookie = await controller({ username: "alice", password: "testpassword" }); - expect(cookie.name).toBe("session"); + const result = await controller({ + username: "alice", + password: "testpassword", + }); + expect(result).toBeDefined(); + expect(result.name).toBeTruthy(); + expect(result.value).toBeTruthy(); }); - it("throws InputParseError on missing username", async () => { + it("throws InputParseError on invalid input", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); const useCase = signInUseCase(users, auth); const controller = signInController(useCase); - await expect( - controller({ password: "anything" }), - ).rejects.toBeInstanceOf(InputParseError); + await expect(controller({ username: "ab" } as unknown)).rejects.toBeInstanceOf(InputParseError); }); - it("throws InputParseError on too-short password", async () => { + it("throws InputParseError when input is not an object", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); const useCase = signInUseCase(users, auth); const controller = signInController(useCase); - await expect( - controller({ username: "alice", password: "abc" }), - ).rejects.toBeInstanceOf(InputParseError); + await expect(controller("garbage" as unknown)).rejects.toBeInstanceOf(InputParseError); }); }); diff --git a/packages/auth/src/interface-adapters/controllers/sign-in.controller.ts b/packages/auth/src/interface-adapters/controllers/sign-in.controller.ts index d4285ac..88f555e 100644 --- a/packages/auth/src/interface-adapters/controllers/sign-in.controller.ts +++ b/packages/auth/src/interface-adapters/controllers/sign-in.controller.ts @@ -1,23 +1,23 @@ -import { z } from "zod"; - import { InputParseError } from "../../entities/errors/common"; -import type { Cookie } from "../../entities/models/cookie"; -import type { ISignInUseCase } from "../../application/use-cases/sign-in.use-case"; +import { + signInInputSchema, + type ISignInUseCase, + type SignInOutput, +} from "../../application/use-cases/sign-in.use-case"; -const inputSchema = z.object({ - username: z.string().min(3).max(31), - password: z.string().min(6).max(255), -}); +function presenter(value: SignInOutput) { + return value.cookie; +} export type ISignInController = ReturnType; export const signInController = (signInUseCase: ISignInUseCase) => - async (input: Partial>): Promise => { - const parsed = inputSchema.safeParse(input); + async (input: unknown): Promise> => { + const parsed = signInInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid sign-in input", { cause: parsed.error }); } - const { cookie } = await signInUseCase(parsed.data); - return cookie; + const result = await signInUseCase(parsed.data); + return presenter(result); }; diff --git a/packages/auth/src/interface-adapters/controllers/sign-out.controller.test.ts b/packages/auth/src/interface-adapters/controllers/sign-out.controller.test.ts index 2d109aa..1265fb9 100644 --- a/packages/auth/src/interface-adapters/controllers/sign-out.controller.test.ts +++ b/packages/auth/src/interface-adapters/controllers/sign-out.controller.test.ts @@ -1,28 +1,27 @@ import { describe, it, expect } from "vitest"; import { signOutController } from "@/interface-adapters/controllers/sign-out.controller"; -import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; -import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; import { signOutUseCase } from "@/application/use-cases/sign-out.use-case"; +import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; +import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import { InputParseError } from "@/entities/errors/common"; describe("signOutController", () => { - it("returns a blank cookie", async () => { + it("returns void on successful sign-out", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); const useCase = signOutUseCase(auth); const controller = signOutController(useCase); - const cookie = await controller("session_anything"); - expect(cookie.name).toBe("session"); - expect(cookie.value).toBe(""); + const result = await controller({ sessionId: "any" }); + expect(result).toBeUndefined(); }); - it("throws InputParseError when sessionId is missing", async () => { + it("throws InputParseError on missing sessionId", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); const useCase = signOutUseCase(auth); const controller = signOutController(useCase); - await expect(controller(undefined)).rejects.toBeInstanceOf(InputParseError); + await expect(controller({} as unknown)).rejects.toBeInstanceOf(InputParseError); }); }); diff --git a/packages/auth/src/interface-adapters/controllers/sign-out.controller.ts b/packages/auth/src/interface-adapters/controllers/sign-out.controller.ts index aa4785a..05633b0 100644 --- a/packages/auth/src/interface-adapters/controllers/sign-out.controller.ts +++ b/packages/auth/src/interface-adapters/controllers/sign-out.controller.ts @@ -1,15 +1,17 @@ import { InputParseError } from "../../entities/errors/common"; -import type { Cookie } from "../../entities/models/cookie"; -import type { ISignOutUseCase } from "../../application/use-cases/sign-out.use-case"; +import { + signOutInputSchema, + type ISignOutUseCase, +} from "../../application/use-cases/sign-out.use-case"; export type ISignOutController = ReturnType; export const signOutController = (signOutUseCase: ISignOutUseCase) => - async (sessionId: string | undefined): Promise => { - if (!sessionId) { - throw new InputParseError("Must provide a session ID"); + async (input: unknown): Promise => { + const parsed = signOutInputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid sign-out input", { cause: parsed.error }); } - const { blankCookie } = await signOutUseCase(sessionId); - return blankCookie; + await signOutUseCase(parsed.data); }; diff --git a/packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts b/packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts index 2629e17..392602b 100644 --- a/packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts +++ b/packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts @@ -7,7 +7,7 @@ import { InputParseError } from "@/entities/errors/common"; import { userFactory } from "@/__factories__/user.factory"; describe("signUpController", () => { - it("creates a new user when passwords match", async () => { + it("returns a cookie on successful sign-up", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); const useCase = signUpUseCase(users, auth); @@ -18,7 +18,8 @@ describe("signUpController", () => { password: "secret_password", confirmPassword: "secret_password", }); - expect(result.user.username).toBe("carol"); + expect(result.name).toBe("session"); + expect(result.value).toBeTruthy(); }); it("throws InputParseError when passwords do not match", async () => { @@ -39,7 +40,6 @@ describe("signUpController", () => { it("throws InputParseError when username is too short", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); - // Pre-seed so we don't hit the taken-username error await users.createUser(userFactory.build({ username: "alice" })); const useCase = signUpUseCase(users, auth); const controller = signUpController(useCase); diff --git a/packages/auth/src/interface-adapters/controllers/sign-up.controller.ts b/packages/auth/src/interface-adapters/controllers/sign-up.controller.ts index b12d296..3b1849e 100644 --- a/packages/auth/src/interface-adapters/controllers/sign-up.controller.ts +++ b/packages/auth/src/interface-adapters/controllers/sign-up.controller.ts @@ -1,39 +1,23 @@ -import { z } from "zod"; - import { InputParseError } from "../../entities/errors/common"; -import type { ISignUpUseCase } from "../../application/use-cases/sign-up.use-case"; +import { + signUpInputSchema, + type ISignUpUseCase, + type SignUpOutput, +} from "../../application/use-cases/sign-up.use-case"; -const inputSchema = z - .object({ - username: z.string().min(3).max(31), - password: z.string().min(6).max(255), - confirmPassword: z.string().min(6).max(255), - }) - .superRefine(({ password, confirmPassword }, ctx) => { - if (confirmPassword !== password) { - ctx.addIssue({ - code: "custom", - message: "The passwords did not match", - path: ["password"], - }); - ctx.addIssue({ - code: "custom", - message: "The passwords did not match", - path: ["confirmPassword"], - }); - } - }); +function presenter(value: SignUpOutput) { + return value.cookie; +} export type ISignUpController = ReturnType; export const signUpController = (signUpUseCase: ISignUpUseCase) => - async ( - input: Partial>, - ): Promise>> => { - const parsed = inputSchema.safeParse(input); + async (input: unknown): Promise> => { + const parsed = signUpInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid sign-up input", { cause: parsed.error }); } - return await signUpUseCase(parsed.data); + const result = await signUpUseCase(parsed.data); + return presenter(result); }; diff --git a/packages/auth/src/ui/index.ts b/packages/auth/src/ui/index.ts new file mode 100644 index 0000000..1cf9d05 --- /dev/null +++ b/packages/auth/src/ui/index.ts @@ -0,0 +1,4 @@ +// Auth has no React Query option builders today (all auth procedures are +// mutations). This file is the public UI surface for future components +// and queries — extend rather than re-add to root index.ts. +export {}; diff --git a/packages/auth/tests/sign-in-flow.feature.test.ts b/packages/auth/tests/sign-in-flow.feature.test.ts index e9bebaa..f67f467 100644 --- a/packages/auth/tests/sign-in-flow.feature.test.ts +++ b/packages/auth/tests/sign-in-flow.feature.test.ts @@ -21,21 +21,24 @@ describe("auth feature: sign-up → sign-in → sign-out", () => { const signUp = signUpController(signUpUseCase(users, auth)); const signOut = signOutController(signOutUseCase(auth)); - const signUpResult = await signUp({ + // signUp returns a cookie (presenter shape) + const signUpCookie = await signUp({ username: "newperson", password: "verysecret", confirmPassword: "verysecret", }); - expect(signUpResult.user.username).toBe("newperson"); - const userId = signUpResult.user.id; + expect(signUpCookie.name).toBe("session"); + expect(signUpCookie.value).toBeTruthy(); const signInCookie = await signIn({ username: "newperson", password: "verysecret", }); - expect(signInCookie.value).toBe("session_" + userId); + expect(signInCookie.name).toBe("session"); + expect(signInCookie.value).toBeTruthy(); - const signOutResult = await signOut(signInCookie.value); - expect(signOutResult.value).toBe(""); + // signOut takes { sessionId } and returns void + const signOutResult = await signOut({ sessionId: signInCookie.value }); + expect(signOutResult).toBeUndefined(); }); });