Testing Auth
This guide covers testing patterns for authentication and authorization in Glasswork applications.
Testing Abilities
Test CASL abilities in isolation using forRole():
typescript
import { abilities } from './abilities';
import { subject } from 'glasswork';
describe('abilities', () => {
describe('admin', () => {
const ability = abilities.forRole('admin');
it('can manage all resources', () => {
expect(ability.can('manage', 'all')).toBe(true);
expect(ability.can('delete', 'Project')).toBe(true);
});
});
describe('member', () => {
const ability = abilities.forRole('member', {
id: 'user-1',
tenantId: 'org-1',
});
it('can read projects in their organization', () => {
expect(ability.can('read', subject('Project', {
organizationId: 'org-1',
}))).toBe(true);
});
it('cannot read projects in other organizations', () => {
expect(ability.can('read', subject('Project', {
organizationId: 'org-2',
}))).toBe(false);
});
it('can only update own projects', () => {
expect(ability.can('update', subject('Project', {
organizationId: 'org-1',
createdBy: 'user-1',
}))).toBe(true);
expect(ability.can('update', subject('Project', {
organizationId: 'org-1',
createdBy: 'user-2',
}))).toBe(false);
});
it('cannot delete projects', () => {
expect(ability.can('delete', 'Project')).toBe(false);
});
});
describe('guest', () => {
const ability = abilities.forRole('guest');
it('has no abilities', () => {
expect(ability.can('read', 'Project')).toBe(false);
expect(ability.can('read', 'User')).toBe(false);
});
});
});Testing Middleware
Test auth middleware using Hono's app.request():
typescript
import { Hono } from 'hono';
import { createAuthMiddleware } from 'glasswork';
import { abilities } from './abilities';
describe('authMiddleware', () => {
const mockProvider = {
name: 'mock',
validateSession: vi.fn(),
invalidateSession: vi.fn(),
};
const middleware = createAuthMiddleware({
provider: mockProvider,
buildAbility: (user) => abilities.for(user),
guestAbility: () => abilities.forRole('guest'),
});
beforeEach(() => {
vi.clearAllMocks();
});
it('allows guest access when no session', async () => {
mockProvider.validateSession.mockResolvedValue(null);
const app = new Hono();
app.use('*', middleware());
app.get('/test', (c) => c.json({
isAuthenticated: c.get('isAuthenticated'),
user: c.get('user'),
}));
const res = await app.request('/test');
const body = await res.json();
expect(res.status).toBe(200);
expect(body.isAuthenticated).toBe(false);
expect(body.user).toBeNull();
});
it('sets user context when session is valid', async () => {
mockProvider.validateSession.mockResolvedValue({
session: { id: 'sess-1', userId: 'user-1', expiresAt: new Date(), createdAt: new Date() },
user: { id: 'user-1', role: 'admin', email: 'admin@example.com' },
});
const app = new Hono();
app.use('*', middleware());
app.get('/test', (c) => c.json({
isAuthenticated: c.get('isAuthenticated'),
user: c.get('user'),
}));
const res = await app.request('/test', {
headers: { Cookie: 'session=valid-token' },
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.isAuthenticated).toBe(true);
expect(body.user.id).toBe('user-1');
});
it('returns 401 when authorization required but not authenticated', async () => {
mockProvider.validateSession.mockResolvedValue(null);
const app = new Hono();
app.use('*', middleware({ action: 'read', subject: 'Project' }));
app.get('/test', (c) => c.json({ ok: true }));
const res = await app.request('/test');
expect(res.status).toBe(401);
});
it('returns 403 when authorized but permission denied', async () => {
mockProvider.validateSession.mockResolvedValue({
session: { id: 'sess-1', userId: 'user-1', expiresAt: new Date(), createdAt: new Date() },
user: { id: 'user-1', role: 'guest' },
});
const app = new Hono();
app.use('*', middleware({ action: 'delete', subject: 'Project' }));
app.get('/test', (c) => c.json({ ok: true }));
const res = await app.request('/test', {
headers: { Cookie: 'session=valid-token' },
});
expect(res.status).toBe(403);
});
});Mock Auth Provider
Create a reusable mock provider for tests:
typescript
// test/helpers/mock-auth-provider.ts
import type { AuthProvider, AuthUser, AuthSession } from 'glasswork';
export function createMockProvider() {
const sessions = new Map<string, { user: AuthUser; session: AuthSession }>();
return {
name: 'mock',
// Add a session for testing
addSession(token: string, user: AuthUser, session?: Partial<AuthSession>) {
sessions.set(token, {
user,
session: {
id: session?.id ?? `session-${Date.now()}`,
userId: user.id,
expiresAt: session?.expiresAt ?? new Date(Date.now() + 86400000),
createdAt: session?.createdAt ?? new Date(),
...session,
},
});
},
async validateSession(token: string) {
return sessions.get(token) ?? null;
},
async invalidateSession(sessionId: string) {
for (const [token, data] of sessions) {
if (data.session.id === sessionId) {
sessions.delete(token);
break;
}
}
},
clear() {
sessions.clear();
},
};
}Usage in tests:
typescript
import { createMockProvider } from '../helpers/mock-auth-provider';
const mockProvider = createMockProvider();
beforeEach(() => {
mockProvider.clear();
});
it('allows authenticated user access', async () => {
mockProvider.addSession('test-token', {
id: 'user-1',
email: 'test@example.com',
role: 'member',
tenantId: 'org-1',
});
const res = await app.request('/api/projects', {
headers: { Cookie: 'session=test-token' },
});
expect(res.status).toBe(200);
});Testing Routes with Auth
Test complete routes including auth context:
typescript
import { bootstrap } from 'glasswork';
import { createMockProvider } from '../helpers/mock-auth-provider';
import { AppModule } from '../../src/app.module';
describe('Project Routes', () => {
let app: Hono;
let mockProvider: ReturnType<typeof createMockProvider>;
beforeAll(async () => {
mockProvider = createMockProvider();
const result = await bootstrap(AppModule, {
// Override provider for testing
authProvider: mockProvider,
});
app = result.app;
});
beforeEach(() => {
mockProvider.clear();
});
describe('GET /api/projects', () => {
it('returns 401 for unauthenticated users', async () => {
const res = await app.request('/api/projects');
expect(res.status).toBe(401);
});
it('returns projects for authenticated users', async () => {
mockProvider.addSession('token', {
id: 'user-1',
role: 'member',
tenantId: 'org-1',
});
const res = await app.request('/api/projects', {
headers: { Cookie: 'session=token' },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
});
});
describe('DELETE /api/projects/:id', () => {
it('returns 403 for members', async () => {
mockProvider.addSession('member-token', {
id: 'user-1',
role: 'member',
tenantId: 'org-1',
});
const res = await app.request('/api/projects/123', {
method: 'DELETE',
headers: { Cookie: 'session=member-token' },
});
expect(res.status).toBe(403);
});
it('returns 204 for admins', async () => {
mockProvider.addSession('admin-token', {
id: 'admin-1',
role: 'admin',
});
const res = await app.request('/api/projects/123', {
method: 'DELETE',
headers: { Cookie: 'session=admin-token' },
});
expect(res.status).toBe(204);
});
});
});Testing Prisma Filtering
Test that CASL abilities correctly filter Prisma queries:
typescript
import { PrismaClient } from '@prisma/client';
import { abilities } from './abilities';
import { ProjectService } from './project.service';
describe('ProjectService with CASL', () => {
let prisma: PrismaClient;
let service: ProjectService;
beforeAll(async () => {
prisma = new PrismaClient();
service = new ProjectService(prisma);
// Seed test data
await prisma.project.createMany({
data: [
{ id: 'p1', name: 'Project 1', organizationId: 'org-1', createdBy: 'user-1' },
{ id: 'p2', name: 'Project 2', organizationId: 'org-1', createdBy: 'user-2' },
{ id: 'p3', name: 'Project 3', organizationId: 'org-2', createdBy: 'user-3' },
],
});
});
afterAll(async () => {
await prisma.project.deleteMany();
await prisma.$disconnect();
});
it('admin sees all projects', async () => {
const ability = abilities.forRole('admin');
const projects = await service.findAll(ability);
expect(projects).toHaveLength(3);
});
it('member sees only their organization projects', async () => {
const ability = abilities.forRole('member', {
id: 'user-1',
tenantId: 'org-1',
});
const projects = await service.findAll(ability);
expect(projects).toHaveLength(2);
expect(projects.every(p => p.organizationId === 'org-1')).toBe(true);
});
it('guest sees no projects', async () => {
const ability = abilities.forRole('guest');
const projects = await service.findAll(ability);
expect(projects).toHaveLength(0);
});
});Best Practices
1. Test Abilities Independently
Abilities are pure logic—test them separately from middleware and routes:
typescript
// Test abilities in isolation
const ability = abilities.forRole('member', { id: 'user-1', tenantId: 'org-1' });
expect(ability.can('read', subject('Project', { organizationId: 'org-1' }))).toBe(true);2. Use Factories for Test Data
Create helper functions for common test scenarios:
typescript
function createMemberUser(overrides = {}) {
return {
id: 'user-1',
email: 'member@example.com',
role: 'member',
tenantId: 'org-1',
...overrides,
};
}
function createAdminUser(overrides = {}) {
return {
id: 'admin-1',
email: 'admin@example.com',
role: 'admin',
...overrides,
};
}3. Test Edge Cases
Cover boundary conditions:
typescript
it('handles expired sessions', async () => {
mockProvider.addSession('expired-token', createMemberUser(), {
expiresAt: new Date(Date.now() - 1000), // Already expired
});
// Your expiry handling logic
});
it('handles missing tenantId', async () => {
const ability = abilities.forRole('member', { id: 'user-1' }); // No tenantId
expect(ability.can('read', 'Project')).toBe(false);
});4. Use Separate Test Database
For integration tests with Prisma, use a separate database:
bash
DATABASE_URL=postgresql://localhost/myapp_test pnpm testNext Steps
- Abilities - Define and test CASL abilities
- Middleware - Middleware configuration options
