wacrm/src/lib/api-keys/keys.test.ts
Arnas Donauskas 456321a2f2
feat(api): public API groundwork — scoped API keys + /api/v1/me
Adds the machine-to-machine auth layer the public REST API needs so
external scripts and automations can drive wacrm (issue #245). This is
the groundwork release; data endpoints land one at a time on top of it.

- Migration 026: account-scoped `api_keys` table. SHA-256 hash at rest
  (plaintext shown once), display `key_prefix`, `scopes[]`, RLS via
  `is_account_member` (members read, admin+ manage).
- `src/lib/api-keys/`: key gen/hash (wacrm_live_ prefix, timing-safe),
  scope vocabulary, service-role auth-path store.
- `requireApiKey(request, scope?)` (src/lib/auth/api-context.ts):
  bearer parse -> hash lookup -> liveness -> per-key rate limit ->
  scopes-only authz. Returns a service-role-scoped account context.
- `/api/v1` envelope ({data} / {error:{code,message}}) + GET /api/v1/me
  reference endpoint that exercises the whole stack.
- Dashboard: admin+ key management routes + Settings -> API keys tab.
- Docs (docs/public-api.md), CHANGELOG, README, .env note.

Checks: typecheck clean, lint 0 errors, full suite green (+27 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:35:43 +03:00

72 lines
2.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
API_KEY_PREFIX,
generateApiKey,
hashApiKey,
looksLikeApiKey,
timingSafeHexEqual,
} from './keys';
describe('generateApiKey', () => {
it('returns a prefixed plaintext, a hash, and a display prefix', () => {
const { plaintext, hash, prefix } = generateApiKey();
expect(plaintext.startsWith(API_KEY_PREFIX)).toBe(true);
expect(plaintext.length).toBeGreaterThan(API_KEY_PREFIX.length + 20);
// SHA-256 hex is 64 chars.
expect(hash).toMatch(/^[0-9a-f]{64}$/);
// Display prefix is the literal prefix + 8 body chars.
expect(prefix.startsWith(API_KEY_PREFIX)).toBe(true);
expect(prefix.length).toBe(API_KEY_PREFIX.length + 8);
// The display prefix is a true prefix of the plaintext.
expect(plaintext.startsWith(prefix)).toBe(true);
});
it('never repeats a key (entropy sanity check)', () => {
const seen = new Set<string>();
for (let i = 0; i < 200; i++) seen.add(generateApiKey().plaintext);
expect(seen.size).toBe(200);
});
it('hash matches an independent hashApiKey of the plaintext', () => {
const { plaintext, hash } = generateApiKey();
expect(hashApiKey(plaintext)).toBe(hash);
});
});
describe('hashApiKey', () => {
it('is deterministic', () => {
expect(hashApiKey('wacrm_live_abc')).toBe(hashApiKey('wacrm_live_abc'));
});
it('differs for different inputs', () => {
expect(hashApiKey('wacrm_live_abc')).not.toBe(hashApiKey('wacrm_live_abd'));
});
});
describe('looksLikeApiKey', () => {
it('accepts a well-formed key', () => {
expect(looksLikeApiKey(generateApiKey().plaintext)).toBe(true);
});
it('rejects the bare prefix, empty, and foreign tokens', () => {
expect(looksLikeApiKey(API_KEY_PREFIX)).toBe(false);
expect(looksLikeApiKey('')).toBe(false);
expect(looksLikeApiKey('some-invite-token')).toBe(false);
});
});
describe('timingSafeHexEqual', () => {
it('is true for identical digests', () => {
const h = hashApiKey('wacrm_live_xyz');
expect(timingSafeHexEqual(h, h)).toBe(true);
});
it('is false for different digests', () => {
expect(timingSafeHexEqual(hashApiKey('a'), hashApiKey('b'))).toBe(false);
});
it('is false (not throwing) on length mismatch', () => {
expect(timingSafeHexEqual('ab', 'abcd')).toBe(false);
});
});