diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fff199e..663ddc7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -13,4 +13,4 @@ jobs: - run: bunx prettier --check . - run: bun test env: - DATABASE_URL: ${{ secrets.DATABASE_URL }} \ No newline at end of file + DATABASE_URL: ${{ secrets.DATABASE_URL }} diff --git a/src/db.ts b/src/db.ts index 09ec229..5d9deea 100644 --- a/src/db.ts +++ b/src/db.ts @@ -12,8 +12,8 @@ export function getDb(forceSync = false): DataSource { type: "postgres", url: process.env.DATABASE_URL, synchronize: process.argv.includes("sync") || forceSync, - entities: [User, Review, Saved] - }) + entities: [User, Review, Saved], + }); } return db; } diff --git a/src/entities/Review.ts b/src/entities/Review.ts index bc2d836..72c145f 100644 --- a/src/entities/Review.ts +++ b/src/entities/Review.ts @@ -1,4 +1,10 @@ -import { BaseEntity, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { + BaseEntity, + Column, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; import { type User } from "./User"; @Entity() @@ -10,12 +16,12 @@ export class Review extends BaseEntity { user: User; @Column({ - type: "float" + type: "float", }) latitude: number; @Column({ - type: "float" + type: "float", }) longitude: number; @@ -23,12 +29,12 @@ export class Review extends BaseEntity { rating: number; @Column({ - type: "text" + type: "text", }) comment: string; @Column({ - default: () => "NOW()" + default: () => "NOW()", }) created_at: Date; -} \ No newline at end of file +} diff --git a/src/entities/Saved.ts b/src/entities/Saved.ts index af12d7e..569dbf9 100644 --- a/src/entities/Saved.ts +++ b/src/entities/Saved.ts @@ -1,4 +1,10 @@ -import { BaseEntity, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { + BaseEntity, + Column, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; import { type User } from "./User"; @Entity() @@ -18,7 +24,7 @@ export class Saved extends BaseEntity { data: string; @Column({ - default: () => "NOW()" + default: () => "NOW()", }) created_at: Date; -} \ No newline at end of file +} diff --git a/src/entities/User.ts b/src/entities/User.ts index ac31948..254c5e4 100644 --- a/src/entities/User.ts +++ b/src/entities/User.ts @@ -1,4 +1,10 @@ -import { BaseEntity, Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import { + BaseEntity, + Column, + Entity, + OneToMany, + PrimaryGeneratedColumn, +} from "typeorm"; import { type Review } from "./Review"; import { type Saved } from "./Saved"; @@ -17,12 +23,12 @@ export class User extends BaseEntity { saved: Saved[]; @Column({ - default: () => "NOW()" + default: () => "NOW()", }) updated_at: Date; @Column({ - default: () => "NOW()" + default: () => "NOW()", }) created_at: Date; -} \ No newline at end of file +} diff --git a/src/entry.ts b/src/entry.ts index d422eb1..59e5ad8 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -3,4 +3,4 @@ import app from "./main"; await initDb(); -Bun.serve(app); \ No newline at end of file +Bun.serve(app); diff --git a/src/main.ts b/src/main.ts index 576e8a3..77bb0ea 100644 --- a/src/main.ts +++ b/src/main.ts @@ -61,36 +61,43 @@ app.get("/api/config", (c) => { app.post("/api/user", async (c) => { const { token } = await c.req.json(); - if(!token) { + if (!token) { return c.json({ error: "Invalid request" }, 400); } - if(!verifyToken(token)) { + if (!verifyToken(token)) { return c.json({ error: "Invalid token" }, 400); } const tokenData = getTokenData(token); - if(!tokenData) return c.json({ error: "Invalid token" }, 400); - if(!tokenData.sub || typeof tokenData.sub != "string") return c.json({ error: "Invalid token" }, 400); - if(!tokenData.preferred_username || typeof tokenData.preferred_username != "string") return c.json({ error: "Invalid token" }, 400); - const user = await User.findOneBy({ id: tokenData.sub }) || new User(); + if (!tokenData) return c.json({ error: "Invalid token" }, 400); + if (!tokenData.sub || typeof tokenData.sub != "string") + return c.json({ error: "Invalid token" }, 400); + if ( + !tokenData.preferred_username || + typeof tokenData.preferred_username != "string" + ) + return c.json({ error: "Invalid token" }, 400); + const user = (await User.findOneBy({ id: tokenData.sub })) || new User(); user.id = tokenData.sub; user.username = tokenData.preferred_username; user.updated_at = new Date(); await user.save(); return c.json({ success: true }); -}) +}); app.get("/api/user", async (c) => { const name = c.req.query("name"); - if(!name) return c.json({ sucess: false }, 400); + if (!name) return c.json({ sucess: false }, 400); const users = await User.findBy({ - username: ILike("%" + name + "%") + username: ILike("%" + name + "%"), + }); + const mapped = users.map((u) => { + return { username: u.username }; }); - const mapped = users.map(u => { return { username: u.username }; }); return c.json(mapped); -}) +}); if (process.env.REVIEWS_ENABLED) { app.get("/api/reviews", async (c) => { @@ -104,7 +111,7 @@ if (process.env.REVIEWS_ENABLED) { console.log(`Fetching reviews for lat: ${lat}, lon: ${lon}`); const reviews = await Review.findBy({ latitude: nlat, - longitude: nlon + longitude: nlon, }); return c.json(reviews); }); @@ -137,7 +144,7 @@ if (process.env.REVIEWS_ENABLED) { } const user = await User.findOneBy({ id: uid }); - if(!user) { + if (!user) { return c.json({ error: "Invalid user ID" }, 400); } @@ -181,19 +188,18 @@ app.get("/api/saved", async (c) => { // return c.json({ error: "Invalid user ID" }, 400); // } - const saved = await Saved.findBy({ user: { - id: uid - } }); + const saved = await Saved.findBy({ + user: { + id: uid, + }, + }); return c.json(saved); -}) +}); app.put("/api/saved", async (c) => { const { name, data } = await c.req.json(); if (!name || !data) { - return c.json( - { error: "name and data are required" }, - 400, - ); + return c.json({ error: "name and data are required" }, 400); } const authHeader = c.req.header("Authorization"); @@ -215,7 +221,7 @@ app.put("/api/saved", async (c) => { } const user = await User.findOneBy({ id: uid }); - if(!user) { + if (!user) { return c.json({ error: "Invalid user ID" }, 400); } @@ -225,16 +231,13 @@ app.put("/api/saved", async (c) => { saved.data = data; await saved.save(); - return c.json({ success: true }) -}) + return c.json({ success: true }); +}); app.delete("/api/saved", async (c) => { const { name } = await c.req.json(); if (!name) { - return c.json( - { error: "name is required" }, - 400, - ); + return c.json({ error: "name is required" }, 400); } const authHeader = c.req.header("Authorization"); @@ -261,13 +264,13 @@ app.delete("/api/saved", async (c) => { // } const saved = await Saved.findOneBy({ user: { id: uid }, name }); - if(!saved) { + if (!saved) { return c.json({ error: "No such save found" }, 400); } await saved.remove(); return c.json({ success: true }); -}) +}); if (process.env.TANKERKOENIG_API_KEY) { app.get("/api/fuel/list", async (c) => { diff --git a/test/keys.ts b/test/keys.ts index b1e8501..c730fce 100644 --- a/test/keys.ts +++ b/test/keys.ts @@ -1,8 +1,8 @@ import { exportJWK, exportPKCS8, generateKeyPair } from "jose"; export async function createTestKey() { - const { publicKey, privateKey } = await generateKeyPair("RS256", { - extractable: true + const { publicKey, privateKey } = await generateKeyPair("RS256", { + extractable: true, }); const jwk = await exportJWK(publicKey); jwk.kid = "test-key"; @@ -10,4 +10,4 @@ export async function createTestKey() { const pem = await exportPKCS8(privateKey); return { jwk, pem }; -} \ No newline at end of file +} diff --git a/test/main.test.ts b/test/main.test.ts index a1d8634..e4fc37d 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -1,6 +1,10 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { getDb, initDb } from "../src/db"; -import { GenericContainer, Wait, type StartedTestContainer } from "testcontainers"; +import { + GenericContainer, + Wait, + type StartedTestContainer, +} from "testcontainers"; import { createTestKey } from "./keys"; import { sign } from "jsonwebtoken"; import { getTokenData, getTokenUID, verifyToken } from "../src/auth"; @@ -13,7 +17,7 @@ process.env.OIDC_TOKEN_URL = "http://oidc.example.com/token"; process.env.NODE_ENV = "test"; process.env.REVIEWS_ENABLED = "true"; -const app = await import("../src/main").then(m => m.default); +const app = await import("../src/main").then((m) => m.default); const PGSQL_IMAGE = "postgres:latest"; let postgresContainer: StartedTestContainer | null = null; @@ -22,12 +26,12 @@ let privateKey: string; // Spin up a temporary Postgres container for testing beforeAll(async () => { - if(!process.env.DATABASE_URL) { + if (!process.env.DATABASE_URL) { postgresContainer = await new GenericContainer(PGSQL_IMAGE) .withEnvironment({ POSTGRES_USER: "tc", POSTGRES_PASSWORD: "tc", - POSTGRES_DB: "tc" + POSTGRES_DB: "tc", }) .withExposedPorts(5432) .withHealthCheck({ @@ -38,10 +42,10 @@ beforeAll(async () => { }) .withWaitStrategy(Wait.forHealthCheck()) .start(); - + process.env.DATABASE_URL = `postgres://tc:tc@localhost:${postgresContainer.getMappedPort(5432)}/tc`; } - + getDb(true); await initDb(); }); @@ -51,7 +55,7 @@ afterAll(async () => { const db = getDb(); await db.dropDatabase(); await db.destroy(); - if(postgresContainer) await postgresContainer.stop(); + if (postgresContainer) await postgresContainer.stop(); }); // Override JWKS URL to prevent external calls during tests @@ -63,7 +67,7 @@ beforeAll(async () => { // Mock the fetch function to return the test JWK // @ts-expect-error - we just need to override fetch for tests global.fetch = async () => ({ - json: async () => ({ keys: [jwk] }) + json: async () => ({ keys: [jwk] }), }); }); @@ -86,31 +90,35 @@ it("Serves a correct config", async () => { expect(config.capabilities).toBeArray(); expect(config.capabilities).toContain("auth"); expect(config.capabilities).toContain("reviews"); - expect(config.oidc).toEqual({CLIENT_ID: "test-client", AUTH_URL: "http://oidc.example.com/auth", TOKEN_URL: "http://oidc.example.com/token"}); -}) + expect(config.oidc).toEqual({ + CLIENT_ID: "test-client", + AUTH_URL: "http://oidc.example.com/auth", + TOKEN_URL: "http://oidc.example.com/token", + }); +}); describe("Authentication", () => { it("verifies a valid token", async () => { const token = sign({ sub: "test" }, privateKey, { algorithm: "RS256", keyid: jwk.kid, - expiresIn: "1h" + expiresIn: "1h", }); expect(await verifyToken(token)).toBe(true); expect(getTokenUID(token)).toBe("test"); expect(getTokenData(token)).toMatchObject({ sub: "test" }); - }) + }); it("fails with invalid KID", async () => { const token = sign({ sub: "test" }, privateKey, { algorithm: "RS256", keyid: "invalid-kid", - expiresIn: "1h" + expiresIn: "1h", }); expect(await verifyToken(token)).toBe(false); expect(getTokenUID(token)).toBe("test"); expect(getTokenData(token)).toMatchObject({ sub: "test" }); - }) + }); it("fails on an invalid token", async () => { const token = "invalid.token.here"; @@ -123,42 +131,52 @@ describe("Authentication", () => { const token = sign({ sub: "test" }, privateKey, { algorithm: "RS256", keyid: jwk.kid, - expiresIn: "-1h" + expiresIn: "-1h", }); expect(await verifyToken(token)).toBe(false); expect(getTokenUID(token)).toBe("test"); expect(getTokenData(token)).toMatchObject({ sub: "test" }); }); -}) +}); let token: string; const uid = crypto.randomUUID(); describe("User Endpoints", () => { it("fails with invalid token", async () => { - const res = await app.fetch(new Request("http://localhost/api/user", { - method: "POST", - body: JSON.stringify({ token: "invalid.token.here" }), - })); + const res = await app.fetch( + new Request("http://localhost/api/user", { + method: "POST", + body: JSON.stringify({ token: "invalid.token.here" }), + }), + ); expect(res.status).toBe(400); }); it("uploads a token", async () => { - token = sign({ - sub: uid, - preferred_username: "testuser", - }, privateKey, { - algorithm: "RS256", - keyid: jwk.kid, - expiresIn: "1h" - }); - const res = await app.fetch(new Request("http://localhost/api/user", { - method: "POST", - body: JSON.stringify({ token }), - })); + token = sign( + { + sub: uid, + preferred_username: "testuser", + }, + privateKey, + { + algorithm: "RS256", + keyid: jwk.kid, + expiresIn: "1h", + }, + ); + const res = await app.fetch( + new Request("http://localhost/api/user", { + method: "POST", + body: JSON.stringify({ token }), + }), + ); expect(res.status).toBe(200); }); it("finds the user in search", async () => { - const res = await app.fetch(new Request("http://localhost/api/user?name=testuser")); + const res = await app.fetch( + new Request("http://localhost/api/user?name=testuser"), + ); expect(res.status).toBe(200); const json = await res.json(); expect(json).toBeDefined(); @@ -166,34 +184,38 @@ describe("User Endpoints", () => { const users = json as { username: string }[]; expect(users.length).toBeGreaterThan(0); expect(users[0]!.username).toBe("testuser"); - }) -}) + }); +}); describe("Reviews", () => { it("creates a review", async () => { - const res = await app.fetch(new Request("http://localhost/api/review", { - method: "POST", - headers: { - "Authorization": `Bearer ${token}`, - "Content-Type": "application/json" - }, - body: JSON.stringify({ - rating: 4, - comment: "This is a test review.", - lat: 51.5074, - lon: 7.4653 + const res = await app.fetch( + new Request("http://localhost/api/review", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + rating: 4, + comment: "This is a test review.", + lat: 51.5074, + lon: 7.4653, + }), }), - })); + ); expect(res.status).toBe(200); const json = await res.json(); expect(json).toBeDefined(); expect(json).toBeObject(); const review = json as { success: boolean }; expect(review.success).toBe(true); - }) + }); it("lists reviews", async () => { - const res = await app.fetch(new Request("http://localhost/api/reviews?lat=51.5074&lon=7.4653")); + const res = await app.fetch( + new Request("http://localhost/api/reviews?lat=51.5074&lon=7.4653"), + ); expect(res.status).toBe(200); const json = await res.json(); expect(json).toBeDefined(); @@ -202,22 +224,24 @@ describe("Reviews", () => { expect(reviews.length).toBeGreaterThan(0); expect(reviews[0]!.rating).toBe(4); expect(reviews[0]!.comment).toBe("This is a test review."); - }) -}) + }); +}); describe("Saved Routes", () => { it("saves a route", async () => { - const res = await app.fetch(new Request("http://localhost/api/saved", { - method: "PUT", - headers: { - "Authorization": `Bearer ${token}`, - "Content-Type": "application/json" - }, - body: JSON.stringify({ - name: "Home to Work", - data: "This is some test route data." + const res = await app.fetch( + new Request("http://localhost/api/saved", { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Home to Work", + data: "This is some test route data.", + }), }), - })); + ); expect(res.status).toBe(200); const json = await res.json(); expect(json).toBeDefined(); @@ -226,12 +250,14 @@ describe("Saved Routes", () => { expect(result.success).toBe(true); }); it("lists saved routes", async () => { - const res = await app.fetch(new Request("http://localhost/api/saved", { - method: "GET", - headers: { - "Authorization": `Bearer ${token}`, - }, - })); + const res = await app.fetch( + new Request("http://localhost/api/saved", { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }), + ); expect(res.status).toBe(200); const json = await res.json(); expect(json).toBeDefined(); @@ -242,16 +268,18 @@ describe("Saved Routes", () => { expect(routes[0]!.data).toBe("This is some test route data."); }); it("deletes a saved route", async () => { - const res = await app.fetch(new Request("http://localhost/api/saved", { - method: "DELETE", - headers: { - "Authorization": `Bearer ${token}`, - "Content-Type": "application/json" - }, - body: JSON.stringify({ - name: "Home to Work", + const res = await app.fetch( + new Request("http://localhost/api/saved", { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Home to Work", + }), }), - })); + ); expect(res.status).toBe(200); const json = await res.json(); expect(json).toBeDefined(); @@ -260,12 +288,14 @@ describe("Saved Routes", () => { expect(result.success).toBe(true); }); it("no longer lists deleted routes", async () => { - const res = await app.fetch(new Request("http://localhost/api/saved", { - method: "GET", - headers: { - "Authorization": `Bearer ${token}`, - }, - })); + const res = await app.fetch( + new Request("http://localhost/api/saved", { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }), + ); expect(res.status).toBe(200); const json = await res.json(); expect(json).toBeDefined(); @@ -273,4 +303,4 @@ describe("Saved Routes", () => { const routes = json as { name: string; data: string }[]; expect(routes.length).toBe(0); }); -}); \ No newline at end of file +}); diff --git a/tsconfig.json b/tsconfig.json index d628374..e7a61d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,6 @@ "strictPropertyInitialization": false, "emitDecoratorMetadata": true, - "experimentalDecorators": true, + "experimentalDecorators": true } }