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:
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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("");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
18
packages/auth/src/integrations/api/procedures.ts
Normal file
18
packages/auth/src/integrations/api/procedures.ts
Normal 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"],
|
||||||
|
]),
|
||||||
|
);
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
4
packages/auth/src/ui/index.ts
Normal file
4
packages/auth/src/ui/index.ts
Normal 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 {};
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user