@brika/auth 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +207 -0
- package/package.json +50 -0
- package/src/__tests__/AuthClient.test.ts +736 -0
- package/src/__tests__/AuthService.test.ts +140 -0
- package/src/__tests__/ScopeService.test.ts +156 -0
- package/src/__tests__/SessionService.test.ts +311 -0
- package/src/__tests__/UserService-avatar.test.ts +277 -0
- package/src/__tests__/UserService.test.ts +223 -0
- package/src/__tests__/canAccess.test.ts +166 -0
- package/src/__tests__/disabledScopes.test.ts +101 -0
- package/src/__tests__/middleware.test.ts +190 -0
- package/src/__tests__/plugin.test.ts +78 -0
- package/src/__tests__/requireSession.test.ts +78 -0
- package/src/__tests__/routes-auth.test.ts +248 -0
- package/src/__tests__/routes-profile.test.ts +403 -0
- package/src/__tests__/routes-scopes.test.ts +64 -0
- package/src/__tests__/routes-sessions.test.ts +235 -0
- package/src/__tests__/routes-users.test.ts +477 -0
- package/src/__tests__/serveImage.test.ts +277 -0
- package/src/__tests__/setup.test.ts +270 -0
- package/src/__tests__/verifyToken.test.ts +219 -0
- package/src/client/AuthClient.ts +312 -0
- package/src/client/http-client.ts +84 -0
- package/src/client/index.ts +19 -0
- package/src/config.ts +82 -0
- package/src/constants.ts +10 -0
- package/src/index.ts +16 -0
- package/src/lib/define-roles.ts +35 -0
- package/src/lib/define-scopes.ts +48 -0
- package/src/middleware/canAccess.ts +126 -0
- package/src/middleware/index.ts +13 -0
- package/src/middleware/requireAuth.ts +35 -0
- package/src/middleware/requireScope.ts +46 -0
- package/src/middleware/verifyToken.ts +52 -0
- package/src/plugin.ts +86 -0
- package/src/react/AuthProvider.tsx +105 -0
- package/src/react/hooks.ts +128 -0
- package/src/react/index.ts +51 -0
- package/src/react/withScopeGuard.tsx +73 -0
- package/src/roles.ts +40 -0
- package/src/schemas.ts +112 -0
- package/src/scopes.ts +60 -0
- package/src/server/index.ts +44 -0
- package/src/server/requireSession.ts +44 -0
- package/src/server/routes/auth.ts +102 -0
- package/src/server/routes/cookie.ts +7 -0
- package/src/server/routes/index.ts +32 -0
- package/src/server/routes/profile.ts +162 -0
- package/src/server/routes/scopes.ts +22 -0
- package/src/server/routes/sessions.ts +68 -0
- package/src/server/routes/setup.ts +50 -0
- package/src/server/routes/users.ts +175 -0
- package/src/server/serveImage.ts +91 -0
- package/src/services/AuthService.ts +80 -0
- package/src/services/ScopeService.ts +94 -0
- package/src/services/SessionService.ts +245 -0
- package/src/services/UserService.ts +245 -0
- package/src/setup.ts +99 -0
- package/src/tanstack/index.ts +15 -0
- package/src/tanstack/routeBuilder.ts +311 -0
- package/src/types.ts +118 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - Per-user scopes (allow-list) tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, it } from 'bun:test';
|
|
6
|
+
import { ROLE_SCOPES } from '../constants';
|
|
7
|
+
import { SessionService } from '../services/SessionService';
|
|
8
|
+
import { UserService } from '../services/UserService';
|
|
9
|
+
import { openAuthDatabase } from '../setup';
|
|
10
|
+
import { Role, Scope } from '../types';
|
|
11
|
+
|
|
12
|
+
describe('Per-user scopes (allow-list)', () => {
|
|
13
|
+
let userService: UserService;
|
|
14
|
+
let sessionService: SessionService;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
const db = openAuthDatabase(':memory:');
|
|
18
|
+
userService = new UserService(db);
|
|
19
|
+
sessionService = new SessionService(db, 3600);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should grant role default scopes to new users', () => {
|
|
23
|
+
const user = userService.createUser('test@test.com', 'Test', Role.USER);
|
|
24
|
+
expect(user.scopes).toEqual(ROLE_SCOPES[Role.USER]);
|
|
25
|
+
|
|
26
|
+
const token = sessionService.createSession(user.id);
|
|
27
|
+
const session = sessionService.validateSession(token);
|
|
28
|
+
expect(session).not.toBeNull();
|
|
29
|
+
expect(session?.scopes).toEqual(ROLE_SCOPES[Role.USER]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should use only explicitly granted scopes in session', () => {
|
|
33
|
+
const user = userService.createUser('test@test.com', 'Test', Role.USER);
|
|
34
|
+
userService.updateUser(user.id, {
|
|
35
|
+
scopes: [Scope.WORKFLOW_READ, Scope.WORKFLOW_EXECUTE],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const token = sessionService.createSession(user.id);
|
|
39
|
+
const session = sessionService.validateSession(token);
|
|
40
|
+
|
|
41
|
+
expect(session).not.toBeNull();
|
|
42
|
+
expect(session?.scopes).toContain(Scope.WORKFLOW_READ);
|
|
43
|
+
expect(session?.scopes).toContain(Scope.WORKFLOW_EXECUTE);
|
|
44
|
+
expect(session?.scopes).not.toContain(Scope.WORKFLOW_WRITE);
|
|
45
|
+
expect(session?.scopes).not.toContain(Scope.BOARD_WRITE);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should always grant admin scopes regardless of stored scopes', () => {
|
|
49
|
+
const user = userService.createUser('admin@test.com', 'Admin', Role.ADMIN);
|
|
50
|
+
// Even if scopes are manually narrowed, admin always gets ADMIN_ALL
|
|
51
|
+
userService.updateUser(user.id, {
|
|
52
|
+
scopes: [Scope.WORKFLOW_READ],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const token = sessionService.createSession(user.id);
|
|
56
|
+
const session = sessionService.validateSession(token);
|
|
57
|
+
|
|
58
|
+
expect(session).not.toBeNull();
|
|
59
|
+
expect(session?.scopes).toContain(Scope.ADMIN_ALL);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should persist and retrieve scopes on User object', () => {
|
|
63
|
+
const user = userService.createUser('test@test.com', 'Test', Role.USER);
|
|
64
|
+
const updated = userService.updateUser(user.id, {
|
|
65
|
+
scopes: [Scope.PLUGIN_READ, Scope.BOARD_READ],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(updated.scopes).toEqual([Scope.PLUGIN_READ, Scope.BOARD_READ]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should handle empty scopes (no permissions)', () => {
|
|
72
|
+
const user = userService.createUser('test@test.com', 'Test', Role.USER);
|
|
73
|
+
const updated = userService.updateUser(user.id, {
|
|
74
|
+
scopes: [],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(updated.scopes).toEqual([]);
|
|
78
|
+
|
|
79
|
+
const token = sessionService.createSession(user.id);
|
|
80
|
+
const session = sessionService.validateSession(token);
|
|
81
|
+
expect(session?.scopes).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should grant role-appropriate defaults for each role', () => {
|
|
85
|
+
const guest = userService.createUser('guest@test.com', 'Guest', Role.GUEST);
|
|
86
|
+
expect(guest.scopes).toEqual(ROLE_SCOPES[Role.GUEST]);
|
|
87
|
+
|
|
88
|
+
const admin = userService.createUser('admin@test.com', 'Admin', Role.ADMIN);
|
|
89
|
+
expect(admin.scopes).toEqual(ROLE_SCOPES[Role.ADMIN]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should drop invalid scope strings when reading from DB', () => {
|
|
93
|
+
const user = userService.createUser('test@test.com', 'Test', Role.USER);
|
|
94
|
+
userService.updateUser(user.id, {
|
|
95
|
+
scopes: [Scope.WORKFLOW_READ, 'not:a:scope' as Scope],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const fetched = userService.getUser(user.id);
|
|
99
|
+
expect(fetched?.scopes).toEqual([Scope.WORKFLOW_READ]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - Middleware Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for requireAuth and requireScope middleware.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'bun:test';
|
|
8
|
+
import { container } from '@brika/di';
|
|
9
|
+
import { requireAuth } from '../middleware/requireAuth';
|
|
10
|
+
import { requireScope } from '../middleware/requireScope';
|
|
11
|
+
import { ScopeService } from '../services/ScopeService';
|
|
12
|
+
import type { Session } from '../types';
|
|
13
|
+
import { Role, Scope } from '../types';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const adminSession: Session = {
|
|
20
|
+
id: 'sess-admin',
|
|
21
|
+
userId: 'user-admin',
|
|
22
|
+
userEmail: 'admin@test.com',
|
|
23
|
+
userName: 'Admin',
|
|
24
|
+
userRole: Role.ADMIN,
|
|
25
|
+
scopes: [Scope.ADMIN_ALL],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const userSession: Session = {
|
|
29
|
+
id: 'sess-user',
|
|
30
|
+
userId: 'user-1',
|
|
31
|
+
userEmail: 'user@test.com',
|
|
32
|
+
userName: 'User',
|
|
33
|
+
userRole: Role.USER,
|
|
34
|
+
scopes: [Scope.WORKFLOW_READ, Scope.WORKFLOW_WRITE, Scope.BOARD_READ],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function mockContext(url: string, session: Session | null = null) {
|
|
38
|
+
const next = vi.fn().mockResolvedValue(undefined);
|
|
39
|
+
const ctx = {
|
|
40
|
+
req: {
|
|
41
|
+
url,
|
|
42
|
+
header: vi.fn().mockReturnValue(undefined),
|
|
43
|
+
},
|
|
44
|
+
get: vi.fn((key: string) => {
|
|
45
|
+
if (key === 'session') {
|
|
46
|
+
return session;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}),
|
|
50
|
+
set: vi.fn(),
|
|
51
|
+
json: vi.fn(
|
|
52
|
+
(body: Record<string, unknown>, status?: number) =>
|
|
53
|
+
new Response(JSON.stringify(body), {
|
|
54
|
+
status: status ?? 200,
|
|
55
|
+
})
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
ctx,
|
|
60
|
+
next,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// requireAuth
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
describe('requireAuth', () => {
|
|
69
|
+
const middleware = requireAuth();
|
|
70
|
+
|
|
71
|
+
it('should call next when session exists', async () => {
|
|
72
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/test', userSession);
|
|
73
|
+
await middleware(ctx as never, next);
|
|
74
|
+
expect(next).toHaveBeenCalled();
|
|
75
|
+
expect(ctx.json).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should return 401 when no session', async () => {
|
|
79
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/test');
|
|
80
|
+
await middleware(ctx as never, next);
|
|
81
|
+
expect(next).not.toHaveBeenCalled();
|
|
82
|
+
expect(ctx.json).toHaveBeenCalledWith(
|
|
83
|
+
{
|
|
84
|
+
error: 'Unauthorized',
|
|
85
|
+
},
|
|
86
|
+
401
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should call next for admin session', async () => {
|
|
91
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/admin', adminSession);
|
|
92
|
+
await middleware(ctx as never, next);
|
|
93
|
+
expect(next).toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// requireScope
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
describe('requireScope', () => {
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
container.clearInstances();
|
|
104
|
+
// ScopeService has no dependencies — register it directly
|
|
105
|
+
container.register(ScopeService, {
|
|
106
|
+
useClass: ScopeService,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
container.clearInstances();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should call next when session has the required scope', async () => {
|
|
115
|
+
const middleware = requireScope(Scope.WORKFLOW_READ);
|
|
116
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/workflows', userSession);
|
|
117
|
+
await middleware(ctx as never, next);
|
|
118
|
+
expect(next).toHaveBeenCalled();
|
|
119
|
+
expect(ctx.json).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should return 403 when session lacks the required scope', async () => {
|
|
123
|
+
const middleware = requireScope(Scope.PLUGIN_MANAGE);
|
|
124
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/plugins', userSession);
|
|
125
|
+
await middleware(ctx as never, next);
|
|
126
|
+
expect(next).not.toHaveBeenCalled();
|
|
127
|
+
expect(ctx.json).toHaveBeenCalledWith(
|
|
128
|
+
expect.objectContaining({
|
|
129
|
+
error: 'insufficient_permissions',
|
|
130
|
+
message: 'This operation requires additional permissions',
|
|
131
|
+
}),
|
|
132
|
+
403
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should return 401 when no session at all', async () => {
|
|
137
|
+
const middleware = requireScope(Scope.WORKFLOW_READ);
|
|
138
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/workflows');
|
|
139
|
+
await middleware(ctx as never, next);
|
|
140
|
+
expect(next).not.toHaveBeenCalled();
|
|
141
|
+
expect(ctx.json).toHaveBeenCalledWith(
|
|
142
|
+
expect.objectContaining({
|
|
143
|
+
error: 'unauthorized',
|
|
144
|
+
}),
|
|
145
|
+
401
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should allow admin to bypass any scope check', async () => {
|
|
150
|
+
const middleware = requireScope(Scope.PLUGIN_MANAGE);
|
|
151
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/plugins', adminSession);
|
|
152
|
+
await middleware(ctx as never, next);
|
|
153
|
+
expect(next).toHaveBeenCalled();
|
|
154
|
+
expect(ctx.json).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should accept an array of scopes (any match)', async () => {
|
|
158
|
+
const middleware = requireScope([Scope.PLUGIN_MANAGE, Scope.WORKFLOW_READ]);
|
|
159
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/mixed', userSession);
|
|
160
|
+
await middleware(ctx as never, next);
|
|
161
|
+
// userSession has WORKFLOW_READ, so should pass
|
|
162
|
+
expect(next).toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should return 403 when none of the array scopes match', async () => {
|
|
166
|
+
const middleware = requireScope([Scope.PLUGIN_MANAGE, Scope.SETTINGS_WRITE]);
|
|
167
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/admin-op', userSession);
|
|
168
|
+
await middleware(ctx as never, next);
|
|
169
|
+
expect(next).not.toHaveBeenCalled();
|
|
170
|
+
expect(ctx.json).toHaveBeenCalledWith(
|
|
171
|
+
expect.objectContaining({
|
|
172
|
+
error: 'insufficient_permissions',
|
|
173
|
+
}),
|
|
174
|
+
403
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should not leak user scopes in 403 response', async () => {
|
|
179
|
+
const middleware = requireScope(Scope.SETTINGS_WRITE);
|
|
180
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/settings', userSession);
|
|
181
|
+
await middleware(ctx as never, next);
|
|
182
|
+
expect(ctx.json).toHaveBeenCalledWith(
|
|
183
|
+
expect.not.objectContaining({
|
|
184
|
+
required: expect.anything(),
|
|
185
|
+
provided: expect.anything(),
|
|
186
|
+
}),
|
|
187
|
+
expect.anything()
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - Plugin Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, it } from 'bun:test';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { container } from '@brika/di';
|
|
9
|
+
import { auth } from '../plugin';
|
|
10
|
+
|
|
11
|
+
const testDataDir = join(tmpdir(), `brika-test-${Date.now()}`);
|
|
12
|
+
|
|
13
|
+
/** Stub server that records middleware and routes */
|
|
14
|
+
function createMockServer() {
|
|
15
|
+
const middleware: unknown[] = [];
|
|
16
|
+
const routes: unknown[] = [];
|
|
17
|
+
return {
|
|
18
|
+
addMiddleware(mw: unknown) {
|
|
19
|
+
middleware.push(mw);
|
|
20
|
+
},
|
|
21
|
+
addRoutes(r: unknown[]) {
|
|
22
|
+
routes.push(...r);
|
|
23
|
+
},
|
|
24
|
+
middleware,
|
|
25
|
+
routes,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('Auth Plugin', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
container.clearInstances();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should create a bootstrap plugin', () => {
|
|
35
|
+
const plugin = auth({
|
|
36
|
+
dataDir: testDataDir,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(plugin.name).toBe('auth');
|
|
40
|
+
expect(typeof plugin.setup).toBe('function');
|
|
41
|
+
expect(typeof plugin.onStart).toBe('function');
|
|
42
|
+
expect(typeof plugin.onStop).toBe('function');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should register services and middleware when server is provided', () => {
|
|
46
|
+
const server = createMockServer();
|
|
47
|
+
const plugin = auth({
|
|
48
|
+
dataDir: testDataDir,
|
|
49
|
+
server,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
plugin.setup?.();
|
|
53
|
+
|
|
54
|
+
expect(server.middleware.length).toBe(1);
|
|
55
|
+
expect(server.routes.length).toBeGreaterThan(0);
|
|
56
|
+
|
|
57
|
+
plugin.onStop?.();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should work without server (CLI mode)', () => {
|
|
61
|
+
const plugin = auth({
|
|
62
|
+
dataDir: testDataDir,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
plugin.setup?.();
|
|
66
|
+
plugin.onStop?.();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should go through full lifecycle', async () => {
|
|
70
|
+
const plugin = auth({
|
|
71
|
+
dataDir: testDataDir,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
plugin.setup?.();
|
|
75
|
+
await plugin.onStart?.();
|
|
76
|
+
plugin.onStop?.();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - requireSession Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'bun:test';
|
|
6
|
+
import { Forbidden, Unauthorized } from '@brika/router';
|
|
7
|
+
import { requireSession } from '../server/requireSession';
|
|
8
|
+
import type { Session } from '../types';
|
|
9
|
+
import { Role, Scope } from '../types';
|
|
10
|
+
|
|
11
|
+
function mockCtx(session: Session | null) {
|
|
12
|
+
return {
|
|
13
|
+
get(key: string): unknown {
|
|
14
|
+
if (key === 'session') {
|
|
15
|
+
return session;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const adminSession: Session = {
|
|
23
|
+
id: 'sess-1',
|
|
24
|
+
userId: 'user-1',
|
|
25
|
+
userEmail: 'admin@test.com',
|
|
26
|
+
userName: 'Admin',
|
|
27
|
+
userRole: Role.ADMIN,
|
|
28
|
+
scopes: [Scope.ADMIN_ALL],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const userSession: Session = {
|
|
32
|
+
id: 'sess-2',
|
|
33
|
+
userId: 'user-2',
|
|
34
|
+
userEmail: 'user@test.com',
|
|
35
|
+
userName: 'User',
|
|
36
|
+
userRole: Role.USER,
|
|
37
|
+
scopes: [Scope.WORKFLOW_READ, Scope.WORKFLOW_WRITE, Scope.BOARD_READ],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('requireSession', () => {
|
|
41
|
+
it('should return session when present (no scope)', () => {
|
|
42
|
+
const session = requireSession(mockCtx(userSession));
|
|
43
|
+
expect(session).toBe(userSession);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should throw Unauthorized when no session', () => {
|
|
47
|
+
expect(() => requireSession(mockCtx(null))).toThrow(Unauthorized);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should return session when scope matches', () => {
|
|
51
|
+
const session = requireSession(mockCtx(userSession), Scope.WORKFLOW_READ);
|
|
52
|
+
expect(session).toBe(userSession);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should throw Forbidden when scope does not match', () => {
|
|
56
|
+
expect(() => requireSession(mockCtx(userSession), Scope.ADMIN_ALL)).toThrow(Forbidden);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should allow admin to access any scope', () => {
|
|
60
|
+
const session = requireSession(mockCtx(adminSession), Scope.WORKFLOW_READ);
|
|
61
|
+
expect(session).toBe(adminSession);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should accept array of scopes (any match)', () => {
|
|
65
|
+
const session = requireSession(mockCtx(userSession), [Scope.ADMIN_ALL, Scope.WORKFLOW_READ]);
|
|
66
|
+
expect(session).toBe(userSession);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should throw Forbidden when no array scopes match', () => {
|
|
70
|
+
expect(() =>
|
|
71
|
+
requireSession(mockCtx(userSession), [Scope.ADMIN_ALL, Scope.PLUGIN_MANAGE])
|
|
72
|
+
).toThrow(Forbidden);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should throw Unauthorized before checking scope when no session', () => {
|
|
76
|
+
expect(() => requireSession(mockCtx(null), Scope.ADMIN_ALL)).toThrow(Unauthorized);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - Auth Route Tests (login, logout, session info)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { stub, useTestBed } from '@brika/di/testing';
|
|
7
|
+
import type { Middleware } from '@brika/router';
|
|
8
|
+
import { TestApp } from '@brika/router/testing';
|
|
9
|
+
import { authProtectedRoutes, authPublicRoutes } from '../server/routes/auth';
|
|
10
|
+
import { AuthService } from '../services/AuthService';
|
|
11
|
+
import { UserService } from '../services/UserService';
|
|
12
|
+
import { Role, Scope, type Session, type User } from '../types';
|
|
13
|
+
|
|
14
|
+
const authRoutes = [...authPublicRoutes, ...authProtectedRoutes];
|
|
15
|
+
|
|
16
|
+
import { getAuthConfig } from '../config';
|
|
17
|
+
|
|
18
|
+
function withSession(session: Session): Middleware {
|
|
19
|
+
return async (c, next) => {
|
|
20
|
+
c.set('session', session);
|
|
21
|
+
await next();
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const adminSession: Session = {
|
|
26
|
+
id: 'sess-admin',
|
|
27
|
+
userId: 'user-admin',
|
|
28
|
+
userEmail: 'admin@test.com',
|
|
29
|
+
userName: 'Admin',
|
|
30
|
+
userRole: Role.ADMIN,
|
|
31
|
+
scopes: [Scope.ADMIN_ALL],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const mockUser: User = {
|
|
35
|
+
id: 'user-admin',
|
|
36
|
+
email: 'admin@test.com',
|
|
37
|
+
name: 'Admin',
|
|
38
|
+
role: Role.ADMIN,
|
|
39
|
+
avatarHash: null,
|
|
40
|
+
createdAt: new Date(),
|
|
41
|
+
updatedAt: new Date(),
|
|
42
|
+
isActive: true,
|
|
43
|
+
scopes: [],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ─── POST /login ────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
describe('POST /login', () => {
|
|
49
|
+
let app: ReturnType<typeof TestApp.create>;
|
|
50
|
+
|
|
51
|
+
useTestBed(() => {
|
|
52
|
+
stub(AuthService, {
|
|
53
|
+
login: async () => ({
|
|
54
|
+
token: 'tok-abc',
|
|
55
|
+
user: mockUser,
|
|
56
|
+
expiresIn: 604800,
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
app = TestApp.create(authRoutes);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('returns 200 with Set-Cookie on success', async () => {
|
|
63
|
+
const res = await app.post('/login', {
|
|
64
|
+
email: 'admin@test.com',
|
|
65
|
+
password: 'secret',
|
|
66
|
+
});
|
|
67
|
+
expect(res.status).toBe(200);
|
|
68
|
+
|
|
69
|
+
const body = res.body as {
|
|
70
|
+
user: User;
|
|
71
|
+
};
|
|
72
|
+
expect(body.user.email).toBe('admin@test.com');
|
|
73
|
+
|
|
74
|
+
const cookie = res.headers.get('set-cookie') ?? '';
|
|
75
|
+
expect(cookie).toContain(`${getAuthConfig().session.cookieName}=tok-abc`);
|
|
76
|
+
expect(cookie).toContain('HttpOnly');
|
|
77
|
+
expect(cookie).toContain('Max-Age=604800');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('extracts IP from x-forwarded-for header', async () => {
|
|
81
|
+
let capturedIp: string | undefined;
|
|
82
|
+
stub(AuthService, {
|
|
83
|
+
login: async (_email: string, _password: string, ip?: string) => {
|
|
84
|
+
capturedIp = ip;
|
|
85
|
+
return {
|
|
86
|
+
token: 'tok',
|
|
87
|
+
user: mockUser,
|
|
88
|
+
expiresIn: 604800,
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
app = TestApp.create(authRoutes);
|
|
93
|
+
|
|
94
|
+
await app.post(
|
|
95
|
+
'/login',
|
|
96
|
+
{
|
|
97
|
+
email: 'a@b.com',
|
|
98
|
+
password: 'x',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
headers: {
|
|
102
|
+
'x-forwarded-for': '10.0.0.1',
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
expect(capturedIp).toBe('10.0.0.1');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('extracts IP from x-real-ip when x-forwarded-for is absent', async () => {
|
|
110
|
+
let capturedIp: string | undefined;
|
|
111
|
+
stub(AuthService, {
|
|
112
|
+
login: async (_email: string, _password: string, ip?: string) => {
|
|
113
|
+
capturedIp = ip;
|
|
114
|
+
return {
|
|
115
|
+
token: 'tok',
|
|
116
|
+
user: mockUser,
|
|
117
|
+
expiresIn: 604800,
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
app = TestApp.create(authRoutes);
|
|
122
|
+
|
|
123
|
+
await app.post(
|
|
124
|
+
'/login',
|
|
125
|
+
{
|
|
126
|
+
email: 'a@b.com',
|
|
127
|
+
password: 'x',
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
headers: {
|
|
131
|
+
'x-real-ip': '10.0.0.2',
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
expect(capturedIp).toBe('10.0.0.2');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('returns 401 when credentials are invalid (service throws)', async () => {
|
|
139
|
+
stub(AuthService, {
|
|
140
|
+
login: async () => {
|
|
141
|
+
throw new Error('Invalid credentials');
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
app = TestApp.create(authRoutes);
|
|
145
|
+
|
|
146
|
+
const res = await app.post('/login', {
|
|
147
|
+
email: 'bad@test.com',
|
|
148
|
+
password: 'wrong',
|
|
149
|
+
});
|
|
150
|
+
expect(res.status).toBe(401);
|
|
151
|
+
const body = res.body as {
|
|
152
|
+
error: string;
|
|
153
|
+
};
|
|
154
|
+
expect(body.error).toBe('Invalid credentials');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ─── POST /logout ───────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
describe('POST /logout — with session', () => {
|
|
161
|
+
let app: ReturnType<typeof TestApp.create>;
|
|
162
|
+
let revokedId: string | undefined;
|
|
163
|
+
|
|
164
|
+
useTestBed(() => {
|
|
165
|
+
revokedId = undefined;
|
|
166
|
+
stub(AuthService, {
|
|
167
|
+
logout: (id: string) => {
|
|
168
|
+
revokedId = id;
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
app = TestApp.create(authRoutes, [withSession(adminSession)]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('clears session cookie when logged in', async () => {
|
|
175
|
+
const res = await app.post('/logout');
|
|
176
|
+
|
|
177
|
+
expect(res.status).toBe(200);
|
|
178
|
+
expect(revokedId).toBe('sess-admin');
|
|
179
|
+
|
|
180
|
+
const cookie = res.headers.get('set-cookie') ?? '';
|
|
181
|
+
expect(cookie).toContain(`${getAuthConfig().session.cookieName}=;`);
|
|
182
|
+
expect(cookie).toContain('Max-Age=0');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('POST /logout — without session', () => {
|
|
187
|
+
let app: ReturnType<typeof TestApp.create>;
|
|
188
|
+
|
|
189
|
+
useTestBed(() => {
|
|
190
|
+
stub(AuthService, {
|
|
191
|
+
logout: () => {},
|
|
192
|
+
});
|
|
193
|
+
// No session middleware — ctx.get('session') returns null
|
|
194
|
+
app = TestApp.create(authRoutes);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('returns 200 with empty cookie when no session (silent logout)', async () => {
|
|
198
|
+
const res = await app.post('/logout');
|
|
199
|
+
|
|
200
|
+
expect(res.status).toBe(200);
|
|
201
|
+
const body = res.body as {
|
|
202
|
+
ok: boolean;
|
|
203
|
+
};
|
|
204
|
+
expect(body.ok).toBe(true);
|
|
205
|
+
|
|
206
|
+
const cookie = res.headers.get('set-cookie') ?? '';
|
|
207
|
+
expect(cookie).toContain('Max-Age=0');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ─── GET /session ───────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
describe('GET /session — authenticated', () => {
|
|
214
|
+
let app: ReturnType<typeof TestApp.create>;
|
|
215
|
+
|
|
216
|
+
useTestBed(() => {
|
|
217
|
+
stub(UserService, {
|
|
218
|
+
getUser: () => mockUser,
|
|
219
|
+
});
|
|
220
|
+
app = TestApp.create(authRoutes, [withSession(adminSession)]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('returns user and scopes', async () => {
|
|
224
|
+
const res = await app.get('/session');
|
|
225
|
+
expect(res.status).toBe(200);
|
|
226
|
+
|
|
227
|
+
const body = res.body as {
|
|
228
|
+
user: User;
|
|
229
|
+
scopes: Scope[];
|
|
230
|
+
};
|
|
231
|
+
expect(body.user.email).toBe('admin@test.com');
|
|
232
|
+
expect(body.scopes).toEqual([Scope.ADMIN_ALL]);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('GET /session — unauthenticated', () => {
|
|
237
|
+
let app: ReturnType<typeof TestApp.create>;
|
|
238
|
+
|
|
239
|
+
useTestBed(() => {
|
|
240
|
+
stub(UserService);
|
|
241
|
+
app = TestApp.create(authRoutes);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('returns 401 without session', async () => {
|
|
245
|
+
const res = await app.get('/session');
|
|
246
|
+
expect(res.status).toBe(401);
|
|
247
|
+
});
|
|
248
|
+
});
|