Admin settings fixed
This commit is contained in:
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('admin guard plugin', () => {
|
||||||
|
let fastify: FastifyInstance;
|
||||||
|
let authenticateMock: jest.Mock;
|
||||||
|
let mockPool: { query: jest.Mock };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
fastify = Fastify();
|
||||||
|
authenticateMock = jest.fn(async (request: FastifyRequest) => {
|
||||||
|
request.userContext = {
|
||||||
|
userId: 'auth0|admin',
|
||||||
|
email: 'admin@motovaultpro.com',
|
||||||
|
isAdmin: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
fastify.decorate('authenticate', authenticateMock);
|
||||||
|
|
||||||
|
await fastify.register(adminGuardPlugin);
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Mock database pool
|
|
||||||
mockPool = {
|
mockPool = {
|
||||||
query: jest.fn(),
|
query: jest.fn().mockResolvedValue({
|
||||||
} as unknown as Pool;
|
rows: [{
|
||||||
|
auth0_sub: 'auth0|admin',
|
||||||
// Mock reply methods
|
|
||||||
mockReply = {
|
|
||||||
code: jest.fn().mockReturnThis(),
|
|
||||||
send: jest.fn().mockReturnThis(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
role: 'admin',
|
||||||
revoked_at: null,
|
revoked_at: null,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
(mockPool.query as jest.Mock).mockResolvedValue({ rows: [adminRecord] });
|
setAdminGuardPool(mockPool as unknown as Pool);
|
||||||
|
|
||||||
// Test would call requireAdmin and verify isAdmin set to true
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject revoked admin users', async () => {
|
afterEach(async () => {
|
||||||
mockRequest = {
|
await fastify.close();
|
||||||
userContext: {
|
setAdminGuardPool(null as unknown as Pool);
|
||||||
userId: 'auth0|123456',
|
jest.clearAllMocks();
|
||||||
isAdmin: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test database query returns no rows (admin is revoked)
|
|
||||||
(mockPool.query as jest.Mock).mockResolvedValue({ rows: [] });
|
|
||||||
|
|
||||||
// Test would call requireAdmin and verify 403 response
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle database errors gracefully', async () => {
|
it('authenticates the request before enforcing admin access', async () => {
|
||||||
mockRequest = {
|
const request = {} as FastifyRequest;
|
||||||
userContext: {
|
const reply = createReply();
|
||||||
userId: 'auth0|123456',
|
|
||||||
isAdmin: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const dbError = new Error('Database connection failed');
|
await fastify.requireAdmin(request, reply as FastifyReply);
|
||||||
(mockPool.query as jest.Mock).mockRejectedValue(dbError);
|
|
||||||
|
|
||||||
// Test would call requireAdmin and verify 500 response
|
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
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user