Files
hermes-control-panel/test/api-users.integration.test.cjs
ZachariahSharma de6b466df7 feat: add managed api users and keys
Implements lib/api-users-store.cjs with full CRUD + auth, 7 admin REST
endpoints in server.cjs, and integration tests for all store functions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 01:47:54 -06:00

189 lines
7.6 KiB
JavaScript

"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const { withTestDatabase } = require("./helpers/db-test.cjs")
const { runMigrations } = require("../lib/db.cjs")
const {
createApiUser,
listApiUsers,
updateApiUser,
rotateApiUserKey,
revokeApiUser,
reactivateApiUser,
deleteApiUser,
authenticateApiKey,
} = require("../lib/api-users-store.cjs")
const VALID_INPUT = {
displayName: "Test User",
allowPre: true,
allowPost: false,
requestsPerMinute: 60,
monthlyTokenLimit: 100000,
expiresAt: null,
}
test("api-users store", { timeout: 30000 }, async (t) => {
await withTestDatabase(t, async ({ pool }) => {
await runMigrations(pool)
await t.test("createApiUser returns plaintext key matching /^hms_/ and user with keySuffix", async () => {
const result = await createApiUser(pool, VALID_INPUT)
assert.ok(result.plaintextKey, "should have plaintextKey")
assert.match(result.plaintextKey, /^hms_/, "key should start with hms_")
assert.ok(result.user, "should have user")
assert.ok(result.user.id, "user should have id")
assert.equal(result.user.display_name, VALID_INPUT.displayName)
assert.equal(result.user.status, "active")
assert.ok(result.user.keySuffix, "user should have keySuffix")
assert.equal(result.user.keySuffix, result.plaintextKey.slice(-4))
})
await t.test("listApiUsers does not include plaintextKey", async () => {
const users = await listApiUsers(pool)
assert.ok(Array.isArray(users), "should return array")
assert.ok(users.length >= 1, "should have at least one user")
for (const u of users) {
assert.equal(u.plaintextKey, undefined, "should not expose plaintextKey")
assert.equal(u.key_hash, undefined, "should not expose key_hash")
assert.ok(u.key_suffix, "should have key_suffix from api_keys")
}
})
await t.test("updateApiUser updates allowed fields", async () => {
const created = await createApiUser(pool, VALID_INPUT)
const updated = await updateApiUser(pool, created.user.id, {
displayName: "Updated Name",
requestsPerMinute: 120,
})
assert.equal(updated.display_name, "Updated Name")
assert.equal(updated.requests_per_minute, 120)
// unchanged fields stay
assert.equal(updated.monthly_token_limit, VALID_INPUT.monthlyTokenLimit)
})
await t.test("rotateApiUserKey invalidates old key and returns new plaintext key", async () => {
const created = await createApiUser(pool, VALID_INPUT)
const oldKey = created.plaintextKey
const rotated = await rotateApiUserKey(pool, created.user.id)
assert.ok(rotated.plaintextKey, "should have new plaintextKey")
assert.notEqual(rotated.plaintextKey, oldKey, "new key should differ from old key")
assert.match(rotated.plaintextKey, /^hms_/, "new key should start with hms_")
// Old key should be invalid
const oldAuth = await authenticateApiKey(pool, oldKey, "pre")
assert.equal(oldAuth.ok, false)
assert.equal(oldAuth.reason, "revoked")
// New key should work
const newAuth = await authenticateApiKey(pool, rotated.plaintextKey, "pre")
assert.equal(newAuth.ok, true)
})
await t.test("revokeApiUser prevents authentication (returns reason: 'revoked')", async () => {
const created = await createApiUser(pool, VALID_INPUT)
await revokeApiUser(pool, created.user.id)
const authResult = await authenticateApiKey(pool, created.plaintextKey, "pre")
assert.equal(authResult.ok, false)
assert.equal(authResult.reason, "revoked")
})
await t.test("reactivateApiUser after revoke allows auth again", async () => {
const created = await createApiUser(pool, VALID_INPUT)
await revokeApiUser(pool, created.user.id)
const reactivated = await reactivateApiUser(pool, created.user.id)
assert.ok(reactivated.plaintextKey, "should have new plaintextKey after reactivation")
assert.equal(reactivated.user.status, "active")
assert.equal(reactivated.user.revoked_at, null)
const authResult = await authenticateApiKey(pool, reactivated.plaintextKey, "pre")
assert.equal(authResult.ok, true)
})
await t.test("deleteApiUser prevents authentication (returns reason: 'revoked' since key is revoked)", async () => {
const created = await createApiUser(pool, VALID_INPUT)
await deleteApiUser(pool, created.user.id)
const authResult = await authenticateApiKey(pool, created.plaintextKey, "pre")
assert.equal(authResult.ok, false)
// key is revoked on delete, so reason is 'revoked'
assert.equal(authResult.reason, "revoked")
// deleted user should not appear in list
const users = await listApiUsers(pool)
const found = users.find((u) => u.id === created.user.id)
assert.equal(found, undefined, "deleted user should not appear in list")
})
await t.test("authenticateApiKey returns reason: 'invalid' for unknown key", async () => {
const result = await authenticateApiKey(pool, "hms_unknownkeyvalue1234567890", "pre")
assert.equal(result.ok, false)
assert.equal(result.reason, "invalid")
})
await t.test("authenticateApiKey returns reason: 'expired' for expired user", async () => {
const pastDate = new Date(Date.now() - 1000) // 1 second in the past
const created = await createApiUser(pool, {
...VALID_INPUT,
displayName: "Expired User",
expiresAt: pastDate,
})
// Need to insert with already-past expiresAt — bypass future validation by patching directly
// Actually createApiUser validates expiresAt must be in the future.
// So we create normally then update expires_at directly via pool.
const created2 = await createApiUser(pool, { ...VALID_INPUT, displayName: "To Expire" })
await pool.query(
"UPDATE api_users SET expires_at = $1 WHERE id = $2",
[new Date(Date.now() - 10000), created2.user.id]
)
const result = await authenticateApiKey(pool, created2.plaintextKey, "pre")
assert.equal(result.ok, false)
assert.equal(result.reason, "expired")
})
await t.test("authenticateApiKey returns reason: 'forbidden' when route not permitted", async () => {
const postOnlyInput = {
...VALID_INPUT,
allowPre: false,
allowPost: true,
}
const created = await createApiUser(pool, postOnlyInput)
// 'pre' route should be forbidden
const result = await authenticateApiKey(pool, created.plaintextKey, "pre")
assert.equal(result.ok, false)
assert.equal(result.reason, "forbidden")
// 'post' route should succeed
const result2 = await authenticateApiKey(pool, created.plaintextKey, "post")
assert.equal(result2.ok, true)
})
await t.test("at most one active key per user (unique index enforced by DB)", async () => {
const created = await createApiUser(pool, { ...VALID_INPUT, displayName: "Unique Key Test" })
// Attempt to insert a second active key directly — should violate unique partial index
const { createApiKey } = require("../lib/security.cjs")
const newKey = createApiKey()
const crypto = require("crypto")
const keyId = crypto.randomUUID()
await assert.rejects(
async () => {
await pool.query(
"INSERT INTO api_keys (id, api_user_id, key_hash, key_suffix) VALUES ($1, $2, $3, $4)",
[keyId, created.user.id, newKey.hash, newKey.suffix]
)
},
/unique/i,
"inserting a second active key should violate the unique partial index"
)
})
})
})