117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
import { strict as assert } from 'node:assert';
|
|
import { describe, it } from 'node:test';
|
|
import type { Principal } from '../src/lib/auth';
|
|
import {
|
|
assertApiKeyActive,
|
|
AuthError,
|
|
hashApiKey,
|
|
requireCapability,
|
|
} from '../src/lib/auth';
|
|
import {
|
|
apiKeyCreationResponse,
|
|
apiKeyCreateSchema,
|
|
apiKeyMetadata,
|
|
generateApiKey,
|
|
resolveApiKeyTarget,
|
|
} from '../src/routes/api-keys';
|
|
|
|
const userId = '00000000-0000-0000-0000-000000000001';
|
|
const otherUserId = '00000000-0000-0000-0000-000000000002';
|
|
|
|
function principal(overrides: Partial<Principal> = {}): Principal {
|
|
return {
|
|
userId,
|
|
email: 'seller@example.com',
|
|
name: 'Seller',
|
|
isPlatformAdmin: false,
|
|
teams: [{ team: 'demand', role: 'member' }],
|
|
via: 'jwt',
|
|
scopes: ['read', 'write'],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function storedKey(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: '00000000-0000-0000-0000-000000000010',
|
|
userId,
|
|
name: 'Codex',
|
|
keyHash: 'stored-hash',
|
|
keyPrefix: 'pig_abc123',
|
|
scopes: ['read'],
|
|
lastUsedAt: null,
|
|
expiresAt: null,
|
|
revokedAt: null,
|
|
createdAt: new Date('2026-08-12T12:00:00.000Z'),
|
|
...overrides,
|
|
} as Parameters<typeof apiKeyMetadata>[0];
|
|
}
|
|
|
|
describe('API key lifecycle decisions', () => {
|
|
it('stores only a SHA-256 hash and returns plaintext only from creation', () => {
|
|
const generated = generateApiKey();
|
|
assert.match(generated.key, /^pig_[A-Za-z0-9_-]{43}$/);
|
|
assert.equal(generated.keyHash, hashApiKey(generated.key));
|
|
assert.equal(generated.keyHash.length, 64);
|
|
assert.equal(generated.keyPrefix, generated.key.slice(0, 10));
|
|
|
|
const row = storedKey({ keyHash: generated.keyHash, keyPrefix: generated.keyPrefix });
|
|
const created = apiKeyCreationResponse(row, generated.key);
|
|
const listed = apiKeyMetadata(row);
|
|
assert.equal(created.key, generated.key);
|
|
assert.equal('key' in listed, false);
|
|
assert.equal('keyHash' in listed, false);
|
|
});
|
|
|
|
it('allows read-only or read/write keys, never write-only keys', () => {
|
|
assert.deepEqual(apiKeyCreateSchema.parse({ name: 'Reader' }).scopes, ['read']);
|
|
assert.deepEqual(
|
|
apiKeyCreateSchema.parse({ name: 'Writer', scopes: ['read', 'write'] }).scopes,
|
|
['read', 'write'],
|
|
);
|
|
assert.equal(apiKeyCreateSchema.safeParse({ name: 'Writer', scopes: ['write'] }).success, false);
|
|
assert.equal(
|
|
apiKeyCreateSchema.safeParse({ name: 'Duplicate', scopes: ['read', 'read'] }).success,
|
|
false,
|
|
);
|
|
|
|
assert.throws(
|
|
() => requireCapability(principal({ via: 'api_key', scopes: ['read'] }), 'deal:write', 'demand'),
|
|
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_scope',
|
|
);
|
|
assert.doesNotThrow(() =>
|
|
requireCapability(
|
|
principal({ via: 'api_key', scopes: ['read', 'write'] }),
|
|
'deal:write',
|
|
'demand',
|
|
),
|
|
);
|
|
});
|
|
|
|
it('rejects a revoked key immediately while leaving an active key usable', () => {
|
|
const now = new Date('2026-08-12T12:00:00.000Z');
|
|
assert.doesNotThrow(() => assertApiKeyActive({ revokedAt: null, expiresAt: null }, now));
|
|
assert.throws(
|
|
() => assertApiKeyActive({ revokedAt: new Date('2026-08-12T11:59:00.000Z'), expiresAt: null }, now),
|
|
(error: unknown) => error instanceof AuthError && error.code === 'revoked_key',
|
|
);
|
|
});
|
|
|
|
it('keeps self-service personal, with explicit platform-admin cross-user access', () => {
|
|
assert.equal(resolveApiKeyTarget(principal()), userId);
|
|
assert.throws(
|
|
() => resolveApiKeyTarget(principal(), otherUserId),
|
|
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
|
);
|
|
assert.equal(
|
|
resolveApiKeyTarget(principal({ isPlatformAdmin: true }), otherUserId),
|
|
otherUserId,
|
|
);
|
|
assert.throws(
|
|
() => resolveApiKeyTarget(principal({ via: 'api_key' })),
|
|
(error: unknown) =>
|
|
error instanceof AuthError && error.code === 'credential_management_forbidden',
|
|
);
|
|
});
|
|
});
|