clean routes
This commit is contained in:
@@ -1,263 +0,0 @@
|
||||
/**
|
||||
* TDD Tests for AuthorizationBlocker
|
||||
*
|
||||
* These tests verify the authorization blocker logic following TDD principles.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { AuthorizationBlocker } from './AuthorizationBlocker';
|
||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
|
||||
// Mock SessionViewModel factory
|
||||
function createMockSession(overrides: Partial<SessionViewModel> = {}): SessionViewModel {
|
||||
const baseSession = {
|
||||
isAuthenticated: true,
|
||||
userId: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
role: undefined,
|
||||
};
|
||||
|
||||
// Handle the case where overrides might have a user object
|
||||
// (for backward compatibility with existing test patterns)
|
||||
if (overrides.user) {
|
||||
const { user, ...rest } = overrides;
|
||||
return {
|
||||
...baseSession,
|
||||
...rest,
|
||||
userId: user.userId || baseSession.userId,
|
||||
email: user.email || baseSession.email,
|
||||
displayName: user.displayName || baseSession.displayName,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...baseSession,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthorizationBlocker', () => {
|
||||
describe('Session Management', () => {
|
||||
it('should start with no session', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
|
||||
expect(blocker.getReason()).toBe('unauthenticated');
|
||||
expect(blocker.canExecute()).toBe(false);
|
||||
});
|
||||
|
||||
it('should update session correctly', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
const session = createMockSession();
|
||||
|
||||
blocker.updateSession(session);
|
||||
|
||||
expect(blocker.getReason()).toBe('enabled');
|
||||
expect(blocker.canExecute()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle null session', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
|
||||
blocker.updateSession(null);
|
||||
|
||||
expect(blocker.getReason()).toBe('unauthenticated');
|
||||
expect(blocker.canExecute()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication State', () => {
|
||||
it('should detect unauthenticated session', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
const session = createMockSession({ isAuthenticated: false });
|
||||
|
||||
blocker.updateSession(session);
|
||||
|
||||
expect(blocker.getReason()).toBe('unauthenticated');
|
||||
expect(blocker.canExecute()).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow access for authenticated session', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
const session = createMockSession({ isAuthenticated: true });
|
||||
|
||||
blocker.updateSession(session);
|
||||
|
||||
expect(blocker.getReason()).toBe('enabled');
|
||||
expect(blocker.canExecute()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Requirements', () => {
|
||||
// Note: Current AuthorizationBlocker implementation always returns 'enabled' for authenticated users
|
||||
// These tests document the intended behavior for when role system is fully implemented
|
||||
|
||||
it('should allow access when no roles required', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
const session = createMockSession();
|
||||
|
||||
blocker.updateSession(session);
|
||||
|
||||
expect(blocker.getReason()).toBe('enabled');
|
||||
expect(blocker.canExecute()).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny access when user lacks required role', () => {
|
||||
const blocker = new AuthorizationBlocker(['admin']);
|
||||
const session = createMockSession();
|
||||
|
||||
blocker.updateSession(session);
|
||||
|
||||
// Session has no role, so access is denied
|
||||
expect(blocker.getReason()).toBe('unauthorized');
|
||||
expect(blocker.canExecute()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Block and Release', () => {
|
||||
it('should block access when requested', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
const session = createMockSession();
|
||||
|
||||
blocker.updateSession(session);
|
||||
expect(blocker.canExecute()).toBe(true);
|
||||
|
||||
blocker.block();
|
||||
expect(blocker.canExecute()).toBe(false);
|
||||
expect(blocker.getReason()).toBe('unauthenticated');
|
||||
});
|
||||
|
||||
it('should release block (no-op in current implementation)', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
const session = createMockSession();
|
||||
|
||||
blocker.updateSession(session);
|
||||
blocker.block();
|
||||
|
||||
// Release is a no-op in current implementation
|
||||
blocker.release();
|
||||
// Block state persists
|
||||
expect(blocker.canExecute()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Block Messages', () => {
|
||||
it('should provide message for unauthenticated user', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
|
||||
const message = blocker.getBlockMessage();
|
||||
|
||||
expect(message).toBe('You must be logged in to access this area.');
|
||||
});
|
||||
|
||||
it('should provide message for unauthorized user', () => {
|
||||
const blocker = new AuthorizationBlocker(['admin']);
|
||||
// Simulate unauthorized state by manually setting reason
|
||||
// Note: This is a limitation of current implementation
|
||||
// In a real implementation, this would be tested differently
|
||||
|
||||
// For now, we'll test the message generation logic
|
||||
// by checking what it would return for different reasons
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should provide message for insufficient role', () => {
|
||||
const blocker = new AuthorizationBlocker(['admin', 'moderator']);
|
||||
|
||||
// Current implementation doesn't support this scenario
|
||||
// but the message template exists
|
||||
expect(blocker.getBlockMessage()).toContain('logged in');
|
||||
});
|
||||
|
||||
it('should provide message for granted access', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
const session = createMockSession();
|
||||
|
||||
blocker.updateSession(session);
|
||||
|
||||
expect(blocker.getBlockMessage()).toBe('Access granted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty required roles array', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
const session = createMockSession();
|
||||
|
||||
blocker.updateSession(session);
|
||||
|
||||
expect(blocker.canExecute()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle undefined session properties', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
const session = {
|
||||
isAuthenticated: true,
|
||||
user: null as any,
|
||||
} as SessionViewModel;
|
||||
|
||||
blocker.updateSession(session);
|
||||
|
||||
// Current implementation allows access
|
||||
expect(blocker.canExecute()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple role updates', () => {
|
||||
const blocker = new AuthorizationBlocker(['admin']);
|
||||
|
||||
// First session with admin role
|
||||
const session1 = createMockSession({
|
||||
user: {
|
||||
userId: 'user-123',
|
||||
email: 'admin@example.com',
|
||||
displayName: 'Admin User',
|
||||
role: 'admin',
|
||||
},
|
||||
});
|
||||
blocker.updateSession(session1);
|
||||
expect(blocker.canExecute()).toBe(true);
|
||||
|
||||
// Update with different session that lacks admin role
|
||||
const session2 = createMockSession({
|
||||
user: {
|
||||
userId: 'user-456',
|
||||
email: 'other@example.com',
|
||||
displayName: 'Other User',
|
||||
role: 'user',
|
||||
},
|
||||
});
|
||||
blocker.updateSession(session2);
|
||||
|
||||
expect(blocker.canExecute()).toBe(false);
|
||||
expect(blocker.getReason()).toBe('insufficient_role');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reason Codes', () => {
|
||||
it('should return correct reason for unauthenticated', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
|
||||
expect(blocker.getReason()).toBe('unauthenticated');
|
||||
});
|
||||
|
||||
it('should return correct reason for enabled (authenticated)', () => {
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
const session = createMockSession();
|
||||
|
||||
blocker.updateSession(session);
|
||||
|
||||
expect(blocker.getReason()).toBe('enabled');
|
||||
});
|
||||
|
||||
it('should return correct reason for loading (handled by AuthContext)', () => {
|
||||
// Loading state is handled by AuthContext, not AuthorizationBlocker
|
||||
// This test documents that limitation
|
||||
const blocker = new AuthorizationBlocker([]);
|
||||
|
||||
// AuthorizationBlocker doesn't have a loading state
|
||||
// It relies on AuthContext to handle loading
|
||||
expect(blocker.getReason()).toBe('unauthenticated');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Blocker: AuthorizationBlocker
|
||||
*
|
||||
* Frontend blocker that prevents unauthorized access to admin features.
|
||||
* This is a UX improvement, NOT a security mechanism.
|
||||
* Security is enforced by backend Guards.
|
||||
*/
|
||||
|
||||
import { Blocker } from './Blocker';
|
||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
|
||||
export type AuthorizationBlockReason =
|
||||
| 'loading' // User data not loaded yet
|
||||
| 'unauthenticated' // User not logged in
|
||||
| 'unauthorized' // User logged in but lacks required role
|
||||
| 'insufficient_role' // User has role but not high enough
|
||||
| 'enabled'; // Access granted
|
||||
|
||||
export class AuthorizationBlocker extends Blocker {
|
||||
private currentSession: SessionViewModel | null = null;
|
||||
private requiredRoles: string[] = [];
|
||||
|
||||
constructor(requiredRoles: string[]) {
|
||||
super();
|
||||
this.requiredRoles = requiredRoles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current session state
|
||||
*/
|
||||
updateSession(session: SessionViewModel | null): void {
|
||||
this.currentSession = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current block reason
|
||||
*/
|
||||
getReason(): AuthorizationBlockReason {
|
||||
if (!this.currentSession) {
|
||||
// Session is null - this means unauthenticated (not loading)
|
||||
// Loading state is handled by AuthContext
|
||||
return 'unauthenticated';
|
||||
}
|
||||
|
||||
if (!this.currentSession.isAuthenticated) {
|
||||
return 'unauthenticated';
|
||||
}
|
||||
|
||||
// If no roles are required, allow access
|
||||
if (this.requiredRoles.length === 0) {
|
||||
return 'enabled';
|
||||
}
|
||||
|
||||
// Check if user has a role
|
||||
if (!this.currentSession.role) {
|
||||
return 'unauthorized';
|
||||
}
|
||||
|
||||
// Check if user's role matches any of the required roles
|
||||
if (this.requiredRoles.includes(this.currentSession.role)) {
|
||||
return 'enabled';
|
||||
}
|
||||
|
||||
// User has a role but it's not in the required list
|
||||
return 'insufficient_role';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can execute (access admin area)
|
||||
*/
|
||||
canExecute(): boolean {
|
||||
const reason = this.getReason();
|
||||
return reason === 'enabled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Block access (for testing/demo purposes)
|
||||
*/
|
||||
block(): void {
|
||||
// Simulate blocking by setting session to null
|
||||
this.currentSession = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the block
|
||||
*/
|
||||
release(): void {
|
||||
// No-op - blocking is state-based, not persistent
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message for block reason
|
||||
*/
|
||||
getBlockMessage(): string {
|
||||
const reason = this.getReason();
|
||||
|
||||
switch (reason) {
|
||||
case 'unauthenticated':
|
||||
return 'You must be logged in to access this area.';
|
||||
case 'unauthorized':
|
||||
return 'You do not have permission to access this area.';
|
||||
case 'insufficient_role':
|
||||
return `Access requires one of: ${this.requiredRoles.join(', ')}`;
|
||||
case 'enabled':
|
||||
return 'Access granted';
|
||||
default:
|
||||
return 'Access denied';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CapabilityBlocker } from './CapabilityBlocker';
|
||||
|
||||
describe('CapabilityBlocker', () => {
|
||||
it('should be defined', () => {
|
||||
expect(CapabilityBlocker).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Blocker } from './Blocker';
|
||||
import type { PolicySnapshotDto } from '../api/policy/PolicyApiClient';
|
||||
import { PolicyService } from '../services/policy/PolicyService';
|
||||
|
||||
export type CapabilityBlockReason = 'loading' | 'enabled' | 'coming_soon' | 'disabled' | 'hidden';
|
||||
|
||||
export class CapabilityBlocker extends Blocker {
|
||||
private snapshot: PolicySnapshotDto | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly policyService: PolicyService,
|
||||
private readonly capabilityKey: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
updateSnapshot(snapshot: PolicySnapshotDto | null): void {
|
||||
this.snapshot = snapshot;
|
||||
}
|
||||
|
||||
canExecute(): boolean {
|
||||
return this.getReason() === 'enabled';
|
||||
}
|
||||
|
||||
getReason(): CapabilityBlockReason {
|
||||
if (!this.snapshot) {
|
||||
return 'loading';
|
||||
}
|
||||
|
||||
return this.policyService.getCapabilityState(this.snapshot, this.capabilityKey);
|
||||
}
|
||||
|
||||
block(): void {
|
||||
this.snapshot = {
|
||||
...(this.snapshot ?? {
|
||||
policyVersion: 0,
|
||||
operationalMode: 'normal',
|
||||
maintenanceAllowlist: { view: [], mutate: [] },
|
||||
capabilities: {},
|
||||
loadedFrom: 'defaults',
|
||||
loadedAtIso: new Date().toISOString(),
|
||||
}),
|
||||
capabilities: {
|
||||
...(this.snapshot?.capabilities ?? {}),
|
||||
[this.capabilityKey]: 'disabled',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.snapshot = {
|
||||
...(this.snapshot ?? {
|
||||
policyVersion: 0,
|
||||
operationalMode: 'normal',
|
||||
maintenanceAllowlist: { view: [], mutate: [] },
|
||||
capabilities: {},
|
||||
loadedFrom: 'defaults',
|
||||
loadedAtIso: new Date().toISOString(),
|
||||
}),
|
||||
capabilities: {
|
||||
...(this.snapshot?.capabilities ?? {}),
|
||||
[this.capabilityKey]: 'enabled',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* Blockers exports
|
||||
*/
|
||||
|
||||
export { Blocker } from './Blocker';
|
||||
export { CapabilityBlocker } from './CapabilityBlocker';
|
||||
export { SubmitBlocker } from './SubmitBlocker';
|
||||
export { ThrottleBlocker } from './ThrottleBlocker';
|
||||
export { AuthorizationBlocker } from './AuthorizationBlocker';
|
||||
export type { AuthorizationBlockReason } from './AuthorizationBlocker';
|
||||
export { ThrottleBlocker } from './ThrottleBlocker';
|
||||
Reference in New Issue
Block a user