Files
gitea-mirror/src/lib/api-keys.test.ts
T
ARUNAVO RAY 83dd74dd52 feat(auth): API keys for programmatic access (#380)
* feat(auth): API keys for programmatic access

Adds the @better-auth/api-key plugin so scripts and CI pipelines can call
the existing endpoints with an x-api-key header instead of a session
cookie. Keys are owned by a user, hashed at rest, prefixed gm_, never
expire unless an expiry is chosen, and are not rate limited. A small
guard plugin refuses key management calls that arrive with a key, so a
leaked key cannot mint or revoke keys.

New API Keys section on the Authentication tab with create, show once,
copy and revoke. Migration 0017 adds the api_keys table with the
validator fixture. docs/API.md documents the header and the calls
automation needs. An e2e spec covers create, use, refuse and revoke over
HTTP. bun.nix regenerated for the new package.

Closes #314

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX

* fix(e2e): send Origin on cookie-authenticated key management calls

Better Auth rejects a cookie-authenticated POST without an Origin header (403 MISSING_OR_NULL_ORIGIN). Browsers always send one, the Playwright request context does not, so the spec sets it on the create and delete calls. Also asserts the guard's 403 code and documents the Origin requirement for scripts that manage keys with a session.

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
2026-09-02 14:57:58 +05:30

69 lines
2.9 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
API_KEY_EXPIRY_OPTIONS,
API_KEY_LENGTH,
API_KEY_NAME_MAX_LENGTH,
API_KEY_PREFIX,
describeKeyExpiry,
expiryOptionToSeconds,
isKeyExpired,
maskKeyStart,
normalizeKeyName,
} from "./api-keys";
const DAY_MS = 24 * 60 * 60 * 1000;
describe("api-keys helpers", () => {
test("prefix ends with an underscore and the key length matches the plugin floor", () => {
expect(API_KEY_PREFIX.endsWith("_")).toBe(true);
expect(API_KEY_LENGTH).toBe(64);
});
test("expiry options map to seconds, never maps to undefined", () => {
expect(expiryOptionToSeconds("never")).toBeUndefined();
expect(expiryOptionToSeconds("30d")).toBe(30 * 24 * 60 * 60);
expect(expiryOptionToSeconds("90d")).toBe(90 * 24 * 60 * 60);
expect(expiryOptionToSeconds("1y")).toBe(365 * 24 * 60 * 60);
});
test("no option exceeds the plugin's 365 day ceiling", () => {
for (const option of API_KEY_EXPIRY_OPTIONS) {
if (option.seconds === null) continue;
expect(option.seconds / (24 * 60 * 60)).toBeLessThanOrEqual(365);
}
});
test("describeKeyExpiry covers never, future, imminent, past and garbage", () => {
const now = new Date("2026-09-02T12:00:00Z");
expect(describeKeyExpiry(null, now)).toBe("Never");
expect(describeKeyExpiry(undefined, now)).toBe("Never");
expect(describeKeyExpiry(new Date(now.getTime() + 30 * DAY_MS), now)).toBe("In 30 days");
expect(describeKeyExpiry(new Date(now.getTime() + 3600 * 1000), now)).toBe("Within a day");
expect(describeKeyExpiry(new Date(now.getTime() - 1000), now)).toBe("Expired");
expect(describeKeyExpiry("not a date", now)).toBe("Unknown");
// ISO strings are what the list endpoint returns over JSON.
expect(describeKeyExpiry(new Date(now.getTime() + 2 * DAY_MS).toISOString(), now)).toBe("In 2 days");
});
test("isKeyExpired treats missing expiry as never expiring", () => {
const now = new Date("2026-09-02T12:00:00Z");
expect(isKeyExpired(null, now)).toBe(false);
expect(isKeyExpired(new Date(now.getTime() + DAY_MS), now)).toBe(false);
expect(isKeyExpired(new Date(now.getTime() - DAY_MS), now)).toBe(true);
});
test("maskKeyStart shows the stored start and falls back to the prefix", () => {
expect(maskKeyStart("gm_abc1234")).toBe("gm_abc1234••••••••");
expect(maskKeyStart(null)).toBe("gm_••••••••");
expect(maskKeyStart(" ")).toBe("gm_••••••••");
});
test("normalizeKeyName trims and enforces the plugin's name limit", () => {
expect(normalizeKeyName(" ci deploy ")).toEqual({ value: "ci deploy", error: null });
expect(normalizeKeyName(" ").error).toContain("name");
const long = "x".repeat(API_KEY_NAME_MAX_LENGTH + 1);
expect(normalizeKeyName(long).error).toContain(String(API_KEY_NAME_MAX_LENGTH));
expect(normalizeKeyName("x".repeat(API_KEY_NAME_MAX_LENGTH)).error).toBeNull();
});
});