Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
310 lines
8.5 KiB
TypeScript
310 lines
8.5 KiB
TypeScript
/**
|
|
* Integration tests for API token handlers.
|
|
*
|
|
* Tests token CRUD and resolution against a real in-memory SQLite database.
|
|
*/
|
|
|
|
import type { Kysely } from "kysely";
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
|
import {
|
|
handleApiTokenCreate,
|
|
handleApiTokenList,
|
|
handleApiTokenRevoke,
|
|
resolveApiToken,
|
|
resolveOAuthToken,
|
|
} from "../../../src/api/handlers/api-tokens.js";
|
|
import { generatePrefixedToken, TOKEN_PREFIXES } from "../../../src/auth/api-tokens.js";
|
|
import type { Database } from "../../../src/database/types.js";
|
|
import { setupTestDatabase } from "../../utils/test-db.js";
|
|
|
|
// Regex patterns for token validation
|
|
const PAT_PREFIX_REGEX = /^ec_pat_/;
|
|
|
|
let db: Kysely<Database>;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabase();
|
|
|
|
// Create a test user
|
|
await db
|
|
.insertInto("users")
|
|
.values({
|
|
id: "user_1",
|
|
email: "admin@test.com",
|
|
name: "Admin",
|
|
role: 50, // ADMIN
|
|
email_verified: 1,
|
|
})
|
|
.execute();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.destroy();
|
|
});
|
|
|
|
describe("handleApiTokenCreate", () => {
|
|
it("creates a token and returns the raw value", async () => {
|
|
const result = await handleApiTokenCreate(db, "user_1", {
|
|
name: "Test Token",
|
|
scopes: ["content:read", "content:write"],
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toBeDefined();
|
|
expect(result.data!.token).toMatch(PAT_PREFIX_REGEX);
|
|
expect(result.data!.info.name).toBe("Test Token");
|
|
expect(result.data!.info.scopes).toEqual(["content:read", "content:write"]);
|
|
expect(result.data!.info.userId).toBe("user_1");
|
|
expect(result.data!.info.prefix).toMatch(PAT_PREFIX_REGEX);
|
|
});
|
|
|
|
it("creates tokens with different hashes", async () => {
|
|
const result1 = await handleApiTokenCreate(db, "user_1", {
|
|
name: "Token 1",
|
|
scopes: ["content:read"],
|
|
});
|
|
const result2 = await handleApiTokenCreate(db, "user_1", {
|
|
name: "Token 2",
|
|
scopes: ["content:read"],
|
|
});
|
|
|
|
expect(result1.data!.token).not.toBe(result2.data!.token);
|
|
});
|
|
|
|
it("stores expiry date when provided", async () => {
|
|
const expiresAt = new Date(Date.now() + 86400000).toISOString();
|
|
const result = await handleApiTokenCreate(db, "user_1", {
|
|
name: "Expiring Token",
|
|
scopes: ["content:read"],
|
|
expiresAt,
|
|
});
|
|
|
|
expect(result.data!.info.expiresAt).toBe(expiresAt);
|
|
});
|
|
});
|
|
|
|
describe("handleApiTokenList", () => {
|
|
it("lists tokens for a user", async () => {
|
|
await handleApiTokenCreate(db, "user_1", {
|
|
name: "Token A",
|
|
scopes: ["content:read"],
|
|
});
|
|
await handleApiTokenCreate(db, "user_1", {
|
|
name: "Token B",
|
|
scopes: ["admin"],
|
|
});
|
|
|
|
const result = await handleApiTokenList(db, "user_1");
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data!.items).toHaveLength(2);
|
|
const names = result.data!.items.map((t) => t.name).toSorted();
|
|
expect(names).toEqual(["Token A", "Token B"]);
|
|
});
|
|
|
|
it("does not return tokens for other users", async () => {
|
|
await db
|
|
.insertInto("users")
|
|
.values({
|
|
id: "user_2",
|
|
email: "other@test.com",
|
|
name: "Other",
|
|
role: 50,
|
|
email_verified: 1,
|
|
})
|
|
.execute();
|
|
|
|
await handleApiTokenCreate(db, "user_1", {
|
|
name: "User 1 Token",
|
|
scopes: ["content:read"],
|
|
});
|
|
await handleApiTokenCreate(db, "user_2", {
|
|
name: "User 2 Token",
|
|
scopes: ["content:read"],
|
|
});
|
|
|
|
const result = await handleApiTokenList(db, "user_1");
|
|
expect(result.data!.items).toHaveLength(1);
|
|
expect(result.data!.items[0].name).toBe("User 1 Token");
|
|
});
|
|
|
|
it("never returns the token hash", async () => {
|
|
await handleApiTokenCreate(db, "user_1", {
|
|
name: "Test",
|
|
scopes: ["content:read"],
|
|
});
|
|
|
|
const result = await handleApiTokenList(db, "user_1");
|
|
const item = result.data!.items[0];
|
|
|
|
// Ensure no hash or raw token is exposed
|
|
expect(item).not.toHaveProperty("token_hash");
|
|
expect(item).not.toHaveProperty("tokenHash");
|
|
expect(item).not.toHaveProperty("token");
|
|
});
|
|
});
|
|
|
|
describe("handleApiTokenRevoke", () => {
|
|
it("revokes a token", async () => {
|
|
const createResult = await handleApiTokenCreate(db, "user_1", {
|
|
name: "To Revoke",
|
|
scopes: ["content:read"],
|
|
});
|
|
const tokenId = createResult.data!.info.id;
|
|
|
|
const result = await handleApiTokenRevoke(db, tokenId, "user_1");
|
|
expect(result.success).toBe(true);
|
|
|
|
// Should be gone from the list
|
|
const list = await handleApiTokenList(db, "user_1");
|
|
expect(list.data!.items).toHaveLength(0);
|
|
});
|
|
|
|
it("returns error for non-existent token", async () => {
|
|
const result = await handleApiTokenRevoke(db, "nonexistent", "user_1");
|
|
expect(result.success).toBe(false);
|
|
expect(result.error!.code).toBe("NOT_FOUND");
|
|
});
|
|
|
|
it("cannot revoke another user's token", async () => {
|
|
await db
|
|
.insertInto("users")
|
|
.values({
|
|
id: "user_2",
|
|
email: "other@test.com",
|
|
name: "Other",
|
|
role: 50,
|
|
email_verified: 1,
|
|
})
|
|
.execute();
|
|
|
|
const createResult = await handleApiTokenCreate(db, "user_1", {
|
|
name: "User 1 Token",
|
|
scopes: ["content:read"],
|
|
});
|
|
const tokenId = createResult.data!.info.id;
|
|
|
|
// User 2 tries to revoke user 1's token
|
|
const result = await handleApiTokenRevoke(db, tokenId, "user_2");
|
|
expect(result.success).toBe(false);
|
|
expect(result.error!.code).toBe("NOT_FOUND");
|
|
|
|
// Token should still exist
|
|
const list = await handleApiTokenList(db, "user_1");
|
|
expect(list.data!.items).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("resolveApiToken", () => {
|
|
it("resolves a valid token to user and scopes", async () => {
|
|
const createResult = await handleApiTokenCreate(db, "user_1", {
|
|
name: "Test",
|
|
scopes: ["content:read", "media:write"],
|
|
});
|
|
const rawToken = createResult.data!.token;
|
|
|
|
const resolved = await resolveApiToken(db, rawToken);
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved!.userId).toBe("user_1");
|
|
expect(resolved!.scopes).toEqual(["content:read", "media:write"]);
|
|
});
|
|
|
|
it("returns null for invalid token", async () => {
|
|
const resolved = await resolveApiToken(db, "ec_pat_invalidtoken123");
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it("returns null for expired token", async () => {
|
|
const pastDate = new Date(Date.now() - 86400000).toISOString(); // Yesterday
|
|
const createResult = await handleApiTokenCreate(db, "user_1", {
|
|
name: "Expired",
|
|
scopes: ["content:read"],
|
|
expiresAt: pastDate,
|
|
});
|
|
const rawToken = createResult.data!.token;
|
|
|
|
const resolved = await resolveApiToken(db, rawToken);
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it("resolves non-expired token", async () => {
|
|
const futureDate = new Date(Date.now() + 86400000).toISOString(); // Tomorrow
|
|
const createResult = await handleApiTokenCreate(db, "user_1", {
|
|
name: "Future",
|
|
scopes: ["admin"],
|
|
expiresAt: futureDate,
|
|
});
|
|
const rawToken = createResult.data!.token;
|
|
|
|
const resolved = await resolveApiToken(db, rawToken);
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved!.scopes).toEqual(["admin"]);
|
|
});
|
|
});
|
|
|
|
describe("resolveOAuthToken", () => {
|
|
it("resolves a valid OAuth access token", async () => {
|
|
// Insert directly since we don't have a Device Flow handler yet
|
|
const { raw, hash } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_ACCESS);
|
|
const futureDate = new Date(Date.now() + 3600000).toISOString();
|
|
|
|
await db
|
|
.insertInto("_emdash_oauth_tokens")
|
|
.values({
|
|
token_hash: hash,
|
|
token_type: "access",
|
|
user_id: "user_1",
|
|
scopes: JSON.stringify(["content:read"]),
|
|
client_type: "cli",
|
|
expires_at: futureDate,
|
|
})
|
|
.execute();
|
|
|
|
const resolved = await resolveOAuthToken(db, raw);
|
|
expect(resolved).not.toBeNull();
|
|
expect(resolved!.userId).toBe("user_1");
|
|
expect(resolved!.scopes).toEqual(["content:read"]);
|
|
});
|
|
|
|
it("returns null for expired OAuth token", async () => {
|
|
const { raw, hash } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_ACCESS);
|
|
const pastDate = new Date(Date.now() - 3600000).toISOString();
|
|
|
|
await db
|
|
.insertInto("_emdash_oauth_tokens")
|
|
.values({
|
|
token_hash: hash,
|
|
token_type: "access",
|
|
user_id: "user_1",
|
|
scopes: JSON.stringify(["content:read"]),
|
|
client_type: "cli",
|
|
expires_at: pastDate,
|
|
})
|
|
.execute();
|
|
|
|
const resolved = await resolveOAuthToken(db, raw);
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it("does not resolve refresh tokens", async () => {
|
|
const { raw, hash } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_REFRESH);
|
|
const futureDate = new Date(Date.now() + 3600000).toISOString();
|
|
|
|
await db
|
|
.insertInto("_emdash_oauth_tokens")
|
|
.values({
|
|
token_hash: hash,
|
|
token_type: "refresh",
|
|
user_id: "user_1",
|
|
scopes: JSON.stringify(["content:read"]),
|
|
client_type: "cli",
|
|
expires_at: futureDate,
|
|
})
|
|
.execute();
|
|
|
|
const resolved = await resolveOAuthToken(db, raw);
|
|
expect(resolved).toBeNull();
|
|
});
|
|
});
|