12 Commits

Author SHA1 Message Date
ramvignesh-b a004322970 test: refactor test setup for dynamic redis client imports
CI / build (pull_request) Successful in 22s
2026-05-12 05:46:31 +05:30
ramvignesh-b 282618bc1c test: migrate global test prefixes to explicit imports
CI / build (pull_request) Failing after 20s
2026-05-12 05:42:27 +05:30
ramvignesh-b dcf5cd3551 refactor: modularize OpenAPI configuration and extract version constants into separate files
CI / build (pull_request) Failing after 22s
2026-05-12 05:27:29 +05:30
ramvignesh-b 9edb7fd989 refactor: update OpenAPI documentation metadata 2026-05-12 05:20:32 +05:30
ramvignesh-b 0a276b9a63 refactor: centralize versioning and path constants and standardize routes across the application and test suites 2026-05-12 05:17:07 +05:30
ramvignesh-b 5d8a9ccb3e refactor: update API documentation routes and paths to v1 naming convention 2026-05-12 05:01:58 +05:30
ramvignesh-b cf37904083 feat: version API and auth endpoints under /v1 prefix and update documentation labels 2026-05-12 04:57:19 +05:30
ramvignesh-b f3349fced4 feat: improve OpenAPI documentation by adding tags and route summaries 2026-05-12 04:52:03 +05:30
ramvignesh-b 4554b2e734 feat: add API reference link to dashboard navigation bar 2026-05-12 04:46:42 +05:30
ramvignesh-b 106edb8bb7 feat: hide api key input when authenticated 2026-05-12 04:42:33 +05:30
ramvignesh-b f89b5b4437 feat: add functionality to delete provider configurations and associated tokens via UI and API 2026-05-12 04:40:55 +05:30
me 558bc9e034 feat: implement secure session management using HttpOnly cookies for API key authentication (#5)
CI / build (push) Successful in 27s
Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
Reviewed-on: #5
2026-05-11 22:56:21 +00:00
16 changed files with 520 additions and 233 deletions
+6
View File
@@ -0,0 +1,6 @@
import { API_VERSION, APP_VERSION } from "./version";
export { API_VERSION, APP_VERSION };
export const API_PREFIX = `/api/${API_VERSION}`;
export const AUTH_PREFIX = `/${API_VERSION}/auth`;
export const DOCS_PREFIX = `/docs/${API_VERSION}`;
+9
View File
@@ -40,4 +40,13 @@ export class ConfigManager {
return result; return result;
} }
async deleteProviderConfig(provider: string): Promise<void> {
await this.redis.del(`config:${provider}`);
// Also clean up tokens
const tokenKeys = await this.redis.keys(`provider:${provider}:*`);
if (tokenKeys.length > 0) {
await this.redis.del(...tokenKeys);
}
}
} }
+11 -19
View File
@@ -4,7 +4,9 @@ import { serveStatic } from "hono/bun";
import { logger } from "hono/logger"; import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json"; import { prettyJSON } from "hono/pretty-json";
import { config } from "./config"; import { config } from "./config";
import { API_PREFIX, AUTH_PREFIX, DOCS_PREFIX } from "./constants";
import { redis } from "./core/RedisClient"; import { redis } from "./core/RedisClient";
import { openApiSpec, securityScheme } from "./openapi";
import { apiRoutes } from "./routes/api"; import { apiRoutes } from "./routes/api";
import { authRoutes } from "./routes/auth"; import { authRoutes } from "./routes/auth";
import { configRoutes } from "./routes/config"; import { configRoutes } from "./routes/config";
@@ -13,29 +15,19 @@ import { dashboardRoutes } from "./routes/dashboard";
const app = new OpenAPIHono({ strict: false }); const app = new OpenAPIHono({ strict: false });
// OpenAPI specs // OpenAPI specs
app.doc("/doc", { app.doc(`${DOCS_PREFIX}/openapi.json`, openApiSpec);
openapi: "3.0.0", app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", securityScheme);
info: {
version: "1.0.0",
title: "toknd — Auth Broker API",
description: "Centralized token management and OAuth2 broker service.",
},
});
app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", {
type: "http",
scheme: "bearer",
});
// Scalar API Reference // Scalar API Reference
app.get( app.get(
"/api", DOCS_PREFIX,
Scalar({ Scalar({
theme: "solarized", theme: "solarized",
url: "/doc", url: `${DOCS_PREFIX}/openapi.json`,
}), }),
); );
app.get("/docs", (c) => c.redirect("/api")); app.get("/docs", (c) => c.redirect(DOCS_PREFIX));
app.get("/api", (c) => c.redirect(DOCS_PREFIX));
app.use("*", logger()); app.use("*", logger());
app.use("*", prettyJSON()); app.use("*", prettyJSON());
@@ -43,9 +35,9 @@ app.use("*", prettyJSON());
app.get("/", (c) => c.redirect("/app")); app.get("/", (c) => c.redirect("/app"));
app.get("/app/dashboard.js", serveStatic({ path: "./src/views/dashboard.js" })); app.get("/app/dashboard.js", serveStatic({ path: "./src/views/dashboard.js" }));
app.route("/auth", authRoutes); app.route(AUTH_PREFIX, authRoutes);
app.route("/api/config", configRoutes); app.route(`${API_PREFIX}/config`, configRoutes);
app.route("/api", apiRoutes); app.route(API_PREFIX, apiRoutes);
app.route("/app", dashboardRoutes); app.route("/app", dashboardRoutes);
app.notFound((c) => { app.notFound((c) => {
+11 -3
View File
@@ -1,14 +1,22 @@
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import { getCookie } from "hono/cookie";
import { config } from "../config"; import { config } from "../config";
export const authMiddleware = async (c: Context, next: Next) => { export const authMiddleware = async (c: Context, next: Next) => {
const authHeader = c.req.header("Authorization"); const authHeader = c.req.header("Authorization");
const cookieToken = getCookie(c, "toknd_api_key");
if (!authHeader?.startsWith("Bearer ")) { let token: string | undefined;
return c.json({ error: "Missing or invalid authorization header" }, 401);
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.split(" ")[1];
} else if (cookieToken) {
token = cookieToken;
} }
const token = authHeader.split(" ")[1]; if (!token) {
return c.json({ error: "Missing or invalid authorization" }, 401);
}
if (token !== config.API_KEY) { if (token !== config.API_KEY) {
return c.json({ error: "Invalid API key" }, 403); return c.json({ error: "Invalid API key" }, 403);
+31
View File
@@ -0,0 +1,31 @@
import { API_VERSION, APP_VERSION } from "./constants";
export const openApiSpec = {
openapi: "3.0.0",
info: {
version: `${API_VERSION}.${APP_VERSION}`,
title: "toknd Auth Broker API",
description:
"A high-performance OAuth2 broker and token management service. Designed to centralize provider configurations and automate token lifecycle management across distributed systems.",
},
tags: [
{
name: "Tokens",
description: "Endpoint operations for accessing and force-refreshing active provider tokens.",
},
{
name: "Management",
description: "Administrative operations for provider lifecycle and configuration.",
},
{
name: "Auth (Internal)",
description: "System-level OAuth2 handshake and callback processing.",
},
],
security: [{ API_KEY: [] }],
};
export const securityScheme = {
type: "http",
scheme: "bearer",
} as const;
+3
View File
@@ -47,6 +47,7 @@ const statusRoute = createRoute({
method: "get", method: "get",
path: "/status", path: "/status",
security: [{ API_KEY: [] }], security: [{ API_KEY: [] }],
tags: ["Tokens"],
responses: { responses: {
200: { 200: {
content: { "application/json": { schema: StatusResponseSchema } }, content: { "application/json": { schema: StatusResponseSchema } },
@@ -59,6 +60,7 @@ const tokenRoute = createRoute({
method: "get", method: "get",
path: "/token/{provider}", path: "/token/{provider}",
security: [{ API_KEY: [] }], security: [{ API_KEY: [] }],
tags: ["Tokens"],
request: { request: {
params: z.object({ params: z.object({
provider: z.string().openapi({ example: "trakt" }), provider: z.string().openapi({ example: "trakt" }),
@@ -80,6 +82,7 @@ const refreshRoute = createRoute({
method: "post", method: "post",
path: "/refresh/{provider}", path: "/refresh/{provider}",
security: [{ API_KEY: [] }], security: [{ API_KEY: [] }],
tags: ["Tokens"],
request: { request: {
params: z.object({ params: z.object({
provider: z.string().openapi({ example: "trakt" }), provider: z.string().openapi({ example: "trakt" }),
+4
View File
@@ -17,6 +17,8 @@ const AuthErrorResponse = z
const loginRoute = createRoute({ const loginRoute = createRoute({
method: "get", method: "get",
path: "/{provider}/login", path: "/{provider}/login",
tags: ["Auth (Internal)"],
summary: "Start OAuth2 flow (Managed by System)",
request: { request: {
params: z.object({ params: z.object({
provider: z.string().openapi({ example: "trakt" }), provider: z.string().openapi({ example: "trakt" }),
@@ -36,6 +38,8 @@ const loginRoute = createRoute({
const callbackRoute = createRoute({ const callbackRoute = createRoute({
method: "get", method: "get",
path: "/callback", path: "/callback",
tags: ["Auth (Internal)"],
summary: "OAuth2 callback handler (Managed by System)",
request: { request: {
query: z.object({ query: z.object({
state: z state: z
+28
View File
@@ -38,6 +38,7 @@ const listConfigRoute = createRoute({
method: "get", method: "get",
path: "/", path: "/",
security: [{ API_KEY: [] }], security: [{ API_KEY: [] }],
tags: ["Management"],
responses: { responses: {
200: { 200: {
content: { "application/json": { schema: AllProvidersResponse } }, content: { "application/json": { schema: AllProvidersResponse } },
@@ -50,6 +51,7 @@ const setConfigRoute = createRoute({
method: "post", method: "post",
path: "/{provider}", path: "/{provider}",
security: [{ API_KEY: [] }], security: [{ API_KEY: [] }],
tags: ["Management"],
request: { request: {
params: z.object({ params: z.object({
provider: z.string().openapi({ example: "trakt" }), provider: z.string().openapi({ example: "trakt" }),
@@ -70,6 +72,24 @@ const setConfigRoute = createRoute({
}, },
}); });
const deleteConfigRoute = createRoute({
method: "delete",
path: "/{provider}",
security: [{ API_KEY: [] }],
tags: ["Management"],
request: {
params: z.object({
provider: z.string().openapi({ example: "trakt" }),
}),
},
responses: {
200: {
content: { "application/json": { schema: SuccessMessage } },
description: "Delete a provider configuration and its tokens",
},
},
});
// Implementations // Implementations
configRoutes.use("*", authMiddleware); configRoutes.use("*", authMiddleware);
@@ -88,4 +108,12 @@ configRoutes.openapi(setConfigRoute, async (c) => {
return c.json({ message: `Config for ${provider} saved successfully` }, 200); return c.json({ message: `Config for ${provider} saved successfully` }, 200);
}); });
configRoutes.openapi(deleteConfigRoute, async (c) => {
const provider = c.req.valid("param").provider;
const configManager = new ConfigManager(redis);
await configManager.deleteProviderConfig(provider);
return c.json({ message: `Config for ${provider} deleted successfully` }, 200);
});
export { configRoutes }; export { configRoutes };
+84 -9
View File
@@ -1,11 +1,14 @@
/** @jsxImportSource hono/jsx */ /** @jsxImportSource hono/jsx */
import { Hono } from "hono"; import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { html } from "hono/html"; import { html } from "hono/html";
import type { Child } from "hono/jsx"; import type { Child } from "hono/jsx";
import { config } from "../config";
import { API_PREFIX, API_VERSION, APP_VERSION, AUTH_PREFIX, DOCS_PREFIX } from "../constants";
const dashboardRoutes = new Hono({ strict: false }); const dashboardRoutes = new Hono({ strict: false });
export const Layout = (props: { title: string; children: Child }) => ( export const Layout = (props: { title: string; children: Child; isUnlocked?: boolean }) => (
<> <>
{html`<!DOCTYPE html>`} {html`<!DOCTYPE html>`}
<html lang="en" data-theme="abyss"> <html lang="en" data-theme="abyss">
@@ -37,7 +40,14 @@ export const Layout = (props: { title: string; children: Child }) => (
</head> </head>
<body <body
class="bg-base-200/50 min-h-screen font-['DM_Sans',sans-serif] antialiased text-base-content tracking-tight" class="bg-base-200/50 min-h-screen font-['DM_Sans',sans-serif] antialiased text-base-content tracking-tight"
x-data="dashboard" x-data={`dashboard({
initialIsUnlocked: ${props.isUnlocked || false},
apiVersion: '${API_VERSION}',
appVersion: '${APP_VERSION}',
apiPrefix: '${API_PREFIX}',
authPrefix: '${AUTH_PREFIX}',
docsPrefix: '${DOCS_PREFIX}'
})`}
> >
{props.children} {props.children}
<script src="/app/dashboard.js"></script> <script src="/app/dashboard.js"></script>
@@ -47,10 +57,10 @@ export const Layout = (props: { title: string; children: Child }) => (
</> </>
); );
export const Dashboard = () => ( export const Dashboard = (props: { isUnlocked: boolean }) => (
<Layout title="toknd — Auth Broker Dashboard"> <Layout title="toknd — Auth Broker Dashboard" isUnlocked={props.isUnlocked}>
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300"> <div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
<div class="flex-1"> <div class="flex-1 flex items-center gap-6">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-semibold text-lg"> <div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-semibold text-lg">
<i class="ph-duotone ph-fingerprint"></i> <i class="ph-duotone ph-fingerprint"></i>
@@ -59,8 +69,26 @@ export const Dashboard = () => (
toknd <span class="text-xs font-normal opacity-50 ml-1">auth broker</span> toknd <span class="text-xs font-normal opacity-50 ml-1">auth broker</span>
</div> </div>
</div> </div>
<nav class="hidden md:flex items-center gap-1">
<a
href={DOCS_PREFIX}
target="_blank"
class="btn btn-ghost btn-sm text-base-content/60 hover:text-primary gap-2 px-3"
rel="noopener"
>
<i class="ph-duotone ph-book-open text-lg"></i>
<span class="font-bold uppercase tracking-widest text-xs">
API Reference{" "}
<sup class="text-[8px] opacity-50 ml-0.5">
{API_VERSION}.{APP_VERSION}
</sup>
</span>
</a>
</nav>
</div> </div>
<div class="flex-none hidden sm:flex"> <div class="flex-none hidden sm:flex">
<template x-if="!isUnlocked">
<div class="join border border-base-200/50 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors"> <div class="join border border-base-200/50 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors">
<div class="join-item flex items-center px-4 bg-base-200"> <div class="join-item flex items-center px-4 bg-base-200">
<i class="ph-duotone ph-key text-secondary text-lg"></i> <i class="ph-duotone ph-key text-secondary text-lg"></i>
@@ -91,9 +119,25 @@ export const Dashboard = () => (
> >
<i class="ph-duotone ph-lock-key-open text-lg" x-show="!loading"></i> <i class="ph-duotone ph-lock-key-open text-lg" x-show="!loading"></i>
<span class="loading loading-spinner loading-xs" x-show="loading"></span> <span class="loading loading-spinner loading-xs" x-show="loading"></span>
<span class="ml-1 hidden md:inline" x-text="loading ? 'Unlocking...' : 'Unlock'"></span> <span
class="ml-1 hidden md:inline"
x-text="loading ? 'Unlocking...' : 'Unlock'"
></span>
</button> </button>
</div> </div>
</template>
<template x-if="isUnlocked">
<button
x-on:click="logout()"
type="button"
class="btn btn-ghost btn-sm text-error hover:bg-error/10 gap-2 px-4"
x-bind:disabled="loading"
>
<i class="ph-bold ph-power text-lg"></i>
<span class="font-bold uppercase tracking-wider text-xs">Logout</span>
</button>
</template>
</div> </div>
</div> </div>
@@ -298,10 +342,20 @@ export const Dashboard = () => (
<div class="card bg-base-200/50 border border-base-300 shadow-sm hover:shadow-md transition-all group"> <div class="card bg-base-200/50 border border-base-300 shadow-sm hover:shadow-md transition-all group">
<div class="card-body p-5"> <div class="card-body p-5">
<div class="flex flex-col mb-4"> <div class="flex flex-col mb-4">
<div class="flex justify-between items-start">
<span <span
x-text="provider.name" x-text="provider.name"
class="text-lg font-black text-base-content/90 uppercase" class="text-lg font-black text-base-content/90 uppercase"
></span> ></span>
<button
type="button"
x-on:click="deleteProvider(provider.name)"
class="btn btn-error btn-xs mt-1 opacity-0 group-hover:opacity-100 transition-all duration-300"
title="Delete Provider"
>
<i class="ph-bold ph-trash text-lg"></i>
</button>
</div>
<span <span
x-text="provider.config.clientId" x-text="provider.config.clientId"
x-bind:title="provider.config.clientId" x-bind:title="provider.config.clientId"
@@ -402,7 +456,7 @@ export const Dashboard = () => (
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<button <button
type="button" type="button"
x-on:click="window.open('/auth/' + provider.name + '/login', '_blank')" x-on:click={`window.open('${AUTH_PREFIX}/' + provider.name + '/login', '_blank')`}
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
> >
<i class="ph-bold ph-link"></i> Connect <i class="ph-bold ph-link"></i> Connect
@@ -419,7 +473,7 @@ export const Dashboard = () => (
type="button" type="button"
x-on:click="forceRefresh(provider.name)" x-on:click="forceRefresh(provider.name)"
class="btn btn-base w-full" class="btn btn-base w-full"
x-bind:disabled="loading" x-bind:disabled="loading || !provider.status.accessToken"
> >
<i class="ph-bold ph-arrows-clockwise text-base mr-1"></i> <i class="ph-bold ph-arrows-clockwise text-base mr-1"></i>
<span class="text-xs uppercase font-bold tracking-widest"> <span class="text-xs uppercase font-bold tracking-widest">
@@ -484,7 +538,28 @@ export const Success = (props: { provider: string }) => (
); );
dashboardRoutes.get("/", async (c) => { dashboardRoutes.get("/", async (c) => {
return c.html(<Dashboard />); const isUnlocked = getCookie(c, "toknd_api_key") === config.API_KEY;
return c.html(<Dashboard isUnlocked={isUnlocked} />);
});
dashboardRoutes.post("/unlock", async (c) => {
const { apiKey } = await c.req.json();
if (apiKey !== config.API_KEY) {
return c.json({ error: "Invalid API Key" }, 401);
}
setCookie(c, "toknd_api_key", apiKey, {
httpOnly: true,
secure: true,
sameSite: "Strict",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 1 week
});
return c.json({ success: true });
});
dashboardRoutes.post("/logout", async (c) => {
deleteCookie(c, "toknd_api_key", { path: "/" });
return c.json({ success: true });
}); });
dashboardRoutes.get("/success", async (c) => { dashboardRoutes.get("/success", async (c) => {
+2
View File
@@ -0,0 +1,2 @@
export const API_VERSION = "v1";
export const APP_VERSION = "1.0";
+104 -32
View File
@@ -10,9 +10,16 @@ document.addEventListener("alpine:init", () => {
return date.toLocaleDateString(); return date.toLocaleDateString();
}; };
window.Alpine.data("dashboard", () => ({ window.Alpine.data(
apiKey: localStorage.getItem("toknd_api_key") || "", "dashboard",
isUnlocked: false, ({ initialIsUnlocked, apiPrefix, authPrefix, docsPrefix, apiVersion, appVersion }) => ({
apiKey: "",
isUnlocked: initialIsUnlocked,
apiPrefix,
authPrefix,
docsPrefix,
apiVersion,
appVersion,
loading: false, loading: false,
providers: [], providers: [],
form: { form: {
@@ -30,8 +37,8 @@ document.addEventListener("alpine:init", () => {
}, },
init() { init() {
if (this.apiKey) { if (this.isUnlocked) {
this.unlock(); this.fetchProviders();
} }
}, },
@@ -39,14 +46,34 @@ document.addEventListener("alpine:init", () => {
if (!this.apiKey) return; if (!this.apiKey) return;
this.loading = true; this.loading = true;
try { try {
localStorage.setItem("toknd_api_key", this.apiKey); const res = await fetch("/app/unlock", {
await this.fetchProviders(); method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: this.apiKey }),
});
if (!res.ok) throw new Error("Invalid API Key");
this.isUnlocked = true; this.isUnlocked = true;
await this.fetchProviders();
this.apiKey = ""; // Clear after success
} catch (err) { } catch (err) {
this.showNotification(`Failed to unlock: ${err.message}. Check your API Key`, "error"); this.showNotification(err.message, "error");
localStorage.removeItem("toknd_api_key");
this.isUnlocked = false; this.isUnlocked = false;
this.apiKey = ""; } finally {
this.loading = false;
}
},
async logout() {
this.loading = true;
try {
await fetch("/app/logout", { method: "POST" });
this.isUnlocked = false;
this.providers = [];
this.showNotification("Logged out successfully");
} catch (err) {
this.showNotification(`Logout failed: ${err.message}`, "error");
} finally { } finally {
this.loading = false; this.loading = false;
} }
@@ -56,41 +83,53 @@ document.addEventListener("alpine:init", () => {
this.loading = true; this.loading = true;
try { try {
const [configRes, statusRes] = await Promise.all([ const [configRes, statusRes] = await Promise.all([
fetch("/api/config", { headers: { Authorization: `Bearer ${this.apiKey}` } }), fetch(`${this.apiPrefix}/config`),
fetch("/api/status", { headers: { Authorization: `Bearer ${this.apiKey}` } }), fetch(`${this.apiPrefix}/status`),
]); ]);
if (!configRes.ok || !statusRes.ok) throw new Error("Unauthorized"); if (configRes.status === 401 || statusRes.status === 401) {
return this.handleSessionExpired();
}
const config = await configRes.json(); if (!configRes.ok || !statusRes.ok) throw new Error("Failed to fetch data");
const status = await statusRes.json();
this.providers = Object.entries(config).map(([name, cfg]) => ({ const [config, status] = await Promise.all([configRes.json(), statusRes.json()]);
name, this.providers = this.mapProviders(config, status);
config: cfg,
status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
}));
} catch (err) { } catch (err) {
this.showNotification(err.message, "error"); this.showNotification(err.message, "error");
throw err;
} finally { } finally {
this.loading = false; this.loading = false;
} }
}, },
async saveConfig(event) { mapProviders(config, status) {
if (event) event.preventDefault(); return Object.entries(config).map(([name, cfg]) => ({
name,
config: cfg,
status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
}));
},
handleSessionExpired() {
this.isUnlocked = false;
this.providers = [];
this.showNotification("Session expired", "error");
},
async saveConfig() {
this.loading = true; this.loading = true;
try { try {
const res = await fetch(`/api/config/${this.form.providerName}`, { const res = await fetch(`${this.apiPrefix}/config/${this.form.providerName}`, {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify(this.form), body: JSON.stringify(this.form),
}); });
if (res.status === 401) {
this.isUnlocked = false;
throw new Error("Session expired");
}
if (!res.ok) throw new Error("Failed to save"); if (!res.ok) throw new Error("Failed to save");
this.showNotification("Saved successfully"); this.showNotification("Saved successfully");
@@ -114,11 +153,14 @@ document.addEventListener("alpine:init", () => {
async forceRefresh(name) { async forceRefresh(name) {
this.loading = true; this.loading = true;
try { try {
const res = await fetch(`/api/refresh/${name}`, { const res = await fetch(`${this.apiPrefix}/refresh/${name}`, {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${this.apiKey}` },
}); });
if (res.status === 401) {
return this.handleSessionExpired();
}
if (!res.ok) throw new Error("Refresh failed"); if (!res.ok) throw new Error("Refresh failed");
this.showNotification(`Refreshed ${name}`); this.showNotification(`Refreshed ${name}`);
@@ -130,6 +172,35 @@ document.addEventListener("alpine:init", () => {
} }
}, },
async deleteProvider(name) {
if (
!confirm(
`Are you sure you want to delete ${name}? This will also remove all associated tokens.`,
)
)
return;
this.loading = true;
try {
const res = await fetch(`${this.apiPrefix}/config/${name}`, {
method: "DELETE",
});
if (res.status === 401) {
return this.handleSessionExpired();
}
if (!res.ok) throw new Error("Delete failed");
this.showNotification(`Deleted ${name}`);
await this.fetchProviders();
} catch (err) {
this.showNotification(err.message, "error");
} finally {
this.loading = false;
}
},
editProvider(provider) { editProvider(provider) {
this.form = { this.form = {
providerName: provider.name, providerName: provider.name,
@@ -143,7 +214,7 @@ document.addEventListener("alpine:init", () => {
}, },
getRedirectUri() { getRedirectUri() {
return `${window.location.origin}/auth/${this.form.providerName || "{provider}"}/callback`; return `${window.location.origin}${this.authPrefix}/${this.form.providerName || "{provider}"}/callback`;
}, },
copyToClipboard(text) { copyToClipboard(text) {
@@ -163,5 +234,6 @@ document.addEventListener("alpine:init", () => {
formatTime(timestamp) { formatTime(timestamp) {
return formatTime(timestamp); return formatTime(timestamp);
}, },
})); }),
);
}); });
+28 -15
View File
@@ -1,16 +1,10 @@
// @ts-nocheck // @ts-nocheck
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; import { describe, expect, it, spyOn } from "bun:test";
import { API_PREFIX } from "../../src/constants";
import { redis } from "../../src/core/RedisClient"; import { redis } from "../../src/core/RedisClient";
import { app } from "../../src/index"; import { app } from "../../src/index";
describe("API Integration", () => { describe("API Integration", () => {
afterEach(() => {
mock.restore();
redis.get.mockImplementation(() => Promise.resolve(null));
redis.set.mockImplementation(() => Promise.resolve());
redis.keys.mockImplementation(() => Promise.resolve([]));
});
const mockTraktConfig = JSON.stringify({ const mockTraktConfig = JSON.stringify({
clientId: "trakt-client-id", clientId: "trakt-client-id",
clientSecret: "trakt-client-secret", clientSecret: "trakt-client-secret",
@@ -20,11 +14,11 @@ describe("API Integration", () => {
}); });
it("should return 401 if API Key is missing", async () => { it("should return 401 if API Key is missing", async () => {
const res = await app.request("/api/status"); const res = await app.request(`${API_PREFIX}/status`);
expect(res.status).toBe(401); expect(res.status).toBe(401);
const body = await res.json(); const body = await res.json();
expect(body).toEqual({ error: "Missing or invalid authorization header" }); expect(body).toEqual({ error: "Missing or invalid authorization" });
}); });
it("should return 200 for health check (no auth needed)", async () => { it("should return 200 for health check (no auth needed)", async () => {
@@ -55,7 +49,7 @@ describe("API Integration", () => {
return Promise.resolve(null); return Promise.resolve(null);
}); });
const res = await app.request("/api/status", { const res = await app.request(`${API_PREFIX}/status`, {
headers: { headers: {
Authorization: "Bearer test-api-key", Authorization: "Bearer test-api-key",
}, },
@@ -67,8 +61,27 @@ describe("API Integration", () => {
expect(body.trakt.accessToken).toBe("current-access-token"); expect(body.trakt.accessToken).toBe("current-access-token");
}); });
it("should return 200 for status with valid Cookie", async () => {
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
redis.get.mockImplementation((key) => {
if (key.includes("config")) return Promise.resolve(mockTraktConfig);
if (key.includes("access_token")) return Promise.resolve("current-access-token");
return Promise.resolve(null);
});
const res = await app.request(`${API_PREFIX}/status`, {
headers: {
Cookie: "toknd_api_key=test-api-key",
},
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.trakt).toBeDefined();
});
it("should return 404 for unknown provider token", async () => { it("should return 404 for unknown provider token", async () => {
const res = await app.request("/api/token/unconfigured-provider", { const res = await app.request(`${API_PREFIX}/token/unconfigured-provider`, {
headers: { headers: {
Authorization: "Bearer test-api-key", Authorization: "Bearer test-api-key",
}, },
@@ -84,7 +97,7 @@ describe("API Integration", () => {
return Promise.resolve(null); return Promise.resolve(null);
}); });
const res = await app.request("/api/token/trakt", { const res = await app.request(`${API_PREFIX}/token/trakt`, {
headers: { headers: {
Authorization: "Bearer test-api-key", Authorization: "Bearer test-api-key",
}, },
@@ -101,7 +114,7 @@ describe("API Integration", () => {
return Promise.resolve(null); return Promise.resolve(null);
}); });
const res = await app.request("/api/token/trakt", { const res = await app.request(`${API_PREFIX}/token/trakt`, {
headers: { headers: {
Authorization: "Bearer test-api-key", Authorization: "Bearer test-api-key",
}, },
@@ -131,7 +144,7 @@ describe("API Integration", () => {
}), }),
); );
const res = await app.request("/api/refresh/trakt", { const res = await app.request(`${API_PREFIX}/refresh/trakt`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: "Bearer test-api-key", Authorization: "Bearer test-api-key",
+6 -10
View File
@@ -1,14 +1,10 @@
// @ts-nocheck // @ts-nocheck
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; import { describe, expect, it, spyOn } from "bun:test";
import { AUTH_PREFIX } from "../../src/constants";
import { redis } from "../../src/core/RedisClient"; import { redis } from "../../src/core/RedisClient";
import { app } from "../../src/index"; import { app } from "../../src/index";
describe("Auth Integration", () => { describe("Auth Integration", () => {
afterEach(() => {
mock.restore();
redis.get.mockImplementation(() => Promise.resolve(null));
});
const mockProviderConfig = JSON.stringify({ const mockProviderConfig = JSON.stringify({
clientId: "trakt-client-id", clientId: "trakt-client-id",
clientSecret: "trakt-client-secret", clientSecret: "trakt-client-secret",
@@ -23,7 +19,7 @@ describe("Auth Integration", () => {
return Promise.resolve(null); return Promise.resolve(null);
}); });
const res = await app.request("/auth/trakt/login"); const res = await app.request(`${AUTH_PREFIX}/trakt/login`);
expect(res.status).toBe(302); expect(res.status).toBe(302);
expect(res.headers.get("Location")).toContain("trakt.tv/oauth/authorize"); expect(res.headers.get("Location")).toContain("trakt.tv/oauth/authorize");
@@ -48,7 +44,7 @@ describe("Auth Integration", () => {
}), }),
); );
const res = await app.request("/auth/callback?state=trakt&code=temporary-auth-code"); const res = await app.request(`${AUTH_PREFIX}/callback?state=trakt&code=temporary-auth-code`);
expect(res.status).toBe(302); expect(res.status).toBe(302);
expect(res.headers.get("Location")).toBe("/app/success?provider=trakt"); expect(res.headers.get("Location")).toBe("/app/success?provider=trakt");
@@ -57,13 +53,13 @@ describe("Auth Integration", () => {
}); });
it("should return 404 if provider not configured during login", async () => { it("should return 404 if provider not configured during login", async () => {
const res = await app.request("/auth/unknown-provider/login"); const res = await app.request(`${AUTH_PREFIX}/unknown-provider/login`);
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
it("should return 400 if callback is missing state or code", async () => { it("should return 400 if callback is missing state or code", async () => {
const res = await app.request("/auth/callback?code=some-code"); const res = await app.request(`${AUTH_PREFIX}/callback?code=some-code`);
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
+17 -11
View File
@@ -1,16 +1,10 @@
// @ts-nocheck // @ts-nocheck
import { afterEach, describe, expect, it, mock } from "bun:test"; import { describe, expect, it } from "bun:test";
import { API_PREFIX } from "../../src/constants";
import { redis } from "../../src/core/RedisClient"; import { redis } from "../../src/core/RedisClient";
import { app } from "../../src/index"; import { app } from "../../src/index";
describe("Config Integration", () => { describe("Config Integration", () => {
afterEach(() => {
mock.restore();
redis.get.mockImplementation(() => Promise.resolve(null));
redis.set.mockImplementation(() => Promise.resolve());
redis.keys.mockImplementation(() => Promise.resolve([]));
});
it("should list all configured providers", async () => { it("should list all configured providers", async () => {
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"])); redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
redis.get.mockImplementation(() => redis.get.mockImplementation(() =>
@@ -25,7 +19,7 @@ describe("Config Integration", () => {
), ),
); );
const res = await app.request("/api/config", { const res = await app.request(`${API_PREFIX}/config`, {
headers: { headers: {
Authorization: "Bearer test-api-key", Authorization: "Bearer test-api-key",
}, },
@@ -46,7 +40,7 @@ describe("Config Integration", () => {
scope: "user:email", scope: "user:email",
}; };
const res = await app.request("/api/config/github", { const res = await app.request(`${API_PREFIX}/config/github`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: "Bearer test-api-key", Authorization: "Bearer test-api-key",
@@ -67,7 +61,7 @@ describe("Config Integration", () => {
clientId: "missing-other-required-fields", clientId: "missing-other-required-fields",
}; };
const res = await app.request("/api/config/invalid", { const res = await app.request(`${API_PREFIX}/config/invalid`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: "Bearer test-api-key", Authorization: "Bearer test-api-key",
@@ -78,4 +72,16 @@ describe("Config Integration", () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it("should delete a provider configuration", async () => {
const res = await app.request(`${API_PREFIX}/config/trakt`, {
method: "DELETE",
headers: {
Authorization: "Bearer test-api-key",
},
});
expect(res.status).toBe(200);
expect(redis.del).toHaveBeenCalled();
});
}); });
+23 -1
View File
@@ -1,5 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { API_PREFIX } from "../../src/constants";
import { redis } from "../../src/core/RedisClient"; import { redis } from "../../src/core/RedisClient";
import { app } from "../../src/index"; import { app } from "../../src/index";
@@ -26,7 +27,7 @@ describe("Dashboard & Common Integration", () => {
throw new Error("Redis Crash"); throw new Error("Redis Crash");
}); });
const res = await app.request("/api/status", { const res = await app.request(`${API_PREFIX}/status`, {
headers: { Authorization: "Bearer test-api-key" }, headers: { Authorization: "Bearer test-api-key" },
}); });
@@ -34,4 +35,25 @@ describe("Dashboard & Common Integration", () => {
const body = await res.json(); const body = await res.json();
expect(body.error).toBe("Internal Server Error"); expect(body.error).toBe("Internal Server Error");
}); });
it("should set a cookie on successful unlock", async () => {
const res = await app.request("/app/unlock", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: "test-api-key" }),
});
expect(res.status).toBe(200);
expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=test-api-key");
expect(res.headers.get("Set-Cookie")).toContain("HttpOnly");
});
it("should clear the cookie on logout", async () => {
const res = await app.request("/app/logout", {
method: "POST",
});
expect(res.status).toBe(200);
expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=;");
});
}); });
+23 -3
View File
@@ -1,18 +1,38 @@
import { mock } from "bun:test"; // @ts-nocheck
// Global test setup to stub environment variables
process.env.API_KEY = "test-api-key"; process.env.API_KEY = "test-api-key";
process.env.REDIS_HOST = "localhost"; process.env.REDIS_HOST = "localhost";
process.env.REDIS_PORT = "6379"; process.env.REDIS_PORT = "6379";
process.env.APP_PORT = "3000"; process.env.APP_PORT = "3000";
import { afterEach, mock } from "bun:test";
// Global config mock
mock.module("../src/config", () => ({
config: {
API_KEY: "test-api-key",
REDIS_HOST: "localhost",
REDIS_PORT: 6379,
APP_PORT: "3000",
},
}));
// Global Redis mock // Global Redis mock
mock.module("../src/core/RedisClient", () => ({ mock.module("../src/core/RedisClient", () => ({
redis: { redis: {
status: "ready", status: "ready",
get: mock(() => Promise.resolve(null)), get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve()), set: mock(() => Promise.resolve()),
del: mock(() => Promise.resolve(1)),
keys: mock(() => Promise.resolve([])), keys: mock(() => Promise.resolve([])),
on: mock(() => {}), on: mock(() => {}),
}, },
})); }));
afterEach(async () => {
const { redis } = await import("../src/core/RedisClient");
mock.restore();
redis.get.mockImplementation(() => Promise.resolve(null));
redis.set.mockImplementation(() => Promise.resolve());
redis.del.mockImplementation(() => Promise.resolve(1));
redis.keys.mockImplementation(() => Promise.resolve([]));
});