feat(tokens): classify machine credentials

This commit is contained in:
root 2026-03-15 06:09:42 +00:00
parent 3398d1400f
commit 507f0fdd1f
6 changed files with 36 additions and 3 deletions

View File

@ -14,6 +14,8 @@ const baseToken: ApiTokenDoc = {
userId: 'user_1',
userName: 'Test User',
name: 'Test Token',
tokenType: 'user_api',
environment: 'prod',
tokenHash: 'hash_abc',
prefix: 'lysnr_',
status: 'active',

View File

@ -23,6 +23,8 @@ const baseToken = {
userId: 'admin_1',
userName: 'admin@example.com',
name: 'ci-token',
tokenType: 'service_api',
environment: 'prod',
prefix: 'wai_12345678',
status: 'active',
scopes: ['read'],
@ -117,6 +119,12 @@ describe('tokenRoutes', () => {
const data = JSON.parse(res.body);
expect(data).toHaveProperty('rawToken');
expect(data.rawToken.startsWith('wai_')).toBe(true);
expect(repoMock.create).toHaveBeenCalledWith(
expect.objectContaining({
tokenType: 'user_api',
environment: 'prod',
})
);
expect(repoMock.hashToken).toHaveBeenCalled();
});

View File

@ -50,7 +50,7 @@ export async function tokenRoutes(app: FastifyInstance) {
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { name, scopes, expiresInDays } = parsed.data;
const { name, tokenType, environment, scopes, expiresInDays } = parsed.data;
const rawToken = `wai_${crypto.randomBytes(32).toString('hex')}`;
const prefix = rawToken.slice(0, 12);
@ -64,6 +64,8 @@ export async function tokenRoutes(app: FastifyInstance) {
userId: payload.sub,
userName: payload.email ?? '',
name,
tokenType,
environment,
prefix,
tokenHash,
status: 'active',

View File

@ -12,6 +12,8 @@ describe('CreateTokenSchema', () => {
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('CI Pipeline');
expect(result.data.tokenType).toBe('user_api');
expect(result.data.environment).toBe('prod');
expect(result.data.scopes).toEqual(['read']);
expect(result.data.expiresInDays).toBe(90);
}
@ -20,11 +22,15 @@ describe('CreateTokenSchema', () => {
it('accepts full input', () => {
const result = CreateTokenSchema.safeParse({
name: 'Deploy Key',
tokenType: 'service_api',
environment: 'staging',
scopes: ['read', 'write', 'admin'],
expiresInDays: 30,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.tokenType).toBe('service_api');
expect(result.data.environment).toBe('staging');
expect(result.data.scopes).toEqual(['read', 'write', 'admin']);
expect(result.data.expiresInDays).toBe(30);
}
@ -102,6 +108,8 @@ describe('ApiTokenDoc type shape', () => {
userId: 'user_1',
userName: 'admin@example.com',
name: 'CI Token',
tokenType: 'service_api',
environment: 'prod',
prefix: 'wai_abc12345',
tokenHash: '$2a$10$hashedvalue',
status: 'active',
@ -113,6 +121,8 @@ describe('ApiTokenDoc type shape', () => {
};
expect(doc.status).toBe('active');
expect(doc.tokenType).toBe('service_api');
expect(doc.environment).toBe('prod');
expect(doc.scopes).toContain('read');
expect(doc.lastUsed).toBeNull();
});
@ -126,6 +136,8 @@ describe('ApiTokenDoc type shape', () => {
userId: 'u1',
userName: 'test',
name: 'test',
tokenType: 'user_api',
environment: 'prod',
prefix: 'wai_',
tokenHash: 'hash',
status,
@ -147,6 +159,8 @@ describe('ApiTokenResponse type shape', () => {
userId: 'u1',
userName: 'test@test.com',
name: 'My Token',
tokenType: 'product_api',
environment: 'staging',
prefix: 'wai_abc',
tokenHash: 'secret_hash',
status: 'active',
@ -157,11 +171,14 @@ describe('ApiTokenResponse type shape', () => {
};
// Simulate what stripHash does
const { tokenHash: _hash, ...response } = doc;
const { tokenHash: _tokenHash, ...response } = doc;
void _tokenHash;
const apiResponse: ApiTokenResponse = response;
expect(apiResponse.id).toBe('tok_1');
expect(apiResponse.name).toBe('My Token');
expect(apiResponse.tokenType).toBe('product_api');
expect(apiResponse.environment).toBe('staging');
expect('tokenHash' in apiResponse).toBe(false);
});
});

View File

@ -10,6 +10,8 @@ export interface ApiTokenDoc {
userId: string;
userName: string;
name: string;
tokenType: 'user_api' | 'product_api' | 'service_api';
environment: 'dev' | 'staging' | 'prod';
prefix: string;
tokenHash: string;
status: 'active' | 'revoked' | 'expired';
@ -24,6 +26,8 @@ export type ApiTokenResponse = Omit<ApiTokenDoc, 'tokenHash'>;
export const CreateTokenSchema = z.object({
name: z.string().min(1),
tokenType: z.enum(['user_api', 'product_api', 'service_api']).default('user_api'),
environment: z.enum(['dev', 'staging', 'prod']).default('prod'),
scopes: z.array(z.string()).default(['read']),
expiresInDays: z.number().int().min(1).max(365).default(90),
});

View File

@ -101,7 +101,7 @@ describe('server bootstrap', () => {
expect(appMock.register).toHaveBeenCalled();
expect(appMock.register.mock.calls.length).toBeGreaterThan(15);
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4003, host: '0.0.0.0' });
});
}, 15000);
it('registers optional jwt parsing with the shared fastify-core helper', async () => {
await import('./server.js');