mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
A cookie named healthlog_session can be set by any host under the same registrable domain, so a page on a sibling subdomain could plant a session of its choosing in a visitor's browser. Wherever the session cookie is Secure it is now issued as __Host-healthlog_session, which a browser accepts only from this exact host. Plain-HTTP installs, which cannot hold a __Host- cookie, keep the old name. Nobody is signed out: the old name is still read after the new one, a session found under it is re-issued under the new name and the old cookie expired, and a browser holding the new cookie is never answered from the old one. Every reader and writer goes through one module, which the proxy, the native-caller check, the native handoff and the OIDC callback use; the session-surface guard follows the new reader.
448 lines
21 KiB
TypeScript
448 lines
21 KiB
TypeScript
/**
|
|
* Structural guards on the surfaces that resolve a caller's identity.
|
|
*
|
|
* `bearer-scope-enforcement-guard.test.ts` freezes ONE mechanism: the files
|
|
* that turn an `Authorization: Bearer` value into a user. That guard is
|
|
* correct and it holds. What it is not is coverage of "who can resolve a
|
|
* caller". Two other families do the same job by other means, and a sweep
|
|
* written by copying the Bearer guard sees neither of them:
|
|
*
|
|
* A. A route or page that reads the session cookie itself, instead of
|
|
* going through `requireAuth()` inside `apiHandler`. `requireAuth` is
|
|
* where the fail-closed scope arm, the audit row, and the admin
|
|
* boundary live; a direct `getSession()` has none of them and answers
|
|
* to nothing but the file it sits in.
|
|
* B. A surface whose whole credential is a token in the URL — the
|
|
* clinician share tree serves one person's complete health record to
|
|
* an anonymous caller holding an `hls_` path segment, and never
|
|
* touches a session at all. No amount of grepping for `getSession`
|
|
* will ever show it to you.
|
|
*
|
|
* Neither family is wrong. Both are deliberate, and both are documented at
|
|
* their own call sites. The defect this file closes is that nothing said so
|
|
* when a new member joined.
|
|
*
|
|
* These are tripwires, not proofs. They cannot show an allowlist is
|
|
* correct — only that it has not changed without someone editing this file.
|
|
*
|
|
* A note on why every matcher carries its own count assertion: several legs
|
|
* below overlap (four of the five share-token files also sit under a
|
|
* `[token]` segment). If one leg's regex silently stopped matching, the
|
|
* union would be unchanged and the guard would stay green for the wrong
|
|
* reason. Each leg therefore asserts its own membership, so a matcher that
|
|
* matches nothing fails instead of passing.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import { join } from "node:path";
|
|
|
|
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
|
|
|
|
const SRC = join(process.cwd(), "src");
|
|
|
|
/**
|
|
* Every non-test `.ts` / `.tsx` under `src/app/`, which is the whole of the
|
|
* request-handling tree: API routes, RSC pages, layouts, route handlers.
|
|
* The generated Prisma client is excluded by construction (it is not under
|
|
* `app/`) and never read — see CLAUDE.md.
|
|
*/
|
|
function appFiles(): string[] {
|
|
return walkSourceFiles(SRC, { floor: 3000 })
|
|
.filter((p) => p.startsWith("app/"))
|
|
.filter((p) => !p.includes("__tests__"))
|
|
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
|
.sort();
|
|
}
|
|
|
|
function read(rel: string): string {
|
|
return readSourceText(join(SRC, rel));
|
|
}
|
|
|
|
function appFilesMatching(re: RegExp): string[] {
|
|
return appFiles().filter((rel) => re.test(read(rel)));
|
|
}
|
|
|
|
describe("S1 — the direct session-resolution set is frozen", () => {
|
|
/**
|
|
* Files under `src/app` that resolve the caller from the session cookie
|
|
* WITHOUT going through `requireAuth()`. Every other authenticated route
|
|
* in the tree — 300-odd of them — reaches its user through `apiHandler`
|
|
* plus `requireAuth`, which is what makes this list short enough to read.
|
|
*
|
|
* An entry here is not a finding. It is a place where the protections
|
|
* `requireAuth` applies centrally have to be argued locally, so the
|
|
* reason is the point of the entry.
|
|
*/
|
|
const SESSION_RESOLUTION_ALLOWLIST: Record<string, string> = {
|
|
// Completes the native ASWebAuthenticationSession handoff. Validates the
|
|
// browser session itself because it also needs `session.createdAt` for
|
|
// the freshness comparison, which `requireAuth` does not project.
|
|
"app/api/auth/native/complete/route.ts":
|
|
"reads createdAt for the handoff freshness check",
|
|
// Anonymous self-registration. Probes for an already-valid session so an
|
|
// invitation opened from inside one is refused instead of minting a second
|
|
// account over the caller's own cookie. `requireAuth` is the wrong gate
|
|
// here twice over: it would throw 401 at the anonymous caller the route
|
|
// exists for, and this route wants the ABSENCE of a session.
|
|
"app/api/auth/register/route.ts": "refuses to register over a live session",
|
|
// v1.42 (#959) — the anonymous managed-profile claim and its preview. Same
|
|
// argument as registration: claiming signs the new owner in, so a Guardian
|
|
// opening their own handover link must be refused rather than signed out
|
|
// of their account and into the person's. The probe only asks whether a
|
|
// session EXISTS; the credential these routes act on is the one-time
|
|
// `hlp_` token in the body, and the probe is resolved in the route file
|
|
// itself (not inside `claimGate`) so both stay visible on this list.
|
|
"app/api/auth/claim/route.ts":
|
|
"refuses to claim a profile over a live session",
|
|
"app/api/auth/claim/preview/route.ts":
|
|
"refuses to preview a handover link over a live session",
|
|
// v1.43 (#959) — the single-sign-on claim start, same argument.
|
|
"app/api/auth/claim/sso/route.ts":
|
|
"refuses to start a single-sign-on claim over a live session",
|
|
// Anonymous IdP callback. Probes for an already-valid session so a
|
|
// replayed callback redirects instead of minting a second identity, and
|
|
// (v1.43, #959) so a single-sign-on claim is refused over a live session,
|
|
// as the claim routes refuse it.
|
|
"app/api/auth/oidc/callback/route.ts":
|
|
"duplicate-callback probe and the claim's live-session refusal on an otherwise anonymous route",
|
|
// Public liveness probe. The session read only decides whether the body
|
|
// carries operational detail; the status code is computed without it.
|
|
"app/api/health/route.ts": "admin-only detail on a public probe",
|
|
// OAuth authorize runs under `withBackgroundEvent`, not `apiHandler`,
|
|
// because it owes RFC-format error bodies — so it resolves the
|
|
// consenting human itself.
|
|
"app/api/mcp/oauth/authorize/route.ts":
|
|
"consent decision on a route outside apiHandler",
|
|
// Provider OAuth callbacks. The browser arrives as a top-level
|
|
// navigation, so the code is bound to the session user and redirected.
|
|
// v1.44 — Withings and Google Health join: a navigation cannot carry the
|
|
// record-session header a record resolver needs, and the session user may
|
|
// be a guardian finishing a managed profile's connect, which
|
|
// `verifyCallbackActor` checks against a live MANAGE grant.
|
|
"app/api/google-health/callback/route.ts":
|
|
"binds the OAuth code to the session user",
|
|
"app/api/withings/callback/route.ts":
|
|
"binds the OAuth code to the session user",
|
|
"app/api/oura/callback/route.ts":
|
|
"binds the OAuth code to the session user",
|
|
"app/api/polar/callback/route.ts":
|
|
"binds the OAuth code to the session user",
|
|
"app/api/strava/callback/route.ts":
|
|
"binds the OAuth code to the session user",
|
|
"app/api/whoop/callback/route.ts":
|
|
"binds the OAuth code to the session user",
|
|
// RSC prefetch: the server component hydrates the client query cache
|
|
// with the session user's own data. Absence of a session skips the
|
|
// prefetch rather than failing the render.
|
|
//
|
|
// These seven resolve through `getUnswitchedSession()`, not `getSession()`,
|
|
// and they stay listed here BECAUSE of it. The helper is still a session
|
|
// derivation outside `requireAuth` — moving the call one file deeper into
|
|
// `lib/` would have taken them off this list while changing nothing about
|
|
// what they do, and a guard that a refactor can walk out of is not one.
|
|
// What the helper adds is the record identity: it answers null while the
|
|
// browser acts on somebody else's record, so the cache these pages seed
|
|
// can only ever hold the caller's own. See
|
|
// `prefetch-page-caching-guard.test.ts` for the rule that keeps a sixth
|
|
// prefetching page from reaching for the bare helper.
|
|
"app/checkups/page.tsx":
|
|
"RSC prefetch of the session user's reminder list, skipped under a switch",
|
|
"app/coach/page.tsx":
|
|
"RSC prefetch of the session user's coach state, skipped under a switch",
|
|
"app/insights/page.tsx":
|
|
"RSC prefetch of the session user's insights, skipped under a switch",
|
|
"app/insights/mood/page.tsx":
|
|
"RSC prefetch of the session user's mood calendar and line, skipped under a switch",
|
|
"app/insights/workouts/page.tsx":
|
|
"RSC prefetch of the session user's workouts, skipped under a switch",
|
|
"app/medications/page.tsx":
|
|
"RSC prefetch of the session user's medications, skipped under a switch",
|
|
"app/mood/page.tsx":
|
|
"RSC prefetch of the session user's mood list, skipped under a switch",
|
|
"app/page.tsx":
|
|
"RSC prefetch of the session user's dashboard snapshot, skipped under a switch",
|
|
// Onboarding routing. Reads the actor's OWN setup state through
|
|
// `loadOnboardingFlowState` (a `getSession` wrapper, listed in the regex
|
|
// below) to guard the step URL and to render the screens; the three
|
|
// writes behind them refuse under a switch, so the pages read the same
|
|
// record the routes write.
|
|
"app/onboarding/[step]/page.tsx":
|
|
"guards the step URL on the actor's own setup state",
|
|
"app/onboarding/page.tsx":
|
|
"resumes or welcomes on the actor's own setup state",
|
|
};
|
|
|
|
/**
|
|
* The identity-deriving helpers exported by `lib/auth/session.ts`, plus the
|
|
* one that wraps it. The lifecycle exports (`createSession`,
|
|
* `destroySession`, `destroyAllSessions`, `destroySessionById`,
|
|
* `destroyOtherSessions`) are deliberately NOT here: they act on a session,
|
|
* they do not hand the handler an identity to scope a query by. Adding a
|
|
* further deriving helper means adding it to this regex.
|
|
*
|
|
* `getUnswitchedSession` (`lib/auth/acting-carrier.ts`) is listed for the
|
|
* reason a wrapper is the easiest way to disappear from a guard: it returns
|
|
* `getSession()`'s answer or null, so a caller of it derives an identity
|
|
* exactly as much as a caller of the thing it wraps. v1.39 (C2) —
|
|
* `loadOnboardingFlowState` (`lib/onboarding/load-flow-state.ts`) is the
|
|
* same shape: `getSession()` plus the caller's own setup row.
|
|
*/
|
|
const SESSION_DERIVING_HELPERS =
|
|
/\b(getSession|getUnswitchedSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt|loadOnboardingFlowState)\s*\(/;
|
|
|
|
/**
|
|
* Reaching for the cookie by name is the way around the helpers. The
|
|
* reader (`readSessionCookie`, which tries both names), the exported names
|
|
* and either raw literal all count; the `__Host-` name joined the old one
|
|
* when a `Secure` session cookie started being issued under it.
|
|
*/
|
|
const RAW_SESSION_COOKIE =
|
|
/\b(readSessionCookie|SESSION_COOKIE_NAMES?|HOST_SESSION_COOKIE|LEGACY_SESSION_COOKIE)\b|"(__Host-)?healthlog_session"/;
|
|
|
|
it("no file under src/app derives a session outside requireAuth unlisted", () => {
|
|
const resolvers = appFilesMatching(SESSION_DERIVING_HELPERS);
|
|
|
|
// Non-zero proof. If the helper names are ever renamed out from under
|
|
// this regex, the match set collapses to empty — which must fail here
|
|
// rather than quietly agree with an emptied allowlist.
|
|
expect(resolvers.length).toBeGreaterThan(0);
|
|
expect(resolvers).toEqual(Object.keys(SESSION_RESOLUTION_ALLOWLIST).sort());
|
|
});
|
|
|
|
it("no file under src/app reads the session cookie by name unlisted", () => {
|
|
const cookieReaders = appFilesMatching(RAW_SESSION_COOKIE);
|
|
|
|
// Same non-zero proof, for this leg's own matcher. These two files also
|
|
// appear in the leg above, which is exactly why this count is asserted
|
|
// separately: an overlapping leg cannot borrow another leg's evidence.
|
|
expect(cookieReaders.length).toBeGreaterThan(0);
|
|
expect(cookieReaders).toEqual([
|
|
"app/api/auth/native/complete/route.ts",
|
|
"app/api/auth/oidc/callback/route.ts",
|
|
]);
|
|
|
|
// And whatever it finds must already be an argued session surface.
|
|
for (const rel of cookieReaders) {
|
|
expect(Object.keys(SESSION_RESOLUTION_ALLOWLIST)).toContain(rel);
|
|
}
|
|
});
|
|
|
|
it("every allowlist entry carries a reason", () => {
|
|
for (const [rel, reason] of Object.entries(SESSION_RESOLUTION_ALLOWLIST)) {
|
|
expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("S2 — the URL-token authentication set is frozen", () => {
|
|
/**
|
|
* Files whose credential is a token in the URL — a path segment or a
|
|
* query parameter — rather than a session cookie or a Bearer header.
|
|
*
|
|
* This is the family the Bearer guard cannot see and a `getSession` sweep
|
|
* cannot see. `src/app/c/[token]/**` resolves one user's entire health
|
|
* record from an `hls_` path segment held by an anonymous caller. That is
|
|
* the product's design, it predates every guard in this file, and it is
|
|
* not a defect. What was a defect is that a sixth door could have been
|
|
* cut next to it without anything saying so.
|
|
*/
|
|
const URL_TOKEN_ALLOWLIST: Record<string, string> = {
|
|
// Clinician share link: the `hls_` path segment is the whole credential.
|
|
"app/c/[token]/page.tsx": "serves the shared record view",
|
|
"app/c/[token]/d/[id]/route.ts": "serves a shared document's bytes",
|
|
"app/c/[token]/fhir/route.ts": "serves the shared record as a FHIR bundle",
|
|
"app/c/[token]/report.pdf/route.ts": "serves the shared record as a PDF",
|
|
// The segment's not-found page: the copy a dead link shows. It reads no
|
|
// token and resolves nothing; the page above decides, and answers 404.
|
|
"app/c/[token]/not-found.tsx": "renders the dead-link copy; reads no token",
|
|
// The passphrase gate for the same token. Anonymous by design; mints the
|
|
// token-scoped unlock cookie the view above checks.
|
|
"app/api/c/[token]/unlock/route.ts": "verifies the share-link passphrase",
|
|
// Webhook entrypoints. The provider supports no headers on a delivery,
|
|
// so the shared secret rides the path segment and is scrubbed from logs
|
|
// by `PATH_SECRET_PATHS`. These resolve a record from the payload after
|
|
// the secret compares equal.
|
|
"app/api/whoop/webhook/[token]/route.ts":
|
|
"path-segment shared secret, plus an HMAC body signature",
|
|
"app/api/withings/webhook/[token]/route.ts":
|
|
"path-segment shared secret; no body signature is available",
|
|
// The legacy form of the same Withings secret, still alive for
|
|
// subscriptions that have not rotated to the path-segment URL.
|
|
"app/api/withings/webhook/route.ts":
|
|
"legacy query-parameter form of the Withings webhook secret",
|
|
// Shape check only. Validates the `hlv_` prefix and redirects; touches
|
|
// no database, resolves nothing, and is not an enumeration oracle.
|
|
// Listed because it is a URL-token surface, with the reason being that
|
|
// it authenticates nothing.
|
|
"app/invite/[token]/page.tsx":
|
|
"validates the invite token's shape and redirects, or says the link is invalid; no lookup",
|
|
// v1.42 (#959) — the managed-profile handover link. The landing is the
|
|
// invite landing's twin: a shape check on the `hlp_` segment and a
|
|
// redirect, no lookup (the proxy normally answers it at the edge).
|
|
"app/claim/[token]/page.tsx":
|
|
"validates the handover token's shape and redirects; no lookup",
|
|
// The claim page reads the token off `?token=` and sends it in the BODY of
|
|
// the anonymous preview and claim requests, which are the only places a
|
|
// handover token is resolved. The page itself authenticates nothing and
|
|
// renders nothing about the profile before the preview answers.
|
|
"app/auth/claim/page.tsx":
|
|
"carries the handover token from the query string into the claim requests' body",
|
|
// v1.42.2 — notification quick answers. A notification app (ntfy's
|
|
// `http` action) calls it with no session, so the signed, user- and
|
|
// action-bound, short-lived token in the path is the credential. Rate
|
|
// limited before verification, single-use through the mood entry's unique
|
|
// key, and scrubbed from logs by `PATH_SECRET_PATHS`.
|
|
"app/api/notifications/action/[token]/route.ts":
|
|
"signed single-use quick-answer token from a notification action",
|
|
};
|
|
|
|
/**
|
|
* Leg 1 — route shape. A dynamic segment whose name carries `token` or
|
|
* `secret` puts a credential in the URL by construction. This matcher
|
|
* reads the filesystem, not the file, so no variable name can slip past
|
|
* it; a new such route is caught before anyone reads its body.
|
|
*/
|
|
function urlCredentialSegmentFiles(): string[] {
|
|
return appFiles().filter((rel) =>
|
|
rel.split("/").some((seg) => /^\[.*(token|secret).*\]$/i.test(seg)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Leg 2 — share-token resolution by content, so a share surface that ever
|
|
* moves out from under a `[token]` segment is still caught.
|
|
*/
|
|
const SHARE_TOKEN_RESOLVERS =
|
|
/\b(resolveShareToken|resolveShareGateState|resolveShareReportDownload|loadShareViewData)\s*\(/;
|
|
|
|
/**
|
|
* Leg 3 — a credential read out of the query string.
|
|
*/
|
|
const QUERY_CREDENTIAL =
|
|
/searchParams\.get\(\s*["'](secret|token|key|passphrase)["']\s*\)/;
|
|
|
|
it("every URL-credential route segment is named", () => {
|
|
const bySegment = urlCredentialSegmentFiles();
|
|
|
|
expect(bySegment.length).toBeGreaterThan(0);
|
|
expect(bySegment).toEqual([
|
|
"app/api/c/[token]/unlock/route.ts",
|
|
"app/api/notifications/action/[token]/route.ts",
|
|
"app/api/whoop/webhook/[token]/route.ts",
|
|
"app/api/withings/webhook/[token]/route.ts",
|
|
"app/c/[token]/d/[id]/route.ts",
|
|
"app/c/[token]/fhir/route.ts",
|
|
"app/c/[token]/not-found.tsx",
|
|
"app/c/[token]/page.tsx",
|
|
"app/c/[token]/report.pdf/route.ts",
|
|
"app/claim/[token]/page.tsx",
|
|
"app/invite/[token]/page.tsx",
|
|
]);
|
|
});
|
|
|
|
it("every share-token resolver is named", () => {
|
|
const byResolver = appFilesMatching(SHARE_TOKEN_RESOLVERS);
|
|
|
|
expect(byResolver.length).toBeGreaterThan(0);
|
|
expect(byResolver).toEqual([
|
|
"app/api/c/[token]/unlock/route.ts",
|
|
"app/c/[token]/d/[id]/route.ts",
|
|
"app/c/[token]/fhir/route.ts",
|
|
"app/c/[token]/page.tsx",
|
|
"app/c/[token]/report.pdf/route.ts",
|
|
]);
|
|
});
|
|
|
|
it("every query-string credential read is named", () => {
|
|
const byQuery = appFilesMatching(QUERY_CREDENTIAL);
|
|
|
|
expect(byQuery.length).toBeGreaterThan(0);
|
|
expect(byQuery).toEqual([
|
|
"app/api/withings/webhook/route.ts",
|
|
"app/auth/claim/page.tsx",
|
|
]);
|
|
});
|
|
|
|
it("the three legs together equal the frozen allowlist", () => {
|
|
const union = [
|
|
...new Set([
|
|
...urlCredentialSegmentFiles(),
|
|
...appFilesMatching(SHARE_TOKEN_RESOLVERS),
|
|
...appFilesMatching(QUERY_CREDENTIAL),
|
|
]),
|
|
].sort();
|
|
|
|
expect(union.length).toBeGreaterThan(0);
|
|
expect(union).toEqual(Object.keys(URL_TOKEN_ALLOWLIST).sort());
|
|
});
|
|
|
|
it("every allowlist entry carries a reason", () => {
|
|
for (const [rel, reason] of Object.entries(URL_TOKEN_ALLOWLIST)) {
|
|
expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("S4 — the single-sign-on claim cookie set is frozen", () => {
|
|
/**
|
|
* v1.43 (#959). A third way to act without a session: the sealed `hl_claim`
|
|
* cookie carries a managed-profile claim from `POST /api/auth/claim/sso`
|
|
* through the OIDC round trip, and the callback turns it — together with
|
|
* the provider's subject — into somebody's account. It is neither a session
|
|
* nor a URL credential, so neither leg above sees it.
|
|
*
|
|
* The handover token itself still only travels in the claim routes' bodies
|
|
* and the claim page's query string (S2); the cookie names the link by id.
|
|
* What this freezes is who may mint the cookie and who may act on it.
|
|
*/
|
|
const CLAIM_COOKIE_ALLOWLIST: Record<string, string> = {
|
|
"app/api/auth/claim/sso/route.ts":
|
|
"mints the cookie, only after checking the token and the username",
|
|
"app/api/auth/oidc/login/route.ts":
|
|
"requires the cookie for intent=claim and binds it into the OIDC state",
|
|
"app/api/auth/oidc/callback/route.ts":
|
|
"binds the provider's subject to the profile the cookie names",
|
|
};
|
|
|
|
const CLAIM_COOKIE_USE =
|
|
/\b(readClaimCookie|setClaimCookie|claimMatchesBinding|clearClaimCookie)\s*\(|\bCLAIM_COOKIE\b|["']hl_claim["']/;
|
|
|
|
it("no file under src/app touches the claim cookie unlisted", () => {
|
|
const users = appFilesMatching(CLAIM_COOKIE_USE);
|
|
expect(users.length).toBeGreaterThan(0);
|
|
expect(users).toEqual(Object.keys(CLAIM_COOKIE_ALLOWLIST).sort());
|
|
});
|
|
|
|
it("only the single-sign-on start mints it", () => {
|
|
const minters = appFilesMatching(/\bsetClaimCookie\s*\(/);
|
|
expect(minters).toEqual(["app/api/auth/claim/sso/route.ts"]);
|
|
});
|
|
|
|
it("every allowlist entry carries a reason", () => {
|
|
for (const [rel, reason] of Object.entries(CLAIM_COOKIE_ALLOWLIST)) {
|
|
expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("S3 — the two families stay distinct", () => {
|
|
/**
|
|
* A file that both resolves a session AND authenticates by a URL token is
|
|
* not forbidden, but it is a shape nobody has reviewed. If one appears,
|
|
* it should appear in a diff that says so rather than in production.
|
|
*/
|
|
it("no file authenticates by both a session and a URL token", () => {
|
|
const sessionFiles = appFilesMatching(
|
|
/\b(getSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt)\s*\(/,
|
|
);
|
|
const tokenFiles = appFiles().filter((rel) =>
|
|
rel.split("/").some((seg) => /^\[.*(token|secret).*\]$/i.test(seg)),
|
|
);
|
|
|
|
expect(sessionFiles.length).toBeGreaterThan(0);
|
|
expect(tokenFiles.length).toBeGreaterThan(0);
|
|
|
|
const both = sessionFiles.filter((rel) => tokenFiles.includes(rel));
|
|
expect(both).toEqual([]);
|
|
});
|
|
});
|