devlyn-cli 0.3.0 → 0.4.0
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 +2 -0
- package/bin/devlyn.js +2 -0
- package/optional-skills/better-auth-setup/SKILL.md +450 -0
- package/optional-skills/better-auth-setup/references/api-keys.md +236 -0
- package/optional-skills/better-auth-setup/references/config-and-entry.md +239 -0
- package/optional-skills/better-auth-setup/references/middleware.md +409 -0
- package/optional-skills/better-auth-setup/references/schema.md +224 -0
- package/optional-skills/better-auth-setup/references/testing.md +241 -0
- package/package.json +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Test Infrastructure Reference
|
|
2
|
+
|
|
3
|
+
Test setup, seed factories, and integration test patterns for Better Auth with Bun.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
1. [Test Preload](#test-preload)
|
|
7
|
+
2. [Seed Data Factory](#seed-data-factory)
|
|
8
|
+
3. [Integration Test App](#integration-test-app)
|
|
9
|
+
4. [Database Cleanup](#database-cleanup)
|
|
10
|
+
5. [Key Testing Patterns](#key-testing-patterns)
|
|
11
|
+
|
|
12
|
+
## Test Preload
|
|
13
|
+
|
|
14
|
+
Prevents the most common testing pitfall: real emails sent during test runs.
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
// src/test-utils/setup.ts — preloaded via bunfig.toml
|
|
18
|
+
// Without this, test signups send real emails through Resend
|
|
19
|
+
// when RESEND_API_KEY is in your .env file.
|
|
20
|
+
process.env.NODE_ENV = "test";
|
|
21
|
+
process.env.RESEND_API_KEY = ""; // Force email to console-log fallback
|
|
22
|
+
|
|
23
|
+
// Bridge test database — use a separate DB for tests
|
|
24
|
+
if (process.env.TEST_DATABASE_URL) {
|
|
25
|
+
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```toml
|
|
30
|
+
# bunfig.toml
|
|
31
|
+
[test]
|
|
32
|
+
preload = ["./src/test-utils/setup.ts"]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Why preload?** Bun's preload runs before any test file imports. This guarantees that config validation (which runs at import time) sees the correct environment variables. Setting `RESEND_API_KEY = ""` before any module loads ensures the email module's lazy client never initializes.
|
|
36
|
+
|
|
37
|
+
## Seed Data Factory
|
|
38
|
+
|
|
39
|
+
Creates a complete tenant hierarchy for integration tests. Every call produces unique identifiers to prevent collisions in parallel test runs.
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
// src/test-utils/db.ts
|
|
43
|
+
import { db } from "../db";
|
|
44
|
+
import {
|
|
45
|
+
organizations, users, orgMemberships,
|
|
46
|
+
projects, apiKeys, sessions, accounts,
|
|
47
|
+
verifications, invitations,
|
|
48
|
+
} from "../db/schema";
|
|
49
|
+
|
|
50
|
+
export async function seedTestData() {
|
|
51
|
+
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
52
|
+
|
|
53
|
+
// Create org → user → membership → project → API key
|
|
54
|
+
const [org] = await db.insert(organizations).values({
|
|
55
|
+
name: `Test Org ${uniqueSuffix}`,
|
|
56
|
+
slug: `test-org-${uniqueSuffix}`,
|
|
57
|
+
plan: "free",
|
|
58
|
+
}).returning();
|
|
59
|
+
|
|
60
|
+
const [user] = await db.insert(users).values({
|
|
61
|
+
email: `test-${uniqueSuffix}@example.com`,
|
|
62
|
+
name: "Test User",
|
|
63
|
+
emailVerified: true,
|
|
64
|
+
}).returning();
|
|
65
|
+
|
|
66
|
+
await db.insert(orgMemberships).values({
|
|
67
|
+
organizationId: org.id,
|
|
68
|
+
userId: user.id,
|
|
69
|
+
role: "owner",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const [project] = await db.insert(projects).values({
|
|
73
|
+
organizationId: org.id,
|
|
74
|
+
name: `Test Project ${uniqueSuffix}`,
|
|
75
|
+
slug: `test-project-${uniqueSuffix}`,
|
|
76
|
+
}).returning();
|
|
77
|
+
|
|
78
|
+
// Generate API key
|
|
79
|
+
const { key, hash, prefix } = await generateTestApiKey();
|
|
80
|
+
const [apiKey] = await db.insert(apiKeys).values({
|
|
81
|
+
projectId: project.id,
|
|
82
|
+
name: "Test Key",
|
|
83
|
+
keyHash: hash,
|
|
84
|
+
keyPrefix: prefix,
|
|
85
|
+
}).returning();
|
|
86
|
+
|
|
87
|
+
return { org, user, project, apiKey, plaintextKey: key };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Helper: generate a test API key
|
|
91
|
+
async function generateTestApiKey() {
|
|
92
|
+
const { API_KEY_PREFIX, base62Encode, hashKey } = await import("../lib/api-keys");
|
|
93
|
+
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
|
94
|
+
const key = API_KEY_PREFIX + base62Encode(randomBytes);
|
|
95
|
+
const hash = await hashKey(key);
|
|
96
|
+
const prefix = key.slice(0, 12);
|
|
97
|
+
return { key, hash, prefix };
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Key design decisions:**
|
|
102
|
+
- `uniqueSuffix` uses `Date.now()` + random chars for collision-free parallel tests
|
|
103
|
+
- `emailVerified: true` so tests don't need to go through email verification flow
|
|
104
|
+
- Returns `plaintextKey` so tests can immediately make authenticated requests
|
|
105
|
+
|
|
106
|
+
## Integration Test App
|
|
107
|
+
|
|
108
|
+
Builds the full middleware chain matching production. This catches middleware ordering bugs before they reach production.
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
// src/test-utils/app.ts
|
|
112
|
+
import { Hono } from "hono";
|
|
113
|
+
|
|
114
|
+
// Lazy imports to avoid circular dependency issues during test setup
|
|
115
|
+
export function createIntegrationApp() {
|
|
116
|
+
const { authMiddleware } = require("../middleware/auth");
|
|
117
|
+
const { tenantContextMiddleware } = require("../middleware/tenant-context");
|
|
118
|
+
const { rateLimitMiddleware } = require("../middleware/rate-limit");
|
|
119
|
+
|
|
120
|
+
const app = new Hono();
|
|
121
|
+
|
|
122
|
+
// Request ID
|
|
123
|
+
app.use("*", async (c, next) => {
|
|
124
|
+
c.set("requestId", crypto.randomUUID());
|
|
125
|
+
await next();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Auth → Tenant Context → Rate Limit (same order as production)
|
|
129
|
+
app.use("*", authMiddleware);
|
|
130
|
+
app.use("*", tenantContextMiddleware);
|
|
131
|
+
app.use("*", rateLimitMiddleware);
|
|
132
|
+
|
|
133
|
+
// Mount route handlers here...
|
|
134
|
+
return app;
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**Why lazy imports?** The auth module imports the database module, which reads `DATABASE_URL` from the environment. If imported eagerly at the top level, the test preload script might not have run yet, causing config validation to fail.
|
|
139
|
+
|
|
140
|
+
## Database Cleanup
|
|
141
|
+
|
|
142
|
+
Delete tables in FK dependency order to avoid constraint violations.
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// Clean tables in FK dependency order
|
|
146
|
+
export async function cleanupDatabase() {
|
|
147
|
+
// Children first, parents last
|
|
148
|
+
await db.delete(apiKeys);
|
|
149
|
+
await db.delete(projects);
|
|
150
|
+
await db.delete(orgMemberships);
|
|
151
|
+
await db.delete(sessions);
|
|
152
|
+
await db.delete(accounts);
|
|
153
|
+
await db.delete(verifications);
|
|
154
|
+
await db.delete(invitations);
|
|
155
|
+
await db.delete(organizations);
|
|
156
|
+
await db.delete(users);
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Order matters.** If you try to delete `organizations` before `projects`, the FK constraint on `projects.organization_id` will reject the delete. Start from leaf tables and work toward root tables.
|
|
161
|
+
|
|
162
|
+
## Key Testing Patterns
|
|
163
|
+
|
|
164
|
+
### Test Auth via API Key
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
168
|
+
|
|
169
|
+
describe("Protected endpoint", () => {
|
|
170
|
+
let testData: Awaited<ReturnType<typeof seedTestData>>;
|
|
171
|
+
|
|
172
|
+
beforeAll(async () => {
|
|
173
|
+
testData = await seedTestData();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
afterAll(async () => {
|
|
177
|
+
await cleanupDatabase();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("returns 200 with valid API key", async () => {
|
|
181
|
+
const app = createIntegrationApp();
|
|
182
|
+
const res = await app.request("/v1/endpoint", {
|
|
183
|
+
headers: {
|
|
184
|
+
Authorization: `Bearer ${testData.plaintextKey}`,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
expect(res.status).toBe(200);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("returns 401 without auth", async () => {
|
|
191
|
+
const app = createIntegrationApp();
|
|
192
|
+
const res = await app.request("/v1/endpoint");
|
|
193
|
+
expect(res.status).toBe(401);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Test Signup Flow
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
test("signup creates user and personal org", async () => {
|
|
202
|
+
const res = await app.request("/auth/sign-up/email", {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { "Content-Type": "application/json" },
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
email: `signup-test-${Date.now()}@example.com`,
|
|
207
|
+
password: "secure-password-123",
|
|
208
|
+
name: "Test User",
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
|
|
214
|
+
// Verify auto-org creation via databaseHooks
|
|
215
|
+
const user = await db.select().from(users)
|
|
216
|
+
.where(eq(users.email, email)).limit(1);
|
|
217
|
+
|
|
218
|
+
const membership = await db.select().from(orgMemberships)
|
|
219
|
+
.where(eq(orgMemberships.userId, user[0].id)).limit(1);
|
|
220
|
+
|
|
221
|
+
expect(membership).toHaveLength(1);
|
|
222
|
+
expect(membership[0].role).toBe("owner");
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Test Tenant Isolation
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
test("cannot access resources from another org", async () => {
|
|
230
|
+
const org1 = await seedTestData();
|
|
231
|
+
const org2 = await seedTestData();
|
|
232
|
+
|
|
233
|
+
// Use org1's API key to try accessing org2's project
|
|
234
|
+
const res = await app.request(`/v1/projects/${org2.project.id}`, {
|
|
235
|
+
headers: { Authorization: `Bearer ${org1.plaintextKey}` },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Should be 404 (not 403, to prevent ID enumeration)
|
|
239
|
+
expect(res.status).toBe(404);
|
|
240
|
+
});
|
|
241
|
+
```
|