@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,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - serveImage Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the image serving utility: null data handling, cache headers,
|
|
5
|
+
* ETag generation, 304 responses, and query parameter parsing with photon resize.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'bun:test';
|
|
9
|
+
import { deflateSync } from 'node:zlib';
|
|
10
|
+
import { type ImageQuery, serveImage } from '../server/serveImage';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
function createCtx(
|
|
17
|
+
query: ImageQuery = {},
|
|
18
|
+
headers: Record<string, string> = {}
|
|
19
|
+
): {
|
|
20
|
+
req: Request;
|
|
21
|
+
query: ImageQuery;
|
|
22
|
+
} {
|
|
23
|
+
const req = new Request('http://localhost:3001/api/auth/avatar/user-1', {
|
|
24
|
+
headers,
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
req,
|
|
28
|
+
query,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Simple 1x1 white PNG as a Buffer for testing. */
|
|
33
|
+
const TINY_IMAGE = Buffer.from(
|
|
34
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQAB' +
|
|
35
|
+
'Nl7BcQAAAABJRU5ErkJggg==',
|
|
36
|
+
'base64'
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
/** Create a valid NxN RGB PNG for resize tests. */
|
|
40
|
+
function makeTestPng(size: number): Buffer {
|
|
41
|
+
function crc32(buf: Buffer): number {
|
|
42
|
+
const table = new Uint32Array(256);
|
|
43
|
+
for (let i = 0; i < 256; i++) {
|
|
44
|
+
let c = i;
|
|
45
|
+
for (let j = 0; j < 8; j++) {
|
|
46
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
47
|
+
}
|
|
48
|
+
table[i] = c;
|
|
49
|
+
}
|
|
50
|
+
let c = 0xffffffff;
|
|
51
|
+
for (let i = 0; i < buf.length; i++) {
|
|
52
|
+
c = (c >>> 8) ^ (table[(c ^ (buf[i] ?? 0)) & 0xff] ?? 0);
|
|
53
|
+
}
|
|
54
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
55
|
+
}
|
|
56
|
+
function chunk(type: string, data: Buffer): Buffer {
|
|
57
|
+
const len = Buffer.alloc(4);
|
|
58
|
+
len.writeUInt32BE(data.length);
|
|
59
|
+
const td = Buffer.concat([Buffer.from(type), data]);
|
|
60
|
+
const crc = Buffer.alloc(4);
|
|
61
|
+
crc.writeUInt32BE(crc32(td));
|
|
62
|
+
return Buffer.concat([len, td, crc]);
|
|
63
|
+
}
|
|
64
|
+
const rowBytes = 1 + size * 3; // filter byte + RGB per pixel
|
|
65
|
+
const raw = Buffer.alloc(size * rowBytes);
|
|
66
|
+
for (let y = 0; y < size; y++) {
|
|
67
|
+
raw[y * rowBytes] = 0; // filter=none
|
|
68
|
+
for (let x = 0; x < size; x++) {
|
|
69
|
+
raw[y * rowBytes + 1 + x * 3] = 255; // R
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const ihdr = Buffer.alloc(13);
|
|
73
|
+
ihdr.writeUInt32BE(size, 0);
|
|
74
|
+
ihdr.writeUInt32BE(size, 4);
|
|
75
|
+
ihdr[8] = 8;
|
|
76
|
+
ihdr[9] = 2; // RGB
|
|
77
|
+
return Buffer.concat([
|
|
78
|
+
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
79
|
+
chunk('IHDR', ihdr),
|
|
80
|
+
chunk('IDAT', deflateSync(raw)),
|
|
81
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Tests
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
describe('serveImage', () => {
|
|
90
|
+
// -------------------------------------------------------------------------
|
|
91
|
+
// null data
|
|
92
|
+
// -------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
describe('null data', () => {
|
|
95
|
+
it('should return 204 when data is null', () => {
|
|
96
|
+
const response = serveImage(null, createCtx());
|
|
97
|
+
expect(response.status).toBe(204);
|
|
98
|
+
expect(response.body).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// -------------------------------------------------------------------------
|
|
103
|
+
// Basic response
|
|
104
|
+
// -------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
describe('basic response', () => {
|
|
107
|
+
it('should return webp content type', () => {
|
|
108
|
+
const response = serveImage(TINY_IMAGE, createCtx());
|
|
109
|
+
expect(response.headers.get('Content-Type')).toBe('image/webp');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should return 200 status', () => {
|
|
113
|
+
const response = serveImage(TINY_IMAGE, createCtx());
|
|
114
|
+
expect(response.status).toBe(200);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should include ETag header', () => {
|
|
118
|
+
const response = serveImage(TINY_IMAGE, createCtx());
|
|
119
|
+
const etag = response.headers.get('ETag');
|
|
120
|
+
expect(etag).not.toBeNull();
|
|
121
|
+
expect(etag).toStartWith('"');
|
|
122
|
+
expect(etag).toEndWith('"');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should return body as Uint8Array', async () => {
|
|
126
|
+
const response = serveImage(TINY_IMAGE, createCtx());
|
|
127
|
+
const body = await response.arrayBuffer();
|
|
128
|
+
expect(body.byteLength).toBeGreaterThan(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should produce consistent ETag for same data', () => {
|
|
132
|
+
const r1 = serveImage(TINY_IMAGE, createCtx());
|
|
133
|
+
const r2 = serveImage(TINY_IMAGE, createCtx());
|
|
134
|
+
expect(r1.headers.get('ETag')).toBe(r2.headers.get('ETag'));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should produce different ETag for different data', () => {
|
|
138
|
+
const otherImage = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]);
|
|
139
|
+
const r1 = serveImage(TINY_IMAGE, createCtx());
|
|
140
|
+
const r2 = serveImage(otherImage, createCtx());
|
|
141
|
+
expect(r1.headers.get('ETag')).not.toBe(r2.headers.get('ETag'));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// -------------------------------------------------------------------------
|
|
146
|
+
// 304 Not Modified
|
|
147
|
+
// -------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
describe('304 Not Modified', () => {
|
|
150
|
+
it('should return 304 when If-None-Match matches ETag', () => {
|
|
151
|
+
const firstResponse = serveImage(TINY_IMAGE, createCtx());
|
|
152
|
+
const etag = firstResponse.headers.get('ETag');
|
|
153
|
+
if (!etag) {
|
|
154
|
+
throw new Error('Expected ETag header to be defined');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const ctx = createCtx(
|
|
158
|
+
{},
|
|
159
|
+
{
|
|
160
|
+
'If-None-Match': etag,
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
const response = serveImage(TINY_IMAGE, ctx);
|
|
164
|
+
|
|
165
|
+
expect(response.status).toBe(304);
|
|
166
|
+
expect(response.headers.get('ETag')).toBe(etag);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should return 200 when If-None-Match does not match', () => {
|
|
170
|
+
const ctx = createCtx(
|
|
171
|
+
{},
|
|
172
|
+
{
|
|
173
|
+
'If-None-Match': '"stale-etag"',
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
const response = serveImage(TINY_IMAGE, ctx);
|
|
177
|
+
expect(response.status).toBe(200);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should return 304 with null body', () => {
|
|
181
|
+
const firstResponse = serveImage(TINY_IMAGE, createCtx());
|
|
182
|
+
const etag = firstResponse.headers.get('ETag');
|
|
183
|
+
if (!etag) {
|
|
184
|
+
throw new Error('Expected ETag header to be defined');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const ctx = createCtx(
|
|
188
|
+
{},
|
|
189
|
+
{
|
|
190
|
+
'If-None-Match': etag,
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
const response = serveImage(TINY_IMAGE, ctx);
|
|
194
|
+
expect(response.status).toBe(304);
|
|
195
|
+
expect(response.body).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// -------------------------------------------------------------------------
|
|
200
|
+
// Cache-Control
|
|
201
|
+
// -------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
describe('Cache-Control', () => {
|
|
204
|
+
it('should default to 1-year max-age with immutable', () => {
|
|
205
|
+
const response = serveImage(TINY_IMAGE, createCtx());
|
|
206
|
+
const cc = response.headers.get('Cache-Control');
|
|
207
|
+
expect(cc).toBe('public, max-age=31536000, immutable');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should use custom maxAge with immutable', () => {
|
|
211
|
+
const response = serveImage(TINY_IMAGE, createCtx(), {
|
|
212
|
+
maxAge: 7200,
|
|
213
|
+
});
|
|
214
|
+
const cc = response.headers.get('Cache-Control');
|
|
215
|
+
expect(cc).toBe('public, max-age=7200, immutable');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should use no-cache when maxAge is 0', () => {
|
|
219
|
+
const response = serveImage(TINY_IMAGE, createCtx(), {
|
|
220
|
+
maxAge: 0,
|
|
221
|
+
});
|
|
222
|
+
const cc = response.headers.get('Cache-Control');
|
|
223
|
+
expect(cc).toBe('no-cache');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// -------------------------------------------------------------------------
|
|
228
|
+
// Query parameters (w, h, s) — resize via @cf-wasm/photon
|
|
229
|
+
// -------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
describe('query parameters', () => {
|
|
232
|
+
it('should handle empty query', () => {
|
|
233
|
+
const response = serveImage(TINY_IMAGE, createCtx({}));
|
|
234
|
+
expect(response.status).toBe(200);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should resize with ?s (square)', () => {
|
|
238
|
+
const validImage = makeTestPng(4);
|
|
239
|
+
const response = serveImage(
|
|
240
|
+
validImage,
|
|
241
|
+
createCtx({
|
|
242
|
+
s: 2,
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
expect(response.status).toBe(200);
|
|
246
|
+
expect(response.headers.get('Content-Type')).toBe('image/webp');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should resize with ?w only', () => {
|
|
250
|
+
const validImage = makeTestPng(4);
|
|
251
|
+
const response = serveImage(
|
|
252
|
+
validImage,
|
|
253
|
+
createCtx({
|
|
254
|
+
w: 3,
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
expect(response.status).toBe(200);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should resize with ?w and ?h', () => {
|
|
261
|
+
const validImage = makeTestPng(4);
|
|
262
|
+
const response = serveImage(
|
|
263
|
+
validImage,
|
|
264
|
+
createCtx({
|
|
265
|
+
w: 3,
|
|
266
|
+
h: 2,
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
expect(response.status).toBe(200);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should skip resize when no dimensions provided', () => {
|
|
273
|
+
const response = serveImage(TINY_IMAGE, createCtx({}));
|
|
274
|
+
expect(response.status).toBe(200);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - Setup Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for openAuthDatabase and setupAuthServices.
|
|
5
|
+
* Verifies schema creation, indices, migrations, and DI registration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Database } from 'bun:sqlite';
|
|
9
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
10
|
+
import { container, inject } from '@brika/di';
|
|
11
|
+
import { AuthService } from '../services/AuthService';
|
|
12
|
+
import { ScopeService } from '../services/ScopeService';
|
|
13
|
+
import { SessionService } from '../services/SessionService';
|
|
14
|
+
import { UserService } from '../services/UserService';
|
|
15
|
+
import { openAuthDatabase, setupAuthServices } from '../setup';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
interface TableInfo {
|
|
22
|
+
name: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface IndexInfo {
|
|
26
|
+
name: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ColumnInfo {
|
|
30
|
+
name: string;
|
|
31
|
+
type: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getTableNames(db: Database): string[] {
|
|
35
|
+
const rows = db
|
|
36
|
+
.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
|
37
|
+
.all() as TableInfo[];
|
|
38
|
+
return rows.map((r) => r.name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getIndexNames(db: Database): string[] {
|
|
42
|
+
const rows = db.query("SELECT name FROM sqlite_master WHERE type='index'").all() as IndexInfo[];
|
|
43
|
+
return rows.map((r) => r.name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getColumns(db: Database, table: string): string[] {
|
|
47
|
+
const rows = db.query(`PRAGMA table_info(${table})`).all() as ColumnInfo[];
|
|
48
|
+
return rows.map((r) => r.name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// openAuthDatabase
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
describe('openAuthDatabase', () => {
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
container.clearInstances();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should create users table', () => {
|
|
61
|
+
const db = openAuthDatabase(':memory:');
|
|
62
|
+
const tables = getTableNames(db);
|
|
63
|
+
expect(tables).toContain('users');
|
|
64
|
+
db.close();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should create sessions table', () => {
|
|
68
|
+
const db = openAuthDatabase(':memory:');
|
|
69
|
+
const tables = getTableNames(db);
|
|
70
|
+
expect(tables).toContain('sessions');
|
|
71
|
+
db.close();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should create users table with expected columns', () => {
|
|
75
|
+
const db = openAuthDatabase(':memory:');
|
|
76
|
+
const columns = getColumns(db, 'users');
|
|
77
|
+
expect(columns).toContain('id');
|
|
78
|
+
expect(columns).toContain('email');
|
|
79
|
+
expect(columns).toContain('password_hash');
|
|
80
|
+
expect(columns).toContain('name');
|
|
81
|
+
expect(columns).toContain('role');
|
|
82
|
+
expect(columns).toContain('is_active');
|
|
83
|
+
expect(columns).toContain('created_at');
|
|
84
|
+
expect(columns).toContain('updated_at');
|
|
85
|
+
db.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should create sessions table with expected columns', () => {
|
|
89
|
+
const db = openAuthDatabase(':memory:');
|
|
90
|
+
const columns = getColumns(db, 'sessions');
|
|
91
|
+
expect(columns).toContain('id');
|
|
92
|
+
expect(columns).toContain('user_id');
|
|
93
|
+
expect(columns).toContain('token_hash');
|
|
94
|
+
expect(columns).toContain('ip');
|
|
95
|
+
expect(columns).toContain('user_agent');
|
|
96
|
+
expect(columns).toContain('created_at');
|
|
97
|
+
expect(columns).toContain('last_seen_at');
|
|
98
|
+
expect(columns).toContain('expires_at');
|
|
99
|
+
expect(columns).toContain('revoked_at');
|
|
100
|
+
db.close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should create indices', () => {
|
|
104
|
+
const db = openAuthDatabase(':memory:');
|
|
105
|
+
const indices = getIndexNames(db);
|
|
106
|
+
expect(indices).toContain('idx_users_email');
|
|
107
|
+
expect(indices).toContain('idx_sessions_token_hash');
|
|
108
|
+
expect(indices).toContain('idx_sessions_user_id');
|
|
109
|
+
db.close();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should have avatar and scopes columns', () => {
|
|
113
|
+
const db = openAuthDatabase(':memory:');
|
|
114
|
+
const columns = getColumns(db, 'users');
|
|
115
|
+
expect(columns).toContain('avatar_data');
|
|
116
|
+
expect(columns).toContain('avatar_mime');
|
|
117
|
+
expect(columns).toContain('avatar_hash');
|
|
118
|
+
expect(columns).toContain('scopes');
|
|
119
|
+
db.close();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should attempt to set WAL journal mode', () => {
|
|
123
|
+
// In-memory databases don't support WAL (they report "memory"),
|
|
124
|
+
// but the code still runs the PRAGMA without error.
|
|
125
|
+
const db = openAuthDatabase(':memory:');
|
|
126
|
+
const result = db.query('PRAGMA journal_mode').get() as {
|
|
127
|
+
journal_mode: string;
|
|
128
|
+
};
|
|
129
|
+
// :memory: databases can't use WAL, so journal_mode stays "memory"
|
|
130
|
+
expect(result.journal_mode).toBe('memory');
|
|
131
|
+
db.close();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should be idempotent (calling twice does not error)', () => {
|
|
135
|
+
const db = openAuthDatabase(':memory:');
|
|
136
|
+
// Simulate running the migrations again on an already-migrated database
|
|
137
|
+
// by closing and re-opening (not possible with :memory:, but we can just
|
|
138
|
+
// verify the ALTER TABLE catch blocks work by calling the function logic).
|
|
139
|
+
// The migrations use try/catch so they silently skip if columns exist.
|
|
140
|
+
// We verify by checking columns are still present.
|
|
141
|
+
const columns = getColumns(db, 'users');
|
|
142
|
+
expect(columns).toContain('avatar_data');
|
|
143
|
+
expect(columns).toContain('scopes');
|
|
144
|
+
db.close();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should create email unique constraint on users', () => {
|
|
148
|
+
const db = openAuthDatabase(':memory:');
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
db.run(
|
|
151
|
+
"INSERT INTO users (id, email, name, role, is_active, created_at, updated_at) VALUES ('u1', 'a@b.com', 'A', 'user', 1, ?, ?)",
|
|
152
|
+
[now, now]
|
|
153
|
+
);
|
|
154
|
+
// Inserting duplicate email should fail
|
|
155
|
+
expect(() =>
|
|
156
|
+
db.run(
|
|
157
|
+
"INSERT INTO users (id, email, name, role, is_active, created_at, updated_at) VALUES ('u2', 'a@b.com', 'B', 'user', 1, ?, ?)",
|
|
158
|
+
[now, now]
|
|
159
|
+
)
|
|
160
|
+
).toThrow();
|
|
161
|
+
db.close();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should create foreign key from sessions to users', () => {
|
|
165
|
+
const db = openAuthDatabase(':memory:');
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
// Insert a user first
|
|
168
|
+
db.run(
|
|
169
|
+
"INSERT INTO users (id, email, name, role, is_active, created_at, updated_at) VALUES ('u1', 'a@b.com', 'A', 'user', 1, ?, ?)",
|
|
170
|
+
[now, now]
|
|
171
|
+
);
|
|
172
|
+
// Insert session referencing the user (should succeed)
|
|
173
|
+
db.run(
|
|
174
|
+
"INSERT INTO sessions (id, user_id, token_hash, created_at, last_seen_at, expires_at) VALUES ('s1', 'u1', 'hash1', ?, ?, ?)",
|
|
175
|
+
[now, now, now + 86400000]
|
|
176
|
+
);
|
|
177
|
+
const sessions = db.query('SELECT * FROM sessions WHERE user_id = ?').all('u1');
|
|
178
|
+
expect(sessions).toHaveLength(1);
|
|
179
|
+
db.close();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// setupAuthServices
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
describe('setupAuthServices', () => {
|
|
188
|
+
afterEach(() => {
|
|
189
|
+
container.clearInstances();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should register SessionService in the container', () => {
|
|
193
|
+
const db = openAuthDatabase(':memory:');
|
|
194
|
+
setupAuthServices(db);
|
|
195
|
+
|
|
196
|
+
const service = inject(SessionService);
|
|
197
|
+
expect(service).toBeDefined();
|
|
198
|
+
expect(service).toBeInstanceOf(SessionService);
|
|
199
|
+
db.close();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should register UserService in the container', () => {
|
|
203
|
+
const db = openAuthDatabase(':memory:');
|
|
204
|
+
setupAuthServices(db);
|
|
205
|
+
|
|
206
|
+
const service = inject(UserService);
|
|
207
|
+
expect(service).toBeDefined();
|
|
208
|
+
expect(service).toBeInstanceOf(UserService);
|
|
209
|
+
db.close();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should register ScopeService in the container', () => {
|
|
213
|
+
const db = openAuthDatabase(':memory:');
|
|
214
|
+
setupAuthServices(db);
|
|
215
|
+
|
|
216
|
+
const service = inject(ScopeService);
|
|
217
|
+
expect(service).toBeDefined();
|
|
218
|
+
expect(service).toBeInstanceOf(ScopeService);
|
|
219
|
+
db.close();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should register AuthService in the container', () => {
|
|
223
|
+
const db = openAuthDatabase(':memory:');
|
|
224
|
+
setupAuthServices(db);
|
|
225
|
+
|
|
226
|
+
const service = inject(AuthService);
|
|
227
|
+
expect(service).toBeDefined();
|
|
228
|
+
expect(service).toBeInstanceOf(AuthService);
|
|
229
|
+
db.close();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should accept custom session TTL', () => {
|
|
233
|
+
const db = openAuthDatabase(':memory:');
|
|
234
|
+
setupAuthServices(db, {
|
|
235
|
+
session: {
|
|
236
|
+
ttl: 3600,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const sessionService = inject(SessionService);
|
|
241
|
+
expect(sessionService).toBeDefined();
|
|
242
|
+
// SessionService should have been constructed with the custom TTL
|
|
243
|
+
expect(sessionService.getSessionTTL()).toBe(3600);
|
|
244
|
+
db.close();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should use default session TTL when not provided', () => {
|
|
248
|
+
const db = openAuthDatabase(':memory:');
|
|
249
|
+
setupAuthServices(db);
|
|
250
|
+
|
|
251
|
+
const sessionService = inject(SessionService);
|
|
252
|
+
// Default TTL is 604800 (7 days)
|
|
253
|
+
expect(sessionService.getSessionTTL()).toBe(604800);
|
|
254
|
+
db.close();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should allow full auth flow after setup (inject AuthService)', () => {
|
|
258
|
+
const db = openAuthDatabase(':memory:');
|
|
259
|
+
setupAuthServices(db);
|
|
260
|
+
|
|
261
|
+
// The AuthService should be resolvable and its dependencies should be wired
|
|
262
|
+
const authService = inject(AuthService);
|
|
263
|
+
expect(authService).toBeDefined();
|
|
264
|
+
expect(authService).toBeInstanceOf(AuthService);
|
|
265
|
+
|
|
266
|
+
// getCurrentUser should return null for unknown user (verifies the chain works)
|
|
267
|
+
expect(authService.getCurrentUser('nonexistent')).toBeNull();
|
|
268
|
+
db.close();
|
|
269
|
+
});
|
|
270
|
+
});
|