@donkeylabs/cli 1.1.17 → 1.1.19

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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/src/commands/generate.ts +154 -3
  3. package/templates/sveltekit-app/package.json +3 -3
  4. package/templates/sveltekit-app/src/lib/permissions.ts +15 -5
  5. package/templates/sveltekit-app/src/routes/+page.server.ts +1 -1
  6. package/templates/sveltekit-app/src/routes/workflows/+page.server.ts +1 -1
  7. package/templates/sveltekit-app/src/server/index.ts +1 -1
  8. package/templates/sveltekit-app/src/server/plugins/auth/auth.test.ts +377 -0
  9. package/templates/sveltekit-app/src/server/plugins/auth/index.ts +7 -7
  10. package/templates/sveltekit-app/src/server/plugins/auth/schema.ts +65 -0
  11. package/templates/sveltekit-app/src/server/plugins/email/email.test.ts +369 -0
  12. package/templates/sveltekit-app/src/server/plugins/email/schema.ts +24 -0
  13. package/templates/sveltekit-app/src/server/plugins/permissions/index.ts +10 -7
  14. package/templates/sveltekit-app/src/server/plugins/permissions/permissions.test.ts +566 -0
  15. package/templates/sveltekit-app/src/server/plugins/permissions/schema.ts +67 -0
  16. package/templates/sveltekit-app/src/server/plugins/workflow-demo/index.ts +3 -2
  17. package/templates/sveltekit-app/src/server/routes/auth/handlers/login.handler.ts +4 -6
  18. package/templates/sveltekit-app/src/server/routes/auth/handlers/logout.handler.ts +5 -8
  19. package/templates/sveltekit-app/src/server/routes/auth/handlers/me.handler.ts +4 -7
  20. package/templates/sveltekit-app/src/server/routes/auth/handlers/refresh.handler.ts +4 -6
  21. package/templates/sveltekit-app/src/server/routes/auth/handlers/register.handler.ts +4 -6
  22. package/templates/sveltekit-app/src/server/routes/auth/handlers/update-profile.handler.ts +5 -8
  23. package/templates/sveltekit-app/src/server/routes/auth/index.ts +6 -7
  24. package/templates/sveltekit-app/src/server/routes/example/handlers/greet.handler.ts +3 -5
  25. package/templates/sveltekit-app/src/server/routes/permissions/index.ts +9 -9
  26. package/templates/sveltekit-app/src/server/routes/tenants/index.ts +18 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/cli",
3
- "version": "1.1.17",
3
+ "version": "1.1.19",
4
4
  "type": "module",
5
5
  "description": "CLI for @donkeylabs/server - project scaffolding and code generation",
6
6
  "main": "./src/index.ts",
@@ -83,6 +83,110 @@ async function extractMiddlewareNames(pluginPath: string): Promise<string[]> {
83
83
  }
84
84
  }
85
85
 
86
+ interface ServiceDefinitionInfo {
87
+ name: string;
88
+ exportName: string;
89
+ filePath: string;
90
+ returnType: string | null;
91
+ }
92
+
93
+ /**
94
+ * Extract defineService calls from source files.
95
+ * Looks for patterns like: export const myService = defineService("name", ...)
96
+ */
97
+ async function extractServiceDefinitions(filePath: string): Promise<ServiceDefinitionInfo[]> {
98
+ try {
99
+ const content = await readFile(filePath, "utf-8");
100
+ const services: ServiceDefinitionInfo[] = [];
101
+
102
+ // Match: export const serviceName = defineService("name", ...)
103
+ // Also try to capture return type annotation if present
104
+ const pattern = /export\s+const\s+(\w+)\s*=\s*defineService\s*\(\s*["'](\w+)["']/g;
105
+
106
+ let match;
107
+ while ((match = pattern.exec(content)) !== null) {
108
+ const exportName = match[1] || "";
109
+ const serviceName = match[2] || "";
110
+
111
+ // Try to find return type from explicit annotation or factory return
112
+ // Look for patterns like: defineService<"name", NVRService>(...) or ): Promise<NVR> =>
113
+ let returnType: string | null = null;
114
+
115
+ // Check for generic type parameter: defineService<"name", ReturnType>
116
+ const genericMatch = content.slice(match.index).match(
117
+ /defineService\s*<\s*["']\w+["']\s*,\s*([^>]+)>/
118
+ );
119
+ if (genericMatch?.[1]) {
120
+ returnType = genericMatch[1].trim();
121
+ }
122
+
123
+ // Check for return type annotation on the factory: (ctx): Promise<Type> =>
124
+ if (!returnType) {
125
+ const factoryMatch = content.slice(match.index, match.index + 500).match(
126
+ /,\s*(?:async\s*)?\([^)]*\)\s*(?::\s*(?:Promise\s*<\s*)?([A-Z]\w+))?/
127
+ );
128
+ if (factoryMatch?.[1]) {
129
+ returnType = factoryMatch[1].trim();
130
+ }
131
+ }
132
+
133
+ services.push({
134
+ name: serviceName,
135
+ exportName,
136
+ filePath,
137
+ returnType,
138
+ });
139
+ }
140
+
141
+ return services;
142
+ } catch {
143
+ return [];
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Find all service definitions in the project.
149
+ * Scans common locations: server entry, services directory, etc.
150
+ */
151
+ async function findServiceDefinitions(entryPath: string, servicesPattern?: string): Promise<ServiceDefinitionInfo[]> {
152
+ const allServices: ServiceDefinitionInfo[] = [];
153
+
154
+ // Scan the entry file
155
+ const entryFullPath = join(process.cwd(), entryPath);
156
+ if (existsSync(entryFullPath)) {
157
+ const entryServices = await extractServiceDefinitions(entryFullPath);
158
+ allServices.push(...entryServices);
159
+ }
160
+
161
+ // Scan services directory if it exists
162
+ const servicesDir = join(process.cwd(), "src/server/services");
163
+ if (existsSync(servicesDir)) {
164
+ const entries = await readdir(servicesDir, { withFileTypes: true });
165
+ for (const entry of entries) {
166
+ if (entry.isFile() && entry.name.endsWith(".ts")) {
167
+ const filePath = join(servicesDir, entry.name);
168
+ const services = await extractServiceDefinitions(filePath);
169
+ allServices.push(...services);
170
+ }
171
+ }
172
+ }
173
+
174
+ // Also check src/lib/services for SvelteKit projects
175
+ const libServicesDir = join(process.cwd(), "src/lib/services");
176
+ if (existsSync(libServicesDir)) {
177
+ const entries = await readdir(libServicesDir, { withFileTypes: true });
178
+ for (const entry of entries) {
179
+ if (entry.isFile() && entry.name.endsWith(".ts")) {
180
+ const filePath = join(libServicesDir, entry.name);
181
+ const services = await extractServiceDefinitions(filePath);
182
+ allServices.push(...services);
183
+ }
184
+ }
185
+ }
186
+
187
+ return allServices;
188
+ }
189
+
86
190
  interface ExtractedRoute {
87
191
  name: string;
88
192
  handler: string;
@@ -519,9 +623,18 @@ export async function generateCommand(_args: string[]): Promise<void> {
519
623
  const entryPath = config.entry || "./src/index.ts";
520
624
  const serverRoutes = await extractRoutesFromServer(entryPath);
521
625
 
626
+ // Find custom service definitions
627
+ const services = await findServiceDefinitions(entryPath);
628
+ if (services.length > 0) {
629
+ console.log(
630
+ pc.green("Found services:"),
631
+ services.map((s) => pc.dim(s.name)).join(", ")
632
+ );
633
+ }
634
+
522
635
  // Generate all files
523
636
  await generateRegistry(plugins, outPath);
524
- await generateContext(plugins, outPath);
637
+ await generateContext(plugins, services, outPath);
525
638
  await generateRouteTypes(fileRoutes, outPath);
526
639
 
527
640
  const generated = ["registry", "context", "routes"];
@@ -668,22 +781,58 @@ ${middlewareBuilderMethods}
668
781
 
669
782
  async function generateContext(
670
783
  plugins: { name: string; path: string; exportName: string }[],
784
+ services: ServiceDefinitionInfo[],
671
785
  outPath: string
672
786
  ) {
673
787
  const schemaIntersection =
674
788
  plugins.map((p) => `PluginRegistry["${p.name}"]["schema"]`).join(" & ") ||
675
789
  "{}";
676
790
 
791
+ // Generate service imports and type entries
792
+ const serviceImports: string[] = [];
793
+ const serviceEntries: string[] = [];
794
+
795
+ for (const service of services) {
796
+ // Calculate relative path from outPath to service file
797
+ const serviceAbsPath = service.filePath.replace(/\.ts$/, "");
798
+ const relativePath = relative(outPath, serviceAbsPath);
799
+ const importPath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
800
+
801
+ serviceImports.push(`import type { ${service.exportName} } from "${importPath}";`);
802
+
803
+ // Use the inferred return type if available, otherwise use Awaited<ReturnType<factory>>
804
+ if (service.returnType) {
805
+ serviceEntries.push(` ${service.name}: ${service.returnType};`);
806
+ } else {
807
+ // Infer type from the service definition
808
+ serviceEntries.push(` ${service.name}: Awaited<ReturnType<typeof ${service.exportName}["factory"]>>;`);
809
+ }
810
+ }
811
+
812
+ const serviceImportsBlock = serviceImports.length > 0 ? serviceImports.join("\n") + "\n" : "";
813
+ const hasServices = serviceEntries.length > 0;
814
+ const servicesTypeDecl = hasServices
815
+ ? `export interface AppServices {\n${serviceEntries.join("\n")}\n }`
816
+ : "export type AppServices = Record<string, never>;";
817
+
677
818
  const content = `// Auto-generated by donkeylabs generate
678
819
  // App context - import as: import type { AppContext } from ".@donkeylabs/server/context";
679
820
 
680
821
  /// <reference path="./registry.d.ts" />
681
- import type { PluginRegistry, CoreServices, Errors } from "@donkeylabs/server";
822
+ import type { PluginRegistry, CoreServices, Errors, ServiceRegistry } from "@donkeylabs/server";
682
823
  import type { Kysely } from "kysely";
683
-
824
+ ${serviceImportsBlock}
684
825
  /** Merged database schema from all plugins */
685
826
  export type DatabaseSchema = ${schemaIntersection};
686
827
 
828
+ /** Custom services registered via defineService() */
829
+ ${servicesTypeDecl}
830
+
831
+ // Augment the ServiceRegistry for type inference in ctx.services
832
+ declare module "@donkeylabs/server" {
833
+ interface ServiceRegistry extends AppServices {}
834
+ }
835
+
687
836
  /**
688
837
  * Fully typed application context.
689
838
  * Use this instead of ServerContext for typed plugin access.
@@ -701,6 +850,8 @@ export interface AppContext {
701
850
  errors: Errors;
702
851
  /** Application config */
703
852
  config: Record<string, any>;
853
+ /** Custom user-registered services */
854
+ services: AppServices;
704
855
  /** Client IP address */
705
856
  ip: string;
706
857
  /** Unique request ID */
@@ -24,9 +24,9 @@
24
24
  "vite": "^7.2.6"
25
25
  },
26
26
  "dependencies": {
27
- "@donkeylabs/cli": "file:/Users/franciscosainzwilliams/Documents/GitHub/server/packages/cli",
28
- "@donkeylabs/adapter-sveltekit": "file:/Users/franciscosainzwilliams/Documents/GitHub/server/packages/adapter-sveltekit",
29
- "@donkeylabs/server": "file:/Users/franciscosainzwilliams/Documents/GitHub/server/packages/server",
27
+ "@donkeylabs/cli": "^1.1.19",
28
+ "@donkeylabs/adapter-sveltekit": "^1.1.19",
29
+ "@donkeylabs/server": "^1.1.19",
30
30
  "bits-ui": "^2.15.4",
31
31
  "clsx": "^2.1.1",
32
32
  "kysely": "^0.27.6",
@@ -26,7 +26,8 @@
26
26
  * ```
27
27
  */
28
28
 
29
- import { createApi } from "./api";
29
+ // Note: createApi is imported dynamically to avoid compile-time dependency
30
+ // on the generated API file which may not have permissions routes yet
30
31
 
31
32
  export interface PermissionContext {
32
33
  tenantId: string;
@@ -78,9 +79,16 @@ export interface PermissionsHelper {
78
79
  /**
79
80
  * Create a permissions helper from context
80
81
  */
81
- export function createPermissions(context: PermissionContext): PermissionsHelper {
82
+ export function createPermissions(context: PermissionContext, api?: any): PermissionsHelper {
82
83
  const permissionSet = new Set(context.permissions);
83
- const api = createApi();
84
+
85
+ // Lazy load API if not provided
86
+ const getApi = () => {
87
+ if (api) return api;
88
+ // Dynamic import to avoid compile-time dependency
89
+ const { createApi } = require("./api");
90
+ return createApi();
91
+ };
84
92
 
85
93
  return {
86
94
  has(permission: string): boolean {
@@ -129,7 +137,8 @@ export function createPermissions(context: PermissionContext): PermissionsHelper
129
137
  action: "create" | "read" | "write" | "delete" | "admin",
130
138
  ownerId?: string
131
139
  ): Promise<boolean> {
132
- const result = await api.permissions.canAccess({
140
+ const apiClient = getApi();
141
+ const result = await apiClient.permissions.canAccess({
133
142
  tenantId: context.tenantId,
134
143
  checks: [{ resourceType, resourceId, action, ownerId }],
135
144
  });
@@ -144,7 +153,8 @@ export function createPermissions(context: PermissionContext): PermissionsHelper
144
153
  ownerId?: string;
145
154
  }>
146
155
  ): Promise<Record<string, boolean>> {
147
- return api.permissions.canAccess({
156
+ const apiClient = getApi();
157
+ return apiClient.permissions.canAccess({
148
158
  tenantId: context.tenantId,
149
159
  checks,
150
160
  });
@@ -8,7 +8,7 @@ export const load: PageServerLoad = async ({ locals }) => {
8
8
 
9
9
  try {
10
10
  // Direct service call through typed client
11
- const result = await client.api.counter.get({});
11
+ const result = await (client as any).demo.counter.get({});
12
12
  return {
13
13
  count: result.count,
14
14
  loadedAt: new Date().toISOString(),
@@ -7,7 +7,7 @@ export const load: PageServerLoad = async ({ locals }) => {
7
7
 
8
8
  try {
9
9
  // Load initial workflow instances
10
- const result = await client.api.workflow.list({});
10
+ const result = await (client as any).demo.workflow.list({});
11
11
  return {
12
12
  instances: result.instances || [],
13
13
  loadedAt: new Date().toISOString(),
@@ -59,7 +59,7 @@ export const server = new AppServer({
59
59
  // =============================================================================
60
60
 
61
61
  // Using default session strategy for this template
62
- server.registerPlugin(authPlugin());
62
+ server.registerPlugin(authPlugin({}));
63
63
 
64
64
  // Email plugin - supports Resend or console (for development)
65
65
  // Configure with process.env.RESEND_API_KEY for production
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Auth Plugin Tests
3
+ *
4
+ * Tests for authentication with multiple strategies:
5
+ * - session: Stateful database sessions
6
+ * - jwt: Stateless JWT tokens
7
+ * - refresh-token: Access + refresh token pattern
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
11
+ import { createTestHarness } from "@donkeylabs/server";
12
+ import { authPlugin } from "./index";
13
+
14
+ // Helper to create test harness with auth plugin
15
+ async function createAuthTestHarness(config: Parameters<typeof authPlugin>[0] = {}) {
16
+ const harness = await createTestHarness(authPlugin(config));
17
+ return { ...harness, auth: harness.manager.getServices().auth };
18
+ }
19
+
20
+ // ==========================================
21
+ // Session Strategy Tests
22
+ // ==========================================
23
+ describe("Auth Plugin - Session Strategy", () => {
24
+ let harness: Awaited<ReturnType<typeof createAuthTestHarness>>;
25
+
26
+ beforeEach(async () => {
27
+ harness = await createAuthTestHarness({ strategy: "session" });
28
+ });
29
+
30
+ afterEach(async () => {
31
+ await harness.db.destroy();
32
+ });
33
+
34
+ it("should register a new user", async () => {
35
+ const result = await harness.auth.register({
36
+ email: "test@example.com",
37
+ password: "password123",
38
+ name: "Test User",
39
+ });
40
+
41
+ expect(result.user).toBeDefined();
42
+ expect(result.user.email).toBe("test@example.com");
43
+ expect(result.user.name).toBe("Test User");
44
+ expect(result.tokens.accessToken).toBeDefined();
45
+ expect(result.tokens.expiresIn).toBeGreaterThan(0);
46
+ });
47
+
48
+ it("should prevent duplicate email registration", async () => {
49
+ await harness.auth.register({
50
+ email: "test@example.com",
51
+ password: "password123",
52
+ });
53
+
54
+ await expect(
55
+ harness.auth.register({
56
+ email: "test@example.com",
57
+ password: "differentpassword",
58
+ })
59
+ ).rejects.toThrow();
60
+ });
61
+
62
+ it("should login with correct credentials", async () => {
63
+ await harness.auth.register({
64
+ email: "test@example.com",
65
+ password: "password123",
66
+ });
67
+
68
+ const result = await harness.auth.login({
69
+ email: "test@example.com",
70
+ password: "password123",
71
+ });
72
+
73
+ expect(result.user.email).toBe("test@example.com");
74
+ expect(result.tokens.accessToken).toBeDefined();
75
+ });
76
+
77
+ it("should reject login with wrong password", async () => {
78
+ await harness.auth.register({
79
+ email: "test@example.com",
80
+ password: "password123",
81
+ });
82
+
83
+ await expect(
84
+ harness.auth.login({
85
+ email: "test@example.com",
86
+ password: "wrongpassword",
87
+ })
88
+ ).rejects.toThrow();
89
+ });
90
+
91
+ it("should reject login for non-existent user", async () => {
92
+ await expect(
93
+ harness.auth.login({
94
+ email: "nonexistent@example.com",
95
+ password: "password123",
96
+ })
97
+ ).rejects.toThrow();
98
+ });
99
+
100
+ it("should validate session token", async () => {
101
+ const { tokens } = await harness.auth.register({
102
+ email: "test@example.com",
103
+ password: "password123",
104
+ });
105
+
106
+ const user = await harness.auth.validateToken(tokens.accessToken);
107
+ expect(user).toBeDefined();
108
+ expect(user?.email).toBe("test@example.com");
109
+ });
110
+
111
+ it("should invalidate session on logout", async () => {
112
+ const { tokens } = await harness.auth.register({
113
+ email: "test@example.com",
114
+ password: "password123",
115
+ });
116
+
117
+ await harness.auth.logout(tokens.accessToken);
118
+
119
+ const user = await harness.auth.validateToken(tokens.accessToken);
120
+ expect(user).toBeNull();
121
+ });
122
+
123
+ it("should return null for invalid token", async () => {
124
+ const user = await harness.auth.validateToken("invalid-token");
125
+ expect(user).toBeNull();
126
+ });
127
+
128
+ it("should get user by ID", async () => {
129
+ const { user: created } = await harness.auth.register({
130
+ email: "test@example.com",
131
+ password: "password123",
132
+ name: "Test User",
133
+ });
134
+
135
+ const user = await harness.auth.getUserById(created.id);
136
+ expect(user).toBeDefined();
137
+ expect(user?.email).toBe("test@example.com");
138
+ });
139
+
140
+ it("should update user profile", async () => {
141
+ const { user: created } = await harness.auth.register({
142
+ email: "test@example.com",
143
+ password: "password123",
144
+ name: "Original Name",
145
+ });
146
+
147
+ const updated = await harness.auth.updateProfile(created.id, {
148
+ name: "Updated Name",
149
+ });
150
+
151
+ expect(updated.name).toBe("Updated Name");
152
+ });
153
+ });
154
+
155
+ // ==========================================
156
+ // JWT Strategy Tests
157
+ // ==========================================
158
+ describe("Auth Plugin - JWT Strategy", () => {
159
+ let harness: Awaited<ReturnType<typeof createAuthTestHarness>>;
160
+
161
+ beforeEach(async () => {
162
+ harness = await createAuthTestHarness({
163
+ strategy: "jwt",
164
+ jwt: { secret: "test-secret-key-at-least-32-chars!" },
165
+ });
166
+ });
167
+
168
+ afterEach(async () => {
169
+ await harness.db.destroy();
170
+ });
171
+
172
+ it("should register and return JWT token", async () => {
173
+ const result = await harness.auth.register({
174
+ email: "test@example.com",
175
+ password: "password123",
176
+ });
177
+
178
+ expect(result.tokens.accessToken).toBeDefined();
179
+ // JWT tokens are longer and have 3 parts
180
+ expect(result.tokens.accessToken.split(".").length).toBe(3);
181
+ expect(result.tokens.refreshToken).toBeUndefined();
182
+ });
183
+
184
+ it("should validate JWT token", async () => {
185
+ const { tokens } = await harness.auth.register({
186
+ email: "test@example.com",
187
+ password: "password123",
188
+ });
189
+
190
+ const user = await harness.auth.validateToken(tokens.accessToken);
191
+ expect(user).toBeDefined();
192
+ expect(user?.email).toBe("test@example.com");
193
+ });
194
+
195
+ it("should reject invalid JWT token", async () => {
196
+ const user = await harness.auth.validateToken("invalid.jwt.token");
197
+ expect(user).toBeNull();
198
+ });
199
+
200
+ it("should login and return JWT", async () => {
201
+ await harness.auth.register({
202
+ email: "test@example.com",
203
+ password: "password123",
204
+ });
205
+
206
+ const result = await harness.auth.login({
207
+ email: "test@example.com",
208
+ password: "password123",
209
+ });
210
+
211
+ expect(result.tokens.accessToken.split(".").length).toBe(3);
212
+ });
213
+ });
214
+
215
+ // ==========================================
216
+ // Refresh Token Strategy Tests
217
+ // ==========================================
218
+ describe("Auth Plugin - Refresh Token Strategy", () => {
219
+ let harness: Awaited<ReturnType<typeof createAuthTestHarness>>;
220
+
221
+ beforeEach(async () => {
222
+ harness = await createAuthTestHarness({
223
+ strategy: "refresh-token",
224
+ jwt: { secret: "test-secret-key-at-least-32-chars!" },
225
+ });
226
+ });
227
+
228
+ afterEach(async () => {
229
+ await harness.db.destroy();
230
+ });
231
+
232
+ it("should return both access and refresh tokens", async () => {
233
+ const result = await harness.auth.register({
234
+ email: "test@example.com",
235
+ password: "password123",
236
+ });
237
+
238
+ expect(result.tokens.accessToken).toBeDefined();
239
+ expect(result.tokens.refreshToken).toBeDefined();
240
+ expect(result.tokens.accessToken.split(".").length).toBe(3);
241
+ expect(result.tokens.refreshToken!.split(".").length).toBe(3);
242
+ });
243
+
244
+ it("should validate access token", async () => {
245
+ const { tokens } = await harness.auth.register({
246
+ email: "test@example.com",
247
+ password: "password123",
248
+ });
249
+
250
+ const user = await harness.auth.validateToken(tokens.accessToken);
251
+ expect(user).toBeDefined();
252
+ expect(user?.email).toBe("test@example.com");
253
+ });
254
+
255
+ it("should refresh tokens with refresh token", async () => {
256
+ const { tokens: initial } = await harness.auth.register({
257
+ email: "test@example.com",
258
+ password: "password123",
259
+ });
260
+
261
+ // Small delay to ensure different token timestamps
262
+ await new Promise(resolve => setTimeout(resolve, 10));
263
+
264
+ const refreshed = await harness.auth.refresh(initial.refreshToken!);
265
+
266
+ expect(refreshed.accessToken).toBeDefined();
267
+ // Verify we got a new valid token (structure check)
268
+ expect(refreshed.accessToken.split(".").length).toBe(3);
269
+ expect(refreshed.expiresIn).toBeGreaterThan(0);
270
+ });
271
+
272
+ it("should reject invalid refresh token", async () => {
273
+ await expect(
274
+ harness.auth.refresh("invalid.refresh.token")
275
+ ).rejects.toThrow();
276
+ });
277
+
278
+ it("should invalidate refresh token on logout", async () => {
279
+ const { tokens } = await harness.auth.register({
280
+ email: "test@example.com",
281
+ password: "password123",
282
+ });
283
+
284
+ await harness.auth.logout(tokens.refreshToken!);
285
+
286
+ await expect(
287
+ harness.auth.refresh(tokens.refreshToken!)
288
+ ).rejects.toThrow();
289
+ });
290
+ });
291
+
292
+ // ==========================================
293
+ // Configuration Tests
294
+ // ==========================================
295
+ describe("Auth Plugin - Configuration", () => {
296
+ it("should use session strategy by default", async () => {
297
+ const harness = await createAuthTestHarness({});
298
+ expect(harness.auth.getStrategy()).toBe("session");
299
+ await harness.db.destroy();
300
+ });
301
+
302
+ it("should respect custom strategy", async () => {
303
+ const harness = await createAuthTestHarness({
304
+ strategy: "jwt",
305
+ jwt: { secret: "test-secret-key-at-least-32-chars!" },
306
+ });
307
+ expect(harness.auth.getStrategy()).toBe("jwt");
308
+ await harness.db.destroy();
309
+ });
310
+
311
+ it("should throw if JWT secret missing for jwt strategy", async () => {
312
+ await expect(
313
+ createAuthTestHarness({ strategy: "jwt" })
314
+ ).rejects.toThrow(/jwt.secret/);
315
+ });
316
+
317
+ it("should throw if JWT secret missing for refresh-token strategy", async () => {
318
+ await expect(
319
+ createAuthTestHarness({ strategy: "refresh-token" })
320
+ ).rejects.toThrow(/jwt.secret/);
321
+ });
322
+
323
+ it("should return cookie config", async () => {
324
+ const harness = await createAuthTestHarness({
325
+ cookie: { name: "myauth", httpOnly: true, secure: false },
326
+ });
327
+ const config = harness.auth.getCookieConfig();
328
+ expect(config.name).toBe("myauth");
329
+ expect(config.httpOnly).toBe(true);
330
+ expect(config.secure).toBe(false);
331
+ await harness.db.destroy();
332
+ });
333
+ });
334
+
335
+ // ==========================================
336
+ // Edge Cases
337
+ // ==========================================
338
+ describe("Auth Plugin - Edge Cases", () => {
339
+ let harness: Awaited<ReturnType<typeof createAuthTestHarness>>;
340
+
341
+ beforeEach(async () => {
342
+ harness = await createAuthTestHarness({ strategy: "session" });
343
+ });
344
+
345
+ afterEach(async () => {
346
+ await harness.db.destroy();
347
+ });
348
+
349
+ it("should handle email case-insensitively", async () => {
350
+ await harness.auth.register({
351
+ email: "Test@Example.COM",
352
+ password: "password123",
353
+ });
354
+
355
+ const result = await harness.auth.login({
356
+ email: "test@example.com",
357
+ password: "password123",
358
+ });
359
+
360
+ expect(result.user.email).toBe("test@example.com");
361
+ });
362
+
363
+ it("should handle registration without name", async () => {
364
+ const result = await harness.auth.register({
365
+ email: "test@example.com",
366
+ password: "password123",
367
+ });
368
+
369
+ expect(result.user.name).toBeNull();
370
+ });
371
+
372
+ it("should return null for non-existent user ID", async () => {
373
+ const user = await harness.auth.getUserById("non-existent-id");
374
+ expect(user).toBeNull();
375
+ });
376
+
377
+ });