diff --git a/server/api/api/auth/login/index.test.ts b/server/api/api/auth/login/index.test.ts new file mode 100644 index 00000000..153213ff --- /dev/null +++ b/server/api/api/auth/login/index.test.ts @@ -0,0 +1,230 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { db } from "~drizzle/db"; +import { Applications } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; +import { + deleteOldTestUsers, + getTestUsers, + sendTestRequest, +} from "~tests/utils"; +import { meta } from "./index"; + +await deleteOldTestUsers(); + +const { users, deleteUsers, passwords } = await getTestUsers(1); + +// Create application +const application = ( + await db + .insert(Applications) + .values({ + name: "Test Application", + clientId: randomBytes(32).toString("hex"), + secret: "test", + redirectUri: "https://example.com", + scopes: "read write", + }) + .returning() +)[0]; + +afterAll(async () => { + await deleteUsers(); + await db.delete(Applications).where(eq(Applications.id, application.id)); +}); + +// /api/auth/login +describe(meta.route, () => { + test("should get a JWT with email", async () => { + const formData = new FormData(); + + formData.append("identifier", users[0]?.getUser().email ?? ""); + formData.append("password", passwords[0]); + + const response = await sendTestRequest( + new Request( + new URL( + `/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, + config.http.base_url, + ), + { + method: "POST", + body: formData, + }, + ), + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBeDefined(); + const locationHeader = new URL( + response.headers.get("Location") ?? "", + "", + ); + + expect(locationHeader.pathname).toBe("/oauth/consent"); + expect(locationHeader.searchParams.get("client_id")).toBe( + application.clientId, + ); + expect(locationHeader.searchParams.get("redirect_uri")).toBe( + "https://example.com", + ); + expect(locationHeader.searchParams.get("response_type")).toBe("code"); + expect(locationHeader.searchParams.get("scope")).toBe("read write"); + + expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/); + }); + + test("should get a JWT with username", async () => { + const formData = new FormData(); + + formData.append("identifier", users[0]?.getUser().username ?? ""); + formData.append("password", passwords[0]); + + const response = await sendTestRequest( + new Request( + new URL( + `/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, + config.http.base_url, + ), + { + method: "POST", + body: formData, + }, + ), + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBeDefined(); + const locationHeader = new URL( + response.headers.get("Location") ?? "", + "", + ); + + expect(locationHeader.pathname).toBe("/oauth/consent"); + expect(locationHeader.searchParams.get("client_id")).toBe( + application.clientId, + ); + expect(locationHeader.searchParams.get("redirect_uri")).toBe( + "https://example.com", + ); + expect(locationHeader.searchParams.get("response_type")).toBe("code"); + expect(locationHeader.searchParams.get("scope")).toBe("read write"); + + expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/); + }); + + describe("should reject invalid credentials", async () => { + // Redirects to /oauth/authorize on invalid + test("invalid email", async () => { + const formData = new FormData(); + + formData.append("identifier", "ababa@gmail.com"); + formData.append("password", "password"); + + const response = await sendTestRequest( + new Request( + new URL( + `/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, + config.http.base_url, + ), + { + method: "POST", + body: formData, + }, + ), + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBeDefined(); + const locationHeader = new URL( + response.headers.get("Location") ?? "", + "", + ); + + expect(locationHeader.pathname).toBe("/oauth/authorize"); + expect(locationHeader.searchParams.get("error")).toBe( + "invalid_grant", + ); + expect(locationHeader.searchParams.get("error_description")).toBe( + "Invalid email or password", + ); + + expect(response.headers.get("Set-Cookie")).toBeNull(); + }); + + test("invalid username", async () => { + const formData = new FormData(); + + formData.append("identifier", "ababa"); + formData.append("password", "password"); + + const response = await sendTestRequest( + new Request( + new URL( + `/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, + config.http.base_url, + ), + { + method: "POST", + body: formData, + }, + ), + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBeDefined(); + const locationHeader = new URL( + response.headers.get("Location") ?? "", + "", + ); + + expect(locationHeader.pathname).toBe("/oauth/authorize"); + expect(locationHeader.searchParams.get("error")).toBe( + "invalid_grant", + ); + expect(locationHeader.searchParams.get("error_description")).toBe( + "Invalid email or password", + ); + + expect(response.headers.get("Set-Cookie")).toBeNull(); + }); + + test("invalid password", async () => { + const formData = new FormData(); + + formData.append("identifier", users[0]?.getUser().email ?? ""); + formData.append("password", "password"); + + const response = await sendTestRequest( + new Request( + new URL( + `/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, + config.http.base_url, + ), + { + method: "POST", + body: formData, + }, + ), + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBeDefined(); + const locationHeader = new URL( + response.headers.get("Location") ?? "", + "", + ); + + expect(locationHeader.pathname).toBe("/oauth/authorize"); + expect(locationHeader.searchParams.get("error")).toBe( + "invalid_grant", + ); + expect(locationHeader.searchParams.get("error_description")).toBe( + "Invalid email or password", + ); + + expect(response.headers.get("Set-Cookie")).toBeNull(); + }); + }); +}); diff --git a/server/api/api/auth/login/index.ts b/server/api/api/auth/login/index.ts index e3096ec3..7485390f 100644 --- a/server/api/api/auth/login/index.ts +++ b/server/api/api/auth/login/index.ts @@ -1,7 +1,7 @@ import { applyConfig, handleZodError } from "@api"; import { zValidator } from "@hono/zod-validator"; import { errorResponse, response } from "@response"; -import { eq } from "drizzle-orm"; +import { eq, or } from "drizzle-orm"; import type { Hono } from "hono"; import { SignJWT } from "jose"; import { z } from "zod"; @@ -24,7 +24,11 @@ export const meta = applyConfig({ export const schemas = { form: z.object({ - email: z.string().email().toLowerCase(), + identifier: z + .string() + .email() + .toLowerCase() + .or(z.string().toLowerCase()), password: z.string().min(2).max(100), }), query: z.object({ @@ -69,7 +73,10 @@ const returnError = (query: object, error: string, description: string) => { searchParams.append("error_description", description); return response(null, 302, { - Location: `/oauth/authorize?${searchParams.toString()}`, + Location: new URL( + `/oauth/authorize?${searchParams.toString()}`, + config.http.base_url, + ).toString(), }); }; @@ -80,12 +87,15 @@ export default (app: Hono) => zValidator("form", schemas.form, handleZodError), zValidator("query", schemas.query, handleZodError), async (context) => { - const { email, password } = context.req.valid("form"); + const { identifier, password } = context.req.valid("form"); const { client_id } = context.req.valid("query"); // Find user const user = await User.fromSql( - eq(Users.email, email.toLowerCase()), + or( + eq(Users.email, identifier.toLowerCase()), + eq(Users.username, identifier.toLowerCase()), + ), ); if ( @@ -97,7 +107,7 @@ export default (app: Hono) => ) return returnError( context.req.query(), - "invalid_request", + "invalid_grant", "Invalid email or password", ); diff --git a/server/api/api/v1/accounts/:id/statuses.test.ts b/server/api/api/v1/accounts/:id/statuses.test.ts index 09cc9953..a796589f 100644 --- a/server/api/api/v1/accounts/:id/statuses.test.ts +++ b/server/api/api/v1/accounts/:id/statuses.test.ts @@ -19,12 +19,6 @@ afterAll(async () => { await deleteUsers(); }); -const getFormData = (object: Record) => - Object.keys(object).reduce((formData, key) => { - formData.append(key, String(object[key])); - return formData; - }, new FormData()); - beforeAll(async () => { const response = await sendTestRequest( new Request( diff --git a/tests/api.test.ts b/tests/api.test.ts index 1431f1d8..6ec0af04 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,3 +1,6 @@ +/** + * @deprecated + */ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { config } from "config-manager"; import { eq } from "drizzle-orm"; diff --git a/tests/api/accounts.test.ts b/tests/api/accounts.test.ts index f71b705c..2027c335 100644 --- a/tests/api/accounts.test.ts +++ b/tests/api/accounts.test.ts @@ -1,9 +1,11 @@ +/** + * @deprecated + */ import { afterAll, describe, expect, test } from "bun:test"; import { config } from "config-manager"; import { getTestUsers, sendTestRequest, wrapRelativeUrl } from "~tests/utils"; import type { Account as APIAccount } from "~types/mastodon/account"; import type { Relationship as APIRelationship } from "~types/mastodon/relationship"; -import type { Status as APIStatus } from "~types/mastodon/status"; const base_url = config.http.base_url; @@ -105,33 +107,6 @@ describe("API Tests", () => { }); }); - describe("GET /api/v1/accounts/:id/statuses", () => { - test("should return the statuses of the specified user", async () => { - const response = await sendTestRequest( - new Request( - wrapRelativeUrl( - `/api/v1/accounts/${user.id}/statuses`, - base_url, - ), - { - headers: { - Authorization: `Bearer ${token.accessToken}`, - }, - }, - ), - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json", - ); - - const statuses = (await response.json()) as APIStatus[]; - - expect(statuses.length).toBe(0); - }); - }); - describe("POST /api/v1/accounts/:id/remove_from_followers", () => { test("should remove the specified user from the authenticated user's followers and return an APIRelationship object", async () => { const response = await sendTestRequest( diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index 9e825cd8..deb3391e 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -1,3 +1,6 @@ +/** + * @deprecated + */ import { afterAll, describe, expect, test } from "bun:test"; import { config } from "config-manager"; import { getTestUsers, sendTestRequest, wrapRelativeUrl } from "~tests/utils"; diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 7f131d0b..670db5a2 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -1,13 +1,11 @@ +/** + * @deprecated + */ import { afterAll, describe, expect, test } from "bun:test"; import { config } from "~packages/config-manager"; import type { Application as APIApplication } from "~types/mastodon/application"; import type { Token as APIToken } from "~types/mastodon/token"; -import { - deleteOldTestUsers, - getTestUsers, - sendTestRequest, - wrapRelativeUrl, -} from "./utils"; +import { getTestUsers, sendTestRequest, wrapRelativeUrl } from "./utils"; const base_url = config.http.base_url; @@ -70,7 +68,7 @@ describe("POST /api/auth/login/", () => { test("should get a JWT", async () => { const formData = new FormData(); - formData.append("email", users[0]?.getUser().email ?? ""); + formData.append("identifier", users[0]?.getUser().email ?? ""); formData.append("password", passwords[0]); const response = await sendTestRequest( @@ -87,21 +85,6 @@ describe("POST /api/auth/login/", () => { ); expect(response.status).toBe(302); - expect(response.headers.get("location")).toBeDefined(); - const locationHeader = new URL( - response.headers.get("Location") ?? "", - "", - ); - - expect(locationHeader.pathname).toBe("/oauth/consent"); - expect(locationHeader.searchParams.get("client_id")).toBe(client_id); - expect(locationHeader.searchParams.get("redirect_uri")).toBe( - "https://example.com", - ); - expect(locationHeader.searchParams.get("response_type")).toBe("code"); - expect(locationHeader.searchParams.get("scope")).toBe("read write"); - - expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/); jwt = response.headers.get("Set-Cookie")?.match(/jwt=([^;]+);/)?.[1] ??