refactor(auth): unify use-case I/O schemas + presenter + feature error map

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
This commit is contained in:
2026-05-06 12:58:10 +02:00
parent aac37fd9af
commit 2bbec70a4e
21 changed files with 353 additions and 176 deletions

View File

@@ -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.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/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 ## 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/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/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/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<typeof presenter> 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<void> 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. Pattern changes (code-level)
### 3.1 Use-case files — input + output schemas + runtime parse ### 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 ### 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<typeof presenter>`. 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 ### 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 ## 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. Public-API surface
### 5.1 ./ui subpath added per feature ### 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 ### 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. Test additions
### 6.1 R25 — output-validation tests (use case) ### 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 ### 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 ### 6.3 R27/R28 — presenter shape tests
(populated when controllers with non-identity presenters add view-shape assertions) (populated when controllers with non-identity presenters add view-shape assertions)

View File

@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./ui": "./src/ui/index.ts",
"./cms": "./src/integrations/cms/index.ts", "./cms": "./src/integrations/cms/index.ts",
"./api": "./src/integrations/api/router.ts", "./api": "./src/integrations/api/router.ts",
"./di/bind-production": "./src/di/bind-production.ts" "./di/bind-production": "./src/di/bind-production.ts"

View File

@@ -1,8 +1,9 @@
import { describe, it, expect } from "vitest"; 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 { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import { AuthenticationError } from "@/entities/errors/auth"; import { AuthenticationError } from "@/entities/errors/auth";
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
import { userFactory } from "@/__factories__/user.factory"; import { userFactory } from "@/__factories__/user.factory";
describe("signInUseCase", () => { describe("signInUseCase", () => {
@@ -45,3 +46,29 @@ describe("signInUseCase", () => {
).rejects.toBeInstanceOf(AuthenticationError); ).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);
});
});

View File

@@ -1,25 +1,37 @@
import { z } from "zod";
import { AuthenticationError } from "../../entities/errors/auth"; import { AuthenticationError } from "../../entities/errors/auth";
import type { Cookie } from "../../entities/models/cookie"; import { cookieSchema } from "../../entities/models/cookie";
import type { Session } from "../../entities/models/session"; import { sessionSchema } from "../../entities/models/session";
import type { IUsersRepository } from "../repositories/users.repository.interface"; import type { IUsersRepository } from "../repositories/users.repository.interface";
import type { IAuthenticationService } from "../services/authentication.service.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<typeof signInInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const signInOutputSchema = z.object({
session: sessionSchema,
cookie: cookieSchema,
});
export type SignInOutput = z.infer<typeof signInOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type ISignInUseCase = ReturnType<typeof signInUseCase>; export type ISignInUseCase = ReturnType<typeof signInUseCase>;
export const signInUseCase = export const signInUseCase =
( (usersRepository: IUsersRepository, authenticationService: IAuthenticationService) =>
usersRepository: IUsersRepository, async (input: SignInInput): Promise<SignInOutput> => {
authenticationService: IAuthenticationService,
) =>
async (input: {
username: string;
password: string;
}): Promise<{ session: Session; cookie: Cookie }> => {
const existingUser = await usersRepository.getUserByUsername(input.username); const existingUser = await usersRepository.getUserByUsername(input.username);
if (!existingUser) { if (!existingUser) {
throw new AuthenticationError("User does not exist"); throw new AuthenticationError("User does not exist");
} }
const validPassword = await authenticationService.verifyPassword( const validPassword = await authenticationService.verifyPassword(
existingUser.passwordHash, existingUser.passwordHash,
input.password, input.password,
@@ -27,6 +39,6 @@ export const signInUseCase =
if (!validPassword) { if (!validPassword) {
throw new AuthenticationError("Incorrect username or password"); throw new AuthenticationError("Incorrect username or password");
} }
const result = await authenticationService.createSession(existingUser);
return await authenticationService.createSession(existingUser); return signInOutputSchema.parse(result);
}; };

View File

@@ -4,13 +4,12 @@ import { MockUsersRepository } from "@/infrastructure/repositories/users.reposit
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
describe("signOutUseCase", () => { describe("signOutUseCase", () => {
it("returns a blank cookie", async () => { it("returns void on successful sign-out", async () => {
const users = new MockUsersRepository([]); const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);
const useCase = signOutUseCase(auth); const useCase = signOutUseCase(auth);
const result = await useCase("session_1"); const result = await useCase({ sessionId: "session_1" });
expect(result.blankCookie.name).toBe("session"); expect(result).toBeUndefined();
expect(result.blankCookie.value).toBe("");
}); });
}); });

View File

@@ -1,10 +1,17 @@
import type { Cookie } from "../../entities/models/cookie"; import { z } from "zod";
import type { IAuthenticationService } from "../services/authentication.service.interface"; import type { IAuthenticationService } from "../services/authentication.service.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const signOutInputSchema = z.object({ sessionId: z.string() }).strict();
export type SignOutInput = z.infer<typeof signOutInputSchema>;
// No xOutputSchema — use case returns void.
// ── Use case ─────────────────────────────────────────────────────────────
export type ISignOutUseCase = ReturnType<typeof signOutUseCase>; export type ISignOutUseCase = ReturnType<typeof signOutUseCase>;
export const signOutUseCase = export const signOutUseCase =
(authenticationService: IAuthenticationService) => (authenticationService: IAuthenticationService) =>
async (sessionId: string): Promise<{ blankCookie: Cookie }> => { async (input: SignOutInput): Promise<void> => {
return await authenticationService.invalidateSession(sessionId); await authenticationService.invalidateSession(input.sessionId);
}; };

View File

@@ -1,12 +1,13 @@
import { describe, it, expect } from "vitest"; 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 { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
import { AuthenticationError } from "@/entities/errors/auth"; import { AuthenticationError } from "@/entities/errors/auth";
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
import { userFactory } from "@/__factories__/user.factory"; import { userFactory } from "@/__factories__/user.factory";
describe("signUpUseCase", () => { 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 users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);
const useCase = signUpUseCase(users, auth); const useCase = signUpUseCase(users, auth);
@@ -14,10 +15,10 @@ describe("signUpUseCase", () => {
const result = await useCase({ const result = await useCase({
username: "carol", username: "carol",
password: "secret_password", password: "secret_password",
confirmPassword: "secret_password",
}); });
expect(result.user.username).toBe("carol"); expect(result.session.userId).toBeTruthy();
expect(result.session.userId).toBe(result.user.id);
expect(result.cookie.name).toBe("session"); expect(result.cookie.name).toBe("session");
}); });
@@ -28,7 +29,34 @@ describe("signUpUseCase", () => {
const useCase = signUpUseCase(users, auth); const useCase = signUpUseCase(users, auth);
await expect( await expect(
useCase({ username: "alice", password: "secret_password" }), useCase({ username: "alice", password: "secret_password", confirmPassword: "secret_password" }),
).rejects.toBeInstanceOf(AuthenticationError); ).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);
});
});

View File

@@ -1,10 +1,33 @@
import { z } from "zod";
import { AuthenticationError } from "../../entities/errors/auth"; import { AuthenticationError } from "../../entities/errors/auth";
import type { Cookie } from "../../entities/models/cookie"; import { cookieSchema } from "../../entities/models/cookie";
import type { Session } from "../../entities/models/session"; import { sessionSchema } from "../../entities/models/session";
import type { User } from "../../entities/models/user";
import type { IUsersRepository } from "../repositories/users.repository.interface"; import type { IUsersRepository } from "../repositories/users.repository.interface";
import type { IAuthenticationService } from "../services/authentication.service.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<typeof signUpInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const signUpOutputSchema = z.object({
session: sessionSchema,
cookie: cookieSchema,
});
export type SignUpOutput = z.infer<typeof signUpOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type ISignUpUseCase = ReturnType<typeof signUpUseCase>; export type ISignUpUseCase = ReturnType<typeof signUpUseCase>;
export const signUpUseCase = export const signUpUseCase =
@@ -12,14 +35,7 @@ export const signUpUseCase =
usersRepository: IUsersRepository, usersRepository: IUsersRepository,
authenticationService: IAuthenticationService, authenticationService: IAuthenticationService,
) => ) =>
async (input: { async (input: SignUpInput): Promise<SignUpOutput> => {
username: string;
password: string;
}): Promise<{
session: Session;
cookie: Cookie;
user: Pick<User, "id" | "username">;
}> => {
const existingUser = await usersRepository.getUserByUsername(input.username); const existingUser = await usersRepository.getUserByUsername(input.username);
if (existingUser) { if (existingUser) {
throw new AuthenticationError("Username taken"); throw new AuthenticationError("Username taken");
@@ -36,9 +52,5 @@ export const signUpUseCase =
const { cookie, session } = await authenticationService.createSession(newUser); const { cookie, session } = await authenticationService.createSession(newUser);
return { return signUpOutputSchema.parse({ session, cookie });
cookie,
session,
user: { id: newUser.id, username: newUser.username },
};
}; };

View File

@@ -1,15 +1,19 @@
type CookieAttributes = { import { z } from "zod";
secure?: boolean;
path?: string;
domain?: string;
sameSite?: "lax" | "strict" | "none";
httpOnly?: boolean;
maxAge?: number;
expires?: Date;
};
export type Cookie = { const cookieAttributesSchema = z.object({
name: string; secure: z.boolean().optional(),
value: string; path: z.string().optional(),
attributes: CookieAttributes; 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<typeof cookieSchema>;

View File

@@ -9,3 +9,29 @@ export {
} from "./entities/errors/auth"; } from "./entities/errors/auth";
export { InputParseError } from "./entities/errors/common"; export { InputParseError } from "./entities/errors/common";
export { SESSION_COOKIE } from "./config"; 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";

View File

@@ -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"],
]),
);

View File

@@ -1,5 +1,13 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, beforeEach } from "vitest";
import { authRouter } from "./router"; 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", () => { describe("authRouter", () => {
it("exposes signIn, signUp, signOut procedures", () => { it("exposes signIn, signUp, signOut procedures", () => {
@@ -20,3 +28,45 @@ describe("authRouter", () => {
expect(result.name).toBe("session"); 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<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).toConstantValue(users);
authContainer
.bind<IAuthenticationService>(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");
}
});
});

View File

@@ -1,43 +1,33 @@
import { z } from "zod"; import { router } from "@repo/core-shared/trpc/init";
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
import { authContainer } from "../../di/container"; import { authContainer } from "../../di/container";
import { AUTH_SYMBOLS } from "../../di/symbols"; 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 { ISignInController } from "../../interface-adapters/controllers/sign-in.controller";
import type { ISignUpController } from "../../interface-adapters/controllers/sign-up.controller"; import type { ISignUpController } from "../../interface-adapters/controllers/sign-up.controller";
import type { ISignOutController } from "../../interface-adapters/controllers/sign-out.controller"; import type { ISignOutController } from "../../interface-adapters/controllers/sign-out.controller";
import { authProcedure } from "./procedures";
export const authRouter = router({ export const authRouter = router({
signIn: publicProcedure signIn: authProcedure.input(signInInputSchema).mutation(({ input }) => {
.input( const ctrl = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
z.object({ return ctrl(input);
username: z.string().min(3).max(31), }),
password: z.string().min(6).max(255),
}),
)
.mutation(({ input }) => {
const ctrl = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
return ctrl(input);
}),
signUp: publicProcedure signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
.input( const ctrl = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
z.object({ return ctrl(input);
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<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
return ctrl(input);
}),
signOut: publicProcedure signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
.input(z.object({ sessionId: z.string() })) const ctrl = authContainer.get<ISignOutController>(AUTH_SYMBOLS.ISignOutController);
.mutation(({ input }) => { return ctrl(input);
const ctrl = authContainer.get<ISignOutController>(AUTH_SYMBOLS.ISignOutController); }),
return ctrl(input.sessionId);
}),
}); });
export type AuthRouter = typeof authRouter; export type AuthRouter = typeof authRouter;

View File

@@ -1,47 +1,45 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { signInController } from "@/interface-adapters/controllers/sign-in.controller"; 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 { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.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 { InputParseError } from "@/entities/errors/common";
import { userFactory } from "@/__factories__/user.factory"; import { userFactory } from "@/__factories__/user.factory";
describe("signInController", () => { describe("signInController", () => {
it("returns a cookie on valid credentials", async () => { it("returns a cookie on successful sign-in", async () => {
const users = new MockUsersRepository([]); const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);
const seedUser = userFactory.build({ const seedUser = userFactory.build({ username: "alice", passwordHash: "hashed_testpassword" });
username: "alice",
passwordHash: "hashed_testpassword",
});
await users.createUser(seedUser); await users.createUser(seedUser);
const useCase = signInUseCase(users, auth); const useCase = signInUseCase(users, auth);
const controller = signInController(useCase); const controller = signInController(useCase);
const cookie = await controller({ username: "alice", password: "testpassword" }); const result = await controller({
expect(cookie.name).toBe("session"); 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 users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);
const useCase = signInUseCase(users, auth); const useCase = signInUseCase(users, auth);
const controller = signInController(useCase); const controller = signInController(useCase);
await expect( await expect(controller({ username: "ab" } as unknown)).rejects.toBeInstanceOf(InputParseError);
controller({ password: "anything" }),
).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 users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);
const useCase = signInUseCase(users, auth); const useCase = signInUseCase(users, auth);
const controller = signInController(useCase); const controller = signInController(useCase);
await expect( await expect(controller("garbage" as unknown)).rejects.toBeInstanceOf(InputParseError);
controller({ username: "alice", password: "abc" }),
).rejects.toBeInstanceOf(InputParseError);
}); });
}); });

View File

@@ -1,23 +1,23 @@
import { z } from "zod";
import { InputParseError } from "../../entities/errors/common"; import { InputParseError } from "../../entities/errors/common";
import type { Cookie } from "../../entities/models/cookie"; import {
import type { ISignInUseCase } from "../../application/use-cases/sign-in.use-case"; signInInputSchema,
type ISignInUseCase,
type SignInOutput,
} from "../../application/use-cases/sign-in.use-case";
const inputSchema = z.object({ function presenter(value: SignInOutput) {
username: z.string().min(3).max(31), return value.cookie;
password: z.string().min(6).max(255), }
});
export type ISignInController = ReturnType<typeof signInController>; export type ISignInController = ReturnType<typeof signInController>;
export const signInController = export const signInController =
(signInUseCase: ISignInUseCase) => (signInUseCase: ISignInUseCase) =>
async (input: Partial<z.infer<typeof inputSchema>>): Promise<Cookie> => { async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = inputSchema.safeParse(input); const parsed = signInInputSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {
throw new InputParseError("Invalid sign-in input", { cause: parsed.error }); throw new InputParseError("Invalid sign-in input", { cause: parsed.error });
} }
const { cookie } = await signInUseCase(parsed.data); const result = await signInUseCase(parsed.data);
return cookie; return presenter(result);
}; };

View File

@@ -1,28 +1,27 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { signOutController } from "@/interface-adapters/controllers/sign-out.controller"; 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 { 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"; import { InputParseError } from "@/entities/errors/common";
describe("signOutController", () => { describe("signOutController", () => {
it("returns a blank cookie", async () => { it("returns void on successful sign-out", async () => {
const users = new MockUsersRepository([]); const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);
const useCase = signOutUseCase(auth); const useCase = signOutUseCase(auth);
const controller = signOutController(useCase); const controller = signOutController(useCase);
const cookie = await controller("session_anything"); const result = await controller({ sessionId: "any" });
expect(cookie.name).toBe("session"); expect(result).toBeUndefined();
expect(cookie.value).toBe("");
}); });
it("throws InputParseError when sessionId is missing", async () => { it("throws InputParseError on missing sessionId", async () => {
const users = new MockUsersRepository([]); const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);
const useCase = signOutUseCase(auth); const useCase = signOutUseCase(auth);
const controller = signOutController(useCase); const controller = signOutController(useCase);
await expect(controller(undefined)).rejects.toBeInstanceOf(InputParseError); await expect(controller({} as unknown)).rejects.toBeInstanceOf(InputParseError);
}); });
}); });

View File

@@ -1,15 +1,17 @@
import { InputParseError } from "../../entities/errors/common"; import { InputParseError } from "../../entities/errors/common";
import type { Cookie } from "../../entities/models/cookie"; import {
import type { ISignOutUseCase } from "../../application/use-cases/sign-out.use-case"; signOutInputSchema,
type ISignOutUseCase,
} from "../../application/use-cases/sign-out.use-case";
export type ISignOutController = ReturnType<typeof signOutController>; export type ISignOutController = ReturnType<typeof signOutController>;
export const signOutController = export const signOutController =
(signOutUseCase: ISignOutUseCase) => (signOutUseCase: ISignOutUseCase) =>
async (sessionId: string | undefined): Promise<Cookie> => { async (input: unknown): Promise<void> => {
if (!sessionId) { const parsed = signOutInputSchema.safeParse(input);
throw new InputParseError("Must provide a session ID"); if (!parsed.success) {
throw new InputParseError("Invalid sign-out input", { cause: parsed.error });
} }
const { blankCookie } = await signOutUseCase(sessionId); await signOutUseCase(parsed.data);
return blankCookie;
}; };

View File

@@ -7,7 +7,7 @@ import { InputParseError } from "@/entities/errors/common";
import { userFactory } from "@/__factories__/user.factory"; import { userFactory } from "@/__factories__/user.factory";
describe("signUpController", () => { 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 users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);
const useCase = signUpUseCase(users, auth); const useCase = signUpUseCase(users, auth);
@@ -18,7 +18,8 @@ describe("signUpController", () => {
password: "secret_password", password: "secret_password",
confirmPassword: "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 () => { it("throws InputParseError when passwords do not match", async () => {
@@ -39,7 +40,6 @@ describe("signUpController", () => {
it("throws InputParseError when username is too short", async () => { it("throws InputParseError when username is too short", async () => {
const users = new MockUsersRepository([]); const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users); const auth = new MockAuthenticationService(users);
// Pre-seed so we don't hit the taken-username error
await users.createUser(userFactory.build({ username: "alice" })); await users.createUser(userFactory.build({ username: "alice" }));
const useCase = signUpUseCase(users, auth); const useCase = signUpUseCase(users, auth);
const controller = signUpController(useCase); const controller = signUpController(useCase);

View File

@@ -1,39 +1,23 @@
import { z } from "zod";
import { InputParseError } from "../../entities/errors/common"; 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 function presenter(value: SignUpOutput) {
.object({ return value.cookie;
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"],
});
}
});
export type ISignUpController = ReturnType<typeof signUpController>; export type ISignUpController = ReturnType<typeof signUpController>;
export const signUpController = export const signUpController =
(signUpUseCase: ISignUpUseCase) => (signUpUseCase: ISignUpUseCase) =>
async ( async (input: unknown): Promise<ReturnType<typeof presenter>> => {
input: Partial<z.infer<typeof inputSchema>>, const parsed = signUpInputSchema.safeParse(input);
): Promise<Awaited<ReturnType<ISignUpUseCase>>> => {
const parsed = inputSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {
throw new InputParseError("Invalid sign-up input", { cause: parsed.error }); throw new InputParseError("Invalid sign-up input", { cause: parsed.error });
} }
return await signUpUseCase(parsed.data); const result = await signUpUseCase(parsed.data);
return presenter(result);
}; };

View File

@@ -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 {};

View File

@@ -21,21 +21,24 @@ describe("auth feature: sign-up → sign-in → sign-out", () => {
const signUp = signUpController(signUpUseCase(users, auth)); const signUp = signUpController(signUpUseCase(users, auth));
const signOut = signOutController(signOutUseCase(auth)); const signOut = signOutController(signOutUseCase(auth));
const signUpResult = await signUp({ // signUp returns a cookie (presenter shape)
const signUpCookie = await signUp({
username: "newperson", username: "newperson",
password: "verysecret", password: "verysecret",
confirmPassword: "verysecret", confirmPassword: "verysecret",
}); });
expect(signUpResult.user.username).toBe("newperson"); expect(signUpCookie.name).toBe("session");
const userId = signUpResult.user.id; expect(signUpCookie.value).toBeTruthy();
const signInCookie = await signIn({ const signInCookie = await signIn({
username: "newperson", username: "newperson",
password: "verysecret", password: "verysecret",
}); });
expect(signInCookie.value).toBe("session_" + userId); expect(signInCookie.name).toBe("session");
expect(signInCookie.value).toBeTruthy();
const signOutResult = await signOut(signInCookie.value); // signOut takes { sessionId } and returns void
expect(signOutResult.value).toBe(""); const signOutResult = await signOut({ sessionId: signInCookie.value });
expect(signOutResult).toBeUndefined();
}); });
}); });