diff options
| author | JustZvan <justzvan@justzvan.xyz> | 2026-04-06 14:25:51 +0200 |
|---|---|---|
| committer | JustZvan <justzvan@justzvan.xyz> | 2026-04-06 14:25:51 +0200 |
| commit | 18efc1e708821fd1f36532d659b928c320f4a143 (patch) | |
| tree | 2938c5fcefb4faccb2bfa01e59eaefc8efd409f7 /test/signin-routes.test.ts | |
| parent | de366e1607ae66fa5227ed8a5d7b0d2a2996e002 (diff) | |
feat: google signin
Diffstat (limited to 'test/signin-routes.test.ts')
| -rw-r--r-- | test/signin-routes.test.ts | 177 |
1 files changed, 161 insertions, 16 deletions
diff --git a/test/signin-routes.test.ts b/test/signin-routes.test.ts index 98d1153..74f205d 100644 --- a/test/signin-routes.test.ts +++ b/test/signin-routes.test.ts @@ -6,6 +6,8 @@ import { users } from "../src/db/schema"; import { eq } from "drizzle-orm"; import signinRouter from "../src/routes/signin"; import type { Request, Response } from "express"; +import { verifyGoogleIdToken } from "../src/account/google"; +import { signJwt } from "../src/account/jwt"; const sendMail = vi.fn(); @@ -15,24 +17,47 @@ vi.mock("../src/email/email", () => ({ }), })); +vi.mock("../src/account/google", () => ({ + verifyGoogleIdToken: vi.fn(), +})); + +vi.mock("../src/account/jwt", async () => { + const actual = await vi.importActual<typeof import("../src/account/jwt")>( + "../src/account/jwt", + ); + + return { + ...actual, + signJwt: vi.fn(), + }; +}); + describe("Signin Routes", () => { beforeEach(() => { process.env.RESET_PASSWORD_JWT_SECRET = "test-reset-secret"; process.env.SMTP_EMAIL = "buddy@example.com"; + process.env.BASE_URL = "https://buddy.example"; sendMail.mockReset(); + vi.mocked(verifyGoogleIdToken).mockReset(); + vi.mocked(signJwt).mockReset(); + vi.mocked(signJwt).mockResolvedValue("signed-jwt"); }); - function getRouteHandler(path: string) { + function getRouteHandler(path: string, method: "get" | "post") { const layer = ( signinRouter as unknown as { stack: Array<{ route?: { path: string; + methods: Record<string, boolean>; stack: Array<{ handle: (req: Request, res: Response) => unknown }>; }; }>; } - ).stack.find((candidate) => candidate.route?.path === path); + ).stack.find( + (candidate) => + candidate.route?.path === path && candidate.route.methods[method], + ); if (!layer?.route?.stack[0]) { throw new Error(`Route handler for ${path} not found`); @@ -41,21 +66,37 @@ describe("Signin Routes", () => { return layer.route.stack[0].handle; } - async function invokeRoute(body: unknown) { - const handler = getRouteHandler("/resetpassword"); - const req = { body } as Request; + async function invokeRoute( + path: string, + method: "get" | "post", + options?: { body?: unknown; params?: Record<string, string> }, + ) { + const handler = getRouteHandler(path, method); + const req = { + body: options?.body, + params: options?.params ?? {}, + } as Request; const res = { statusCode: 200, body: undefined as unknown, + responseType: undefined as string | undefined, status(code: number) { this.statusCode = code; return this; }, + type(value: string) { + this.responseType = value; + return this; + }, send(payload: unknown) { this.body = payload; return this; }, - } as Response & { statusCode: number; body: unknown }; + } as Response & { + statusCode: number; + body: unknown; + responseType?: string; + }; await handler(req, res); @@ -63,9 +104,10 @@ describe("Signin Routes", () => { } test("should send a password reset email with a signed reset link", async () => { - const response = await invokeRoute({ - email: "test@example.com", - link: "https://buddy.example/reset-password", + const response = await invokeRoute("/resetpassword", "post", { + body: { + email: "test@example.com", + }, }); expect(response.statusCode).toBe(200); @@ -83,7 +125,10 @@ describe("Signin Routes", () => { "Your Buddy password reset link is ", "", ); - const token = new URL(sentLink).searchParams.get("token"); + expect(sentLink.startsWith("https://buddy.example/reset-password/")).toBe( + true, + ); + const token = sentLink.split("/").pop(); expect(token).toBeTruthy(); @@ -109,24 +154,124 @@ describe("Signin Routes", () => { .setExpirationTime("1h") .sign(new TextEncoder().encode(process.env.RESET_PASSWORD_JWT_SECRET!)); - const response = await invokeRoute({ - token, - password: "new-password", + const response = await invokeRoute("/reset-password/:token", "post", { + params: { token }, + body: { + password: "new-password", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.responseType).toBe("html"); + expect(String(response.body)).toContain( + "Your password has been updated successfully.", + ); + + const updatedUser = ( + await db.select().from(users).where(eq(users.id, 1)).limit(1) + )[0]; + + expect(updatedUser).toBeTruthy(); + expect(await argon2.verify(updatedUser!.password, "new-password")).toBe( + true, + ); + }); + + test("should create a new verified user from a valid Google login", async () => { + vi.mocked(verifyGoogleIdToken).mockResolvedValue({ + email: "google-user@example.com", + emailVerified: true, + subject: "google-subject-1", + }); + + const response = await invokeRoute("/signin/google", "post", { + body: { + idToken: "google-id-token", + }, }); expect(response.statusCode).toBe(200); expect(response.body).toEqual({ success: true, + token: "signed-jwt", + reason: "", + }); + + const insertedUser = ( + await db + .select() + .from(users) + .where(eq(users.email, "google-user@example.com")) + .limit(1) + )[0]; + + expect(insertedUser).toBeTruthy(); + expect(insertedUser!.emailVerified).toBe(true); + expect(insertedUser!.emailCode).toHaveLength(6); + expect(await argon2.verify(insertedUser!.password, "google-id-token")).toBe( + false, + ); + expect(signJwt).toHaveBeenCalledWith( + { id: insertedUser!.id, type: "parent" }, + "urn:buddy:users", + ); + }); + + test("should log in an existing user with Google and mark it verified", async () => { + await db + .update(users) + .set({ emailVerified: false }) + .where(eq(users.email, "test@example.com")); + + vi.mocked(verifyGoogleIdToken).mockResolvedValue({ + email: "test@example.com", + emailVerified: true, + subject: "google-subject-2", + }); + + const response = await invokeRoute("/signin/google", "post", { + body: { + idToken: "google-id-token", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + success: true, + token: "signed-jwt", reason: "", }); const updatedUser = ( - await db.select().from(users).where(eq(users.id, 1)).limit(1) + await db.select().from(users).where(eq(users.email, "test@example.com")) )[0]; expect(updatedUser).toBeTruthy(); - expect(await argon2.verify(updatedUser!.password, "new-password")).toBe( - true, + expect(updatedUser!.emailVerified).toBe(true); + expect(signJwt).toHaveBeenCalledWith( + { id: updatedUser!.id, type: "parent" }, + "urn:buddy:users", + ); + }); + + test("should render the hosted reset password form for a valid token", async () => { + const token = await new jose.SignJWT({ id: 1, type: "password_reset" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setIssuer("urn:lajosh:buddy") + .setAudience("urn:buddy:password-reset") + .setExpirationTime("1h") + .sign(new TextEncoder().encode(process.env.RESET_PASSWORD_JWT_SECRET!)); + + const response = await invokeRoute("/reset-password/:token", "get", { + params: { token }, + }); + + expect(response.statusCode).toBe(200); + expect(response.responseType).toBe("html"); + expect(String(response.body)).toContain("Reset Password"); + expect(String(response.body)).toContain( + `action="/reset-password/${encodeURIComponent(token)}"`, ); }); }); |