style: run eslint and prettier
All checks were successful
TrafficCue Server CI / check (push) Successful in 23s

This commit is contained in:
2025-08-30 10:31:03 +02:00
parent 3b876bbc80
commit fae7308af8
10 changed files with 185 additions and 134 deletions

View File

@ -12,8 +12,8 @@ export function getDb(forceSync = false): DataSource {
type: "postgres", type: "postgres",
url: process.env.DATABASE_URL, url: process.env.DATABASE_URL,
synchronize: process.argv.includes("sync") || forceSync, synchronize: process.argv.includes("sync") || forceSync,
entities: [User, Review, Saved] entities: [User, Review, Saved],
}) });
} }
return db; return db;
} }

View File

@ -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"; import { type User } from "./User";
@Entity() @Entity()
@ -10,12 +16,12 @@ export class Review extends BaseEntity {
user: User; user: User;
@Column({ @Column({
type: "float" type: "float",
}) })
latitude: number; latitude: number;
@Column({ @Column({
type: "float" type: "float",
}) })
longitude: number; longitude: number;
@ -23,12 +29,12 @@ export class Review extends BaseEntity {
rating: number; rating: number;
@Column({ @Column({
type: "text" type: "text",
}) })
comment: string; comment: string;
@Column({ @Column({
default: () => "NOW()" default: () => "NOW()",
}) })
created_at: Date; created_at: Date;
} }

View File

@ -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"; import { type User } from "./User";
@Entity() @Entity()
@ -18,7 +24,7 @@ export class Saved extends BaseEntity {
data: string; data: string;
@Column({ @Column({
default: () => "NOW()" default: () => "NOW()",
}) })
created_at: Date; created_at: Date;
} }

View File

@ -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 Review } from "./Review";
import { type Saved } from "./Saved"; import { type Saved } from "./Saved";
@ -17,12 +23,12 @@ export class User extends BaseEntity {
saved: Saved[]; saved: Saved[];
@Column({ @Column({
default: () => "NOW()" default: () => "NOW()",
}) })
updated_at: Date; updated_at: Date;
@Column({ @Column({
default: () => "NOW()" default: () => "NOW()",
}) })
created_at: Date; created_at: Date;
} }

View File

@ -61,36 +61,43 @@ app.get("/api/config", (c) => {
app.post("/api/user", async (c) => { app.post("/api/user", async (c) => {
const { token } = await c.req.json(); const { token } = await c.req.json();
if(!token) { if (!token) {
return c.json({ error: "Invalid request" }, 400); return c.json({ error: "Invalid request" }, 400);
} }
if(!verifyToken(token)) { if (!verifyToken(token)) {
return c.json({ error: "Invalid token" }, 400); return c.json({ error: "Invalid token" }, 400);
} }
const tokenData = getTokenData(token); const tokenData = getTokenData(token);
if(!tokenData) return c.json({ error: "Invalid token" }, 400); 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.sub || typeof tokenData.sub != "string")
if(!tokenData.preferred_username || typeof tokenData.preferred_username != "string") return c.json({ error: "Invalid token" }, 400); return c.json({ error: "Invalid token" }, 400);
const user = await User.findOneBy({ id: tokenData.sub }) || new User(); 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.id = tokenData.sub;
user.username = tokenData.preferred_username; user.username = tokenData.preferred_username;
user.updated_at = new Date(); user.updated_at = new Date();
await user.save(); await user.save();
return c.json({ success: true }); return c.json({ success: true });
}) });
app.get("/api/user", async (c) => { app.get("/api/user", async (c) => {
const name = c.req.query("name"); 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({ 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); return c.json(mapped);
}) });
if (process.env.REVIEWS_ENABLED) { if (process.env.REVIEWS_ENABLED) {
app.get("/api/reviews", async (c) => { app.get("/api/reviews", async (c) => {
@ -104,7 +111,7 @@ if (process.env.REVIEWS_ENABLED) {
console.log(`Fetching reviews for lat: ${lat}, lon: ${lon}`); console.log(`Fetching reviews for lat: ${lat}, lon: ${lon}`);
const reviews = await Review.findBy({ const reviews = await Review.findBy({
latitude: nlat, latitude: nlat,
longitude: nlon longitude: nlon,
}); });
return c.json(reviews); return c.json(reviews);
}); });
@ -137,7 +144,7 @@ if (process.env.REVIEWS_ENABLED) {
} }
const user = await User.findOneBy({ id: uid }); const user = await User.findOneBy({ id: uid });
if(!user) { if (!user) {
return c.json({ error: "Invalid user ID" }, 400); 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); // return c.json({ error: "Invalid user ID" }, 400);
// } // }
const saved = await Saved.findBy({ user: { const saved = await Saved.findBy({
id: uid user: {
} }); id: uid,
},
});
return c.json(saved); return c.json(saved);
}) });
app.put("/api/saved", async (c) => { app.put("/api/saved", async (c) => {
const { name, data } = await c.req.json(); const { name, data } = await c.req.json();
if (!name || !data) { if (!name || !data) {
return c.json( return c.json({ error: "name and data are required" }, 400);
{ error: "name and data are required" },
400,
);
} }
const authHeader = c.req.header("Authorization"); const authHeader = c.req.header("Authorization");
@ -215,7 +221,7 @@ app.put("/api/saved", async (c) => {
} }
const user = await User.findOneBy({ id: uid }); const user = await User.findOneBy({ id: uid });
if(!user) { if (!user) {
return c.json({ error: "Invalid user ID" }, 400); return c.json({ error: "Invalid user ID" }, 400);
} }
@ -225,16 +231,13 @@ app.put("/api/saved", async (c) => {
saved.data = data; saved.data = data;
await saved.save(); await saved.save();
return c.json({ success: true }) return c.json({ success: true });
}) });
app.delete("/api/saved", async (c) => { app.delete("/api/saved", async (c) => {
const { name } = await c.req.json(); const { name } = await c.req.json();
if (!name) { if (!name) {
return c.json( return c.json({ error: "name is required" }, 400);
{ error: "name is required" },
400,
);
} }
const authHeader = c.req.header("Authorization"); 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 }); const saved = await Saved.findOneBy({ user: { id: uid }, name });
if(!saved) { if (!saved) {
return c.json({ error: "No such save found" }, 400); return c.json({ error: "No such save found" }, 400);
} }
await saved.remove(); await saved.remove();
return c.json({ success: true }); return c.json({ success: true });
}) });
if (process.env.TANKERKOENIG_API_KEY) { if (process.env.TANKERKOENIG_API_KEY) {
app.get("/api/fuel/list", async (c) => { app.get("/api/fuel/list", async (c) => {

View File

@ -1,8 +1,8 @@
import { exportJWK, exportPKCS8, generateKeyPair } from "jose"; import { exportJWK, exportPKCS8, generateKeyPair } from "jose";
export async function createTestKey() { export async function createTestKey() {
const { publicKey, privateKey } = await generateKeyPair("RS256", { const { publicKey, privateKey } = await generateKeyPair("RS256", {
extractable: true extractable: true,
}); });
const jwk = await exportJWK(publicKey); const jwk = await exportJWK(publicKey);
jwk.kid = "test-key"; jwk.kid = "test-key";

View File

@ -1,6 +1,10 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { getDb, initDb } from "../src/db"; 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 { createTestKey } from "./keys";
import { sign } from "jsonwebtoken"; import { sign } from "jsonwebtoken";
import { getTokenData, getTokenUID, verifyToken } from "../src/auth"; 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.NODE_ENV = "test";
process.env.REVIEWS_ENABLED = "true"; 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"; const PGSQL_IMAGE = "postgres:latest";
let postgresContainer: StartedTestContainer | null = null; let postgresContainer: StartedTestContainer | null = null;
@ -22,12 +26,12 @@ let privateKey: string;
// Spin up a temporary Postgres container for testing // Spin up a temporary Postgres container for testing
beforeAll(async () => { beforeAll(async () => {
if(!process.env.DATABASE_URL) { if (!process.env.DATABASE_URL) {
postgresContainer = await new GenericContainer(PGSQL_IMAGE) postgresContainer = await new GenericContainer(PGSQL_IMAGE)
.withEnvironment({ .withEnvironment({
POSTGRES_USER: "tc", POSTGRES_USER: "tc",
POSTGRES_PASSWORD: "tc", POSTGRES_PASSWORD: "tc",
POSTGRES_DB: "tc" POSTGRES_DB: "tc",
}) })
.withExposedPorts(5432) .withExposedPorts(5432)
.withHealthCheck({ .withHealthCheck({
@ -51,7 +55,7 @@ afterAll(async () => {
const db = getDb(); const db = getDb();
await db.dropDatabase(); await db.dropDatabase();
await db.destroy(); await db.destroy();
if(postgresContainer) await postgresContainer.stop(); if (postgresContainer) await postgresContainer.stop();
}); });
// Override JWKS URL to prevent external calls during tests // Override JWKS URL to prevent external calls during tests
@ -63,7 +67,7 @@ beforeAll(async () => {
// Mock the fetch function to return the test JWK // Mock the fetch function to return the test JWK
// @ts-expect-error - we just need to override fetch for tests // @ts-expect-error - we just need to override fetch for tests
global.fetch = async () => ({ 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).toBeArray();
expect(config.capabilities).toContain("auth"); expect(config.capabilities).toContain("auth");
expect(config.capabilities).toContain("reviews"); 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", () => { describe("Authentication", () => {
it("verifies a valid token", async () => { it("verifies a valid token", async () => {
const token = sign({ sub: "test" }, privateKey, { const token = sign({ sub: "test" }, privateKey, {
algorithm: "RS256", algorithm: "RS256",
keyid: jwk.kid, keyid: jwk.kid,
expiresIn: "1h" expiresIn: "1h",
}); });
expect(await verifyToken(token)).toBe(true); expect(await verifyToken(token)).toBe(true);
expect(getTokenUID(token)).toBe("test"); expect(getTokenUID(token)).toBe("test");
expect(getTokenData(token)).toMatchObject({ sub: "test" }); expect(getTokenData(token)).toMatchObject({ sub: "test" });
}) });
it("fails with invalid KID", async () => { it("fails with invalid KID", async () => {
const token = sign({ sub: "test" }, privateKey, { const token = sign({ sub: "test" }, privateKey, {
algorithm: "RS256", algorithm: "RS256",
keyid: "invalid-kid", keyid: "invalid-kid",
expiresIn: "1h" expiresIn: "1h",
}); });
expect(await verifyToken(token)).toBe(false); expect(await verifyToken(token)).toBe(false);
expect(getTokenUID(token)).toBe("test"); expect(getTokenUID(token)).toBe("test");
expect(getTokenData(token)).toMatchObject({ sub: "test" }); expect(getTokenData(token)).toMatchObject({ sub: "test" });
}) });
it("fails on an invalid token", async () => { it("fails on an invalid token", async () => {
const token = "invalid.token.here"; const token = "invalid.token.here";
@ -123,42 +131,52 @@ describe("Authentication", () => {
const token = sign({ sub: "test" }, privateKey, { const token = sign({ sub: "test" }, privateKey, {
algorithm: "RS256", algorithm: "RS256",
keyid: jwk.kid, keyid: jwk.kid,
expiresIn: "-1h" expiresIn: "-1h",
}); });
expect(await verifyToken(token)).toBe(false); expect(await verifyToken(token)).toBe(false);
expect(getTokenUID(token)).toBe("test"); expect(getTokenUID(token)).toBe("test");
expect(getTokenData(token)).toMatchObject({ sub: "test" }); expect(getTokenData(token)).toMatchObject({ sub: "test" });
}); });
}) });
let token: string; let token: string;
const uid = crypto.randomUUID(); const uid = crypto.randomUUID();
describe("User Endpoints", () => { describe("User Endpoints", () => {
it("fails with invalid token", async () => { it("fails with invalid token", async () => {
const res = await app.fetch(new Request("http://localhost/api/user", { const res = await app.fetch(
method: "POST", new Request("http://localhost/api/user", {
body: JSON.stringify({ token: "invalid.token.here" }), method: "POST",
})); body: JSON.stringify({ token: "invalid.token.here" }),
}),
);
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it("uploads a token", async () => { it("uploads a token", async () => {
token = sign({ token = sign(
sub: uid, {
preferred_username: "testuser", sub: uid,
}, privateKey, { preferred_username: "testuser",
algorithm: "RS256", },
keyid: jwk.kid, privateKey,
expiresIn: "1h" {
}); algorithm: "RS256",
const res = await app.fetch(new Request("http://localhost/api/user", { keyid: jwk.kid,
method: "POST", expiresIn: "1h",
body: JSON.stringify({ token }), },
})); );
const res = await app.fetch(
new Request("http://localhost/api/user", {
method: "POST",
body: JSON.stringify({ token }),
}),
);
expect(res.status).toBe(200); expect(res.status).toBe(200);
}); });
it("finds the user in search", async () => { 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); expect(res.status).toBe(200);
const json = await res.json(); const json = await res.json();
expect(json).toBeDefined(); expect(json).toBeDefined();
@ -166,34 +184,38 @@ describe("User Endpoints", () => {
const users = json as { username: string }[]; const users = json as { username: string }[];
expect(users.length).toBeGreaterThan(0); expect(users.length).toBeGreaterThan(0);
expect(users[0]!.username).toBe("testuser"); expect(users[0]!.username).toBe("testuser");
}) });
}) });
describe("Reviews", () => { describe("Reviews", () => {
it("creates a review", async () => { it("creates a review", async () => {
const res = await app.fetch(new Request("http://localhost/api/review", { const res = await app.fetch(
method: "POST", new Request("http://localhost/api/review", {
headers: { method: "POST",
"Authorization": `Bearer ${token}`, headers: {
"Content-Type": "application/json" Authorization: `Bearer ${token}`,
}, "Content-Type": "application/json",
body: JSON.stringify({ },
rating: 4, body: JSON.stringify({
comment: "This is a test review.", rating: 4,
lat: 51.5074, comment: "This is a test review.",
lon: 7.4653 lat: 51.5074,
lon: 7.4653,
}),
}), }),
})); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
const json = await res.json(); const json = await res.json();
expect(json).toBeDefined(); expect(json).toBeDefined();
expect(json).toBeObject(); expect(json).toBeObject();
const review = json as { success: boolean }; const review = json as { success: boolean };
expect(review.success).toBe(true); expect(review.success).toBe(true);
}) });
it("lists reviews", async () => { 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); expect(res.status).toBe(200);
const json = await res.json(); const json = await res.json();
expect(json).toBeDefined(); expect(json).toBeDefined();
@ -202,22 +224,24 @@ describe("Reviews", () => {
expect(reviews.length).toBeGreaterThan(0); expect(reviews.length).toBeGreaterThan(0);
expect(reviews[0]!.rating).toBe(4); expect(reviews[0]!.rating).toBe(4);
expect(reviews[0]!.comment).toBe("This is a test review."); expect(reviews[0]!.comment).toBe("This is a test review.");
}) });
}) });
describe("Saved Routes", () => { describe("Saved Routes", () => {
it("saves a route", async () => { it("saves a route", async () => {
const res = await app.fetch(new Request("http://localhost/api/saved", { const res = await app.fetch(
method: "PUT", new Request("http://localhost/api/saved", {
headers: { method: "PUT",
"Authorization": `Bearer ${token}`, headers: {
"Content-Type": "application/json" Authorization: `Bearer ${token}`,
}, "Content-Type": "application/json",
body: JSON.stringify({ },
name: "Home to Work", body: JSON.stringify({
data: "This is some test route data." name: "Home to Work",
data: "This is some test route data.",
}),
}), }),
})); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
const json = await res.json(); const json = await res.json();
expect(json).toBeDefined(); expect(json).toBeDefined();
@ -226,12 +250,14 @@ describe("Saved Routes", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
it("lists saved routes", async () => { it("lists saved routes", async () => {
const res = await app.fetch(new Request("http://localhost/api/saved", { const res = await app.fetch(
method: "GET", new Request("http://localhost/api/saved", {
headers: { method: "GET",
"Authorization": `Bearer ${token}`, headers: {
}, Authorization: `Bearer ${token}`,
})); },
}),
);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const json = await res.json(); const json = await res.json();
expect(json).toBeDefined(); expect(json).toBeDefined();
@ -242,16 +268,18 @@ describe("Saved Routes", () => {
expect(routes[0]!.data).toBe("This is some test route data."); expect(routes[0]!.data).toBe("This is some test route data.");
}); });
it("deletes a saved route", async () => { it("deletes a saved route", async () => {
const res = await app.fetch(new Request("http://localhost/api/saved", { const res = await app.fetch(
method: "DELETE", new Request("http://localhost/api/saved", {
headers: { method: "DELETE",
"Authorization": `Bearer ${token}`, headers: {
"Content-Type": "application/json" Authorization: `Bearer ${token}`,
}, "Content-Type": "application/json",
body: JSON.stringify({ },
name: "Home to Work", body: JSON.stringify({
name: "Home to Work",
}),
}), }),
})); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
const json = await res.json(); const json = await res.json();
expect(json).toBeDefined(); expect(json).toBeDefined();
@ -260,12 +288,14 @@ describe("Saved Routes", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
it("no longer lists deleted routes", async () => { it("no longer lists deleted routes", async () => {
const res = await app.fetch(new Request("http://localhost/api/saved", { const res = await app.fetch(
method: "GET", new Request("http://localhost/api/saved", {
headers: { method: "GET",
"Authorization": `Bearer ${token}`, headers: {
}, Authorization: `Bearer ${token}`,
})); },
}),
);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const json = await res.json(); const json = await res.json();
expect(json).toBeDefined(); expect(json).toBeDefined();

View File

@ -27,6 +27,6 @@
"strictPropertyInitialization": false, "strictPropertyInitialization": false,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true
} }
} }