Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
#
# Source repository: https://github.com/actions/dependency-review-action
# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
name: 'Dependency Review'
name: "Dependency Review"

on:
on:
pull_request:
branches:
branches:
- main

permissions:
Expand All @@ -19,12 +19,12 @@ jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
- name: "Checkout Repository"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd

- name: 'Dependency Review'
- name: "Dependency Review"
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48
with:
base-ref: ${{ github.event.pull_request.base.sha || 'main' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
comment-summary-in-pr: on-failure
comment-summary-in-pr: on-failure
6 changes: 5 additions & 1 deletion src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import versionsV1 from "../services/versions/v1";
import statsV1 from "../services/stats/v1";
import { platformMiddleware } from "../lib/middleware";
import { createCloudflareBindings } from "../lib/adapters/cloudflare";
import { logError } from "../lib/logger";

const app = new Hono<{ Bindings: CloudflareBindings }>();

Expand All @@ -25,11 +26,14 @@ app.onError((err, c) => {
if (err instanceof HTTPException) {
return err.getResponse();
}
logError("app", "Unhandled request error", {
message: err instanceof Error ? err.message : String(err)
});
return c.json(
{
result: null,
error: {
message: err.message || "Internal Server Error",
message: "Internal Server Error",
code: "INTERNAL_SERVER_ERROR"
}
},
Expand Down
12 changes: 12 additions & 0 deletions src/lib/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Context } from "hono";

export function normalizePublicCacheKey(url: string): string {
const cacheUrl = new URL(url);
cacheUrl.search = "";
cacheUrl.hash = "";
return cacheUrl.toString();
}

export function publicCacheKey(c: Context): string {
return normalizePublicCacheKey(c.req.url);
}
7 changes: 6 additions & 1 deletion src/services/central-alerts/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { cors } from "hono/cors";
import { trimTrailingSlash } from "hono/trailing-slash";
import { CentralAlertsDatabase } from "./database";
import { getPlatform } from "../../../lib/middleware";
import { logError } from "../../../lib/logger";

const centralAlertsV1 = new Hono<{ Bindings: CloudflareBindings }>();

Expand All @@ -16,11 +17,15 @@ centralAlertsV1.get("/list", async (c) => {
const { data, error } = await db.getAllAlerts();

if (error) {
logError("central-alerts", "Failed to list central alerts", {
message: error.message,
code: error.code
});
return c.json(
{
result: null,
error: {
message: error.message,
message: "Unable to load central alerts",
code: error.code || "DATABASE_ERROR"
}
},
Expand Down
4 changes: 3 additions & 1 deletion src/services/stats/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Releases } from "../../versions/v1/interfaces";
import { StatsData, ReleasesPerYearData } from "./interfaces";
import { getPlatform } from "../../../lib/middleware";
import { ICache } from "../../../lib/interfaces";
import { publicCacheKey } from "../../../lib/cache";
import { logError, logInfo } from "../../../lib/logger";
import { GitHubError } from "../../../lib/github-errors";

Expand Down Expand Up @@ -38,7 +39,8 @@ function registerCachedRoute<P extends string>(
path,
cache({
cacheName: STATS_CACHE_NAME,
cacheControl: STATS_CACHE_CONTROL
cacheControl: STATS_CACHE_CONTROL,
keyGenerator: publicCacheKey
}),
etag(),
prettyJSON(),
Expand Down
18 changes: 12 additions & 6 deletions src/services/versions/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { Releases, ReleaseDetails } from "./interfaces";
import { getPlatform } from "../../../lib/middleware";
import { ICache } from "../../../lib/interfaces";
import { publicCacheKey } from "../../../lib/cache";
import { logError, logWarn, logInfo } from "../../../lib/logger";
import {
GitHubError,
Expand All @@ -34,8 +35,9 @@ const RELEASES_URL =
"https://api.github.com/repos/FOSSBilling/FOSSBilling/releases";
type VersionsEnv = { Bindings: CloudflareBindings };

// Cache for UPDATE_TOKEN to avoid repeated KV lookups
let updateTokenCache: string | null = null;
const UPDATE_TOKEN_CACHE_TTL_MS = 60_000;

let updateTokenCache: { token: string; expiresAt: number } | null = null;

const versionsV1 = new Hono<VersionsEnv>();

Expand All @@ -48,8 +50,8 @@ versionsV1.use(
);

async function getUpdateToken(cache: ICache): Promise<string> {
if (updateTokenCache) {
return updateTokenCache;
if (updateTokenCache && updateTokenCache.expiresAt > Date.now()) {
return updateTokenCache.token;
}

const token = await cache.get("UPDATE_TOKEN");
Expand All @@ -58,7 +60,10 @@ async function getUpdateToken(cache: ICache): Promise<string> {
throw new Error("UPDATE_TOKEN not found in AUTH_KV storage");
}

updateTokenCache = token;
updateTokenCache = {
token,
expiresAt: Date.now() + UPDATE_TOKEN_CACHE_TTL_MS
};
Comment thread
admdly marked this conversation as resolved.

return token;
}
Expand All @@ -71,7 +76,8 @@ function registerCachedRoute<P extends string>(
path,
cache({
cacheName: RELEASES_CACHE_NAME,
cacheControl: RELEASES_CACHE_CONTROL
cacheControl: RELEASES_CACHE_CONTROL,
keyGenerator: publicCacheKey
}),
etag(),
prettyJSON(),
Expand Down
2 changes: 2 additions & 0 deletions test/app/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ vi.mock("@octokit/request", () => ({
}));

import { request as ghRequest } from "@octokit/request";
import { resetUpdateTokenCache } from "../../src/services/versions/v1/index";

const mockGitHubReleases = [
{
Expand Down Expand Up @@ -57,6 +58,7 @@ describe("FOSSBilling API Worker - Main App", () => {
beforeEach(async () => {
// Clear KV cache before each test
await env.CACHE_KV.delete("gh-fossbilling-releases");
resetUpdateTokenCache();

// Set up UPDATE_TOKEN in AUTH_KV storage for tests
const testUpdateToken = "test-update-token-12345";
Expand Down
2 changes: 2 additions & 0 deletions test/integration/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ vi.mock("@octokit/request", () => ({
}));

import { request as ghRequest } from "@octokit/request";
import { resetUpdateTokenCache } from "../../src/services/versions/v1/index";

describe("FOSSBilling API Worker - Full App Integration", () => {
beforeEach(async () => {
await env.CACHE_KV.delete("gh-fossbilling-releases");
resetUpdateTokenCache();
await env.AUTH_KV.put("UPDATE_TOKEN", "test-update-token-12345");

env.DB_CENTRAL_ALERTS = mockD1Database;
Expand Down
2 changes: 2 additions & 0 deletions test/integration/versions/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ vi.mock("@octokit/request", () => ({
}));

import { request as ghRequest } from "@octokit/request";
import { resetUpdateTokenCache } from "../../../src/services/versions/v1/index";

describe("Versions API v1 - Integration Tests", () => {
beforeEach(async () => {
await env.CACHE_KV.delete("gh-fossbilling-releases");
resetUpdateTokenCache();
await env.AUTH_KV.put("UPDATE_TOKEN", "test-update-token-12345");

vi.resetAllMocks();
Expand Down
20 changes: 20 additions & 0 deletions test/lib/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { normalizePublicCacheKey } from "../../src/lib/cache";

describe("cache helpers", () => {
it("normalizes public cache keys by removing query strings and fragments", () => {
expect(
normalizePublicCacheKey(
"https://api.fossbilling.net/versions/v1/latest?cacheBust=1#section"
)
).toBe("https://api.fossbilling.net/versions/v1/latest");
});

it("keeps different public paths isolated", () => {
expect(
normalizePublicCacheKey("https://api.fossbilling.net/versions/v1")
).not.toBe(
normalizePublicCacheKey("https://api.fossbilling.net/versions/v1/count")
);
});
});
24 changes: 24 additions & 0 deletions test/services/central-alerts/v1/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,30 @@ describe("Central Alerts API v1", () => {
});

describe("Error Cases", () => {
it("should not expose database exception details", async () => {
env.DB_CENTRAL_ALERTS = {
prepare() {
throw new Error("secret schema detail");
}
} as unknown as D1Database;

const ctx = createExecutionContext();
const response = await app.request(
"/central-alerts/v1/list",
{},
env,
ctx
);
await waitOnExecutionContext(ctx);

expect(response.status).toBe(500);
const data = (await response.json()) as {
error: { message: string; code: string };
};
expect(data.error.message).toBe("Unable to load central alerts");
expect(data.error.message).not.toContain("secret schema detail");
});

it("should return 404 for unknown routes", async () => {
const ctx = createExecutionContext();
const response = await app.request(
Expand Down
7 changes: 7 additions & 0 deletions test/services/versions/v1/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ vi.mock("@octokit/request", () => ({
}));

import { request as ghRequest } from "@octokit/request";
import { resetUpdateTokenCache } from "../../../../src/services/versions/v1/index";

let restoreConsole: (() => void) | null = null;

describe("Versions API v1 - Error Handling", () => {
beforeEach(async () => {
restoreConsole = suppressConsole();
await env.CACHE_KV.delete("gh-fossbilling-releases");
resetUpdateTokenCache();
await env.AUTH_KV.put("UPDATE_TOKEN", "test-update-token-12345");

vi.resetAllMocks();
Expand Down Expand Up @@ -71,6 +73,11 @@ describe("Versions API v1 - Error Handling", () => {
await waitOnExecutionContext(ctx);

expect(response.status).toBe(500);
const data = (await response.json()) as {
error: { message: string; code: string };
};
expect(data.error.message).toBe("Internal Server Error");
expect(data.error.message).not.toContain("UPDATE_TOKEN");
});

it("should reject malformed Authorization headers", async () => {
Expand Down
69 changes: 69 additions & 0 deletions test/services/versions/v1/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,75 @@ describe("Versions API v1", () => {

expect(response.status).toBe(401);
});

it("should stop accepting a rotated token after the token cache TTL", async () => {
const now = Date.now();
const dateNow = vi.spyOn(Date, "now").mockReturnValue(now);

try {
const ctx1 = createExecutionContext();
const response1 = await app.request(
"/versions/v1/update",
{
headers: {
Authorization: "Bearer test-update-token-12345"
}
},
env,
ctx1
);
await waitOnExecutionContext(ctx1);
expect(response1.status).toBe(200);

await env.AUTH_KV.put("UPDATE_TOKEN", "rotated-update-token-12345");

const ctx2 = createExecutionContext();
const response2 = await app.request(
"/versions/v1/update",
{
headers: {
Authorization: "Bearer test-update-token-12345"
}
},
env,
ctx2
);
await waitOnExecutionContext(ctx2);
expect(response2.status).toBe(200);

dateNow.mockReturnValue(now + 60_001);

const ctx3 = createExecutionContext();
const response3 = await app.request(
"/versions/v1/update",
{
headers: {
Authorization: "Bearer test-update-token-12345"
}
},
env,
ctx3
);
await waitOnExecutionContext(ctx3);
expect(response3.status).toBe(401);

const ctx4 = createExecutionContext();
const response4 = await app.request(
"/versions/v1/update",
{
headers: {
Authorization: "Bearer rotated-update-token-12345"
}
},
env,
ctx4
);
await waitOnExecutionContext(ctx4);
expect(response4.status).toBe(200);
} finally {
dateNow.mockRestore();
}
});
});

describe("Error Handling", () => {
Expand Down
2 changes: 2 additions & 0 deletions test/services/versions/v1/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ vi.mock("@octokit/request", () => ({
}));

import { request as ghRequest } from "@octokit/request";
import { resetUpdateTokenCache } from "../../../../src/services/versions/v1/index";

let restoreConsole: (() => void) | null = null;

describe("Versions API v1 - Middleware", () => {
beforeEach(async () => {
restoreConsole = suppressConsole();
await env.CACHE_KV.delete("gh-fossbilling-releases");
resetUpdateTokenCache();
await env.AUTH_KV.put("UPDATE_TOKEN", "test-update-token-12345");

vi.clearAllMocks();
Expand Down
Loading