summaryrefslogtreecommitdiff
path: root/test/signin-routes.test.ts
diff options
context:
space:
mode:
authorJustZvan <justzvan@justzvan.xyz>2026-04-06 14:25:51 +0200
committerJustZvan <justzvan@justzvan.xyz>2026-04-06 14:25:51 +0200
commit18efc1e708821fd1f36532d659b928c320f4a143 (patch)
tree2938c5fcefb4faccb2bfa01e59eaefc8efd409f7 /test/signin-routes.test.ts
parentde366e1607ae66fa5227ed8a5d7b0d2a2996e002 (diff)
feat: google signin
Diffstat (limited to 'test/signin-routes.test.ts')
-rw-r--r--test/signin-routes.test.ts177
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)}"`,
);
});
});