Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,309 @@
/**
* 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();
});
});