Skip to content
File

Blob: tests/worker/helpers.ts

typescript227 lines
1import { env, exports } from "cloudflare:workers";
2 
3import { makeAuth } from "@/worker/auth";
4import { encodeBase64Url, sha256 } from "@/worker/services/crypto";
5 
6export const SELF = exports.default;
7export const ISSUER = "http://localhost";
8export const DEFAULT_PASSWORD = "correct-horse-battery-staple";
9export const DEFAULT_REDIRECT_URI = "http://127.0.0.1:0/cb";
10 
11export interface TestCredential {
12 email: string;
13 password: string;
14 name: string;
15}
16 
17export interface CreatedOAuthClient {
18 client_id: string;
19 client_secret: string;
20}
21 
22export interface OAuthAuthorizeResult {
23 status: number;
24 location: string | null;
25 code: string | null;
26 verifier: string;
27}
28 
29export interface OAuthTokenResponse {
30 access_token?: string;
31 id_token?: string;
32 refresh_token?: string;
33 token_type?: string;
34 expires_in?: number;
35}
36 
37export interface JsonErrorBody {
38 error?: string;
39 code?: string;
40 message?: string;
41}
42 
43export interface CountRow {
44 c: number;
45}
46 
47export type OAuthTokenTable = "oauth_access_tokens" | "oauth_refresh_tokens";
48 
49export interface CreateOAuthClientOptions {
50 name: string;
51 redirectUris?: string[];
52 skipConsent?: boolean;
53 uri?: string;
54}
55 
56export interface PkcePair {
57 verifier: string;
58 challenge: string;
59}
60 
61export const testHeaders = (overrides: Record<string, string> = {}): Record<string, string> => ({
62 "content-type": "application/json",
63 origin: ISSUER,
64 ...overrides,
65});
66 
67// Workers' Headers conflates multiple Set-Cookie values into one comma-joined
68// string. Splitting on comma-not-followed-by-an-attribute keeps `Expires=Wed,
69// 01 Jan ...` from being read as a cookie boundary.
70export const extractSessionCookie = (res: Response): string | null => {
71 const setCookie = res.headers.get("set-cookie");
72 if (!setCookie) return null;
73 const cookie = setCookie
74 .split(/,(?=\s*[^\s,]+=)/)
75 .map((c) => c.split(";")[0]?.trim())
76 .filter((c): c is string => Boolean(c) && /better-auth\./.test(c))
77 .join("; ");
78 return cookie || null;
79};
80 
81// `ip` controls the rate-limit bucket. Sign-ins within a suite share a bucket,
82// so pass per-suite IPs to keep bursts under the per-IP threshold.
83export const signInForCookie = async (email: string, password: string, ip: string = "10.0.0.1"): Promise<string> => {
84 const res = await SELF.fetch(`${ISSUER}/api/sign-in`, {
85 method: "POST",
86 headers: testHeaders({ "CF-Connecting-IP": ip }),
87 body: JSON.stringify({ email, password, turnstileToken: "loopback" }),
88 });
89 if (res.status !== 200) {
90 throw new Error(`sign-in failed: ${res.status} ${await res.text()}`);
91 }
92 const cookie = extractSessionCookie(res);
93 if (!cookie) throw new Error("sign-in returned no session cookie");
94 return cookie;
95};
96 
97export const makeTestAuth = () => makeAuth(env, { baseURL: ISSUER, issuer: ISSUER });
98 
99export const signUpTestUser = async (credential: TestCredential): Promise<void> => {
100 const auth = makeTestAuth();
101 await auth.api.signUpEmail({ body: credential, asResponse: false });
102};
103 
104export const promoteUserToAdmin = async (email: string): Promise<void> => {
105 await env.DB.prepare("UPDATE users SET role = ? WHERE email = ?").bind("admin", email).run();
106};
107 
108export const signUpAdmin = async (credential: TestCredential): Promise<void> => {
109 await signUpTestUser(credential);
110 await promoteUserToAdmin(credential.email);
111};
112 
113export const createOAuthClientAsAdmin = async (
114 adminCookie: string,
115 options: CreateOAuthClientOptions,
116): Promise<CreatedOAuthClient> => {
117 const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
118 method: "POST",
119 headers: testHeaders({ cookie: adminCookie }),
120 body: JSON.stringify({
121 redirectUris: [DEFAULT_REDIRECT_URI],
122 ...options,
123 }),
124 });
125 if (!res.ok) {
126 throw new Error(`setup create-client failed: ${res.status} ${await res.text()}`);
127 }
128 return (await res.json()) as CreatedOAuthClient;
129};
130 
131export const createPkcePair = async (): Promise<PkcePair> => {
132 const verifier = encodeBase64Url(crypto.getRandomValues(new Uint8Array(32)));
133 const challenge = await sha256(verifier);
134 return { verifier, challenge };
135};
136 
137export const buildAuthorizeUrl = (options: {
138 clientId: string;
139 redirectUri?: string;
140 scope?: string;
141 state?: string;
142 challenge: string;
143}): URL => {
144 const url = new URL(`${ISSUER}/api/auth/oauth2/authorize`);
145 url.searchParams.set("response_type", "code");
146 url.searchParams.set("client_id", options.clientId);
147 url.searchParams.set("redirect_uri", options.redirectUri ?? DEFAULT_REDIRECT_URI);
148 url.searchParams.set("scope", options.scope ?? "openid profile email");
149 url.searchParams.set("state", options.state ?? "test-state");
150 url.searchParams.set("code_challenge", options.challenge);
151 url.searchParams.set("code_challenge_method", "S256");
152 return url;
153};
154 
155export const authorizeWithPkce = async (options: {
156 clientId: string;
157 cookie?: string;
158 redirectUri?: string;
159 scope?: string;
160 state?: string;
161}): Promise<OAuthAuthorizeResult> => {
162 const { verifier, challenge } = await createPkcePair();
163 const url = buildAuthorizeUrl({
164 clientId: options.clientId,
165 redirectUri: options.redirectUri,
166 scope: options.scope,
167 state: options.state,
168 challenge,
169 });
170 const res = await SELF.fetch(url, {
171 headers: options.cookie ? { cookie: options.cookie } : undefined,
172 redirect: "manual",
173 });
174 const location = res.headers.get("location");
175 let code: string | null = null;
176 if (location) {
177 try {
178 code = new URL(location, ISSUER).searchParams.get("code");
179 } catch {
180 code = null;
181 }
182 }
183 return { status: res.status, location, code, verifier };
184};
185 
186export const exchangeAuthorizationCode = async (options: {
187 code: string;
188 verifier: string;
189 clientId: string;
190 clientSecret: string;
191 redirectUri?: string;
192}): Promise<Response> =>
193 SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, {
194 method: "POST",
195 headers: {
196 "content-type": "application/x-www-form-urlencoded",
197 authorization: `Basic ${btoa(`${options.clientId}:${options.clientSecret}`)}`,
198 },
199 body: new URLSearchParams({
200 grant_type: "authorization_code",
201 code: options.code,
202 redirect_uri: options.redirectUri ?? DEFAULT_REDIRECT_URI,
203 code_verifier: options.verifier,
204 }),
205 });
206 
207export const getUserIdByEmail = async (email: string): Promise<string> => {
208 const row = await env.DB.prepare("SELECT id FROM users WHERE email = ?").bind(email).first<{ id: string }>();
209 if (!row) throw new Error(`user row missing for ${email}`);
210 return row.id;
211};
212 
213export const getLatestSessionIdForUser = async (userId: string): Promise<string> => {
214 const row = await env.DB.prepare("SELECT id FROM sessions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1")
215 .bind(userId)
216 .first<{ id: string }>();
217 if (!row) throw new Error(`session row missing for user ${userId}`);
218 return row.id;
219};
220 
221export const countOAuthTokensForClient = async (table: OAuthTokenTable, clientId: string): Promise<number> => {
222 const row = await env.DB.prepare(`SELECT COUNT(*) AS c FROM ${table} WHERE client_id = ?`)
223 .bind(clientId)
224 .first<CountRow>();
225 return row?.c ?? 0;
226};