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:
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cms": "./src/integrations/cms/index.ts",
|
||||
"./api": "./src/integrations/api/router.ts",
|
||||
"./di/bind-production": "./src/di/bind-production.ts"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { signInUseCase } from "@/application/use-cases/sign-in.use-case";
|
||||
import { signInUseCase, signInOutputSchema } from "@/application/use-cases/sign-in.use-case";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
import { AuthenticationError } from "@/entities/errors/auth";
|
||||
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
|
||||
import { userFactory } from "@/__factories__/user.factory";
|
||||
|
||||
describe("signInUseCase", () => {
|
||||
@@ -45,3 +46,29 @@ describe("signInUseCase", () => {
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signInUseCase output validation (R25)", () => {
|
||||
it("throws when authenticationService returns a malformed session", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const seed = userFactory.build({ username: "alice" });
|
||||
await users.createUser(seed);
|
||||
|
||||
const auth = {
|
||||
verifyPassword: async () => true,
|
||||
// session missing required fields → should fail signInOutputSchema.parse
|
||||
createSession: async () => ({ session: { id: 123 }, cookie: null }),
|
||||
} as unknown as IAuthenticationService;
|
||||
|
||||
const useCase = signInUseCase(users, auth);
|
||||
await expect(useCase({ username: "alice", password: "x" })).rejects.toThrow(/parse|invalid/i);
|
||||
});
|
||||
|
||||
it("exports an output schema that mirrors the success shape", () => {
|
||||
expect(signInOutputSchema).toBeDefined();
|
||||
const parsed = signInOutputSchema.safeParse({
|
||||
session: { id: "s1", userId: "u1", expiresAt: new Date() },
|
||||
cookie: { name: "session", value: "s1", attributes: {} },
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { AuthenticationError } from "../../entities/errors/auth";
|
||||
import type { Cookie } from "../../entities/models/cookie";
|
||||
import type { Session } from "../../entities/models/session";
|
||||
import { cookieSchema } from "../../entities/models/cookie";
|
||||
import { sessionSchema } from "../../entities/models/session";
|
||||
import type { IUsersRepository } from "../repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const signInInputSchema = z
|
||||
.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
})
|
||||
.strict();
|
||||
export type SignInInput = z.infer<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 const signInUseCase =
|
||||
(
|
||||
usersRepository: IUsersRepository,
|
||||
authenticationService: IAuthenticationService,
|
||||
) =>
|
||||
async (input: {
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<{ session: Session; cookie: Cookie }> => {
|
||||
(usersRepository: IUsersRepository, authenticationService: IAuthenticationService) =>
|
||||
async (input: SignInInput): Promise<SignInOutput> => {
|
||||
const existingUser = await usersRepository.getUserByUsername(input.username);
|
||||
if (!existingUser) {
|
||||
throw new AuthenticationError("User does not exist");
|
||||
}
|
||||
|
||||
const validPassword = await authenticationService.verifyPassword(
|
||||
existingUser.passwordHash,
|
||||
input.password,
|
||||
@@ -27,6 +39,6 @@ export const signInUseCase =
|
||||
if (!validPassword) {
|
||||
throw new AuthenticationError("Incorrect username or password");
|
||||
}
|
||||
|
||||
return await authenticationService.createSession(existingUser);
|
||||
const result = await authenticationService.createSession(existingUser);
|
||||
return signInOutputSchema.parse(result);
|
||||
};
|
||||
|
||||
@@ -4,13 +4,12 @@ import { MockUsersRepository } from "@/infrastructure/repositories/users.reposit
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
|
||||
describe("signOutUseCase", () => {
|
||||
it("returns a blank cookie", async () => {
|
||||
it("returns void on successful sign-out", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signOutUseCase(auth);
|
||||
|
||||
const result = await useCase("session_1");
|
||||
expect(result.blankCookie.name).toBe("session");
|
||||
expect(result.blankCookie.value).toBe("");
|
||||
const result = await useCase({ sessionId: "session_1" });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { Cookie } from "../../entities/models/cookie";
|
||||
import { z } from "zod";
|
||||
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const signOutInputSchema = z.object({ sessionId: z.string() }).strict();
|
||||
export type SignOutInput = z.infer<typeof signOutInputSchema>;
|
||||
|
||||
// No xOutputSchema — use case returns void.
|
||||
|
||||
// ── Use case ─────────────────────────────────────────────────────────────
|
||||
export type ISignOutUseCase = ReturnType<typeof signOutUseCase>;
|
||||
|
||||
export const signOutUseCase =
|
||||
(authenticationService: IAuthenticationService) =>
|
||||
async (sessionId: string): Promise<{ blankCookie: Cookie }> => {
|
||||
return await authenticationService.invalidateSession(sessionId);
|
||||
async (input: SignOutInput): Promise<void> => {
|
||||
await authenticationService.invalidateSession(input.sessionId);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { signUpUseCase } from "@/application/use-cases/sign-up.use-case";
|
||||
import { signUpUseCase, signUpOutputSchema } from "@/application/use-cases/sign-up.use-case";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
import { AuthenticationError } from "@/entities/errors/auth";
|
||||
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
|
||||
import { userFactory } from "@/__factories__/user.factory";
|
||||
|
||||
describe("signUpUseCase", () => {
|
||||
it("creates a new user and returns session + cookie + user", async () => {
|
||||
it("creates a new user and returns session + cookie", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signUpUseCase(users, auth);
|
||||
@@ -14,10 +15,10 @@ describe("signUpUseCase", () => {
|
||||
const result = await useCase({
|
||||
username: "carol",
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
});
|
||||
|
||||
expect(result.user.username).toBe("carol");
|
||||
expect(result.session.userId).toBe(result.user.id);
|
||||
expect(result.session.userId).toBeTruthy();
|
||||
expect(result.cookie.name).toBe("session");
|
||||
});
|
||||
|
||||
@@ -28,7 +29,34 @@ describe("signUpUseCase", () => {
|
||||
|
||||
const useCase = signUpUseCase(users, auth);
|
||||
await expect(
|
||||
useCase({ username: "alice", password: "secret_password" }),
|
||||
useCase({ username: "alice", password: "secret_password", confirmPassword: "secret_password" }),
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signUpUseCase output validation (R25)", () => {
|
||||
it("throws when authenticationService returns a malformed session", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = {
|
||||
hashPassword: async () => "hashed_x",
|
||||
generateUserId: () => "uid1",
|
||||
verifyPassword: async () => true,
|
||||
// session missing required fields → should fail signUpOutputSchema.parse
|
||||
createSession: async () => ({ session: { id: 123 }, cookie: null }),
|
||||
} as unknown as IAuthenticationService;
|
||||
|
||||
const useCase = signUpUseCase(users, auth);
|
||||
await expect(
|
||||
useCase({ username: "carol", password: "secret_password", confirmPassword: "secret_password" }),
|
||||
).rejects.toThrow(/parse|invalid/i);
|
||||
});
|
||||
|
||||
it("exports an output schema that mirrors the success shape", () => {
|
||||
expect(signUpOutputSchema).toBeDefined();
|
||||
const parsed = signUpOutputSchema.safeParse({
|
||||
session: { id: "s1", userId: "u1", expiresAt: new Date() },
|
||||
cookie: { name: "session", value: "s1", attributes: {} },
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { AuthenticationError } from "../../entities/errors/auth";
|
||||
import type { Cookie } from "../../entities/models/cookie";
|
||||
import type { Session } from "../../entities/models/session";
|
||||
import type { User } from "../../entities/models/user";
|
||||
import { cookieSchema } from "../../entities/models/cookie";
|
||||
import { sessionSchema } from "../../entities/models/session";
|
||||
import type { IUsersRepository } from "../repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
export const signUpInputSchema = z
|
||||
.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
confirmPassword: z.string().min(6).max(255),
|
||||
})
|
||||
.strict()
|
||||
.refine((d) => d.password === d.confirmPassword, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
export type SignUpInput = z.infer<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 const signUpUseCase =
|
||||
@@ -12,14 +35,7 @@ export const signUpUseCase =
|
||||
usersRepository: IUsersRepository,
|
||||
authenticationService: IAuthenticationService,
|
||||
) =>
|
||||
async (input: {
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<{
|
||||
session: Session;
|
||||
cookie: Cookie;
|
||||
user: Pick<User, "id" | "username">;
|
||||
}> => {
|
||||
async (input: SignUpInput): Promise<SignUpOutput> => {
|
||||
const existingUser = await usersRepository.getUserByUsername(input.username);
|
||||
if (existingUser) {
|
||||
throw new AuthenticationError("Username taken");
|
||||
@@ -36,9 +52,5 @@ export const signUpUseCase =
|
||||
|
||||
const { cookie, session } = await authenticationService.createSession(newUser);
|
||||
|
||||
return {
|
||||
cookie,
|
||||
session,
|
||||
user: { id: newUser.id, username: newUser.username },
|
||||
};
|
||||
return signUpOutputSchema.parse({ session, cookie });
|
||||
};
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
type CookieAttributes = {
|
||||
secure?: boolean;
|
||||
path?: string;
|
||||
domain?: string;
|
||||
sameSite?: "lax" | "strict" | "none";
|
||||
httpOnly?: boolean;
|
||||
maxAge?: number;
|
||||
expires?: Date;
|
||||
};
|
||||
import { z } from "zod";
|
||||
|
||||
export type Cookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
attributes: CookieAttributes;
|
||||
};
|
||||
const cookieAttributesSchema = z.object({
|
||||
secure: z.boolean().optional(),
|
||||
path: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
sameSite: z.enum(["lax", "strict", "none"]).optional(),
|
||||
httpOnly: z.boolean().optional(),
|
||||
maxAge: z.number().optional(),
|
||||
expires: z.date().optional(),
|
||||
});
|
||||
|
||||
export const cookieSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
attributes: cookieAttributesSchema,
|
||||
});
|
||||
|
||||
export type Cookie = z.infer<typeof cookieSchema>;
|
||||
|
||||
@@ -9,3 +9,29 @@ export {
|
||||
} from "./entities/errors/auth";
|
||||
export { InputParseError } from "./entities/errors/common";
|
||||
export { SESSION_COOKIE } from "./config";
|
||||
|
||||
// Use case schemas + types (Plan 9 R18)
|
||||
export {
|
||||
signInInputSchema,
|
||||
signInOutputSchema,
|
||||
type SignInInput,
|
||||
type SignInOutput,
|
||||
type ISignInUseCase,
|
||||
} from "./application/use-cases/sign-in.use-case";
|
||||
export {
|
||||
signUpInputSchema,
|
||||
signUpOutputSchema,
|
||||
type SignUpInput,
|
||||
type SignUpOutput,
|
||||
type ISignUpUseCase,
|
||||
} from "./application/use-cases/sign-up.use-case";
|
||||
export {
|
||||
signOutInputSchema,
|
||||
type SignOutInput,
|
||||
type ISignOutUseCase,
|
||||
} from "./application/use-cases/sign-out.use-case";
|
||||
|
||||
// Controller type aliases
|
||||
export type { ISignInController } from "./interface-adapters/controllers/sign-in.controller";
|
||||
export type { ISignUpController } from "./interface-adapters/controllers/sign-up.controller";
|
||||
export type { ISignOutController } from "./interface-adapters/controllers/sign-out.controller";
|
||||
|
||||
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 { authRouter } from "./router";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { authRouter } from "@/integrations/api/router";
|
||||
import { authContainer } from "@/di/container";
|
||||
import { AUTH_SYMBOLS } from "@/di/symbols";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
|
||||
|
||||
describe("authRouter", () => {
|
||||
it("exposes signIn, signUp, signOut procedures", () => {
|
||||
@@ -20,3 +28,45 @@ describe("authRouter", () => {
|
||||
expect(result.name).toBe("session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authRouter (R26 error mapping)", () => {
|
||||
beforeEach(() => {
|
||||
if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
|
||||
authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
|
||||
}
|
||||
if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) {
|
||||
authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService);
|
||||
}
|
||||
const users = new MockUsersRepository();
|
||||
const auth = new MockAuthenticationService(users);
|
||||
authContainer.bind<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, publicProcedure } from "@repo/core-shared/trpc/init";
|
||||
import { router } from "@repo/core-shared/trpc/init";
|
||||
|
||||
import { authContainer } from "../../di/container";
|
||||
import { AUTH_SYMBOLS } from "../../di/symbols";
|
||||
|
||||
import { signInInputSchema } from "../../application/use-cases/sign-in.use-case";
|
||||
import { signUpInputSchema } from "../../application/use-cases/sign-up.use-case";
|
||||
import { signOutInputSchema } from "../../application/use-cases/sign-out.use-case";
|
||||
|
||||
import type { ISignInController } from "../../interface-adapters/controllers/sign-in.controller";
|
||||
import type { ISignUpController } from "../../interface-adapters/controllers/sign-up.controller";
|
||||
import type { ISignOutController } from "../../interface-adapters/controllers/sign-out.controller";
|
||||
|
||||
import { authProcedure } from "./procedures";
|
||||
|
||||
export const authRouter = router({
|
||||
signIn: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input }) => {
|
||||
const ctrl = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
|
||||
return ctrl(input);
|
||||
}),
|
||||
signIn: authProcedure.input(signInInputSchema).mutation(({ input }) => {
|
||||
const ctrl = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
|
||||
return ctrl(input);
|
||||
}),
|
||||
|
||||
signUp: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
confirmPassword: z.string().min(6).max(255),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input }) => {
|
||||
const ctrl = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
|
||||
return ctrl(input);
|
||||
}),
|
||||
signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
|
||||
const ctrl = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
|
||||
return ctrl(input);
|
||||
}),
|
||||
|
||||
signOut: publicProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const ctrl = authContainer.get<ISignOutController>(AUTH_SYMBOLS.ISignOutController);
|
||||
return ctrl(input.sessionId);
|
||||
}),
|
||||
signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
|
||||
const ctrl = authContainer.get<ISignOutController>(AUTH_SYMBOLS.ISignOutController);
|
||||
return ctrl(input);
|
||||
}),
|
||||
});
|
||||
|
||||
export type AuthRouter = typeof authRouter;
|
||||
|
||||
@@ -1,47 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { signInController } from "@/interface-adapters/controllers/sign-in.controller";
|
||||
import { signInUseCase } from "@/application/use-cases/sign-in.use-case";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
import { signInUseCase } from "@/application/use-cases/sign-in.use-case";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import { userFactory } from "@/__factories__/user.factory";
|
||||
|
||||
describe("signInController", () => {
|
||||
it("returns a cookie on valid credentials", async () => {
|
||||
it("returns a cookie on successful sign-in", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const seedUser = userFactory.build({
|
||||
username: "alice",
|
||||
passwordHash: "hashed_testpassword",
|
||||
});
|
||||
const seedUser = userFactory.build({ username: "alice", passwordHash: "hashed_testpassword" });
|
||||
await users.createUser(seedUser);
|
||||
|
||||
const useCase = signInUseCase(users, auth);
|
||||
const controller = signInController(useCase);
|
||||
|
||||
const cookie = await controller({ username: "alice", password: "testpassword" });
|
||||
expect(cookie.name).toBe("session");
|
||||
const result = await controller({
|
||||
username: "alice",
|
||||
password: "testpassword",
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBeTruthy();
|
||||
expect(result.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws InputParseError on missing username", async () => {
|
||||
it("throws InputParseError on invalid input", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signInUseCase(users, auth);
|
||||
const controller = signInController(useCase);
|
||||
|
||||
await expect(
|
||||
controller({ password: "anything" }),
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
await expect(controller({ username: "ab" } as unknown)).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
|
||||
it("throws InputParseError on too-short password", async () => {
|
||||
it("throws InputParseError when input is not an object", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signInUseCase(users, auth);
|
||||
const controller = signInController(useCase);
|
||||
|
||||
await expect(
|
||||
controller({ username: "alice", password: "abc" }),
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
await expect(controller("garbage" as unknown)).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
import type { Cookie } from "../../entities/models/cookie";
|
||||
import type { ISignInUseCase } from "../../application/use-cases/sign-in.use-case";
|
||||
import {
|
||||
signInInputSchema,
|
||||
type ISignInUseCase,
|
||||
type SignInOutput,
|
||||
} from "../../application/use-cases/sign-in.use-case";
|
||||
|
||||
const inputSchema = z.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
});
|
||||
function presenter(value: SignInOutput) {
|
||||
return value.cookie;
|
||||
}
|
||||
|
||||
export type ISignInController = ReturnType<typeof signInController>;
|
||||
|
||||
export const signInController =
|
||||
(signInUseCase: ISignInUseCase) =>
|
||||
async (input: Partial<z.infer<typeof inputSchema>>): Promise<Cookie> => {
|
||||
const parsed = inputSchema.safeParse(input);
|
||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||
const parsed = signInInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new InputParseError("Invalid sign-in input", { cause: parsed.error });
|
||||
}
|
||||
const { cookie } = await signInUseCase(parsed.data);
|
||||
return cookie;
|
||||
const result = await signInUseCase(parsed.data);
|
||||
return presenter(result);
|
||||
};
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { signOutController } from "@/interface-adapters/controllers/sign-out.controller";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
import { signOutUseCase } from "@/application/use-cases/sign-out.use-case";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
|
||||
describe("signOutController", () => {
|
||||
it("returns a blank cookie", async () => {
|
||||
it("returns void on successful sign-out", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signOutUseCase(auth);
|
||||
const controller = signOutController(useCase);
|
||||
|
||||
const cookie = await controller("session_anything");
|
||||
expect(cookie.name).toBe("session");
|
||||
expect(cookie.value).toBe("");
|
||||
const result = await controller({ sessionId: "any" });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws InputParseError when sessionId is missing", async () => {
|
||||
it("throws InputParseError on missing sessionId", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signOutUseCase(auth);
|
||||
const controller = signOutController(useCase);
|
||||
|
||||
await expect(controller(undefined)).rejects.toBeInstanceOf(InputParseError);
|
||||
await expect(controller({} as unknown)).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
import type { Cookie } from "../../entities/models/cookie";
|
||||
import type { ISignOutUseCase } from "../../application/use-cases/sign-out.use-case";
|
||||
import {
|
||||
signOutInputSchema,
|
||||
type ISignOutUseCase,
|
||||
} from "../../application/use-cases/sign-out.use-case";
|
||||
|
||||
export type ISignOutController = ReturnType<typeof signOutController>;
|
||||
|
||||
export const signOutController =
|
||||
(signOutUseCase: ISignOutUseCase) =>
|
||||
async (sessionId: string | undefined): Promise<Cookie> => {
|
||||
if (!sessionId) {
|
||||
throw new InputParseError("Must provide a session ID");
|
||||
async (input: unknown): Promise<void> => {
|
||||
const parsed = signOutInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new InputParseError("Invalid sign-out input", { cause: parsed.error });
|
||||
}
|
||||
const { blankCookie } = await signOutUseCase(sessionId);
|
||||
return blankCookie;
|
||||
await signOutUseCase(parsed.data);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { InputParseError } from "@/entities/errors/common";
|
||||
import { userFactory } from "@/__factories__/user.factory";
|
||||
|
||||
describe("signUpController", () => {
|
||||
it("creates a new user when passwords match", async () => {
|
||||
it("returns a cookie on successful sign-up", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
const useCase = signUpUseCase(users, auth);
|
||||
@@ -18,7 +18,8 @@ describe("signUpController", () => {
|
||||
password: "secret_password",
|
||||
confirmPassword: "secret_password",
|
||||
});
|
||||
expect(result.user.username).toBe("carol");
|
||||
expect(result.name).toBe("session");
|
||||
expect(result.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws InputParseError when passwords do not match", async () => {
|
||||
@@ -39,7 +40,6 @@ describe("signUpController", () => {
|
||||
it("throws InputParseError when username is too short", async () => {
|
||||
const users = new MockUsersRepository([]);
|
||||
const auth = new MockAuthenticationService(users);
|
||||
// Pre-seed so we don't hit the taken-username error
|
||||
await users.createUser(userFactory.build({ username: "alice" }));
|
||||
const useCase = signUpUseCase(users, auth);
|
||||
const controller = signUpController(useCase);
|
||||
|
||||
@@ -1,39 +1,23 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { InputParseError } from "../../entities/errors/common";
|
||||
import type { ISignUpUseCase } from "../../application/use-cases/sign-up.use-case";
|
||||
import {
|
||||
signUpInputSchema,
|
||||
type ISignUpUseCase,
|
||||
type SignUpOutput,
|
||||
} from "../../application/use-cases/sign-up.use-case";
|
||||
|
||||
const inputSchema = z
|
||||
.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
confirmPassword: z.string().min(6).max(255),
|
||||
})
|
||||
.superRefine(({ password, confirmPassword }, ctx) => {
|
||||
if (confirmPassword !== password) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "The passwords did not match",
|
||||
path: ["password"],
|
||||
});
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "The passwords did not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
});
|
||||
function presenter(value: SignUpOutput) {
|
||||
return value.cookie;
|
||||
}
|
||||
|
||||
export type ISignUpController = ReturnType<typeof signUpController>;
|
||||
|
||||
export const signUpController =
|
||||
(signUpUseCase: ISignUpUseCase) =>
|
||||
async (
|
||||
input: Partial<z.infer<typeof inputSchema>>,
|
||||
): Promise<Awaited<ReturnType<ISignUpUseCase>>> => {
|
||||
const parsed = inputSchema.safeParse(input);
|
||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||
const parsed = signUpInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new InputParseError("Invalid sign-up input", { cause: parsed.error });
|
||||
}
|
||||
return await signUpUseCase(parsed.data);
|
||||
const result = await signUpUseCase(parsed.data);
|
||||
return presenter(result);
|
||||
};
|
||||
|
||||
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 signOut = signOutController(signOutUseCase(auth));
|
||||
|
||||
const signUpResult = await signUp({
|
||||
// signUp returns a cookie (presenter shape)
|
||||
const signUpCookie = await signUp({
|
||||
username: "newperson",
|
||||
password: "verysecret",
|
||||
confirmPassword: "verysecret",
|
||||
});
|
||||
expect(signUpResult.user.username).toBe("newperson");
|
||||
const userId = signUpResult.user.id;
|
||||
expect(signUpCookie.name).toBe("session");
|
||||
expect(signUpCookie.value).toBeTruthy();
|
||||
|
||||
const signInCookie = await signIn({
|
||||
username: "newperson",
|
||||
password: "verysecret",
|
||||
});
|
||||
expect(signInCookie.value).toBe("session_" + userId);
|
||||
expect(signInCookie.name).toBe("session");
|
||||
expect(signInCookie.value).toBeTruthy();
|
||||
|
||||
const signOutResult = await signOut(signInCookie.value);
|
||||
expect(signOutResult.value).toBe("");
|
||||
// signOut takes { sessionId } and returns void
|
||||
const signOutResult = await signOut({ sessionId: signInCookie.value });
|
||||
expect(signOutResult).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user