Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d28903c611 | |||
| a4f3ea7837 | |||
| 7c4ef8a51c | |||
| 2eab4b92cc | |||
| 51502055db | |||
| 553d9647c2 | |||
| b954ce5f72 | |||
| b258ee0a07 | |||
| e6354aae00 | |||
| 78520b9069 | |||
| dfff0e913d | |||
| 21c030fee5 | |||
| 3716c42668 |
+3
-4
@@ -1,6 +1,5 @@
|
|||||||
# Core Server Configuration
|
APP_PORT=3000
|
||||||
PORT=3000
|
|
||||||
API_KEY=your_secret_api_key_here
|
API_KEY=your_secret_api_key_here
|
||||||
|
|
||||||
# Redis Configuration (Use redis://redis:6379 for Docker)
|
REDIS_HOST=redis
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_PORT=6379
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v1
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: bun run check-all
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: bun run test
|
||||||
+4
-1
@@ -11,4 +11,7 @@ ENV NODE_ENV=production
|
|||||||
USER bun
|
USER bun
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["bun", "run", "src/index.ts"]
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD bun -e "fetch('http://localhost:3000/health').then(res => res.ok ? process.exit(0) : process.exit(1)).catch(e => process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["bun", "run", "start"]
|
||||||
|
|||||||
@@ -20,47 +20,63 @@
|
|||||||
- **Styling**: Tailwind CSS & DaisyUI
|
- **Styling**: Tailwind CSS & DaisyUI
|
||||||
- **Schema & Validation**: Zod
|
- **Schema & Validation**: Zod
|
||||||
|
|
||||||
## Quick Start
|
## Getting Started
|
||||||
|
|
||||||
|
toknd can be deployed either as a containerized service or self-hosted directly on your hardware.
|
||||||
|
|
||||||
### 1. Environment Setup
|
### 1. Environment Setup
|
||||||
Clone the repository and create your environment file:
|
Clone the repository and create your environment file:
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
Ensure you define a strong `API_KEY` in your `.env`.
|
Define a strong `API_KEY` and ensure `REDIS_URL` points to a valid Redis instance.
|
||||||
|
|
||||||
### 2. Local Development (with Auto-Watch)
|
### 2. Choose Deployment Method
|
||||||
We use a Docker Compose override system to enable hot-reloading locally:
|
|
||||||
```bash
|
|
||||||
podman compose up --build
|
|
||||||
```
|
|
||||||
*Note: This mounts your ./src directory into the container and uses bun --hot to restart on any code changes.*
|
|
||||||
|
|
||||||
### 3. Production Deployment
|
#### Option A: Containerized (Recommended)
|
||||||
For production, only the core docker-compose.yml is used:
|
This is the easiest way to get up and running, as it bundles the application and a Redis instance together.
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
- **Development (with Hot-Reload)**:
|
||||||
```
|
```bash
|
||||||
|
podman compose up --build
|
||||||
|
```
|
||||||
|
- **Production**:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option B: Self-Hosting (Bare Metal)
|
||||||
|
Ideal for lightweight deployments or custom environments where you already have Bun and Redis.
|
||||||
|
|
||||||
|
1. **Install Dependencies**:
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
2. **Start the Server**:
|
||||||
|
- **Development**: `bun run dev` (with hot-reload)
|
||||||
|
- **Production**: `bun run start`
|
||||||
|
*Note: Ensure your Redis server is running and accessible via the `REDIS_URL` in your `.env`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
All protected endpoints require an Authorization header:
|
toknd provides a built-in **Scalar API Reference** that allows you to explore and test all endpoints directly from your browser.
|
||||||
|
|
||||||
|
- **Interactive UI**: [http://localhost:3000/api](http://localhost:3000/api) (or `/docs`)
|
||||||
|
- **OpenAPI Spec (JSON)**: [http://localhost:3000/doc](http://localhost:3000/doc)
|
||||||
|
|
||||||
|
All protected endpoints require a Bearer token in the `Authorization` header:
|
||||||
`Authorization: Bearer <your_master_api_key>`
|
`Authorization: Bearer <your_master_api_key>`
|
||||||
|
|
||||||
### Token Brokerage
|
### Core Concepts
|
||||||
- **Get Valid Token**: `GET /api/token/:provider`
|
- **Token Brokerage**: Automated access token retrieval and background refreshes for all configured providers.
|
||||||
- Returns a valid access token. Automatically triggers a refresh if the current one is expired.
|
- **Provider Management**: Register and manage OAuth2 providers via the Dashboard or the configuration API.
|
||||||
- **Registry Status**: `GET /api/status`
|
|
||||||
- Returns the connectivity and refresh status of all configured providers.
|
|
||||||
|
|
||||||
### Authentication Flow
|
|
||||||
1. **Initiate**: `GET /auth/:provider/login`
|
|
||||||
2. **Callback**: `GET /auth/callback` (Handled internally by toknd)
|
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
Access the toknd dashboard at:
|
Access the **toknd** dashboard at:
|
||||||
`http://localhost:3000/app`
|
`http://localhost:3000/app`
|
||||||
|
|
||||||
Authenticate the registry using your Master API Key to manage your providers and view live token status.
|
The dashboard allows you to manage provider configurations, view live token statuses, and manually trigger refreshes. Authenticate using your **Master API Key**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+13
-1
@@ -61,5 +61,17 @@
|
|||||||
"organizeImports": "on"
|
"organizeImports": "on"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["tests/**/*"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.15",
|
"@biomejs/biome": "2.4.15",
|
||||||
"@types/node": "^22.19.18",
|
"@types/node": "^22.19.18",
|
||||||
|
"bun-types": "^1.3.13",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^17.0.4",
|
"lint-staged": "^17.0.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
@@ -66,6 +67,8 @@
|
|||||||
|
|
||||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||||
|
|
||||||
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||||
|
|
||||||
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
|
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./tests/setup.ts"]
|
||||||
+5
-2
@@ -3,9 +3,10 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
- API_KEY=${API_KEY}
|
- API_KEY=${API_KEY}
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
@@ -13,6 +14,8 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
restart: always
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --hot src/index.ts",
|
"dev": "bun run --hot src/index.ts",
|
||||||
|
"start": "bun src/index.ts",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"lint": "bunx @biomejs/biome check src",
|
"lint": "bunx @biomejs/biome check src",
|
||||||
|
"check-all": "bunx @biomejs/biome check .",
|
||||||
"format": "bunx @biomejs/biome format --write src",
|
"format": "bunx @biomejs/biome format --write src",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.15",
|
"@biomejs/biome": "2.4.15",
|
||||||
"@types/node": "^22.19.18",
|
"@types/node": "^22.19.18",
|
||||||
|
"bun-types": "^1.3.13",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^17.0.4",
|
"lint-staged": "^17.0.4",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const configSchema = z.object({
|
const configSchema = z.object({
|
||||||
PORT: z.string().default("3000"),
|
APP_PORT: z.string().default("3000"),
|
||||||
REDIS_URL: z.string(),
|
REDIS_HOST: z.string().default("redis"),
|
||||||
|
REDIS_PORT: z.coerce.number().default(6379),
|
||||||
API_KEY: z.string(),
|
API_KEY: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Redis } from "ioredis";
|
import { Redis } from "ioredis";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
|
|
||||||
export const redis = new Redis(config.REDIS_URL);
|
export const redis = new Redis({
|
||||||
|
host: config.REDIS_HOST,
|
||||||
|
port: config.REDIS_PORT,
|
||||||
|
});
|
||||||
|
|||||||
+10
-2
@@ -3,6 +3,7 @@ import { Scalar } from "@scalar/hono-api-reference";
|
|||||||
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 { redis } from "./core/RedisClient";
|
||||||
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";
|
||||||
@@ -63,9 +64,16 @@ app.onError((err, c) => {
|
|||||||
return c.json({ error: "Internal Server Error", message: err.message }, 500);
|
return c.json({ error: "Internal Server Error", message: err.message }, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
app.get("/health", async (c) => {
|
||||||
|
if (redis.status !== "ready") {
|
||||||
|
return c.json({ status: "error", message: "Redis down", redis: redis.status }, 503);
|
||||||
|
}
|
||||||
|
return c.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
export { app };
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
port: Number.parseInt(config.PORT, 10),
|
port: Number.parseInt(config.APP_PORT, 10),
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { OAuthProvider, TokenResponse } from "./interface";
|
|||||||
const TokenResponseSchema = z.object({
|
const TokenResponseSchema = z.object({
|
||||||
access_token: z.string(),
|
access_token: z.string(),
|
||||||
refresh_token: z.string().optional(),
|
refresh_token: z.string().optional(),
|
||||||
expires_in: z.number(),
|
expires_in: z.coerce.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export class GenericProvider implements OAuthProvider {
|
export class GenericProvider implements OAuthProvider {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { describe, expect, it, mock } from "bun:test";
|
||||||
|
import { ConfigManager } from "../../src/core/ConfigManager";
|
||||||
|
|
||||||
|
describe("ConfigManager", () => {
|
||||||
|
it("should save and retrieve provider configuration", async () => {
|
||||||
|
const storage = {};
|
||||||
|
const redis = {
|
||||||
|
set: mock((key, val) => {
|
||||||
|
storage[key] = val;
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
get: mock((key) => Promise.resolve(storage[key] || null)),
|
||||||
|
};
|
||||||
|
const manager = new ConfigManager(redis);
|
||||||
|
const traktConfig = {
|
||||||
|
clientId: "trakt-client-id",
|
||||||
|
clientSecret: "trakt-client-secret",
|
||||||
|
authUrl: "https://trakt.tv/oauth/authorize",
|
||||||
|
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||||
|
scope: "public",
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.setProviderConfig("trakt", traktConfig);
|
||||||
|
const retrieved = await manager.getProviderConfig("trakt");
|
||||||
|
|
||||||
|
expect(retrieved).toEqual(traktConfig);
|
||||||
|
expect(redis.set).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return all providers", async () => {
|
||||||
|
const redis = {
|
||||||
|
keys: mock(() => Promise.resolve(["config:trakt", "config:github"])),
|
||||||
|
get: mock((key) =>
|
||||||
|
Promise.resolve(
|
||||||
|
JSON.stringify({
|
||||||
|
clientId: `${key}-id`,
|
||||||
|
clientSecret: "secret",
|
||||||
|
authUrl: "https://auth.com",
|
||||||
|
tokenUrl: "https://token.com",
|
||||||
|
scope: "all",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const manager = new ConfigManager(redis);
|
||||||
|
|
||||||
|
const providers = await manager.getAllProviders();
|
||||||
|
|
||||||
|
expect(Object.keys(providers)).toHaveLength(2);
|
||||||
|
expect(providers.trakt.clientId).toBe("config:trakt-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for non-existent provider", async () => {
|
||||||
|
const redis = { get: mock(() => Promise.resolve(null)) };
|
||||||
|
const manager = new ConfigManager(redis);
|
||||||
|
|
||||||
|
const config = await manager.getProviderConfig("missing-provider");
|
||||||
|
|
||||||
|
expect(config).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,65 +1,76 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import { describe, expect, it, mock } from "bun:test";
|
import { describe, expect, it, mock } from "bun:test";
|
||||||
import { TokenManager } from "../../src/core/TokenManager";
|
import { TokenManager } from "../../src/core/TokenManager";
|
||||||
|
|
||||||
describe("TokenManager", () => {
|
describe("TokenManager", () => {
|
||||||
it("should return token from redis if available", async () => {
|
it("should return token from redis if available", async () => {
|
||||||
const redisMock = { get: mock(() => Promise.resolve("valid_token")) };
|
const redis = { get: mock(() => Promise.resolve("active-access-token")) };
|
||||||
const manager = new TokenManager(redisMock as any, {} as any);
|
const manager = new TokenManager(redis, {});
|
||||||
|
|
||||||
const token = await manager.getAccessToken("trakt");
|
const token = await manager.getAccessToken("trakt");
|
||||||
expect(token).toBe("valid_token");
|
|
||||||
|
expect(token).toBe("active-access-token");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should refresh token if access token is missing but refresh token exists", async () => {
|
it("should refresh token if access token is missing but refresh token exists", async () => {
|
||||||
const redisMock = {
|
const redis = {
|
||||||
get: mock((key: string) => Promise.resolve(key.includes("refresh") ? "refresh_token" : null)),
|
get: mock((key) => Promise.resolve(key.includes("refresh") ? "valid-refresh-token" : null)),
|
||||||
set: mock(() => Promise.resolve()),
|
set: mock(() => Promise.resolve()),
|
||||||
};
|
};
|
||||||
const providerMock = {
|
const provider = {
|
||||||
refreshToken: mock(() =>
|
refreshToken: mock(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
accessToken: "new_token",
|
accessToken: "newly-refreshed-access-token",
|
||||||
refreshToken: "new_refresh",
|
refreshToken: "newly-refreshed-refresh-token",
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const manager = new TokenManager(redisMock as any, providerMock as any);
|
const manager = new TokenManager(redis, provider);
|
||||||
|
|
||||||
const token = await manager.getAccessToken("trakt");
|
const token = await manager.getAccessToken("trakt");
|
||||||
expect(token).toBe("new_token");
|
|
||||||
expect(redisMock.set).toHaveBeenCalled();
|
expect(token).toBe("newly-refreshed-access-token");
|
||||||
|
expect(redis.set).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return null if no tokens are found", async () => {
|
it("should return null if no tokens are found", async () => {
|
||||||
const redisMock = { get: mock(() => Promise.resolve(null)) };
|
const redis = { get: mock(() => Promise.resolve(null)) };
|
||||||
const manager = new TokenManager(redisMock as any, {} as any);
|
const manager = new TokenManager(redis, {});
|
||||||
|
|
||||||
const token = await manager.getAccessToken("trakt");
|
const token = await manager.getAccessToken("trakt");
|
||||||
|
|
||||||
expect(token).toBeNull();
|
expect(token).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should refresh token via forceRefresh", async () => {
|
it("should refresh token via refreshAccessToken", async () => {
|
||||||
const redisMock = {
|
const redis = {
|
||||||
get: mock(() => Promise.resolve("refresh_token")),
|
get: mock(() => Promise.resolve("existing-refresh-token")),
|
||||||
set: mock(() => Promise.resolve()),
|
set: mock(() => Promise.resolve()),
|
||||||
};
|
};
|
||||||
const providerMock = {
|
const provider = {
|
||||||
refreshToken: mock(() =>
|
refreshToken: mock(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
accessToken: "forced_token",
|
accessToken: "manually-refreshed-access-token",
|
||||||
refreshToken: "new_refresh",
|
refreshToken: "manually-refreshed-refresh-token",
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const manager = new TokenManager(redisMock as any, providerMock as any);
|
const manager = new TokenManager(redis, provider);
|
||||||
const token = await manager.forceRefresh("trakt");
|
|
||||||
expect(token).toBe("forced_token");
|
const token = await manager.refreshAccessToken("trakt");
|
||||||
expect(providerMock.refreshToken).toHaveBeenCalledWith("refresh_token");
|
|
||||||
|
expect(token).toBe("manually-refreshed-access-token");
|
||||||
|
expect(provider.refreshToken).toHaveBeenCalledWith("existing-refresh-token");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return null in forceRefresh if no refresh token is found", async () => {
|
it("should return null in refreshAccessToken if no refresh token is found", async () => {
|
||||||
const redisMock = { get: mock(() => Promise.resolve(null)) };
|
const redis = { get: mock(() => Promise.resolve(null)) };
|
||||||
const manager = new TokenManager(redisMock as any, {} as any);
|
const manager = new TokenManager(redis, {});
|
||||||
const token = await manager.forceRefresh("trakt");
|
|
||||||
|
const token = await manager.refreshAccessToken("trakt");
|
||||||
|
|
||||||
expect(token).toBeNull();
|
expect(token).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||||
|
import { redis } from "../../src/core/RedisClient";
|
||||||
|
import { app } from "../../src/index";
|
||||||
|
|
||||||
|
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({
|
||||||
|
clientId: "trakt-client-id",
|
||||||
|
clientSecret: "trakt-client-secret",
|
||||||
|
authUrl: "https://trakt.tv/oauth/authorize",
|
||||||
|
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||||
|
scope: "public",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 401 if API Key is missing", async () => {
|
||||||
|
const res = await app.request("/api/status");
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ error: "Missing or invalid authorization header" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 200 for health check (no auth needed)", async () => {
|
||||||
|
const res = await app.request("/health");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 503 for health check if redis is down", async () => {
|
||||||
|
redis.status = "connecting";
|
||||||
|
|
||||||
|
const res = await app.request("/health");
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(body.status).toBe("error");
|
||||||
|
expect(body.redis).toBe("connecting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 200 for status with valid API Key", 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");
|
||||||
|
if (key.includes("refresh_token")) return Promise.resolve("current-refresh-token");
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/api/status", {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.trakt).toBeDefined();
|
||||||
|
expect(body.trakt.accessToken).toBe("current-access-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 for unknown provider token", async () => {
|
||||||
|
const res = await app.request("/api/token/unconfigured-provider", {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return token for a configured provider", async () => {
|
||||||
|
redis.get.mockImplementation((key) => {
|
||||||
|
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
||||||
|
if (key.includes("access_token")) return Promise.resolve("trakt-active-token");
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/api/token/trakt", {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.access_token).toBe("trakt-active-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 if no access token is in redis for a valid provider", async () => {
|
||||||
|
redis.get.mockImplementation((key) => {
|
||||||
|
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/api/token/trakt", {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toContain("No tokens found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should successfully refresh a token", async () => {
|
||||||
|
redis.get.mockImplementation((key) => {
|
||||||
|
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
|
||||||
|
if (key.includes("refresh_token")) return Promise.resolve("old-refresh-token");
|
||||||
|
return Promise.resolve("new-access-token-from-refresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
access_token: "new-access-token-from-fetch",
|
||||||
|
refresh_token: "new-refresh-token-from-fetch",
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request("/api/refresh/trakt", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.status.accessToken).toBe("new-access-token-from-refresh");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||||
|
import { redis } from "../../src/core/RedisClient";
|
||||||
|
import { app } from "../../src/index";
|
||||||
|
|
||||||
|
describe("Auth Integration", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
redis.get.mockImplementation(() => Promise.resolve(null));
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockProviderConfig = JSON.stringify({
|
||||||
|
clientId: "trakt-client-id",
|
||||||
|
clientSecret: "trakt-client-secret",
|
||||||
|
authUrl: "https://trakt.tv/oauth/authorize",
|
||||||
|
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||||
|
scope: "public",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redirect to provider login", async () => {
|
||||||
|
redis.get.mockImplementation((key) => {
|
||||||
|
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/auth/trakt/login");
|
||||||
|
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
expect(res.headers.get("Location")).toContain("trakt.tv/oauth/authorize");
|
||||||
|
expect(res.headers.get("Location")).toContain("client_id=trakt-client-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle callback and exchange code", async () => {
|
||||||
|
redis.get.mockImplementation((key) => {
|
||||||
|
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
access_token: "exchange-access-token",
|
||||||
|
refresh_token: "exchange-refresh-token",
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request("/auth/callback?state=trakt&code=temporary-auth-code");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const html = await res.text();
|
||||||
|
expect(html).toContain("trakt");
|
||||||
|
expect(redis.set).toHaveBeenCalled();
|
||||||
|
expect(fetchSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 if provider not configured during login", async () => {
|
||||||
|
const res = await app.request("/auth/unknown-provider/login");
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 if callback is missing state or code", async () => {
|
||||||
|
const res = await app.request("/auth/callback?code=some-code");
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { afterEach, describe, expect, it, mock } from "bun:test";
|
||||||
|
import { redis } from "../../src/core/RedisClient";
|
||||||
|
import { app } from "../../src/index";
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
|
||||||
|
redis.get.mockImplementation(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
JSON.stringify({
|
||||||
|
clientId: "trakt-client-id",
|
||||||
|
clientSecret: "trakt-client-secret",
|
||||||
|
authUrl: "https://trakt.tv/oauth/authorize",
|
||||||
|
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||||
|
scope: "public",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request("/api/config", {
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.trakt).toBeDefined();
|
||||||
|
expect(body.trakt.clientId).toBe("trakt-client-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set a new provider config", async () => {
|
||||||
|
const newProviderConfig = {
|
||||||
|
clientId: "github-client-id",
|
||||||
|
clientSecret: "github-client-secret",
|
||||||
|
authUrl: "https://github.com/login/oauth/authorize",
|
||||||
|
tokenUrl: "https://github.com/login/oauth/access_token",
|
||||||
|
scope: "user:email",
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await app.request("/api/config/github", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(newProviderConfig),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(redis.set).toHaveBeenCalledWith(
|
||||||
|
"config:github",
|
||||||
|
expect.stringContaining("github-client-id"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 for invalid config body", async () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
clientId: "missing-other-required-fields",
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await app.request("/api/config/invalid", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer test-api-key",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(invalidConfig),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { redis } from "../../src/core/RedisClient";
|
||||||
|
import { app } from "../../src/index";
|
||||||
|
|
||||||
|
describe("Dashboard & Common Integration", () => {
|
||||||
|
it("should serve the dashboard HTML", async () => {
|
||||||
|
const res = await app.request("/app");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toContain("text/html");
|
||||||
|
const html = await res.text();
|
||||||
|
expect(html).toContain("<!DOCTYPE html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 with custom handler for unknown routes", async () => {
|
||||||
|
const res = await app.request("/unknown-route");
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toBe("Not Found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 500 for internal errors", async () => {
|
||||||
|
redis.keys.mockImplementationOnce(() => {
|
||||||
|
throw new Error("Redis Crash");
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request("/api/status", {
|
||||||
|
headers: { Authorization: "Bearer test-api-key" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toBe("Internal Server Error");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||||
|
import { GenericProvider } from "../../src/providers/GenericProvider";
|
||||||
|
|
||||||
|
describe("GenericProvider", () => {
|
||||||
|
const traktConfig = {
|
||||||
|
clientId: "trakt-client-id",
|
||||||
|
clientSecret: "trakt-client-secret",
|
||||||
|
authUrl: "https://trakt.tv/oauth/authorize",
|
||||||
|
tokenUrl: "https://api.trakt.tv/oauth/token",
|
||||||
|
scope: "public",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate correct auth URL", () => {
|
||||||
|
const provider = new GenericProvider("trakt", traktConfig);
|
||||||
|
|
||||||
|
const url = provider.getAuthUrl("random-state-123", "https://callback.com");
|
||||||
|
|
||||||
|
expect(url).toContain("client_id=trakt-client-id");
|
||||||
|
expect(url).toContain("redirect_uri=https%3A%2F%2Fcallback.com");
|
||||||
|
expect(url).toContain("state=random-state-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle token response with string expiry", async () => {
|
||||||
|
const provider = new GenericProvider("trakt", traktConfig);
|
||||||
|
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
access_token: "new-access-token",
|
||||||
|
refresh_token: "new-refresh-token",
|
||||||
|
expires_in: "7200",
|
||||||
|
}),
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = await provider.refreshToken("old-refresh-token");
|
||||||
|
|
||||||
|
expect(tokens.accessToken).toBe("new-access-token");
|
||||||
|
expect(tokens.expiresIn).toBe(7200);
|
||||||
|
expect(fetchSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle token response without new refresh token", async () => {
|
||||||
|
const provider = new GenericProvider("trakt", traktConfig);
|
||||||
|
spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
access_token: "new-access-token-only",
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = await provider.refreshToken("existing-refresh-token");
|
||||||
|
|
||||||
|
expect(tokens.accessToken).toBe("new-access-token-only");
|
||||||
|
expect(tokens.refreshToken).toBe("existing-refresh-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { mock } from "bun:test";
|
||||||
|
|
||||||
|
// Global test setup to stub environment variables
|
||||||
|
process.env.API_KEY = "test-api-key";
|
||||||
|
process.env.REDIS_HOST = "localhost";
|
||||||
|
process.env.REDIS_PORT = "6379";
|
||||||
|
process.env.APP_PORT = "3000";
|
||||||
|
|
||||||
|
// Global Redis mock
|
||||||
|
mock.module("../src/core/RedisClient", () => ({
|
||||||
|
redis: {
|
||||||
|
status: "ready",
|
||||||
|
get: mock(() => Promise.resolve(null)),
|
||||||
|
set: mock(() => Promise.resolve()),
|
||||||
|
keys: mock(() => Promise.resolve([])),
|
||||||
|
on: mock(() => {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
+2
-2
@@ -6,7 +6,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"types": ["node"]
|
"types": ["node", "bun-types"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*", "tests/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user