Skip to content
Chapter 88Lesson 3

The signedInAs fixture

Building a reusable test fixture that signs a caller into a real user and org, so every Server Action authorization test can stub the session seam at the right depth instead of rolling its own.

Every protected action in this app starts the same way. It reads the session, pulls out the user, and checks their role and which org they belong to before it touches a single row. createInvoice does it, archiveInvoice does it, the route handler behind your Stripe portal does it. So every test of one of those actions has to answer the same question before it can assert anything: who is signed in?

Answer it by hand and it takes about five lines: insert a user, insert their org, build a fake session, stub the cookie the wrapper reads. Five lines you write once, then again in the next test, then again, slightly differently, in the third, because this one needs an admin and you forgot to set the org and the test passed anyway. That last sentence is the whole problem. A hand-rolled auth setup that’s subtly wrong doesn’t fail loudly: it passes for the wrong reason, and an authorization test that passes for the wrong reason is worse than no test at all.

So we build the thing every test reaches for on its first line, signedInAs. It’s one call that hands back a signed-in test context, a real user, a real org, a stubbed session, and a cookie jar, so that the rest of the test is free to assert what the action actually does. By the end of this lesson you’ll reach for await signedInAs({ role: 'admin' }, tx) as readily as you reach for buildInvoice(...), and you’ll be able to say exactly which seam it stubs and why we stub that one and not a deeper one.

We’ll build it the way you’d actually arrive at it: write the painful version first, feel each thing that’s wrong with it, then extract signedInAs one responsibility at a time until every pain is gone.

The session stub you write by hand, five tests in

Section titled “The session stub you write by hand, five tests in”

Here’s the action under test. It’s the createInvoice you’ve been carrying through this chapter, shown at its wrapper shape so the seams are easy to see.

export const createInvoice = authedAction(
'member',
createInvoiceSchema,
async (input, { user, orgId, db }) => {
const [invoice] = await db
.insert(invoices)
.values({ ...input, orgId, createdBy: user.id })
.returning();
return ok(invoice);
},
);

That authedAction wrapper runs the five-seam shape every action in the project follows: it parses the input through createInvoiceSchema, authorizes the caller (here, “must be at least a member”), runs your mutate body, revalidates the affected paths, and returns a Result. This lesson covers exactly one of those seams, authorize. Asserting on the parse failures, the revalidation calls, and the redirect throws is its own topic, and it’s the next lesson over. Right now we only care about getting a caller into the action so the authorize check has a session to read.

Here’s the test you’d write before you had a fixture. It uses withRollback, the wrapper from earlier in this chapter that runs your test body inside a transaction and rolls it back unconditionally, handing you a tx to write against. Read tx as “the database, but every write vanishes when the test ends.”

createInvoice.int.test.ts
// installed for the whole file — every test below is now this user
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: async () => session } },
}));
vi.mock('next/headers', () => ({ cookies: async () => emptyCookieJar }));
const session = { user: { id: 'user_1', email: 'a@b.co' } };
it('creates an invoice', withRollback(async ({ tx }) => {
await tx.insert(users).values({ id: 'user_1', email: 'a@b.co' });
await tx.insert(organizations).values({ id: 'org_1', plan: 'free' });
await tx.insert(memberships).values({ userId: 'user_1', role: 'member' });
const result = await createInvoice(input, { db: tx });
expect(result).toBeOkResult();
}));

Hand that to anyone who’s reviewed tests before and they’ll quickly flag three things.

First, the membership row is missing its organizationId. Depending on how the column is declared, that either throws or, worse, leaves the user attached to nothing in particular, so requireOrgUser quietly resolves them into the baseline seed org instead. Now picture the test you were actually trying to write: a cross-tenant check that proves a user in org A can’t see org B’s invoices. With the org link silently wrong, the user lands in the seed org on both sides, the query finds nothing because there’s nothing there, and the test goes green. That’s a false negative : the bug is real, the test is green, and you have learned nothing.

Second, the user_1 id is hardcoded, so the moment a second test copies this setup you have two tests claiming to be the same user. As long as both only read, you might get away with it. The first one that mutates that row introduces aliasing , and now the order the tests run in changes the result. That’s the run-order coupling you spent the last chapter learning to avoid, smuggled back in through a copy-pasted id.

Third, the vi.mock('@/lib/auth', ...) sits at file scope with a fixed session baked in. Vitest hoists that mock above the imports, so it’s installed for the whole file: every test in it is now signed in as user_1 whether it asked to be or not, unless something resets it between tests. Identity leaks from one test into the next.

None of these three is exotic. They’re what goes wrong whenever each test rolls its own auth setup, and they go wrong quietly, which is the dangerous kind. The arrange block is identical across every protected test, and duplicating it is exactly how authorization tests rot. So we write it once, correctly, and never again. That’s the fixture.

Before we write a line of its body, let’s pin down the contract, meaning the signature and what it returns, so the rest of the lesson is just “make this real” rather than “discover the shape as we go.”

signedInAs(opts: SignedInOptions, tx: DbOrTx): Promise<SignedInContext>;
type SignedInOptions = {
role?: Role;
plan?: Plan;
orgId?: string;
};
type SignedInContext = {
user: User;
org: Organization;
session: Session;
cookieJar: CookieJar;
};

Every option is optional, and the defaults are role: 'member' and plan: 'free', the most common signed-in user. You pass tx explicitly as the second argument rather than reaching for some ambient handle. This is the same call-site-explicit convention the rest of this chapter uses, and it works because you call signedInAs inside the withRollback body, where tx is right there in scope.

The return value is everything a test could need to either drive the action or assert against it: the user and org rows so you can scope a factory call to them, the session it stubbed, and a CookieJar the action wrapper reads.

Now for the most important boundary in this lesson, worth drawing before you’re tempted to cross it. signedInAs answers one question, who is signed in, and nothing else.

Identity signedInAs owns
  • the user row
  • the org row (or reuse of the seed org)
  • the membership linking user → org with a role
  • the session
  • the cookie + Origin headers
Activity Per-test factories own
  • “the user has 3 invoices” await buildInvoice({ orgId: ctx.org.id }, tx) ×3
  • “the org is on a past-due subscription”
  • “an invoice already exists with number INV-1001”
Identity is the fixture's job; world-state is the test's job.

This line matters not for tidiness but because the fixture has to stay changeable. The day someone adds invoiceCount to signedInAs “for convenience,” it starts to grow a hasActiveSubscription, then a withOverdueBalance, and within a quarter it’s a four-hundred-line object that every test depends on and nobody dares touch. The discipline is simple, and you keep it forever: identity is the fixture’s job, world-state is the test’s job. If the sentence starts with “the user is…” it’s probably an argument to signedInAs. If it starts with “the user has…” it’s a factory call.

One more thing to name up front. The module we’re building, src/test/fixtures/auth.ts, exports exactly two things: signedInAs for the signed-in case, and anonymous for the signed-out case. The unauthenticated path deserves a first-class, intention-revealing call too, which we’ll build at the end. For now, know that anonymous is the deliberate counterpart to signedInAs, not an afterthought.

Now we build it. The fixture has two halves: insert real rows, then stub the session. We’ll do the rows first.

There’s an obvious objection to get ahead of: if we’re going to stub the session anyway, why insert real user and org rows at all? Hold that thought. We’ll answer it in full once you’ve seen the code, because the code makes the answer concrete.

export const signedInAs = async (
{ role = 'member', plan = 'free', orgId }: SignedInOptions,
tx: DbOrTx,
): Promise<SignedInContext> => {
const org = orgId
? await getOrCreateOrg({ orgId, plan }, tx)
: await insertOrg(buildOrganization({ plan }), tx);
const user = await insertUser(buildUser(), tx);
await insertMembership({ userId: user.id, organizationId: org.id, role }, tx);
const session = await insertSession(
{ userId: user.id, expiresAt: FROZEN.add({ days: 30 }) },
tx,
);
return { user, org, session, cookieJar: buildCookieJar() };
};

The signature, with the defaults living right in the destructure: a member on the free plan unless the caller says otherwise. tx is the second positional argument, in scope from the surrounding withRollback body.

export const signedInAs = async (
{ role = 'member', plan = 'free', orgId }: SignedInOptions,
tx: DbOrTx,
): Promise<SignedInContext> => {
const org = orgId
? await getOrCreateOrg({ orgId, plan }, tx)
: await insertOrg(buildOrganization({ plan }), tx);
const user = await insertUser(buildUser(), tx);
await insertMembership({ userId: user.id, organizationId: org.id, role }, tx);
const session = await insertSession(
{ userId: user.id, expiresAt: FROZEN.add({ days: 30 }) },
tx,
);
return { user, org, session, cookieJar: buildCookieJar() };
};

Reuse or create the org. If the caller named an orgId we look it up (or build it with that id); otherwise we mint a fresh org. This is the line multi-tenant tests lean on to pin org_A against org_B.

export const signedInAs = async (
{ role = 'member', plan = 'free', orgId }: SignedInOptions,
tx: DbOrTx,
): Promise<SignedInContext> => {
const org = orgId
? await getOrCreateOrg({ orgId, plan }, tx)
: await insertOrg(buildOrganization({ plan }), tx);
const user = await insertUser(buildUser(), tx);
await insertMembership({ userId: user.id, organizationId: org.id, role }, tx);
const session = await insertSession(
{ userId: user.id, expiresAt: FROZEN.add({ days: 30 }) },
tx,
);
return { user, org, session, cookieJar: buildCookieJar() };
};

buildUser() is the previous chapter’s in-memory factory, but there it returned a plain object; here we persist it through tx so the action’s own queries can find it. Every call gets a fresh, sequence-unique id, which is what removes the user_1 aliasing bug from before.

export const signedInAs = async (
{ role = 'member', plan = 'free', orgId }: SignedInOptions,
tx: DbOrTx,
): Promise<SignedInContext> => {
const org = orgId
? await getOrCreateOrg({ orgId, plan }, tx)
: await insertOrg(buildOrganization({ plan }), tx);
const user = await insertUser(buildUser(), tx);
await insertMembership({ userId: user.id, organizationId: org.id, role }, tx);
const session = await insertSession(
{ userId: user.id, expiresAt: FROZEN.add({ days: 30 }) },
tx,
);
return { user, org, session, cookieJar: buildCookieJar() };
};

The join row carrying the user’s role within this org. It’s the exact row requireOrgUser reads to resolve role and tenant: get it right and your authz assertions are real; forget its organizationId and you’re back to the silent false negative.

export const signedInAs = async (
{ role = 'member', plan = 'free', orgId }: SignedInOptions,
tx: DbOrTx,
): Promise<SignedInContext> => {
const org = orgId
? await getOrCreateOrg({ orgId, plan }, tx)
: await insertOrg(buildOrganization({ plan }), tx);
const user = await insertUser(buildUser(), tx);
await insertMembership({ userId: user.id, organizationId: org.id, role }, tx);
const session = await insertSession(
{ userId: user.id, expiresAt: FROZEN.add({ days: 30 }) },
tx,
);
return { user, org, session, cookieJar: buildCookieJar() };
};

A session row pointing at the user, with expiresAt derived from the frozen clock so it’s deterministic and never “expired relative to wall-clock.” This reuses the FROZEN instant from the previous chapter.

export const signedInAs = async (
{ role = 'member', plan = 'free', orgId }: SignedInOptions,
tx: DbOrTx,
): Promise<SignedInContext> => {
const org = orgId
? await getOrCreateOrg({ orgId, plan }, tx)
: await insertOrg(buildOrganization({ plan }), tx);
const user = await insertUser(buildUser(), tx);
await insertMembership({ userId: user.id, organizationId: org.id, role }, tx);
const session = await insertSession(
{ userId: user.id, expiresAt: FROZEN.add({ days: 30 }) },
tx,
);
return { user, org, session, cookieJar: buildCookieJar() };
};

Hand back identity: the user, org, and session, plus a cookieJar built by a helper we write in the next section. For now treat that last one as a forward reference.

1 / 1

Note buildUser and buildOrganization. Those are the in-memory factories from the previous chapter, the ones that hand you a valid row object with sensible defaults. There they returned a plain object you asserted against in memory. Here we take that valid object and insert it through tx. That’s the only difference, and it’s the whole point of this half: the rows have to actually exist in the database the action is about to query.

The session’s expiresAt is computed off FROZEN , the frozen clock instant from the previous chapter, not off Date.now(). That matters here for a specific reason: if you stamped the session against the real wall clock, a test that also froze time to the past would see a session dated in its future and could read as not yet valid. Anchor both to the same frozen instant and the session is reliably live for the length of the test.

That answers the objection from a moment ago. Picture the two paths the action takes through your test, side by side.

Real — runs in tx, rolls back
action body
requireInvoice / tenant query
user · org · membership · invoices rows in tx
Stubbed — no DB, no crypto
action wrapper
requireOrgUser
auth.api.getSession
fake session no DB · no crypto
Real where our code reads; stubbed where the framework reads.

Lane A is your action reading its own data: when the body calls requireInvoice or a tenant-scoped query, that SQL runs for real against tx and has to find a real user, org, membership, and whatever rows the test set up. Mock those and you’re back to testing a fiction. Lane B is the session decode: the wrapper calls requireOrgUser, which calls auth.api.getSession, and that we stub, because re-deriving a real signed session cookie and round-tripping it through the auth adapter tests the framework’s crypto, not your code. So we keep real rows where our code reads, and a stubbed session where the framework reads. The hybrid isn’t a compromise; it’s drawing the mock at exactly the right depth.

One thing will trip you up if you let it. signedInAs inserts through tx, and tx only exists, and only rolls back, inside a withRollback body. Call signedInAs outside one and those inserts hit the committed database and stay there, leaking a phantom user into every test that runs afterward. It’s the same silent-commit bug this chapter warned about with the global db handle, in a different form. The rule is unchanged: if you’re calling the fixture, you’re inside withRollback.

Now the half that people argue about: the session stub. The rows were mechanical; this is a decision. Which seam do you stub?

This is the conceptual center of the lesson, so let’s look at the three places you could cut, side by side, because the right answer only makes sense against the wrong ones.

vi.mock('@/lib/auth', () => ({
requireOrgUser: async () => ({ user, orgId, role }),
}));

It works, but it couples every test to one helper and silently skips the other two. Mocking requireOrgUser hands back a canned { user, orgId, role }, but any code path that reads getCurrentUser or requireUser instead gets nothing, and you’ve bypassed the wrapper’s real branching.

Consider why the middle one wins. The session-read ladder in this app has getSession at the bottom, then getCurrentUser, requireUser, and requireOrgUser layered on top, and it’s built so that every helper resolves through that one getSession call. Stub getSession and you’ve fed all three at once with a single fake, and the helpers’ own logic still runs: requireOrgUser still reads the membership row from tx, still computes the role, still decides whether to allow or refuse. You’ve replaced only the part that decodes a cookie, which is the one part you have no business reimplementing in a test.

The heuristic generalizes, and it’s worth keeping: mock at the boundary your code calls, not at the library’s internals. Your code calls getSession; it does not call the cookie verifier. You’ll apply the same instinct to the Stripe SDK in the next lesson, mocking the wire it talks to rather than its internals. Same principle, different boundary.

Now for the mechanics of doing it cleanly, because there’s a trap in the obvious approach. Look again at the seeded bug from the opening test: a vi.mock('@/lib/auth') with a session baked into the factory leaks that identity across the whole file. The fix is to separate registering the mock from setting its value.

First, what lives in the integration setupFiles, registered once, a placeholder only:

setupFiles — registered once
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
}));

Then what signedInAs does per call, inside its body, after building the session:

signedInAs — set per call
vi.mocked(auth.api.getSession).mockResolvedValue({ user, session });

The vi.mock call is registered once, in the integration setupFiles you set up earlier in this chapter, the same place the next/headers mock and the MSW server live. But it registers only a vi.fn() placeholder; it does not know who’s signed in. That’s deliberate, and there’s a firm reason for it. Vitest hoists vi.mock above the file’s imports so the mock is installed before any module loads. This means the mock factory runs before any particular test exists, so it physically cannot close over a per-call { user, session }. (Hoisted factories can’t reference top-level variables either, for the same reason.) So the factory supplies an empty vi.fn(), and signedInAs fills in the actual session per call with mockResolvedValue. That split isn’t an accident of the API; it’s the only shape that works, and it’s why the fixture owns the implementation instead of a static top-level mock.

The last piece is reset discipline, and it’s the cure for the third opening bug. A session implementation set in one test outlives that test unless something clears it. Leave it, and the next test runs signed in as the previous test’s user: green, and completely wrong. So the integration setupFiles resets between every test:

afterEach(() => {
vi.mocked(auth.api.getSession).mockReset();
});

This lives in setupFiles, once, not copy-pasted into every test file. (If your setup already calls vi.resetAllMocks() in afterEach, as the previous chapter’s clock seam does, that covers it too.) A leftover session implementation is a textbook flake: the test passes or fails depending on what ran before it, which is exactly the kind of structural cause we’ll catalogue later in this chapter. Resetting it is one line, and it’s the line that keeps identity from leaking.

Here’s an afternoon you can lose, so that you don’t.

You’ve stubbed the session perfectly. The membership row is right, getSession resolves your user, and the reset is in place. You call the action and it fails, with a generic error that reads like an auth problem. So you stare at the session stub. You log it. It’s fine. You re-check the membership. It’s fine. An hour goes by.

The cause has nothing to do with auth. Next.js 16 Server Actions defend against CSRF by comparing the request’s Origin header against the host, and they reject the request before the action body runs if the two don’t match. Your test sends no Origin header at all. So the action is refused at the gate, the body never executes, and the session you carefully stubbed never even gets read. The failure surfaces as something that looks like broken auth.

That’s why the fixture deals with cookies and headers: not because cookies are interesting, but because skipping them produces a failure that hides its real cause. Two small pieces close the gap.

First, the cookie jar, a Map dressed up to look exactly like what cookies() returns, and nothing more:

src/test/fixtures/auth.ts
export const buildCookieJar = () => {
const store = new Map<string, string>();
return {
get: (name: string) =>
store.has(name) ? { name, value: store.get(name)! } : undefined,
set: (name: string, value: string) => store.set(name, value),
delete: (name: string) => store.delete(name),
has: (name: string) => store.has(name),
};
};

The shape matches cookies()’s read surface deliberately: get returns { name, value } or undefined, just like the real thing, so the action’s cookie reads resolve without knowing they’re in a test. Two notes. cookies() is async in Next.js 16, so code reads await cookies(), and the next/headers mock returns the jar from an async function. And that next/headers mock, like the auth one, was registered once back in your integration setupFiles; the fixture only populates the jar per call.

Second, the header that closes the hole behind that lost afternoon. The fixture sets the request’s Origin and Host to the same canonical test origin, so the CSRF comparison passes:

src/test/fixtures/auth.ts
const TEST_ORIGIN = 'http://localhost:3000';
requestHeaders.set('origin', TEST_ORIGIN);
requestHeaders.set('host', new URL(TEST_ORIGIN).host);

Set Origin equal to Host and the gate opens. Omit it and every action test fails the CSRF check, all reading like auth bugs. That’s the entire operational rule, and it’s all you need here. Why Next.js derives the allowed origins the way it does, and how the token is built, is a security topic for later in the course. For testing, the knowledge is one sentence: set Origin to Host so the gate passes.

Roles, plans, and tenants as one call argument

Section titled “Roles, plans, and tenants as one call argument”

Here’s where the fixture earns its keep. There are three options, role, plan, and orgId, and each one flips a different branch of the authorization logic, so a single call configures the exact access scenario a test needs.

Role drives the membership row, which requireOrgUser('admin') reads to decide. The default is member. Reach for signedInAs({ role: 'admin' }) to test the privileged path, and signedInAs({ role: 'guest' }) to drive the refused path, the test that asserts a non-privileged caller gets back toBeErrResult('forbidden'). You’re not asserting on an error message; you’re asserting on the Result code, the same discipline from the previous chapter. The message is UI copy that changes; the code is the contract.

Plan gates plan-restricted actions. The default is free. signedInAs({ plan: 'pro' }) clears a pro-only gate; signedInAs({ plan: 'free' }) against that same action asserts the refusal. Same rule on the assertion: check the Result code, never the user-facing text.

Tenant is the consequential one. signedInAs({ orgId: 'org_A' }) signs the user into a named org. The canonical cross-tenant test reads like a sentence: sign in to org_A, build a row under org_B, assert that the org_A-scoped query can’t see it. The fixture creates both orgs; the assertion proves the tenant scoping holds. And here’s the rule that prevents the exact false negative from the opening of this lesson: in a cross-tenant test, always name the orgId on both sides. Lean on the default seed org for one side and both ends collapse into the same tenant, the query trivially finds nothing, and your isolation test passes without testing isolation. Name the orgs so the scoping does real work.

There’s a quieter payoff in the types: free regression detection on your most security-sensitive surface.

export const signedInAs = async <
R extends Role = 'member',
P extends Plan = 'free',
>(
{ role, plan, orgId }: SignedInOptions<R, P>,
tx: DbOrTx,
): Promise<SignedInContext<R, P>> => { ... };

Make signedInAs generic over the role and plan, and the returned SignedInContext carries the literal you passed: signedInAs({ role: 'admin' }) hands back a context typed as an admin, not a vague “some role.” The Role and Plan unions themselves come straight from the Drizzle enum columns; you never hand-list the literals, you derive them from the schema, the same $inferSelect instinct the data layer runs on. So the day someone adds a superadmin role to the schema, every signedInAs call site that didn’t account for it can break at compile time. The type system does regression detection on the auth surface for free, before a single test runs.

Let’s lock in the boundary that holds all of this together, identity versus activity, because it’s the highest-value idea to walk away with.

Each line is something a test needs to be true. Decide whether signedInAs should know it, whether a per-test factory should arrange it, or whether it belongs to neither. Drag each item into the bucket it belongs to, then press Check.

A signedInAs argument Identity — who is signed in (the user is…)
A per-test factory call Activity — what exists in their world (the user has…)
Neither — that's anonymous() No one is signed in at all
the user is an admin
the user has 3 paid invoices
the request is unauthenticated
an invoice already exists with number INV-1001
the user is on the pro plan
the org’s subscription is past due
the user belongs to org_A

The two “is” sentences in the right column are the trap. “The org’s subscription is past due” sounds like identity, but it’s world-state the test arranges with a factory: being past due is something the org has, not who the user is. And “the request is unauthenticated” is neither a signedInAs argument nor a factory call: it’s the absence of a session, which gets its own first-class name, anonymous().

Run your eye down the items: every “the user is…” lands left, every “the user has…” lands right, and the one about being unauthenticated belongs to neither. That’s the cue for the last piece of the module.

Naming the unauthenticated path with anonymous

Section titled “Naming the unauthenticated path with anonymous”

The signed-out path needs a test too: the one that proves your action refuses a caller with no session at all. You could write that test by simply not calling signedInAs, but don’t. A test that calls the action with no fixture inherits whatever the previous test’s session mock left lying around, and even with the afterEach reset, “I deliberately have no session” and “I forgot to set one up” read identically at the call site. So we give the absence a name.

src/test/fixtures/auth.ts
export const anonymous = (): AnonymousContext => {
vi.mocked(auth.api.getSession).mockResolvedValue(null);
requestHeaders.set('origin', TEST_ORIGIN);
requestHeaders.set('host', new URL(TEST_ORIGIN).host);
return { cookieJar: buildCookieJar() };
};

anonymous() resolves getSession to null and leaves the cookie jar empty, so there’s no one signed in. But notice it still sets the Origin and Host headers. That’s not an oversight: the CSRF gate fires before the session check, so an unauthenticated request that skips Origin would die at the gate, and you’d be back to the failure that hides its real cause from two sections ago. anonymous() returns nothing you’ll assert against; its entire value is the named intent at the call site.

One thing the call site has to get right, and it’s the branch almost everyone models wrong: a session-less caller does not come back as toBeErrResult('unauthenticated'). There’s no such Result code in this codebase. The auth ladder, from requireOrgUser down to requireUser, doesn’t return an error for a missing session; it redirects the caller to sign-in, and Next.js’s redirect() works by throwing NEXT_REDIRECT straight out of the wrapper, before the action body ever runs. So the test asserts the throw, not a Result, and checks that the body never wrote anything:

it('redirects when signed out', withRollback(async ({ tx }) => {
anonymous();
await expect(
createInvoice(input, { db: tx }),
).rejects.toThrow('NEXT_REDIRECT');
const rows = await tx.select().from(invoices);
expect(rows).toHaveLength(0);
}));

Hand expect the un-awaited promise and assert it rejects with NEXT_REDIRECT; don’t write const result = await …, because the call throws before any value comes back. Then assert zero rows: the redirect fired before the body, so nothing should have been written. Don’t mock redirect; let it throw and observe the throw. The next lesson treats this branch in full; here it’s enough to know the signed-out path is a thrown redirect, not a Result code. And because anonymous() sets the null session rather than relying on the previous test having left one, it’s reset-safe by construction, the same discipline that kept identity from leaking, applied to its absence.

With that, the module’s public surface is closed: src/test/fixtures/auth.ts exports signedInAs and anonymous, and nothing else. That export list is a tell. The day it grows a third name, ask carefully what that name is doing, because a fixture module that keeps sprouting exports is showing the early signs of becoming an overgrown object.

Let’s assemble the whole pattern end to end. Here’s a realistic authorization test file with the load-bearing decisions blanked out. Fill each one in.

Fill the four load-bearing blanks: the fixture each case reaches for, the Result code the in-place refusal returns, and what the signed-out act throws. Pick the right option from each dropdown, then press Check.

describe('createInvoice — authorize seam', () => {
it('refuses a guest', withRollback(async ({ tx }) => {
await ___({ role: 'guest' }, tx);
const result = await createInvoice(input, { db: tx });
expect(result).toBeErrResult('___');
}));
it('redirects when signed out', withRollback(async ({ tx }) => {
___();
await expect(
createInvoice(input, { db: tx }),
).rejects.toThrow('___');
}));
});

If you reached for signedInAs to set up a role, anonymous to mean “no session,” and matched each refusal to the right exit, toBeErrResult('forbidden') for the wrong role and a thrown NEXT_REDIRECT for no session, you’ve got the whole pattern. The two exits aren’t interchangeable: an authenticated but underprivileged caller is refused in place with a Result code, while a session-less caller is redirected to sign-in, which throws. That’s the fixture, and it’s the first line of nearly every Server Action and route-handler test you’ll write for the rest of this chapter and the project that follows. From here on, the arrange block is one call, and the test gets to be about what the action does.

Next we leave auth behind and turn to the other boundary an action crosses, the network, where the same seam-depth instinct decides where to cut.