feat(sharing): publish which records an account may open

`GET /api/auth/me` grows an `accountAccess` block: the accounts this caller
may act on, the one the browser is inside right now, and the booleans a UI
binds directly — `canSwitch`, and `canWrite` per account. Every value is
resolved server-side. A client that worked out what it may do from grant rows
would be a second program deciding one person's access to a health record, and
the two answer differently the first time an expiry or a revocation appears.

`canWrite` is false for every grant in v1 because invitations write READ only.
It is resolved through the grant state machine rather than hardcoded, so the
day a WRITE grant exists the answer changes without this file being edited.

The route becomes an actor surface. It has to be: the switcher, the banner
naming whose record is open, and the way back out all read this payload, and
under a bare `requireAuth()` the whole app shell would refuse the moment a
switch was on. Every field it returns is still the caller's own.

The active record is published as a full entry, not an id to look up. A banner
that had to join two fields could render unnamed the moment they disagreed;
here the active entry is one of the listed ones, selected by the pass that
built them. A stamp left behind by a grant that lapsed reads as not switched —
the same answer the resolver gives the next delegated request.

Not published: the other account's avatar. Those bytes are owner-scoped, so a
URL here would resolve to a refusal and paint a broken image. Clients keep the
initials fallback.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 14:25:55 +02:00
1 parent 6a096a75eb
commit 562123d4d1
6 files changed
+562 -32

No files matched your search

@@ -83,6 +83,16 @@ describe("the acting-account carrier is read in one place", () => {
// themselves call these two functions and never name the column, which is
// what keeps this list from growing one entry per surface.
"lib/sharing/acting-session.ts",
// v1.36.0 — the only thing that PUBLISHES it, and it publishes the stamp
// only after re-deciding it. The account payload has to say which record
// the browser is inside so the banner can name a person, but the value it
// prints is not the column: an entry reaches `active` solely by surviving
// the same live-grant pass that builds the switcher list, so a stamp left
// behind by a lapsed grant reads as "not switched" — the same answer the
// resolver will independently give the next delegated request. Takes the
// whole auth context for exactly this reason: had it taken the id, the
// route would name the column too, and the list would grow per surface.
"lib/sharing/account-access.ts",
].sort();
it("no other file reads or writes the carrier column", () => {
@@ -252,13 +252,15 @@ const DELEGABLE_ROUTES: Record<string, string> = {};
*
* The intended members are the surfaces a switched session needs in order to
* stay usable and to get back out: the account bootstrap payload, the switch
* endpoint, logout, the native refresh route, the locale read. One of them
* exists so far. The rest arrive as their own diffs here; naming them before
* endpoint, logout, the native refresh route, the locale read. Two of them
* exist so far. The rest arrive as their own diffs here; naming them before
* they exist would freeze a guess.
*/
const ACTOR_ROUTES: Record<string, string> = {
"app/api/account/switch/route.ts":
"The way back out. Every other route refuses under a switch, so if this one did too a browser could enter a record and never leave it. It reads and writes exactly one row — the caller's own session — renders nothing of the owner's, and grants nothing: the account it stamps is validated against a live grant first, and the stamp is re-checked on every request after.",
"app/api/auth/me/route.ts":
"The app shell reads it on every boot, including while a switch is on: the switcher, the banner naming whose record is open, and the route back out all bind this payload. Every field it returns is the caller's own — their preferences, their modules, their identity — and the one field about the switch says only which records they may open and which they are inside. It reads no row of the owner's, and its `accountAccess` block grants nothing: the resolver re-checks the grant on every delegated request regardless of what this payload said a moment ago.",
};
/**
@@ -266,7 +268,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* a formality by accident: every addition has to be counted here as well as
* listed above, which is one more place a careless admission has to pass.
*/
const FROZEN_ENTRY_COUNT = 1;
const FROZEN_ENTRY_COUNT = 2;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
+60 -29
View File
@@ -1,5 +1,23 @@
/**
* `GET /api/auth/me` — the account payload, and the one surface that keeps
* answering while the browser is acting on somebody else's record.
*
* v1.36.0 made it an ACTOR surface (`requireActorAuth`). It has to be: the
* switcher, the banner naming whose record is open, and the way back out all
* read this payload, and all three are about the person at the keyboard, not
* the record they are looking at. Under a bare `requireAuth()` the whole app
* shell would 403 the moment a switch was on.
*
* So every field below is still the CALLER's own — their preferences, their
* modules, their identity — and the one field about the switch,
* `accountAccess`, says so explicitly. Nothing here ever renders the owner's
* data; a delegate reading their own display preferences while inside
* somebody else's record is the correct behaviour and the reason the mode
* exists (design §4: locale, theme and layout belong to the person, not the
* record).
*/
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
@@ -12,11 +30,13 @@ import {
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const auth = await requireActorAuth();
const { user } = auth;
annotate({ action: { name: "auth.me" } });
@@ -27,33 +47,38 @@ export const GET = apiHandler(async () => {
// cycle-tracking gate; no row is forced, a NULL toggle derives from
// gender) is a Postgres round-trip. Running them sequentially added
// the cookie hop to every /me — and /me sits on every app boot path.
const [, , cycleProfile, modules, moduleAvailability] = await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
// corrected here; an account that enrols (or has the policy lifted) loses
// the redirect on the next /me read.
syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
}),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
// module. cycle/coach reflect their real delegated state (the cycle
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(user.id),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
getOperatorModuleAvailability(),
]);
const [, , cycleProfile, modules, moduleAvailability, accountAccess] =
await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
// corrected here; an account that enrols (or has the policy lifted) loses
// the redirect on the next /me read.
syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
}),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
// module. cycle/coach reflect their real delegated state (the cycle
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(user.id),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
getOperatorModuleAvailability(),
// v1.36.0 — which records this caller may open, which one they are
// inside, and what they may do there. Every value resolved server-side;
// see the module docblock for why none of it is left to the client.
resolveAccountAccess(auth),
]);
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
// v1.7.0 — patient-identity fields for the health-record export. The
@@ -142,5 +167,11 @@ export const GET = apiHandler(async () => {
// this to show a "disabled server-wide" read-only row; everywhere else
// the already-AND-ed `modules` map is the single gate to read.
moduleAvailability,
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
// `active` is the record this session is inside (null when it is in its
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
// directly. Always present — an account with no grants gets an empty list,
// not a missing field.
accountAccess,
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* v1.36.0 — the shape of the sharing block, and nothing else.
*
* Types only, with no imports at all, because both ends read them: the route
* that builds the block (`account-access.ts`, which talks to Postgres) and the
* client that renders it. Declaring them beside the server code would put a
* module that imports Prisma on the client's import graph — erased at compile
* today, one careless value export away from being bundled tomorrow. The
* boundary is cheaper to keep than to rediscover.
*
* What these types are FOR is stated once here and holds everywhere they are
* used: they carry resolved answers. `canWrite` and `canSwitch` are decisions
* the server has already made; a client that recomputed either from the rest
* of the payload would be the second program deciding one person's access.
*/
/** One account this caller may act on. */
export interface AccountAccessEntry {
/** The account whose record it is — the value `POST /api/account/switch` takes. */
accountId: string;
username: string;
displayName: string | null;
/** The grant's level. `"read"` throughout v1. */
access: "read" | "write";
/** May this caller change that record. Resolved server-side; `false` in v1. */
canWrite: boolean;
}
/** The `accountAccess` block on `GET /api/auth/me`. */
export interface AccountAccess {
/** Every account this caller may open, newest grant first. */
accounts: AccountAccessEntry[];
/**
* The record this browser is inside right now, resolved to a full entry —
* not an id the client has to look up. Null when it is in its own record,
* and always null on the Bearer transport.
*/
active: AccountAccessEntry | null;
/** Is there anywhere to switch to. */
canSwitch: boolean;
}
/**
* The name to put in front of a person, given what the payload published.
*
* `displayName` when the account set one, the username otherwise. This is a
* presentation fallback and not a derivation of anything the server decided —
* both values are published, and which of the two reads better is the client's
* business. Kept here so the banner, the switcher and the sharing panel cannot
* disagree about what to call somebody.
*/
export function accountLabel(entry: {
displayName: string | null;
username: string;
}): string {
const named = entry.displayName?.trim();
return named && named.length > 0 ? named : entry.username;
}
+97
View File
@@ -0,0 +1,97 @@
/**
* v1.36.0 — what a caller may do with account sharing, resolved server-side.
*
* This module answers three questions for one caller, once per `/api/auth/me`:
* which records may I open, which one am I inside right now, and what may I do
* in it. Every answer is a VALUE, not an input to a client-side calculation.
*
* That is the whole design of this file and it is worth stating plainly,
* because the tempting alternative is one line shorter: publish the grant rows
* and let the client work out what they mean. Then two programs decide one
* person's access — the resolver in `src/lib/api-handler.ts`, and whichever
* client is being read today — and they answer differently the first time an
* expiry, a revocation or a second access level appears. The client renders
* `canSwitch` and `canWrite`; it never computes them, and there is nothing in
* the payload it could compute them from.
*
* `canWrite` is `false` for every entry in v1, because `inviteGrant` writes
* READ and only READ. It is resolved here through {@link grantAllows} against
* the real row rather than hardcoded, so the day a WRITE grant exists the
* answer changes without this file being edited — and, more importantly, so
* that nobody has to remember that it should.
*
* What is deliberately NOT published: the other account's avatar. The avatar
* bytes are owner-scoped (`/api/user/avatar/{id}` refuses any id but the
* caller's own), so a URL here would resolve to a 403 and paint a broken
* image. Opening that route to a delegate is a classification decision about
* a route, not a decision the account payload gets to make on the side.
* Clients paint the initials fallback they already have.
*/
import { prisma } from "@/lib/db";
import type {
AccountAccess,
AccountAccessEntry,
} from "@/lib/sharing/account-access-view";
import { grantAllows, isGrantActive } from "@/lib/sharing/grants";
/**
* Resolve the sharing block for one caller.
*
* The block is always present, even for an account with no grants in either
* direction: an empty `accounts` array with `canSwitch: false` says "nobody has
* shared anything with you", which is a different statement from a missing
* field (which says "this server does not know about sharing"). Absence reads
* as absence.
*
* Takes the whole auth context rather than a pair of ids, so the carrier column
* is named here and in no route file — the boundary guard in
* `src/__tests__/acting-account-boundary-guard.test.ts` keeps that list short
* on purpose, and a field plucked out at every call site is how such a list
* grows one entry per surface.
*
* The session's stamp is deliberately NOT trusted to mean access: an entry
* appears in `active` only if the same pass that
* built `accounts` admitted it. So a stamp left behind by a grant that lapsed
* between two requests reads as "not switched" here, which is exactly what the
* request resolver will independently decide about the next delegated read.
* The client's job on seeing that mismatch is to clear the stamp, not to
* reason about it.
*/
export async function resolveAccountAccess(auth: {
user: { id: string };
session: { actingAsUserId?: string | null };
}): Promise<AccountAccess> {
const actorId = auth.user.id;
const stamped = auth.session.actingAsUserId ?? null;
const now = new Date();
// Narrow in SQL on the one condition the partial unique index makes cheap,
// and let the state machine decide the rest — the same split
// `findActiveGrant` uses, and for the same reason: a SQL predicate spelling
// out "accepted and not expired" beside a TypeScript one is two places
// deciding one question.
const rows = await prisma.accountGrant.findMany({
where: { granteeId: actorId, revokedAt: null },
orderBy: { createdAt: "desc" },
include: {
grantor: { select: { id: true, username: true, displayName: true } },
},
});
const accounts: AccountAccessEntry[] = rows
.filter((grant) => isGrantActive(grant, now))
.map((grant) => ({
accountId: grant.grantor.id,
username: grant.grantor.username,
displayName: grant.grantor.displayName,
access: grant.access === "WRITE" ? "write" : "read",
canWrite: grantAllows(grant, "write", now),
}));
const active =
stamped === null
? null
: (accounts.find((a) => a.accountId === stamped) ?? null);
return { accounts, active, canSwitch: accounts.length > 0 };
}
@@ -0,0 +1,332 @@
/**
* The `accountAccess` block on `GET /api/auth/me`, over the real route,
* against real Postgres.
*
* The block is the only thing either client is allowed to know about its own
* sharing permissions, so what is tested here is not "the field is present"
* but "the field says the same thing the request resolver would". Every case
* below is a state the grant table can actually be in — pending, active,
* expired, revoked, self — and the question each time is whether the payload
* would let a client paint a switcher entry the server would then refuse.
*
* Nothing is mocked below the route. A unit test with a stubbed Prisma could
* prove the route calls the resolver; it could not prove that a grant which
* lapsed an hour ago drops out of the list, which is the property the whole
* block exists to carry.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: () => {},
delete: () => {},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import { ACCOUNT_SELECTOR_HEADER } from "@/lib/api-handler";
import { hashToken } from "@/lib/auth/hmac";
let counter = 0;
async function makeUser(label: string) {
const suffix = `${label}-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `access-${suffix}`,
email: `access-${suffix}@example.test`,
displayName: `Access ${suffix}`,
role: "USER",
onboardingCompletedAt: new Date(),
},
});
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
interface AccountAccessEntry {
accountId: string;
username: string;
displayName: string | null;
access: "read" | "write";
canWrite: boolean;
}
interface AccountAccessBlock {
accounts: AccountAccessEntry[];
active: AccountAccessEntry | null;
canSwitch: boolean;
}
async function readMe(): Promise<{
status: number;
accountAccess: AccountAccessBlock;
id: string;
}> {
const { GET } = await import("@/app/api/auth/me/route");
const res = await GET();
const body = (await res.json()) as {
data: { id: string; accountAccess: AccountAccessBlock };
};
return {
status: res.status,
accountAccess: body.data?.accountAccess,
id: body.data?.id,
};
}
/** An accepted, never-ending grant from `grantorId` to `granteeId`. */
async function grantAccess(
grantorId: string,
granteeId: string,
extra: { expiresAt?: Date | null; acceptedAt?: Date | null } = {},
) {
return getPrismaClient().accountGrant.create({
data: {
grantorId,
granteeId,
access: "READ",
invitedAt: new Date(),
acceptedAt: extra.acceptedAt === undefined ? new Date() : extra.acceptedAt,
expiresAt: extra.expiresAt ?? null,
},
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("accountAccess — what the payload publishes", () => {
it("is present and empty for an account nobody has shared with", async () => {
const alone = await makeUser("alone");
await signIn(alone.id);
const { status, accountAccess } = await readMe();
expect(status).toBe(200);
// Present, not missing. A client that got no field could not tell "this
// server does not do sharing" from "nobody shared anything with me", and
// would have to guess which.
expect(accountAccess).toEqual({
accounts: [],
active: null,
canSwitch: false,
});
});
it("lists an accepted grant with the level resolved, and canWrite false", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id);
await signIn(delegate.id);
const { accountAccess } = await readMe();
expect(accountAccess.accounts).toEqual([
{
accountId: owner.id,
username: owner.username,
displayName: owner.displayName,
access: "read",
canWrite: false,
},
]);
expect(accountAccess.canSwitch).toBe(true);
expect(accountAccess.active).toBeNull();
});
it("omits a pending invitation — being offered access is not having it", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id, { acceptedAt: null });
await signIn(delegate.id);
const { accountAccess } = await readMe();
expect(accountAccess.accounts).toEqual([]);
expect(accountAccess.canSwitch).toBe(false);
});
it("omits a grant that has lapsed, against the request clock", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id, {
expiresAt: new Date(Date.now() - 60_000),
});
await signIn(delegate.id);
const { accountAccess } = await readMe();
expect(accountAccess.accounts).toEqual([]);
});
it("omits a revoked grant", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const grant = await grantAccess(owner.id, delegate.id);
await getPrismaClient().accountGrant.update({
where: { id: grant.id },
data: { revokedAt: new Date(), revokedBy: "GRANTOR" },
});
await signIn(delegate.id);
const { accountAccess } = await readMe();
expect(accountAccess.accounts).toEqual([]);
});
it("never lists a grant this account GAVE — the block is about what it may open", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id);
// The owner signs in. They gave the access; they did not receive any.
await signIn(owner.id);
const { accountAccess } = await readMe();
expect(accountAccess.accounts).toEqual([]);
expect(accountAccess.canSwitch).toBe(false);
});
});
describe("accountAccess — the active record", () => {
it("resolves the stamped account to a full entry, not just an id", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id);
const session = await signIn(delegate.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
const { accountAccess, id } = await readMe();
// Still the delegate's own payload — the switch changes which record the
// delegable routes read, never whose preferences /me returns.
expect(id).toBe(delegate.id);
expect(accountAccess.active).toEqual({
accountId: owner.id,
username: owner.username,
displayName: owner.displayName,
access: "read",
canWrite: false,
});
// The active entry is one of the listed ones, by construction. A banner
// that had to join the two could render unnamed when they disagreed.
expect(accountAccess.accounts).toContainEqual(accountAccess.active);
});
it("reads as not-switched when the stamp outlives the grant", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const grant = await grantAccess(owner.id, delegate.id);
const session = await signIn(delegate.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: owner.id },
});
// The grant lapses while the browser sits inside the record. Expiry has
// no session cleanup — only revocation does — so the stamp survives it.
await getPrismaClient().accountGrant.update({
where: { id: grant.id },
data: { expiresAt: new Date(Date.now() - 1000) },
});
const { accountAccess } = await readMe();
// The payload agrees with what the resolver will decide about the next
// delegated read: there is no live grant, so there is no active record.
// The client's job is to clear the stamp, which is what the
// `sharing.access.denied` reset does.
expect(accountAccess.active).toBeNull();
expect(accountAccess.accounts).toEqual([]);
});
});
describe("accountAccess — the actor surface refuses a selector header", () => {
it("refuses a Bearer caller that attaches an account selector", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id);
const raw = `hlk_${delegate.id}-token`.padEnd(20, "0");
await getPrismaClient().apiToken.create({
data: {
userId: delegate.id,
name: "account-access-test",
tokenHash: hashToken(raw),
permissions: ["*"],
},
});
cookieJar.clear();
headerJar.set("authorization", `Bearer ${raw}`);
headerJar.set(ACCOUNT_SELECTOR_HEADER, owner.id);
const { GET } = await import("@/app/api/auth/me/route");
const res = await GET();
const body = (await res.json()) as { meta?: { errorCode?: string } };
// The account payload is DEFINED as the caller's own. A client that
// attached a selector to it believes it is asking about the owner and is
// not; answering correctly to the wrong question is how a support ticket
// about "data that looks wrong" starts.
expect(res.status).toBe(403);
expect(body.meta?.errorCode).toBe("sharing.not_permitted");
});
it("serves a Bearer caller with no selector, and reports no active record", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id);
const raw = `hlk_${delegate.id}-plain`.padEnd(20, "0");
await getPrismaClient().apiToken.create({
data: {
userId: delegate.id,
name: "account-access-test",
tokenHash: hashToken(raw),
permissions: ["*"],
},
});
cookieJar.clear();
headerJar.set("authorization", `Bearer ${raw}`);
const { status, accountAccess } = await readMe();
expect(status).toBe(200);
// The token transport carries its selector per request, so this payload
// is never inside a record. `accounts` still answers "which records may
// I open", which is what the native switcher renders.
expect(accountAccess.active).toBeNull();
expect(accountAccess.accounts).toHaveLength(1);
expect(accountAccess.canSwitch).toBe(true);
});
});