@podosoft/podokit 1.6.2 → 1.6.4
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/dist/deploy-checks.d.ts +20 -2
- package/dist/deploy-checks.js +83 -4
- package/dist/deploy-compose-render.js +15 -4
- package/dist/deploy-compose.js +211 -27
- package/dist/deploy.js +16 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -2
- package/dist/templates/fullstack/tests/README.md +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +28 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +16 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +31 -8
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +531 -29
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +253 -60
- package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.spec.ts +232 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.ts +68 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +53 -47
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +9 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +60 -15
- package/package.json +1 -1
|
@@ -1,31 +1,90 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { createHash, createHmac } from "node:crypto";
|
|
3
|
+
import { apiKey } from "@better-auth/api-key";
|
|
3
4
|
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
4
5
|
import { envAuthConfig } from "@podosoft/podokit-auth";
|
|
5
|
-
import { betterAuth } from "better-auth";
|
|
6
|
+
import { betterAuth, type BetterAuthOptions, type BetterAuthPlugin } from "better-auth";
|
|
6
7
|
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
8
|
+
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
9
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
7
10
|
import { getAuthTables } from "better-auth/db";
|
|
8
|
-
import { admin, emailOTP, jwt, magicLink, twoFactor } from "better-auth/plugins";
|
|
11
|
+
import { admin, bearer, emailOTP, jwt, magicLink, multiSession, phoneNumber, twoFactor } from "better-auth/plugins";
|
|
9
12
|
import { buildAuth } from "../auth/auth";
|
|
10
13
|
import { setAuditRecorder, type AuditEntry } from "./audit-events";
|
|
11
|
-
import { auditPlugin, SIGN_IN_METHODS } from "./audit-hook";
|
|
14
|
+
import { auditPlugin, NOT_SIGN_INS, SIGN_IN_METHODS } from "./audit-hook";
|
|
12
15
|
|
|
13
16
|
const BASE_URL = "http://localhost:5002";
|
|
14
17
|
const PASSWORD = "Podokit3e-Str0ng!pw";
|
|
15
18
|
|
|
16
|
-
//
|
|
19
|
+
// The plugins PodoKit's auth.ts and modules configure. Endpoints of other
|
|
20
|
+
// plugins, such as one an application adds, are not classified below: the
|
|
21
|
+
// audit plugin records their sign-ins by default.
|
|
22
|
+
const PODOKIT_PLUGINS = new Set([
|
|
23
|
+
"podokit-feature-gate",
|
|
24
|
+
"podokit-revoke-every-other-session",
|
|
25
|
+
"two-factor",
|
|
26
|
+
"magic-link",
|
|
27
|
+
"email-otp",
|
|
28
|
+
"username",
|
|
29
|
+
"multi-session",
|
|
30
|
+
"phone-number",
|
|
31
|
+
"api-key",
|
|
32
|
+
"passkey",
|
|
33
|
+
"organization",
|
|
34
|
+
"bearer",
|
|
35
|
+
"jwt",
|
|
36
|
+
"oauth-provider",
|
|
37
|
+
"open-api",
|
|
38
|
+
"have-i-been-pwned",
|
|
39
|
+
"admin",
|
|
40
|
+
"podokit-audit",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
// Endpoints of Better Auth and of those plugins that never end with a session
|
|
44
|
+
// other than the one the request came with, which some of them set again. A
|
|
45
|
+
// Better Auth upgrade that adds an endpoint fails "classifies every endpoint"
|
|
46
|
+
// until the endpoint is added here, to SIGN_IN_METHODS, or to NOT_SIGN_INS.
|
|
17
47
|
const STARTS_NO_SESSION = new Set([
|
|
18
|
-
"/
|
|
19
|
-
"/
|
|
20
|
-
"/
|
|
21
|
-
"/
|
|
22
|
-
"/
|
|
48
|
+
"/.well-known/oauth-authorization-server", "/.well-known/openid-configuration", "/account-info",
|
|
49
|
+
"/admin/ban-user", "/admin/create-user", "/admin/get-user", "/admin/has-permission",
|
|
50
|
+
"/admin/list-user-sessions", "/admin/list-users", "/admin/oauth2/create-client", "/admin/oauth2/resources",
|
|
51
|
+
"/admin/oauth2/resources/:identifier", "/admin/oauth2/resources/:identifier/clients/:client_id",
|
|
52
|
+
"/admin/oauth2/update-client", "/admin/remove-user", "/admin/revoke-user-session", "/admin/revoke-user-sessions",
|
|
53
|
+
"/admin/set-role", "/admin/set-user-password", "/admin/unban-user", "/admin/update-user",
|
|
54
|
+
"/api-key/create", "/api-key/delete", "/api-key/get", "/api-key/list", "/api-key/update",
|
|
55
|
+
"/change-email", "/delete-user", "/delete-user/callback",
|
|
56
|
+
"/email-otp/change-email", "/email-otp/check-verification-otp", "/email-otp/request-email-change",
|
|
57
|
+
"/email-otp/request-password-reset", "/email-otp/reset-password", "/email-otp/send-verification-otp",
|
|
58
|
+
"/error", "/forget-password/email-otp", "/get-access-token", "/get-session", "/is-username-available", "/jwks",
|
|
59
|
+
"/link-social", "/list-accounts", "/list-sessions", "/multi-session/list-device-sessions",
|
|
60
|
+
"/oauth2/authorize", "/oauth2/client/rotate-keys", "/oauth2/client/rotate-secret", "/oauth2/consent",
|
|
61
|
+
"/oauth2/continue", "/oauth2/create-client", "/oauth2/delete-client", "/oauth2/delete-consent",
|
|
62
|
+
"/oauth2/end-session", "/oauth2/end-session/confirm", "/oauth2/get-client", "/oauth2/get-clients",
|
|
63
|
+
"/oauth2/get-consent", "/oauth2/get-consents", "/oauth2/introspect", "/oauth2/public-client",
|
|
64
|
+
"/oauth2/public-client-prelogin", "/oauth2/register", "/oauth2/revoke", "/oauth2/token", "/oauth2/update-client",
|
|
65
|
+
"/oauth2/update-consent", "/oauth2/userinfo", "/ok", "/open-api/generate-schema",
|
|
66
|
+
"/organization/accept-invitation", "/organization/cancel-invitation", "/organization/check-slug",
|
|
67
|
+
"/organization/create", "/organization/delete", "/organization/get-active-member",
|
|
68
|
+
"/organization/get-active-member-role", "/organization/get-full-organization", "/organization/get-invitation",
|
|
69
|
+
"/organization/get-organization", "/organization/has-permission", "/organization/invite-member",
|
|
70
|
+
"/organization/leave", "/organization/list", "/organization/list-invitations", "/organization/list-members",
|
|
71
|
+
"/organization/list-user-invitations", "/organization/reject-invitation", "/organization/remove-member",
|
|
72
|
+
"/organization/set-active", "/organization/update", "/organization/update-member-role",
|
|
73
|
+
"/passkey/delete-passkey", "/passkey/generate-authenticate-options", "/passkey/generate-register-options",
|
|
74
|
+
"/passkey/list-user-passkeys", "/passkey/update-passkey", "/passkey/verify-registration",
|
|
75
|
+
"/phone-number/request-password-reset", "/phone-number/reset-password", "/phone-number/send-otp",
|
|
76
|
+
"/reference", "/refresh-token", "/request-password-reset", "/reset-password", "/reset-password/:token",
|
|
77
|
+
"/revoke-other-sessions", "/revoke-session", "/revoke-sessions", "/send-verification-email",
|
|
78
|
+
"/sign-in/magic-link", "/sign-out", "/token", "/two-factor/generate-backup-codes", "/two-factor/get-totp-uri",
|
|
79
|
+
"/two-factor/send-otp", "/unlink-account", "/update-session", "/update-user", "/verify-password",
|
|
23
80
|
]);
|
|
24
81
|
|
|
25
82
|
interface Mailbox {
|
|
26
83
|
magicLink: string;
|
|
27
84
|
otp: string;
|
|
28
85
|
verification: string;
|
|
86
|
+
reset: string;
|
|
87
|
+
sms: string;
|
|
29
88
|
}
|
|
30
89
|
|
|
31
90
|
interface AuthHandler {
|
|
@@ -52,18 +111,77 @@ afterEach(() => restoreRecorder());
|
|
|
52
111
|
const logins = (): AuditEntry[] => entries.filter((entry) => entry.action === "auth.login");
|
|
53
112
|
const actions = (action: string): AuditEntry[] => entries.filter((entry) => entry.action === action);
|
|
54
113
|
|
|
55
|
-
//
|
|
56
|
-
// the
|
|
57
|
-
|
|
58
|
-
|
|
114
|
+
// Like @better-auth/electron's /electron/token, which a desktop app calls with
|
|
115
|
+
// the code its browser sign-in handed over: an endpoint of an application's own
|
|
116
|
+
// plugin that starts a session for a user it looks up.
|
|
117
|
+
function desktopSignIn(): BetterAuthPlugin {
|
|
118
|
+
return {
|
|
119
|
+
id: "desktop-sign-in",
|
|
120
|
+
endpoints: {
|
|
121
|
+
desktopToken: createAuthEndpoint("/desktop/token", { method: "POST" }, async (ctx) => {
|
|
122
|
+
const { code } = (ctx.body ?? {}) as { code?: string };
|
|
123
|
+
const found = code ? await ctx.context.internalAdapter.findUserByEmail(code) : null;
|
|
124
|
+
if (!found) throw new APIError("NOT_FOUND", { message: "Invalid or expired token." });
|
|
125
|
+
const session = await ctx.context.internalAdapter.createSession(found.user.id);
|
|
126
|
+
await setSessionCookie(ctx, { session, user: found.user });
|
|
127
|
+
return ctx.json({ token: session.token });
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface StartOptions {
|
|
134
|
+
enabled?: () => Promise<boolean>;
|
|
135
|
+
session?: BetterAuthOptions["session"];
|
|
136
|
+
/** Self-registered accounts wait for approval without a session, as auth.ts does. */
|
|
137
|
+
signupApproval?: boolean;
|
|
138
|
+
/** Whether an email verification link signs its user in. */
|
|
139
|
+
verificationSignIn?: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Like auth.ts with sign-up approval on: a self-registered account starts
|
|
143
|
+
// pending and gets no session until an administrator approves it.
|
|
144
|
+
const signupApprovalHooks: BetterAuthOptions["databaseHooks"] = {
|
|
145
|
+
user: {
|
|
146
|
+
create: {
|
|
147
|
+
before: async (user, context) => ({ data: { ...user, signupApproved: context?.path === "/admin/create-user" } }),
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
session: {
|
|
151
|
+
create: {
|
|
152
|
+
before: async (session, context) => {
|
|
153
|
+
const user = (await context?.context.internalAdapter.findUserById(session.userId)) as { signupApproved?: boolean } | null;
|
|
154
|
+
if (user?.signupApproved === false) throw new APIError("FORBIDDEN", { message: "Waiting for approval." });
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// The sign-in methods PodoKit's auth.ts enables and plugins that set or swap
|
|
161
|
+
// sessions without a sign-in, on an in-memory database, with the audit plugin
|
|
162
|
+
// pushed last as the audit-log module does.
|
|
163
|
+
function startAuth({ enabled = async () => true, session, signupApproval = false, verificationSignIn = true }: StartOptions = {}) {
|
|
164
|
+
const mail: Mailbox = { magicLink: "", otp: "", verification: "", reset: "", sms: "" };
|
|
59
165
|
const db: Record<string, Record<string, unknown>[]> = {};
|
|
60
166
|
const options = {
|
|
61
167
|
baseURL: BASE_URL,
|
|
62
168
|
secret: "audit-hook-test-secret-0123456789abcdef",
|
|
63
169
|
database: memoryAdapter(db),
|
|
64
|
-
|
|
170
|
+
...(session ? { session } : {}),
|
|
171
|
+
...(signupApproval ? { databaseHooks: signupApprovalHooks } : {}),
|
|
172
|
+
user: {
|
|
173
|
+
changeEmail: { enabled: true, updateEmailWithoutVerification: true },
|
|
174
|
+
additionalFields: { signupApproved: { type: "boolean" as const, required: false, defaultValue: true, input: false } },
|
|
175
|
+
},
|
|
176
|
+
emailAndPassword: {
|
|
177
|
+
enabled: true,
|
|
178
|
+
autoSignIn: !signupApproval,
|
|
179
|
+
sendResetPassword: async ({ token }: { token: string }) => {
|
|
180
|
+
mail.reset = token;
|
|
181
|
+
},
|
|
182
|
+
},
|
|
65
183
|
emailVerification: {
|
|
66
|
-
autoSignInAfterVerification:
|
|
184
|
+
autoSignInAfterVerification: verificationSignIn,
|
|
67
185
|
sendVerificationEmail: async ({ url }: { url: string }) => {
|
|
68
186
|
mail.verification = url;
|
|
69
187
|
},
|
|
@@ -99,32 +217,50 @@ function startAuth(enabled: () => Promise<boolean> = async () => true) {
|
|
|
99
217
|
},
|
|
100
218
|
}),
|
|
101
219
|
emailOTP({
|
|
220
|
+
changeEmail: { enabled: true },
|
|
102
221
|
sendVerificationOTP: async ({ otp }) => {
|
|
103
222
|
mail.otp = otp;
|
|
104
223
|
},
|
|
105
224
|
}),
|
|
225
|
+
phoneNumber({
|
|
226
|
+
sendOTP: async ({ code }) => {
|
|
227
|
+
mail.sms = code;
|
|
228
|
+
},
|
|
229
|
+
sendPasswordResetOTP: async ({ code }) => {
|
|
230
|
+
mail.sms = code;
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
233
|
+
multiSession(),
|
|
234
|
+
apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),
|
|
235
|
+
bearer({ requireSignature: true }),
|
|
106
236
|
jwt({ disableSettingJwtHeader: true }),
|
|
107
237
|
oauthProvider({ loginPage: "/login", consentPage: "/oauth2/consent" }),
|
|
108
238
|
admin(),
|
|
239
|
+
desktopSignIn(),
|
|
109
240
|
auditPlugin({ enabled }),
|
|
110
241
|
],
|
|
111
242
|
};
|
|
112
243
|
for (const table of Object.values(getAuthTables(options))) db[table.modelName] = [];
|
|
113
|
-
return { auth: betterAuth(options), mail };
|
|
244
|
+
return { auth: betterAuth(options), mail, db };
|
|
114
245
|
}
|
|
115
246
|
|
|
116
|
-
// A cookie jar in front of the Better Auth router, like a browser's
|
|
247
|
+
// A cookie jar in front of the Better Auth router, like a browser's, or a client
|
|
248
|
+
// that sends the same headers with every request.
|
|
117
249
|
class Browser {
|
|
118
250
|
private readonly auth: AuthHandler;
|
|
251
|
+
private readonly sent: Record<string, string>;
|
|
119
252
|
private readonly cookies = new Map<string, string>();
|
|
120
253
|
|
|
121
|
-
constructor(auth: AuthHandler) {
|
|
254
|
+
constructor(auth: AuthHandler, sent: Record<string, string> = {}) {
|
|
122
255
|
this.auth = auth;
|
|
256
|
+
this.sent = sent;
|
|
123
257
|
}
|
|
124
258
|
|
|
125
259
|
/** Request headers that carry this browser's cookies, for a server-side auth.api call. */
|
|
126
260
|
headers(): Headers {
|
|
127
|
-
|
|
261
|
+
const headers = new Headers(this.sent);
|
|
262
|
+
if (this.cookies.size > 0) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
|
|
263
|
+
return headers;
|
|
128
264
|
}
|
|
129
265
|
|
|
130
266
|
get(path: string): Promise<Response> {
|
|
@@ -160,6 +296,10 @@ async function signUp(auth: AuthHandler, email: string): Promise<Browser> {
|
|
|
160
296
|
return browser;
|
|
161
297
|
}
|
|
162
298
|
|
|
299
|
+
async function userId(browser: Browser): Promise<string> {
|
|
300
|
+
return ((await (await browser.get("/get-session")).json()) as { user: { id: string } }).user.id;
|
|
301
|
+
}
|
|
302
|
+
|
|
163
303
|
// The current code for an otpauth:// URI, as an authenticator app computes it.
|
|
164
304
|
function totp(uri: string): string {
|
|
165
305
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
@@ -175,10 +315,39 @@ function totp(uri: string): string {
|
|
|
175
315
|
|
|
176
316
|
const otherCode = (code: string): string => String((Number(code) + 1) % 1_000_000).padStart(6, "0");
|
|
177
317
|
|
|
318
|
+
// A Google sign-in through the provider's callback, with the token endpoint
|
|
319
|
+
// stubbed; the provider reports social@example.com, verified.
|
|
320
|
+
async function googleSignIn(browser: Browser): Promise<Response> {
|
|
321
|
+
const realFetch = globalThis.fetch;
|
|
322
|
+
const tokenEndpoint = spyOn(globalThis, "fetch").mockImplementation(((input: RequestInfo | URL, init?: RequestInit) => {
|
|
323
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
324
|
+
if (url.startsWith("https://oauth2.googleapis.com/token")) {
|
|
325
|
+
return Promise.resolve(Response.json({ access_token: "access", expires_in: 3600, token_type: "Bearer" }));
|
|
326
|
+
}
|
|
327
|
+
return realFetch(input, init);
|
|
328
|
+
}) as typeof fetch);
|
|
329
|
+
try {
|
|
330
|
+
const started = await browser.post("/sign-in/social", { provider: "google", callbackURL: `${BASE_URL}/` });
|
|
331
|
+
const state = new URL(((await started.json()) as { url: string }).url).searchParams.get("state") ?? "";
|
|
332
|
+
return await browser.get(`/callback/google?code=code&state=${encodeURIComponent(state)}`);
|
|
333
|
+
} finally {
|
|
334
|
+
tokenEndpoint.mockRestore();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function makeAdmin(db: Record<string, Record<string, unknown>[]>, browser: Browser): Promise<string> {
|
|
339
|
+
const id = await userId(browser);
|
|
340
|
+
const row = db.user?.find((user) => user.id === id);
|
|
341
|
+
if (row) row.role = "admin";
|
|
342
|
+
return id;
|
|
343
|
+
}
|
|
344
|
+
|
|
178
345
|
describe("audit plugin", () => {
|
|
179
346
|
test("records a password sign-in once and a wrong password not at all", async () => {
|
|
180
347
|
const { auth } = startAuth();
|
|
181
348
|
await signUp(auth, "password@example.com");
|
|
349
|
+
// Signing up starts a session too, recorded as the sign-up only.
|
|
350
|
+
expect(entries.map((entry) => entry.action)).toEqual(["auth.signup"]);
|
|
182
351
|
|
|
183
352
|
const wrong = await new Browser(auth).post("/sign-in/email", { email: "password@example.com", password: "Wrong-password-1" });
|
|
184
353
|
expect(wrong.status).toBe(401);
|
|
@@ -329,6 +498,28 @@ describe("audit plugin", () => {
|
|
|
329
498
|
expect(signedIn.status).toBe(200);
|
|
330
499
|
expect(await signedIn.json()).toMatchObject({ redirect: true, url: expect.stringContaining("code=") });
|
|
331
500
|
expect(logins()).toEqual([expect.objectContaining({ actorEmail: "oauth@example.com", metadata: { method: "password" } })]);
|
|
501
|
+
|
|
502
|
+
// Authorizing again with that session and exchanging the code sign nobody in.
|
|
503
|
+
const again = await browser.get(`/oauth2/authorize?${query}`);
|
|
504
|
+
expect(again.status).toBe(302);
|
|
505
|
+
const code = new URL(again.headers.get("location") ?? "").searchParams.get("code") ?? "";
|
|
506
|
+
expect(code).not.toBe("");
|
|
507
|
+
const exchange = new URLSearchParams({
|
|
508
|
+
grant_type: "authorization_code",
|
|
509
|
+
code,
|
|
510
|
+
redirect_uri: "https://client.example.com/callback",
|
|
511
|
+
code_verifier: verifier,
|
|
512
|
+
});
|
|
513
|
+
const credentials = Buffer.from(`${client.client_id}:${client.client_secret ?? ""}`).toString("base64");
|
|
514
|
+
const token = await auth.handler(
|
|
515
|
+
new Request(`${BASE_URL}/api/auth/oauth2/token`, {
|
|
516
|
+
method: "POST",
|
|
517
|
+
headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${credentials}` },
|
|
518
|
+
body: exchange,
|
|
519
|
+
}),
|
|
520
|
+
);
|
|
521
|
+
expect(token.status).toBe(200);
|
|
522
|
+
expect(logins()).toHaveLength(1);
|
|
332
523
|
});
|
|
333
524
|
|
|
334
525
|
test("does not record failed account and admin requests", async () => {
|
|
@@ -354,14 +545,322 @@ describe("audit plugin", () => {
|
|
|
354
545
|
expect(logins()).toEqual([]);
|
|
355
546
|
});
|
|
356
547
|
|
|
548
|
+
test("records a sign-in through an endpoint of an application's plugin with its path", async () => {
|
|
549
|
+
const { auth } = startAuth();
|
|
550
|
+
await signUp(auth, "desktop@example.com");
|
|
551
|
+
entries = [];
|
|
552
|
+
|
|
553
|
+
const desktop = new Browser(auth);
|
|
554
|
+
expect((await desktop.post("/desktop/token", { code: "nobody@example.com" })).status).toBe(404);
|
|
555
|
+
expect(logins()).toEqual([]);
|
|
556
|
+
expect((await desktop.post("/desktop/token", { code: "desktop@example.com" })).status).toBe(200);
|
|
557
|
+
expect(logins()).toEqual([
|
|
558
|
+
expect.objectContaining({ actorEmail: "desktop@example.com", metadata: { method: "/desktop/token" } }),
|
|
559
|
+
]);
|
|
560
|
+
// The session it started is then the desktop app's own.
|
|
561
|
+
expect((await desktop.get("/get-session")).status).toBe(200);
|
|
562
|
+
expect((await desktop.post("/update-user", { name: "Desktop" })).status).toBe(200);
|
|
563
|
+
expect(logins()).toHaveLength(1);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("records no sign-in for reads and updates that refresh the session", async () => {
|
|
567
|
+
// Every session read extends the session and the cookie cache expires at once.
|
|
568
|
+
const { auth } = startAuth({ session: { updateAge: 0, cookieCache: { enabled: true, maxAge: 1 } } });
|
|
569
|
+
const owner = await signUp(auth, "refresh@example.com");
|
|
570
|
+
entries = [];
|
|
571
|
+
expect((await owner.get("/get-session")).status).toBe(200);
|
|
572
|
+
expect((await owner.get("/get-session?disableCookieCache=true")).status).toBe(200);
|
|
573
|
+
expect((await owner.post("/update-user", { name: "Refreshed" })).status).toBe(200);
|
|
574
|
+
expect((await owner.post("/change-email", { newEmail: "refreshed@example.com" })).status).toBe(200);
|
|
575
|
+
expect((await owner.post("/update-session", {})).status).toBeLessThan(500);
|
|
576
|
+
expect((await owner.get("/token")).status).toBe(200);
|
|
577
|
+
expect((await owner.get("/list-sessions")).status).toBe(200);
|
|
578
|
+
expect(actions("account.update")).toHaveLength(1);
|
|
579
|
+
expect(actions("auth.email_change")).toHaveLength(1);
|
|
580
|
+
expect(logins()).toEqual([]);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("records no sign-in for bearer and API key requests", async () => {
|
|
584
|
+
const { auth } = startAuth({ session: { updateAge: 0 } });
|
|
585
|
+
const owner = await signUp(auth, "client@example.com");
|
|
586
|
+
const signIn = await new Browser(auth).post("/sign-in/email", { email: "client@example.com", password: PASSWORD });
|
|
587
|
+
const token = signIn.headers.get("set-auth-token") ?? "";
|
|
588
|
+
expect(token).not.toBe("");
|
|
589
|
+
const created = await owner.post("/api-key/create", {});
|
|
590
|
+
expect(created.status).toBe(200);
|
|
591
|
+
const { key } = (await created.json()) as { key: string };
|
|
592
|
+
entries = [];
|
|
593
|
+
|
|
594
|
+
for (const client of [new Browser(auth, { authorization: `Bearer ${token}` }), new Browser(auth, { "x-api-key": key })]) {
|
|
595
|
+
for (let i = 0; i < 3; i++) expect((await client.get("/get-session")).status).toBe(200);
|
|
596
|
+
expect((await client.post("/update-user", { name: "Client" })).status).toBe(200);
|
|
597
|
+
expect((await client.get("/list-sessions")).status).toBe(200);
|
|
598
|
+
}
|
|
599
|
+
expect(actions("account.update")).toHaveLength(2);
|
|
600
|
+
expect(logins()).toEqual([]);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("records no sign-in when an administrator impersonates a user or a browser switches accounts", async () => {
|
|
604
|
+
const { auth, db } = startAuth();
|
|
605
|
+
const operator = await signUp(auth, "operator@example.com");
|
|
606
|
+
const operatorId = await userId(operator);
|
|
607
|
+
const row = db.user?.find((user) => user.id === operatorId);
|
|
608
|
+
if (row) row.role = "admin";
|
|
609
|
+
const target = await signUp(auth, "impersonated@example.com");
|
|
610
|
+
const targetId = await userId(target);
|
|
611
|
+
entries = [];
|
|
612
|
+
|
|
613
|
+
expect((await operator.post("/admin/impersonate-user", { userId: targetId })).status).toBe(200);
|
|
614
|
+
expect(await userId(operator)).toBe(targetId);
|
|
615
|
+
expect((await operator.post("/admin/stop-impersonating", {})).status).toBe(200);
|
|
616
|
+
expect(await userId(operator)).toBe(operatorId);
|
|
617
|
+
expect(actions("user.impersonate")).toHaveLength(1);
|
|
618
|
+
expect(actions("user.stop_impersonate")).toHaveLength(1);
|
|
619
|
+
expect(logins()).toEqual([]);
|
|
620
|
+
|
|
621
|
+
// Signing in to a second account in the same browser is a sign-in; switching
|
|
622
|
+
// between the two and signing one out are not.
|
|
623
|
+
const second = await operator.post("/sign-in/email", { email: "impersonated@example.com", password: PASSWORD });
|
|
624
|
+
expect(second.status).toBe(200);
|
|
625
|
+
expect(logins()).toEqual([expect.objectContaining({ actorEmail: "impersonated@example.com" })]);
|
|
626
|
+
const sessions = (await (await operator.get("/multi-session/list-device-sessions")).json()) as Array<{
|
|
627
|
+
session: { token: string };
|
|
628
|
+
user: { id: string };
|
|
629
|
+
}>;
|
|
630
|
+
const tokenOf = (id: string): string => sessions.find((entry) => entry.user.id === id)?.session.token ?? "";
|
|
631
|
+
expect((await operator.post("/multi-session/set-active", { sessionToken: tokenOf(operatorId) })).status).toBe(200);
|
|
632
|
+
expect(await userId(operator)).toBe(operatorId);
|
|
633
|
+
expect((await operator.post("/multi-session/revoke", { sessionToken: tokenOf(operatorId) })).status).toBe(200);
|
|
634
|
+
expect(await userId(operator)).toBe(targetId);
|
|
635
|
+
expect((await operator.post("/revoke-other-sessions", {})).status).toBe(200);
|
|
636
|
+
expect((await operator.post("/revoke-sessions", {})).status).toBe(200);
|
|
637
|
+
expect(logins()).toHaveLength(1);
|
|
638
|
+
|
|
639
|
+
const again = await new Browser(auth).post("/sign-in/email", { email: "operator@example.com", password: PASSWORD });
|
|
640
|
+
expect(again.status).toBe(200);
|
|
641
|
+
const signedOut = new Browser(auth);
|
|
642
|
+
await signedOut.post("/sign-in/email", { email: "operator@example.com", password: PASSWORD });
|
|
643
|
+
expect((await signedOut.post("/sign-out", {})).status).toBe(200);
|
|
644
|
+
expect(actions("auth.logout")).toHaveLength(1);
|
|
645
|
+
expect(logins()).toHaveLength(3);
|
|
646
|
+
});
|
|
647
|
+
|
|
357
648
|
test("records nothing while auditing is off", async () => {
|
|
358
|
-
const { auth } = startAuth(async () => false);
|
|
649
|
+
const { auth } = startAuth({ enabled: async () => false });
|
|
359
650
|
await signUp(auth, "off@example.com");
|
|
360
651
|
expect((await new Browser(auth).post("/sign-in/email", { email: "off@example.com", password: PASSWORD })).status).toBe(200);
|
|
361
652
|
expect(entries).toEqual([]);
|
|
362
653
|
});
|
|
363
654
|
});
|
|
364
655
|
|
|
656
|
+
describe("audit plugin accounts and sessions", () => {
|
|
657
|
+
test("records an email sign-up once, and not a duplicate answered as if it succeeded", async () => {
|
|
658
|
+
const { auth } = startAuth({ signupApproval: true });
|
|
659
|
+
const first = await new Browser(auth).post("/sign-up/email", { email: "pending@example.com", password: PASSWORD, name: "P" });
|
|
660
|
+
expect(first.status).toBe(200);
|
|
661
|
+
const id = (await first.json()) as { user: { id: string } };
|
|
662
|
+
// With auto sign-in off, Better Auth answers a taken address with the same 200.
|
|
663
|
+
const again = await new Browser(auth).post("/sign-up/email", { email: "pending@example.com", password: PASSWORD, name: "P" });
|
|
664
|
+
expect(again.status).toBe(200);
|
|
665
|
+
expect(entries).toEqual([
|
|
666
|
+
expect.objectContaining({
|
|
667
|
+
action: "auth.signup",
|
|
668
|
+
actorEmail: "pending@example.com",
|
|
669
|
+
targetId: id.user.id,
|
|
670
|
+
targetLabel: "pending@example.com",
|
|
671
|
+
metadata: { method: "password" },
|
|
672
|
+
}),
|
|
673
|
+
]);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("records a social sign-up that waits for approval, without a sign-in", async () => {
|
|
677
|
+
const { auth, db } = startAuth({ signupApproval: true });
|
|
678
|
+
// The approval hook refuses the session, so the callback fails after the
|
|
679
|
+
// account is stored.
|
|
680
|
+
expect((await googleSignIn(new Browser(auth))).status).toBe(403);
|
|
681
|
+
expect(db.session).toEqual([]);
|
|
682
|
+
expect(db.user).toHaveLength(1);
|
|
683
|
+
expect(entries).toEqual([
|
|
684
|
+
expect.objectContaining({
|
|
685
|
+
action: "auth.signup",
|
|
686
|
+
actorEmail: "social@example.com",
|
|
687
|
+
targetId: db.user?.[0]?.id,
|
|
688
|
+
metadata: { method: "social", provider: "google" },
|
|
689
|
+
}),
|
|
690
|
+
]);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("records an approved social sign-up and its sign-in, and a later sign-in alone", async () => {
|
|
694
|
+
const { auth } = startAuth();
|
|
695
|
+
expect((await googleSignIn(new Browser(auth))).status).toBe(302);
|
|
696
|
+
expect(entries.map((entry) => [entry.action, entry.metadata])).toEqual([
|
|
697
|
+
["auth.signup", { method: "social", provider: "google" }],
|
|
698
|
+
["auth.login", { method: "social", provider: "google" }],
|
|
699
|
+
]);
|
|
700
|
+
expect((await googleSignIn(new Browser(auth))).status).toBe(302);
|
|
701
|
+
expect(entries.map((entry) => entry.action)).toEqual(["auth.signup", "auth.login", "auth.login"]);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("records the account a magic link or an email code creates as a sign-up", async () => {
|
|
705
|
+
const { auth, mail } = startAuth();
|
|
706
|
+
const linked = new Browser(auth);
|
|
707
|
+
await linked.post("/sign-in/magic-link", { email: "new-magic@example.com", callbackURL: `${BASE_URL}/` });
|
|
708
|
+
expect((await linked.get(mail.magicLink)).status).toBe(302);
|
|
709
|
+
const coded = new Browser(auth);
|
|
710
|
+
await coded.post("/email-otp/send-verification-otp", { email: "new-code@example.com", type: "sign-in" });
|
|
711
|
+
expect((await coded.post("/sign-in/email-otp", { email: "new-code@example.com", otp: mail.otp })).status).toBe(200);
|
|
712
|
+
expect(entries.map((entry) => [entry.action, entry.actorEmail, entry.metadata])).toEqual([
|
|
713
|
+
["auth.signup", "new-magic@example.com", { method: "magic-link" }],
|
|
714
|
+
["auth.login", "new-magic@example.com", { method: "magic-link" }],
|
|
715
|
+
["auth.signup", "new-code@example.com", { method: "email-otp" }],
|
|
716
|
+
["auth.login", "new-code@example.com", { method: "email-otp" }],
|
|
717
|
+
]);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("records an administrator's new user as user.create only, with its id", async () => {
|
|
721
|
+
const { auth, db } = startAuth({ signupApproval: true });
|
|
722
|
+
await new Browser(auth).post("/sign-up/email", { email: "creator@example.com", password: PASSWORD, name: "C" });
|
|
723
|
+
const creator = db.user?.[0];
|
|
724
|
+
if (creator) Object.assign(creator, { role: "admin", signupApproved: true });
|
|
725
|
+
const operator = new Browser(auth);
|
|
726
|
+
expect((await operator.post("/sign-in/email", { email: "creator@example.com", password: PASSWORD })).status).toBe(200);
|
|
727
|
+
entries = [];
|
|
728
|
+
|
|
729
|
+
const created = await operator.post("/admin/create-user", { email: "made@example.com", password: PASSWORD, name: "M" });
|
|
730
|
+
expect(created.status).toBe(200);
|
|
731
|
+
const { user } = (await created.json()) as { user: { id: string } };
|
|
732
|
+
expect(entries).toEqual([
|
|
733
|
+
expect.objectContaining({ action: "user.create", actorEmail: "creator@example.com", targetId: user.id, targetLabel: "made@example.com" }),
|
|
734
|
+
]);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("records an account created outside a request without an actor", async () => {
|
|
738
|
+
const { auth } = startAuth();
|
|
739
|
+
const context = await auth.$context;
|
|
740
|
+
const user = await context.internalAdapter.createUser(
|
|
741
|
+
{ email: "seeded@example.com", name: "Seeded", emailVerified: true },
|
|
742
|
+
{ method: "seed" },
|
|
743
|
+
);
|
|
744
|
+
expect(entries).toEqual([
|
|
745
|
+
{ action: "auth.signup", targetId: user.id, targetLabel: "seeded@example.com", ip: null },
|
|
746
|
+
]);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("names the user who signs out, and records nothing for a sign-out without a session", async () => {
|
|
750
|
+
const { auth } = startAuth();
|
|
751
|
+
const owner = await signUp(auth, "leaving@example.com");
|
|
752
|
+
const id = await userId(owner);
|
|
753
|
+
entries = [];
|
|
754
|
+
expect((await new Browser(auth).post("/sign-out", {})).status).toBe(200);
|
|
755
|
+
const forged = new Browser(auth, { cookie: "better-auth.session_token=forged.signature" });
|
|
756
|
+
expect((await forged.post("/sign-out", {})).status).toBe(200);
|
|
757
|
+
expect(entries).toEqual([]);
|
|
758
|
+
|
|
759
|
+
expect((await owner.post("/sign-out", {})).status).toBe(200);
|
|
760
|
+
expect(entries).toEqual([
|
|
761
|
+
expect.objectContaining({ action: "auth.logout", actorId: id, actorEmail: "leaving@example.com", targetId: id }),
|
|
762
|
+
]);
|
|
763
|
+
// The session is gone, so signing out again ends nothing.
|
|
764
|
+
expect((await owner.post("/sign-out", {})).status).toBe(200);
|
|
765
|
+
expect(actions("auth.logout")).toHaveLength(1);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test("records a sign-out of every account a browser holds", async () => {
|
|
769
|
+
const { auth } = startAuth();
|
|
770
|
+
await signUp(auth, "first@example.com");
|
|
771
|
+
await signUp(auth, "second@example.com");
|
|
772
|
+
const browser = new Browser(auth);
|
|
773
|
+
await browser.post("/sign-in/email", { email: "first@example.com", password: PASSWORD });
|
|
774
|
+
await browser.post("/sign-in/email", { email: "second@example.com", password: PASSWORD });
|
|
775
|
+
entries = [];
|
|
776
|
+
expect((await browser.post("/sign-out", {})).status).toBe(200);
|
|
777
|
+
expect(actions("auth.logout").map((entry) => entry.actorEmail).sort()).toEqual(["first@example.com", "second@example.com"]);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("names the owner of a session an administrator revokes, and the administrator who stops impersonating", async () => {
|
|
781
|
+
const { auth, db } = startAuth();
|
|
782
|
+
const operator = await signUp(auth, "revoker@example.com");
|
|
783
|
+
await makeAdmin(db, operator);
|
|
784
|
+
const target = await signUp(auth, "revoked@example.com");
|
|
785
|
+
const targetId = await userId(target);
|
|
786
|
+
const { session } = (await (await target.get("/get-session")).json()) as { session: { token: string } };
|
|
787
|
+
entries = [];
|
|
788
|
+
|
|
789
|
+
expect((await operator.post("/admin/revoke-user-session", { sessionToken: session.token })).status).toBe(200);
|
|
790
|
+
expect(actions("user.session_revoke")).toEqual([
|
|
791
|
+
expect.objectContaining({ actorEmail: "revoker@example.com", targetId, targetLabel: "revoked@example.com" }),
|
|
792
|
+
]);
|
|
793
|
+
|
|
794
|
+
expect((await operator.post("/admin/impersonate-user", { userId: targetId })).status).toBe(200);
|
|
795
|
+
expect((await operator.post("/admin/stop-impersonating", {})).status).toBe(200);
|
|
796
|
+
expect(actions("user.stop_impersonate")).toEqual([
|
|
797
|
+
expect.objectContaining({ actorEmail: "revoker@example.com", targetId, targetLabel: "revoked@example.com" }),
|
|
798
|
+
]);
|
|
799
|
+
// Ending the impersonation session is not a sign-out.
|
|
800
|
+
expect(actions("auth.logout")).toEqual([]);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("names the user a password reset link, email code, or text message code resets", async () => {
|
|
804
|
+
const { auth, mail, db } = startAuth();
|
|
805
|
+
await signUp(auth, "reset@example.com");
|
|
806
|
+
const id = db.user?.[0]?.id;
|
|
807
|
+
entries = [];
|
|
808
|
+
|
|
809
|
+
const anonymous = new Browser(auth);
|
|
810
|
+
await anonymous.post("/request-password-reset", { email: "reset@example.com" });
|
|
811
|
+
const wrong = await anonymous.post("/reset-password", { token: "forged", newPassword: "N3w-Str0ng!password" });
|
|
812
|
+
expect(wrong.status).toBe(400);
|
|
813
|
+
expect((await anonymous.post("/reset-password", { token: mail.reset, newPassword: "N3w-Str0ng!password" })).status).toBe(200);
|
|
814
|
+
|
|
815
|
+
await anonymous.post("/email-otp/request-password-reset", { email: "reset@example.com" });
|
|
816
|
+
const coded = await anonymous.post("/email-otp/reset-password", { email: "reset@example.com", otp: mail.otp, password: PASSWORD });
|
|
817
|
+
expect(coded.status).toBe(200);
|
|
818
|
+
|
|
819
|
+
const row = db.user?.[0];
|
|
820
|
+
if (row) Object.assign(row, { phoneNumber: "+15550100", phoneNumberVerified: true });
|
|
821
|
+
await anonymous.post("/phone-number/request-password-reset", { phoneNumber: "+15550100" });
|
|
822
|
+
const texted = await anonymous.post("/phone-number/reset-password", {
|
|
823
|
+
phoneNumber: "+15550100",
|
|
824
|
+
otp: mail.sms,
|
|
825
|
+
newPassword: "Th1rd-Str0ng!password",
|
|
826
|
+
});
|
|
827
|
+
expect(texted.status).toBe(200);
|
|
828
|
+
|
|
829
|
+
expect(actions("auth.password_reset")).toEqual([
|
|
830
|
+
expect.objectContaining({ actorId: id, actorEmail: "reset@example.com", targetId: id, targetLabel: "reset@example.com" }),
|
|
831
|
+
expect.objectContaining({ actorId: id, actorEmail: "reset@example.com", targetId: id, targetLabel: "reset@example.com" }),
|
|
832
|
+
expect.objectContaining({ actorId: id, actorEmail: "reset@example.com", targetId: id, targetLabel: "reset@example.com" }),
|
|
833
|
+
]);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("names the user an email verification link or code verifies", async () => {
|
|
837
|
+
const { auth, mail } = startAuth({ verificationSignIn: false });
|
|
838
|
+
const owner = await signUp(auth, "verify-me@example.com");
|
|
839
|
+
const id = await userId(owner);
|
|
840
|
+
await owner.post("/send-verification-email", { email: "verify-me@example.com", callbackURL: `${BASE_URL}/` });
|
|
841
|
+
expect((await new Browser(auth).get(mail.verification)).status).toBe(302);
|
|
842
|
+
|
|
843
|
+
const coded = await signUp(auth, "verify-code@example.com");
|
|
844
|
+
const codedId = await userId(coded);
|
|
845
|
+
const anonymous = new Browser(auth);
|
|
846
|
+
await anonymous.post("/email-otp/send-verification-otp", { email: "verify-code@example.com", type: "email-verification" });
|
|
847
|
+
expect((await anonymous.post("/email-otp/verify-email", { email: "verify-code@example.com", otp: mail.otp })).status).toBe(200);
|
|
848
|
+
|
|
849
|
+
expect(actions("auth.email_verified")).toEqual([
|
|
850
|
+
expect.objectContaining({ actorId: id, actorEmail: "verify-me@example.com", targetId: id, targetLabel: "verify-me@example.com" }),
|
|
851
|
+
expect.objectContaining({ actorId: codedId, targetId: codedId, targetLabel: "verify-code@example.com" }),
|
|
852
|
+
]);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test("records an email change by code", async () => {
|
|
856
|
+
const { auth, mail } = startAuth();
|
|
857
|
+
const owner = await signUp(auth, "old-address@example.com");
|
|
858
|
+
expect((await owner.post("/email-otp/request-email-change", { newEmail: "new-address@example.com" })).status).toBe(200);
|
|
859
|
+
expect((await owner.post("/email-otp/change-email", { newEmail: "new-address@example.com", otp: mail.otp })).status).toBe(200);
|
|
860
|
+
expect(actions("auth.email_change")).toEqual([expect.objectContaining({ actorEmail: "old-address@example.com" })]);
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
|
|
365
864
|
describe("the app's Better Auth", () => {
|
|
366
865
|
test("runs the audit hook after every plugin's after hooks", () => {
|
|
367
866
|
const instance = buildAuth(envAuthConfig());
|
|
@@ -370,16 +869,19 @@ describe("the app's Better Auth", () => {
|
|
|
370
869
|
expect(instance.options.hooks?.after).toBeUndefined();
|
|
371
870
|
});
|
|
372
871
|
|
|
373
|
-
test("classifies every endpoint
|
|
872
|
+
test("classifies every endpoint of Better Auth and PodoKit's plugins", () => {
|
|
374
873
|
const instance = buildAuth(envAuthConfig());
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
!STARTS_NO_SESSION.has(path),
|
|
874
|
+
type Endpoints = Record<string, { path?: string }> | undefined;
|
|
875
|
+
const pathsOf = (endpoints: Endpoints): string[] =>
|
|
876
|
+
Object.values(endpoints ?? {}).flatMap((endpoint) => (endpoint.path ? [endpoint.path] : []));
|
|
877
|
+
const plugins = instance.options.plugins as Array<{ id: string; endpoints?: Endpoints }>;
|
|
878
|
+
const applications = new Set(
|
|
879
|
+
plugins.filter((plugin) => !PODOKIT_PLUGINS.has(plugin.id)).flatMap((plugin) => pathsOf(plugin.endpoints)),
|
|
382
880
|
);
|
|
383
|
-
|
|
881
|
+
const paths = new Set(pathsOf(instance.api as Endpoints).filter((path) => !applications.has(path)));
|
|
882
|
+
expect(Object.keys(SIGN_IN_METHODS).filter((path) => !paths.has(path))).toEqual([]);
|
|
883
|
+
const classified = (path: string): boolean =>
|
|
884
|
+
SIGN_IN_METHODS[path] !== undefined || NOT_SIGN_INS.has(path) || STARTS_NO_SESSION.has(path);
|
|
885
|
+
expect([...paths].filter((path) => !classified(path))).toEqual([]);
|
|
384
886
|
});
|
|
385
887
|
});
|