de6b466df7
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>
189 lines
7.6 KiB
JavaScript
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"
|
|
)
|
|
})
|
|
})
|
|
})
|