Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a004322970 | |||
| 282618bc1c | |||
| dcf5cd3551 | |||
| 9edb7fd989 | |||
| 0a276b9a63 | |||
| 5d8a9ccb3e | |||
| cf37904083 | |||
| f3349fced4 | |||
| 4554b2e734 | |||
| 106edb8bb7 | |||
| f89b5b4437 | |||
| 558bc9e034 |
@@ -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}`;
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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" }),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
+112
-37
@@ -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,41 +69,75 @@ 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">
|
||||||
<div class="join border border-base-200/50 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors">
|
<template x-if="!isUnlocked">
|
||||||
<div class="join-item flex items-center px-4 bg-base-200">
|
<div class="join border border-base-200/50 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors">
|
||||||
<i class="ph-duotone ph-key text-secondary text-lg"></i>
|
<div class="join-item flex items-center px-4 bg-base-200">
|
||||||
</div>
|
<i class="ph-duotone ph-key text-secondary text-lg"></i>
|
||||||
<div class="relative flex-1" x-data="{ show: false }">
|
</div>
|
||||||
<input
|
<div class="relative flex-1" x-data="{ show: false }">
|
||||||
x-bind:type="show ? 'text' : 'password'"
|
<input
|
||||||
id="apiKey"
|
x-bind:type="show ? 'text' : 'password'"
|
||||||
name="apiKey"
|
id="apiKey"
|
||||||
x-model="apiKey"
|
name="apiKey"
|
||||||
aria-label="Master API Key"
|
x-model="apiKey"
|
||||||
placeholder="API_KEY"
|
aria-label="Master API Key"
|
||||||
class="input join-item input-sm bg-transparent border-none focus:outline-none w-48 lg:w-64 text-xs pr-10 font-mono"
|
placeholder="API_KEY"
|
||||||
/>
|
class="input join-item input-sm bg-transparent border-none focus:outline-none w-48 lg:w-64 text-xs pr-10 font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-on:click="show = !show"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square"
|
||||||
|
>
|
||||||
|
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
x-on:click="unlock()"
|
||||||
x-on:click="show = !show"
|
type="submit"
|
||||||
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square"
|
class="btn btn-primary btn-sm join-item px-6"
|
||||||
|
x-bind:disabled="loading"
|
||||||
>
|
>
|
||||||
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></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="ml-1 hidden md:inline"
|
||||||
|
x-text="loading ? 'Unlocking...' : 'Unlock'"
|
||||||
|
></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="isUnlocked">
|
||||||
<button
|
<button
|
||||||
x-on:click="unlock()"
|
x-on:click="logout()"
|
||||||
type="submit"
|
type="button"
|
||||||
class="btn btn-primary btn-sm join-item px-6"
|
class="btn btn-ghost btn-sm text-error hover:bg-error/10 gap-2 px-4"
|
||||||
x-bind:disabled="loading"
|
x-bind:disabled="loading"
|
||||||
>
|
>
|
||||||
<i class="ph-duotone ph-lock-key-open text-lg" x-show="!loading"></i>
|
<i class="ph-bold ph-power text-lg"></i>
|
||||||
<span class="loading loading-spinner loading-xs" x-show="loading"></span>
|
<span class="font-bold uppercase tracking-wider text-xs">Logout</span>
|
||||||
<span class="ml-1 hidden md:inline" x-text="loading ? 'Unlocking...' : 'Unlock'"></span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
||||||
<span
|
<div class="flex justify-between items-start">
|
||||||
x-text="provider.name"
|
<span
|
||||||
class="text-lg font-black text-base-content/90 uppercase"
|
x-text="provider.name"
|
||||||
></span>
|
class="text-lg font-black text-base-content/90 uppercase"
|
||||||
|
></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) => {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const API_VERSION = "v1";
|
||||||
|
export const APP_VERSION = "1.0";
|
||||||
+206
-134
@@ -10,158 +10,230 @@ 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 }) => ({
|
||||||
loading: false,
|
apiKey: "",
|
||||||
providers: [],
|
isUnlocked: initialIsUnlocked,
|
||||||
form: {
|
apiPrefix,
|
||||||
providerName: "",
|
authPrefix,
|
||||||
clientId: "",
|
docsPrefix,
|
||||||
clientSecret: "",
|
apiVersion,
|
||||||
authUrl: "",
|
appVersion,
|
||||||
tokenUrl: "",
|
loading: false,
|
||||||
scope: "public",
|
providers: [],
|
||||||
},
|
form: {
|
||||||
notification: {
|
providerName: "",
|
||||||
show: false,
|
clientId: "",
|
||||||
message: "",
|
clientSecret: "",
|
||||||
type: "success",
|
authUrl: "",
|
||||||
},
|
tokenUrl: "",
|
||||||
|
scope: "public",
|
||||||
|
},
|
||||||
|
notification: {
|
||||||
|
show: false,
|
||||||
|
message: "",
|
||||||
|
type: "success",
|
||||||
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if (this.apiKey) {
|
if (this.isUnlocked) {
|
||||||
this.unlock();
|
this.fetchProviders();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async unlock() {
|
async unlock() {
|
||||||
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",
|
||||||
this.isUnlocked = true;
|
headers: { "Content-Type": "application/json" },
|
||||||
} catch (err) {
|
body: JSON.stringify({ apiKey: this.apiKey }),
|
||||||
this.showNotification(`Failed to unlock: ${err.message}. Check your API Key`, "error");
|
});
|
||||||
localStorage.removeItem("toknd_api_key");
|
|
||||||
this.isUnlocked = false;
|
|
||||||
this.apiKey = "";
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchProviders() {
|
if (!res.ok) throw new Error("Invalid API Key");
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
const [configRes, statusRes] = await Promise.all([
|
|
||||||
fetch("/api/config", { headers: { Authorization: `Bearer ${this.apiKey}` } }),
|
|
||||||
fetch("/api/status", { headers: { Authorization: `Bearer ${this.apiKey}` } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!configRes.ok || !statusRes.ok) throw new Error("Unauthorized");
|
this.isUnlocked = true;
|
||||||
|
await this.fetchProviders();
|
||||||
|
this.apiKey = ""; // Clear after success
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
this.isUnlocked = false;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
const config = await configRes.json();
|
async logout() {
|
||||||
const status = await statusRes.json();
|
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 {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
this.providers = Object.entries(config).map(([name, cfg]) => ({
|
async fetchProviders() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const [configRes, statusRes] = await Promise.all([
|
||||||
|
fetch(`${this.apiPrefix}/config`),
|
||||||
|
fetch(`${this.apiPrefix}/status`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (configRes.status === 401 || statusRes.status === 401) {
|
||||||
|
return this.handleSessionExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!configRes.ok || !statusRes.ok) throw new Error("Failed to fetch data");
|
||||||
|
|
||||||
|
const [config, status] = await Promise.all([configRes.json(), statusRes.json()]);
|
||||||
|
this.providers = this.mapProviders(config, status);
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mapProviders(config, status) {
|
||||||
|
return Object.entries(config).map(([name, cfg]) => ({
|
||||||
name,
|
name,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
|
status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
},
|
||||||
this.showNotification(err.message, "error");
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveConfig(event) {
|
handleSessionExpired() {
|
||||||
if (event) event.preventDefault();
|
this.isUnlocked = false;
|
||||||
this.loading = true;
|
this.providers = [];
|
||||||
try {
|
this.showNotification("Session expired", "error");
|
||||||
const res = await fetch(`/api/config/${this.form.providerName}`, {
|
},
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${this.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(this.form),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error("Failed to save");
|
async saveConfig() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiPrefix}/config/${this.form.providerName}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(this.form),
|
||||||
|
});
|
||||||
|
|
||||||
this.showNotification("Saved successfully");
|
if (res.status === 401) {
|
||||||
await this.fetchProviders();
|
this.isUnlocked = false;
|
||||||
|
throw new Error("Session expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to save");
|
||||||
|
|
||||||
|
this.showNotification("Saved successfully");
|
||||||
|
await this.fetchProviders();
|
||||||
|
|
||||||
|
this.form = {
|
||||||
|
providerName: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
authUrl: "",
|
||||||
|
tokenUrl: "",
|
||||||
|
scope: "public",
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async forceRefresh(name) {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiPrefix}/refresh/${name}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
return this.handleSessionExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Refresh failed");
|
||||||
|
|
||||||
|
this.showNotification(`Refreshed ${name}`);
|
||||||
|
await this.fetchProviders();
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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) {
|
||||||
this.form = {
|
this.form = {
|
||||||
providerName: "",
|
providerName: provider.name,
|
||||||
clientId: "",
|
clientId: provider.config.clientId,
|
||||||
clientSecret: "",
|
clientSecret: provider.config.clientSecret,
|
||||||
authUrl: "",
|
authUrl: provider.config.authUrl,
|
||||||
tokenUrl: "",
|
tokenUrl: provider.config.tokenUrl,
|
||||||
scope: "public",
|
scope: provider.config.scope,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
this.showNotification(err.message, "error");
|
},
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async forceRefresh(name) {
|
getRedirectUri() {
|
||||||
this.loading = true;
|
return `${window.location.origin}${this.authPrefix}/${this.form.providerName || "{provider}"}/callback`;
|
||||||
try {
|
},
|
||||||
const res = await fetch(`/api/refresh/${name}`, {
|
|
||||||
method: "POST",
|
copyToClipboard(text) {
|
||||||
headers: { Authorization: `Bearer ${this.apiKey}` },
|
if (!text) return;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
this.showNotification("Copied");
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
if (!res.ok) throw new Error("Refresh failed");
|
showNotification(message, type = "success") {
|
||||||
|
this.notification = { show: true, message, type };
|
||||||
|
setTimeout(() => {
|
||||||
|
this.notification.show = false;
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
|
||||||
this.showNotification(`Refreshed ${name}`);
|
formatTime(timestamp) {
|
||||||
await this.fetchProviders();
|
return formatTime(timestamp);
|
||||||
} catch (err) {
|
},
|
||||||
this.showNotification(err.message, "error");
|
}),
|
||||||
} finally {
|
);
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
editProvider(provider) {
|
|
||||||
this.form = {
|
|
||||||
providerName: provider.name,
|
|
||||||
clientId: provider.config.clientId,
|
|
||||||
clientSecret: provider.config.clientSecret,
|
|
||||||
authUrl: provider.config.authUrl,
|
|
||||||
tokenUrl: provider.config.tokenUrl,
|
|
||||||
scope: provider.config.scope,
|
|
||||||
};
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
},
|
|
||||||
|
|
||||||
getRedirectUri() {
|
|
||||||
return `${window.location.origin}/auth/${this.form.providerName || "{provider}"}/callback`;
|
|
||||||
},
|
|
||||||
|
|
||||||
copyToClipboard(text) {
|
|
||||||
if (!text) return;
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
|
||||||
this.showNotification("Copied");
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
showNotification(message, type = "success") {
|
|
||||||
this.notification = { show: true, message, type };
|
|
||||||
setTimeout(() => {
|
|
||||||
this.notification.show = false;
|
|
||||||
}, 3000);
|
|
||||||
},
|
|
||||||
|
|
||||||
formatTime(timestamp) {
|
|
||||||
return formatTime(timestamp);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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([]));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user