- createAuthMiddleware(): RS256 JWKS + HS256 fallback (parameterized) - createRequestContext(): productId validation + getUserId() - Fastify request type augmentation for jwtPayload - 15 tests passing
146 lines
4.6 KiB
TypeScript
146 lines
4.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { SignJWT } from 'jose';
|
|
import { createAuthMiddleware, createRequestContext } from './index.js';
|
|
import type { JwtPayload } from './types.js';
|
|
|
|
const TEST_SECRET = 'test-jwt-secret-for-fastify-auth-package';
|
|
|
|
interface MockReq {
|
|
headers: Record<string, string | undefined>;
|
|
jwtPayload?: JwtPayload;
|
|
}
|
|
|
|
function makeReq(token?: string): MockReq {
|
|
return {
|
|
headers: {
|
|
authorization: token ? `Bearer ${token}` : undefined,
|
|
},
|
|
jwtPayload: undefined,
|
|
};
|
|
}
|
|
|
|
async function signToken(payload: Record<string, unknown>, secret = TEST_SECRET) {
|
|
return new SignJWT(payload)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuedAt()
|
|
.setExpirationTime('5m')
|
|
.sign(new TextEncoder().encode(secret));
|
|
}
|
|
|
|
describe('createAuthMiddleware', () => {
|
|
const { extractAuth, requireRole } = createAuthMiddleware({
|
|
jwtSecret: TEST_SECRET,
|
|
});
|
|
|
|
it('extracts auth payload from valid access token', async () => {
|
|
const token = await signToken({
|
|
sub: 'user-1',
|
|
email: 'test@test.com',
|
|
role: 'admin',
|
|
type: 'access',
|
|
});
|
|
const payload = await extractAuth(makeReq(token));
|
|
expect(payload.sub).toBe('user-1');
|
|
expect(payload.email).toBe('test@test.com');
|
|
expect(payload.role).toBe('admin');
|
|
});
|
|
|
|
it('throws UnauthorizedError when no Authorization header', async () => {
|
|
await expect(extractAuth(makeReq())).rejects.toThrow('Unauthorized');
|
|
});
|
|
|
|
it('throws UnauthorizedError for invalid token', async () => {
|
|
await expect(extractAuth(makeReq('bad-token'))).rejects.toThrow('Invalid or expired token');
|
|
});
|
|
|
|
it('throws UnauthorizedError for non-access token type', async () => {
|
|
const token = await signToken({ sub: 'u1', type: 'refresh' });
|
|
await expect(extractAuth(makeReq(token))).rejects.toThrow('Invalid or expired token');
|
|
});
|
|
|
|
it('throws UnauthorizedError for wrong secret', async () => {
|
|
const token = await signToken({ sub: 'u1', type: 'access' }, 'wrong');
|
|
await expect(extractAuth(makeReq(token))).rejects.toThrow('Invalid or expired token');
|
|
});
|
|
|
|
it('requireRole passes when role matches', async () => {
|
|
const token = await signToken({
|
|
sub: 'u1',
|
|
role: 'admin',
|
|
type: 'access',
|
|
});
|
|
const payload = await requireRole(makeReq(token), 'admin', 'superadmin');
|
|
expect(payload.sub).toBe('u1');
|
|
});
|
|
|
|
it('requireRole throws ForbiddenError when role does not match', async () => {
|
|
const token = await signToken({
|
|
sub: 'u1',
|
|
role: 'viewer',
|
|
type: 'access',
|
|
});
|
|
await expect(requireRole(makeReq(token), 'admin')).rejects.toThrow('Insufficient permissions');
|
|
});
|
|
|
|
it('requireRole passes with no required roles (any authenticated user)', async () => {
|
|
const token = await signToken({
|
|
sub: 'u1',
|
|
type: 'access',
|
|
});
|
|
const payload = await requireRole(makeReq(token));
|
|
expect(payload.sub).toBe('u1');
|
|
});
|
|
});
|
|
|
|
describe('createRequestContext', () => {
|
|
const { getRequestProductId, getUserId } = createRequestContext({
|
|
productId: 'testproduct',
|
|
});
|
|
|
|
function makeFastifyReq(overrides?: {
|
|
jwtPayload?: JwtPayload;
|
|
headers?: Record<string, string | undefined>;
|
|
}) {
|
|
const req = makeReq();
|
|
if (overrides?.jwtPayload !== undefined) req.jwtPayload = overrides.jwtPayload;
|
|
if (overrides?.headers) Object.assign(req.headers, overrides.headers);
|
|
return req as unknown as import('fastify').FastifyRequest;
|
|
}
|
|
|
|
it('returns product ID for valid request', () => {
|
|
expect(getRequestProductId(makeFastifyReq())).toBe('testproduct');
|
|
});
|
|
|
|
it('returns product ID when JWT productId matches', () => {
|
|
expect(
|
|
getRequestProductId(makeFastifyReq({ jwtPayload: { sub: 'u1', productId: 'testproduct' } }))
|
|
).toBe('testproduct');
|
|
});
|
|
|
|
it('throws BadRequestError when JWT productId does not match', () => {
|
|
expect(() =>
|
|
getRequestProductId(makeFastifyReq({ jwtPayload: { sub: 'u1', productId: 'wrong' } }))
|
|
).toThrow('Invalid productId');
|
|
});
|
|
|
|
it('throws BadRequestError when X-Product-Id header does not match', () => {
|
|
expect(() =>
|
|
getRequestProductId(makeFastifyReq({ headers: { 'x-product-id': 'wrong' } }))
|
|
).toThrow('Invalid productId');
|
|
});
|
|
|
|
it('getUserId returns sub from JWT payload', () => {
|
|
expect(getUserId(makeFastifyReq({ jwtPayload: { sub: 'user-42' } }))).toBe('user-42');
|
|
});
|
|
|
|
it('getUserId throws when no JWT payload', () => {
|
|
expect(() => getUserId(makeFastifyReq())).toThrow('Missing userId');
|
|
});
|
|
|
|
it('getUserId throws when JWT has no sub', () => {
|
|
expect(() => getUserId(makeFastifyReq({ jwtPayload: {} as JwtPayload }))).toThrow(
|
|
'Missing userId'
|
|
);
|
|
});
|
|
});
|