Testing Auth
This guide covers testing patterns for authentication and authorization in Glasswork applications.
Testing Abilities
Test CASL abilities in isolation using forRole():
import { abilities } from './abilities';
import { subject } from 'glasswork/auth';
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():
import { Hono } from 'hono';
import { createAuthMiddleware } from 'glasswork/auth';
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:
// test/helpers/mock-auth-provider.ts
import { type AuthProvider, type AuthUser, type AuthSession } from 'glasswork/auth';
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:
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:
import { bootstrap } from 'glasswork/core';
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:
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);
});
});Testing Tenant Isolation
When using Row Level Security, add PostgreSQL integration tests with the application's restricted runtime role. Ability tests and mocked Prisma queries cannot prove that database policies isolate tenants.
Seed data for two tenants and check reads and writes under each tenant context, including queries without accessibleBy, raw SQL, and transaction rollback. Test the combined HTTP flow as well: an admin ability must remain within the active tenant, and a member in that tenant must still be denied actions their ability does not allow.
The withTenant and withBypass helpers only bind context. They do not install policies or change database privileges. Keep the CASL filtering examples above as ability tests; an RLS-enabled admin query only sees the active tenant's rows.
Best Practices
1. Test Abilities Independently
Abilities are pure logic—test them separately from middleware and routes:
// 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:
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:
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:
DATABASE_URL=postgresql://localhost/myapp_test pnpm testNext Steps
- Abilities - Define and test CASL abilities
- Middleware - Middleware configuration options
