@everystack/auth 0.1.0 → 0.2.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/src/testing.ts ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @everystack/auth/testing — E2E test helpers for authorization and security.
3
+ *
4
+ * Provides tools for testing multiple roles, seeding test users, and making
5
+ * authenticated requests against any everystack handler — local or deployed.
6
+ *
7
+ * Features:
8
+ * - createTestAuth(secret) — sign/verify JWTs for any role
9
+ * - seedTestUser(sql, opts) — ensure a test user exists with known credentials
10
+ * - createTestRoles(secret) — pre-built tokens for anon/authenticated/admin
11
+ *
12
+ * Works against any environment — local dev, CI, deployed stages.
13
+ * For deployed stages, retrieve secrets via: sst secret get JWT_SECRET --stage <name>
14
+ *
15
+ * @see docs/testing.md for the full testing guide
16
+ */
17
+
18
+ import { createJwt } from './jwt';
19
+ import { hmacNode } from './jwt/hmac-node';
20
+ import { hashPassword } from './passwords';
21
+
22
+ /** Validates a SQL identifier (table/column name) to prevent injection. */
23
+ function assertIdentifier(name: string, label: string): void {
24
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
25
+ throw new Error(`${label} contains invalid characters: ${name}`);
26
+ }
27
+ }
28
+
29
+ // ─── createTestAuth ──────────────────────────────────────────────────────────
30
+
31
+ export interface TestAuthOptions {
32
+ /** Token lifetime in seconds. Default: 3600 (1 hour). */
33
+ ttlSec?: number;
34
+ /** Default claims merged into every sign() call. */
35
+ defaults?: Record<string, unknown>;
36
+ }
37
+
38
+ export interface TestAuth {
39
+ /** Sign a JWT with the given claims. Auto-fills iat/exp if not present. */
40
+ sign(claims: Record<string, unknown>): Promise<string>;
41
+ /** Verify a token and return its claims, or null if invalid/expired. */
42
+ verify(token: string): Promise<Record<string, unknown> | null>;
43
+ /** Decode a token without verification. For debugging only. */
44
+ decode(token: string): Record<string, unknown> | null;
45
+ }
46
+
47
+ /**
48
+ * Create a test auth helper bound to a JWT secret.
49
+ *
50
+ * @param secret - The JWT_SECRET used by the handler under test.
51
+ * @param options - Optional defaults and TTL override.
52
+ */
53
+ export function createTestAuth(secret: string, options?: TestAuthOptions): TestAuth {
54
+ if (!secret) throw new Error('createTestAuth: secret is required');
55
+
56
+ const jwt = createJwt({
57
+ secret,
58
+ hmac: hmacNode,
59
+ ttlSec: options?.ttlSec ?? 3600,
60
+ includeJti: false,
61
+ });
62
+
63
+ const defaults = options?.defaults ?? {};
64
+
65
+ return {
66
+ async sign(claims: Record<string, unknown>): Promise<string> {
67
+ return jwt.sign({ ...defaults, ...claims });
68
+ },
69
+ verify: (token: string) => jwt.verify(token),
70
+ decode: (token: string) => jwt.decode(token),
71
+ };
72
+ }
73
+
74
+ // ─── createTestRoles ─────────────────────────────────────────────────────────
75
+
76
+ export interface TestRoles {
77
+ /** No token — represents an unauthenticated request. */
78
+ anon: null;
79
+ /** JWT for a regular authenticated user. */
80
+ authenticated: string;
81
+ /** JWT for an admin/super user. */
82
+ admin: string;
83
+ /** Sign a custom token with arbitrary claims. */
84
+ sign(claims: Record<string, unknown>): Promise<string>;
85
+ }
86
+
87
+ /**
88
+ * Create pre-signed tokens for common test roles.
89
+ *
90
+ * Usage:
91
+ * const roles = await createTestRoles(process.env.JWT_SECRET!, {
92
+ * authenticated: { user_id: 42, user_state: 'active' },
93
+ * admin: { user_id: 1, user_state: 'super', role: 'admin' },
94
+ * });
95
+ *
96
+ * // Unauthenticated request
97
+ * await fetch('/api/contents', { headers: {} });
98
+ *
99
+ * // Authenticated request
100
+ * await fetch('/api/rpc/get_user', {
101
+ * headers: { Authorization: `Bearer ${roles.authenticated}` },
102
+ * });
103
+ *
104
+ * // Admin request
105
+ * await fetch('/api/rpc/admin_action', {
106
+ * headers: { Authorization: `Bearer ${roles.admin}` },
107
+ * });
108
+ */
109
+ export async function createTestRoles(
110
+ secret: string,
111
+ claims: {
112
+ authenticated: Record<string, unknown>;
113
+ admin: Record<string, unknown>;
114
+ },
115
+ ): Promise<TestRoles> {
116
+ const auth = createTestAuth(secret);
117
+ const [authenticated, admin] = await Promise.all([
118
+ auth.sign({ role: 'authenticated', ...claims.authenticated }),
119
+ auth.sign({ role: 'admin', ...claims.admin }),
120
+ ]);
121
+
122
+ return {
123
+ anon: null,
124
+ authenticated,
125
+ admin,
126
+ sign: (c) => auth.sign(c),
127
+ };
128
+ }
129
+
130
+ // ─── seedTestUser ────────────────────────────────────────────────────────────
131
+
132
+ export interface SeedTestUserOptions {
133
+ /** Email for the test user. */
134
+ email: string;
135
+ /** Plaintext password (stored as bcrypt hash). */
136
+ password: string;
137
+ /** Users table name. Default: 'users'. */
138
+ table?: string;
139
+ /** Email column name. Default: 'email'. */
140
+ emailColumn?: string;
141
+ /** Password column name. Default: 'encrypted_password'. */
142
+ passwordColumn?: string;
143
+ /**
144
+ * Called after the user is created (first run only).
145
+ * Use for app-specific setup: set name, activate, create system lists, etc.
146
+ */
147
+ afterCreate?: (sql: any, userId: number) => Promise<void>;
148
+ }
149
+
150
+ export interface TestUser {
151
+ /** Database ID of the test user. */
152
+ id: number;
153
+ /** Email address. */
154
+ email: string;
155
+ /** Plaintext password (for tests that call auth.sign_in directly). */
156
+ password: string;
157
+ }
158
+
159
+ /**
160
+ * Ensure a test user exists with known credentials.
161
+ *
162
+ * Idempotent — creates the user on first run, returns the existing one on
163
+ * subsequent runs. Password is always reset to ensure it matches.
164
+ *
165
+ * Requires a postgres.js sql client (not Drizzle).
166
+ *
167
+ * Usage:
168
+ * const testUser = await seedTestUser(sql, {
169
+ * email: process.env.TEST_USER_EMAIL!,
170
+ * password: process.env.TEST_USER_PASSWORD!,
171
+ * afterCreate: async (sql, userId) => {
172
+ * // App-specific setup: set name, activate, etc.
173
+ * await sql`UPDATE users SET first_name = 'E2E', last_name = 'Test', state = 'active' WHERE id = ${userId}`;
174
+ * },
175
+ * });
176
+ *
177
+ * For deployed stages, store credentials in SST secrets:
178
+ * sst secret set TEST_USER_EMAIL "e2e-test@app.com" --stage dev
179
+ * sst secret set TEST_USER_PASSWORD "test-password" --stage dev
180
+ */
181
+ export async function seedTestUser(
182
+ sql: any,
183
+ options: SeedTestUserOptions,
184
+ ): Promise<TestUser> {
185
+ const {
186
+ email,
187
+ password,
188
+ table = 'users',
189
+ emailColumn = 'email',
190
+ passwordColumn = 'encrypted_password',
191
+ afterCreate,
192
+ } = options;
193
+
194
+ if (!email) throw new Error('seedTestUser: email is required');
195
+ if (!password) throw new Error('seedTestUser: password is required');
196
+ assertIdentifier(table, 'table');
197
+ assertIdentifier(emailColumn, 'emailColumn');
198
+ assertIdentifier(passwordColumn, 'passwordColumn');
199
+
200
+ const hash = await hashPassword(password);
201
+
202
+ // Check if user exists
203
+ const existing = await sql.unsafe(
204
+ `SELECT id::int FROM ${table} WHERE ${emailColumn} = $1 LIMIT 1`,
205
+ [email],
206
+ );
207
+
208
+ if (existing.length > 0) {
209
+ // Ensure password matches (may have been rotated)
210
+ await sql.unsafe(
211
+ `UPDATE ${table} SET ${passwordColumn} = $1 WHERE id = $2`,
212
+ [hash, existing[0].id],
213
+ );
214
+ return { id: existing[0].id, email, password };
215
+ }
216
+
217
+ // Create the user with minimal columns — apps can customize via afterCreate
218
+ const created = await sql.unsafe(
219
+ `INSERT INTO ${table} (${emailColumn}, ${passwordColumn}, created_at, updated_at)
220
+ VALUES ($1, $2, NOW(), NOW())
221
+ RETURNING id::int`,
222
+ [email, hash],
223
+ );
224
+
225
+ const userId = created[0].id;
226
+
227
+ if (afterCreate) {
228
+ await afterCreate(sql, userId);
229
+ }
230
+
231
+ return { id: userId, email, password };
232
+ }