fix(tokens): issue machine-ready defaults
This commit is contained in:
parent
eac633e1e7
commit
d1b3faae8b
@ -57,6 +57,7 @@ describe('tokenRoutes', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
delete process.env.PLATFORM_RUNTIME_ENV;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /tokens returns 401 when unauthenticated', async () => {
|
it('GET /tokens returns 401 when unauthenticated', async () => {
|
||||||
@ -121,13 +122,70 @@ describe('tokenRoutes', () => {
|
|||||||
expect(data.rawToken.startsWith('wai_')).toBe(true);
|
expect(data.rawToken.startsWith('wai_')).toBe(true);
|
||||||
expect(repoMock.create).toHaveBeenCalledWith(
|
expect(repoMock.create).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
tokenType: 'user_api',
|
tokenType: 'product_api',
|
||||||
environment: 'prod',
|
environment: 'dev',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(repoMock.hashToken).toHaveBeenCalled();
|
expect(repoMock.hashToken).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /tokens respects explicit environment overrides', async () => {
|
||||||
|
repoMock.hashToken.mockResolvedValue('hashed_token');
|
||||||
|
repoMock.create.mockResolvedValue(baseToken);
|
||||||
|
const app = await buildApp({
|
||||||
|
sub: 'admin_1',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
role: 'admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/tokens',
|
||||||
|
payload: {
|
||||||
|
name: 'staging-token',
|
||||||
|
tokenType: 'service_api',
|
||||||
|
environment: 'staging',
|
||||||
|
scopes: ['jobs:read'],
|
||||||
|
expiresInDays: 30,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(repoMock.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
tokenType: 'service_api',
|
||||||
|
environment: 'staging',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /tokens defaults environment from runtime configuration', async () => {
|
||||||
|
process.env.PLATFORM_RUNTIME_ENV = 'staging';
|
||||||
|
repoMock.hashToken.mockResolvedValue('hashed_token');
|
||||||
|
repoMock.create.mockResolvedValue(baseToken);
|
||||||
|
const app = await buildApp({
|
||||||
|
sub: 'admin_1',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
role: 'admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/tokens',
|
||||||
|
payload: { name: 'runtime-default-token', scopes: ['jobs:read'], expiresInDays: 30 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(repoMock.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
tokenType: 'product_api',
|
||||||
|
environment: 'staging',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /tokens/count returns count for admin', async () => {
|
it('GET /tokens/count returns count for admin', async () => {
|
||||||
repoMock.countActive.mockResolvedValue(4);
|
repoMock.countActive.mockResolvedValue(4);
|
||||||
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|||||||
@ -14,6 +14,22 @@ import { BadRequestError, ForbiddenError, UnauthorizedError } from '../../lib/er
|
|||||||
import { CreateTokenSchema, PatchTokenSchema, type ApiTokenDoc } from './types.js';
|
import { CreateTokenSchema, PatchTokenSchema, type ApiTokenDoc } from './types.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
|
||||||
|
function getRuntimeEnvironment(): 'dev' | 'staging' | 'prod' {
|
||||||
|
const explicit = process.env.PLATFORM_RUNTIME_ENV;
|
||||||
|
if (explicit === 'dev' || explicit === 'staging' || explicit === 'prod') {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (process.env.NODE_ENV) {
|
||||||
|
case 'production':
|
||||||
|
return 'prod';
|
||||||
|
case 'test':
|
||||||
|
return 'dev';
|
||||||
|
default:
|
||||||
|
return 'dev';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function tokenRoutes(app: FastifyInstance) {
|
export async function tokenRoutes(app: FastifyInstance) {
|
||||||
function requireAuth(req: import('fastify').FastifyRequest) {
|
function requireAuth(req: import('fastify').FastifyRequest) {
|
||||||
const payload = req.jwtPayload;
|
const payload = req.jwtPayload;
|
||||||
@ -51,6 +67,7 @@ export async function tokenRoutes(app: FastifyInstance) {
|
|||||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||||
}
|
}
|
||||||
const { name, tokenType, environment, scopes, expiresInDays } = parsed.data;
|
const { name, tokenType, environment, scopes, expiresInDays } = parsed.data;
|
||||||
|
const resolvedEnvironment = environment ?? getRuntimeEnvironment();
|
||||||
|
|
||||||
const rawToken = `wai_${crypto.randomBytes(32).toString('hex')}`;
|
const rawToken = `wai_${crypto.randomBytes(32).toString('hex')}`;
|
||||||
const prefix = rawToken.slice(0, 12);
|
const prefix = rawToken.slice(0, 12);
|
||||||
@ -65,7 +82,7 @@ export async function tokenRoutes(app: FastifyInstance) {
|
|||||||
userName: payload.email ?? '',
|
userName: payload.email ?? '',
|
||||||
name,
|
name,
|
||||||
tokenType,
|
tokenType,
|
||||||
environment,
|
environment: resolvedEnvironment,
|
||||||
prefix,
|
prefix,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
|||||||
@ -12,8 +12,8 @@ describe('CreateTokenSchema', () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(result.data.name).toBe('CI Pipeline');
|
expect(result.data.name).toBe('CI Pipeline');
|
||||||
expect(result.data.tokenType).toBe('user_api');
|
expect(result.data.tokenType).toBe('product_api');
|
||||||
expect(result.data.environment).toBe('prod');
|
expect(result.data.environment).toBeUndefined();
|
||||||
expect(result.data.scopes).toEqual(['read']);
|
expect(result.data.scopes).toEqual(['read']);
|
||||||
expect(result.data.expiresInDays).toBe(90);
|
expect(result.data.expiresInDays).toBe(90);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,8 +26,8 @@ export type ApiTokenResponse = Omit<ApiTokenDoc, 'tokenHash'>;
|
|||||||
|
|
||||||
export const CreateTokenSchema = z.object({
|
export const CreateTokenSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
tokenType: z.enum(['user_api', 'product_api', 'service_api']).default('user_api'),
|
tokenType: z.enum(['user_api', 'product_api', 'service_api']).default('product_api'),
|
||||||
environment: z.enum(['dev', 'staging', 'prod']).default('prod'),
|
environment: z.enum(['dev', 'staging', 'prod']).optional(),
|
||||||
scopes: z.array(z.string()).default(['read']),
|
scopes: z.array(z.string()).default(['read']),
|
||||||
expiresInDays: z.number().int().min(1).max(365).default(90),
|
expiresInDays: z.number().int().min(1).max(365).default(90),
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user