Admin settings fixed

This commit is contained in:
Eric Gullickson
2025-11-06 14:07:16 -06:00
parent 8174e0d5f9
commit 858cf31d38
3 changed files with 119 additions and 104 deletions

View File

@@ -3,7 +3,7 @@
* @ai-context Checks if authenticated user is an admin and enforces access control * @ai-context Checks if authenticated user is an admin and enforces access control
*/ */
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'; import { FastifyPluginAsync, FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
import fp from 'fastify-plugin'; import fp from 'fastify-plugin';
import { Pool } from 'pg'; import { Pool } from 'pg';
import { logger } from '../logging/logger'; import { logger } from '../logging/logger';
@@ -23,8 +23,21 @@ export function setAdminGuardPool(pool: Pool): void {
const adminGuardPlugin: FastifyPluginAsync = async (fastify) => { const adminGuardPlugin: FastifyPluginAsync = async (fastify) => {
// Decorate with requireAdmin function that enforces admin authorization // Decorate with requireAdmin function that enforces admin authorization
fastify.decorate('requireAdmin', async function(request: FastifyRequest, reply: FastifyReply) { fastify.decorate('requireAdmin', async function(this: FastifyInstance, request: FastifyRequest, reply: FastifyReply) {
try { try {
if (typeof this.authenticate !== 'function') {
logger.error('Admin guard: authenticate handler missing');
return reply.code(500).send({
error: 'Internal server error',
message: 'Authentication handler missing'
});
}
await this.authenticate(request, reply);
if (reply.sent) {
return;
}
// Ensure user is authenticated first // Ensure user is authenticated first
if (!request.userContext?.userId) { if (!request.userContext?.userId) {
logger.warn('Admin guard: user context missing'); logger.warn('Admin guard: user context missing');

View File

@@ -35,11 +35,15 @@ export class AdminController {
const userId = request.userContext?.userId; const userId = request.userContext?.userId;
const userEmail = this.resolveUserEmail(request); const userEmail = this.resolveUserEmail(request);
console.log('[DEBUG] Admin verify - userId:', userId);
console.log('[DEBUG] Admin verify - userEmail:', userEmail);
if (userEmail && request.userContext) { if (userEmail && request.userContext) {
request.userContext.email = userEmail.toLowerCase(); request.userContext.email = userEmail.toLowerCase();
} }
if (!userId && !userEmail) { if (!userId && !userEmail) {
console.log('[DEBUG] Admin verify - No userId or userEmail, returning 401');
return reply.code(401).send({ return reply.code(401).send({
error: 'Unauthorized', error: 'Unauthorized',
message: 'User context missing' message: 'User context missing'
@@ -50,15 +54,26 @@ export class AdminController {
? await this.adminService.getAdminByAuth0Sub(userId) ? await this.adminService.getAdminByAuth0Sub(userId)
: null; : null;
console.log('[DEBUG] Admin verify - adminRecord by auth0Sub:', adminRecord ? 'FOUND' : 'NOT FOUND');
// Fallback: attempt to resolve admin by email for legacy records // Fallback: attempt to resolve admin by email for legacy records
if (!adminRecord && userEmail) { if (!adminRecord && userEmail) {
const emailMatch = await this.adminService.getAdminByEmail(userEmail.toLowerCase()); const emailMatch = await this.adminService.getAdminByEmail(userEmail.toLowerCase());
console.log('[DEBUG] Admin verify - emailMatch:', emailMatch ? 'FOUND' : 'NOT FOUND');
if (emailMatch) {
console.log('[DEBUG] Admin verify - emailMatch.auth0Sub:', emailMatch.auth0Sub);
console.log('[DEBUG] Admin verify - emailMatch.revokedAt:', emailMatch.revokedAt);
}
if (emailMatch && !emailMatch.revokedAt) { if (emailMatch && !emailMatch.revokedAt) {
// If the stored auth0Sub differs, link it to the authenticated user // If the stored auth0Sub differs, link it to the authenticated user
if (userId && emailMatch.auth0Sub !== userId) { if (userId && emailMatch.auth0Sub !== userId) {
console.log('[DEBUG] Admin verify - Calling linkAdminAuth0Sub to update auth0Sub');
adminRecord = await this.adminService.linkAdminAuth0Sub(userEmail, userId); adminRecord = await this.adminService.linkAdminAuth0Sub(userEmail, userId);
console.log('[DEBUG] Admin verify - adminRecord after link:', adminRecord ? 'SUCCESS' : 'FAILED');
} else { } else {
console.log('[DEBUG] Admin verify - Using emailMatch as adminRecord');
adminRecord = emailMatch; adminRecord = emailMatch;
} }
} }
@@ -70,6 +85,7 @@ export class AdminController {
request.userContext.adminRecord = adminRecord; request.userContext.adminRecord = adminRecord;
} }
console.log('[DEBUG] Admin verify - Returning isAdmin: true');
// User is an active admin // User is an active admin
return reply.code(200).send({ return reply.code(200).send({
isAdmin: true, isAdmin: true,
@@ -86,12 +102,14 @@ export class AdminController {
request.userContext.adminRecord = undefined; request.userContext.adminRecord = undefined;
} }
console.log('[DEBUG] Admin verify - Returning isAdmin: false');
// User is not an admin // User is not an admin
return reply.code(200).send({ return reply.code(200).send({
isAdmin: false, isAdmin: false,
adminRecord: null adminRecord: null
}); });
} catch (error) { } catch (error) {
console.log('[DEBUG] Admin verify - Error caught:', error instanceof Error ? error.message : 'Unknown error');
logger.error('Error verifying admin access', { logger.error('Error verifying admin access', {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
userId: request.userContext?.userId?.substring(0, 8) + '...' userId: request.userContext?.userId?.substring(0, 8) + '...'
@@ -381,6 +399,9 @@ export class AdminController {
} }
private resolveUserEmail(request: FastifyRequest): string | undefined { private resolveUserEmail(request: FastifyRequest): string | undefined {
console.log('[DEBUG] resolveUserEmail - request.userContext:', JSON.stringify(request.userContext, null, 2));
console.log('[DEBUG] resolveUserEmail - request.user:', JSON.stringify((request as any).user, null, 2));
const candidates: Array<string | undefined> = [ const candidates: Array<string | undefined> = [
request.userContext?.email, request.userContext?.email,
(request as any).user?.email, (request as any).user?.email,
@@ -389,11 +410,15 @@ export class AdminController {
(request as any).user?.preferred_username, (request as any).user?.preferred_username,
]; ];
console.log('[DEBUG] resolveUserEmail - candidates:', candidates);
for (const value of candidates) { for (const value of candidates) {
if (typeof value === 'string' && value.includes('@')) { if (typeof value === 'string' && value.includes('@')) {
console.log('[DEBUG] resolveUserEmail - found email:', value);
return value.trim(); return value.trim();
} }
} }
console.log('[DEBUG] resolveUserEmail - no email found');
return undefined; return undefined;
} }
} }

View File

@@ -1,123 +1,100 @@
/** import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
* @ai-summary Admin guard plugin unit tests
* @ai-context Tests authorization logic for admin-only routes
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { Pool } from 'pg'; import { Pool } from 'pg';
import { setAdminGuardPool } from '../../../../core/plugins/admin-guard.plugin'; import adminGuardPlugin, { setAdminGuardPool } from '../../../../core/plugins/admin-guard.plugin';
describe('Admin Guard', () => { const createReply = (): Partial<FastifyReply> & { payload?: unknown; statusCode?: number } => {
let mockPool: Pool; return {
let mockRequest: Partial<FastifyRequest>; sent: false,
let mockReply: Partial<FastifyReply>; code: jest.fn(function(this: any, status: number) {
this.statusCode = status;
return this;
}),
send: jest.fn(function(this: any, payload: unknown) {
this.payload = payload;
this.sent = true;
return this;
}),
};
};
beforeEach(() => { describe('admin guard plugin', () => {
// Mock database pool let fastify: FastifyInstance;
mockPool = { let authenticateMock: jest.Mock;
query: jest.fn(), let mockPool: { query: jest.Mock };
} as unknown as Pool;
// Mock reply methods beforeEach(async () => {
mockReply = { fastify = Fastify();
code: jest.fn().mockReturnThis(), authenticateMock = jest.fn(async (request: FastifyRequest) => {
send: jest.fn().mockReturnThis(), request.userContext = {
}; userId: 'auth0|admin',
});
describe('Authorization checks', () => {
it('should reject request without user context', async () => {
mockRequest = {
userContext: undefined,
};
const requireAdmin = jest.fn();
// Test would call requireAdmin and verify 401 response
});
it('should reject non-admin users', async () => {
mockRequest = {
userContext: {
userId: 'auth0|123456',
isAdmin: false,
},
};
// Test database query returns no admin record
(mockPool.query as jest.Mock).mockResolvedValue({ rows: [] });
// Test would call requireAdmin and verify 403 response
});
it('should accept active admin users', async () => {
mockRequest = {
userContext: {
userId: 'auth0|123456',
isAdmin: false,
},
};
const adminRecord = {
auth0_sub: 'auth0|123456',
email: 'admin@motovaultpro.com', email: 'admin@motovaultpro.com',
role: 'admin', isAdmin: false,
revoked_at: null,
}; };
(mockPool.query as jest.Mock).mockResolvedValue({ rows: [adminRecord] });
// Test would call requireAdmin and verify isAdmin set to true
}); });
fastify.decorate('authenticate', authenticateMock);
it('should reject revoked admin users', async () => { await fastify.register(adminGuardPlugin);
mockRequest = {
userContext: {
userId: 'auth0|123456',
isAdmin: false,
},
};
// Test database query returns no rows (admin is revoked) mockPool = {
(mockPool.query as jest.Mock).mockResolvedValue({ rows: [] }); query: jest.fn().mockResolvedValue({
rows: [{
auth0_sub: 'auth0|admin',
email: 'admin@motovaultpro.com',
role: 'admin',
revoked_at: null,
}],
}),
};
// Test would call requireAdmin and verify 403 response setAdminGuardPool(mockPool as unknown as Pool);
}); });
it('should handle database errors gracefully', async () => { afterEach(async () => {
mockRequest = { await fastify.close();
userContext: { setAdminGuardPool(null as unknown as Pool);
userId: 'auth0|123456', jest.clearAllMocks();
isAdmin: false, });
},
};
const dbError = new Error('Database connection failed'); it('authenticates the request before enforcing admin access', async () => {
(mockPool.query as jest.Mock).mockRejectedValue(dbError); const request = {} as FastifyRequest;
const reply = createReply();
// Test would call requireAdmin and verify 500 response await fastify.requireAdmin(request, reply as FastifyReply);
expect(authenticateMock).toHaveBeenCalledTimes(1);
expect(mockPool.query).toHaveBeenCalledTimes(1);
expect(request.userContext?.isAdmin).toBe(true);
expect(reply.code).not.toHaveBeenCalled();
expect(reply.send).not.toHaveBeenCalled();
});
it('rejects non-admin users with 403', async () => {
mockPool.query.mockResolvedValue({ rows: [] });
const request = {} as FastifyRequest;
const reply = createReply();
await fastify.requireAdmin(request, reply as FastifyReply);
expect(authenticateMock).toHaveBeenCalledTimes(1);
expect(mockPool.query).toHaveBeenCalledTimes(1);
expect(reply.code).toHaveBeenCalledWith(403);
expect(reply.send).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Admin access required',
}); });
}); });
describe('Pool management', () => { it('responds with 500 when database pool is not initialized', async () => {
it('should set and use database pool for queries', () => { setAdminGuardPool(null as unknown as Pool);
const testPool = {} as Pool; const request = {} as FastifyRequest;
setAdminGuardPool(testPool); const reply = createReply();
// Pool should be available for guards to use await fastify.requireAdmin(request, reply as FastifyReply);
});
it('should handle missing pool gracefully', async () => { expect(reply.code).toHaveBeenCalledWith(500);
// Reset pool to null expect(reply.send).toHaveBeenCalledWith({
setAdminGuardPool(null as any); error: 'Internal server error',
message: 'Admin check unavailable',
mockRequest = {
userContext: {
userId: 'auth0|123456',
isAdmin: false,
},
};
// Test would call requireAdmin and verify 500 response for missing pool
}); });
}); });
}); });