Skip to content

test(directus-client): add memory refresh coordinator E2E coverage #291

Description

@remihuigen

Context

The Directus client already has the E2E foundations needed to test the memory refresh coordinator through a real Nuxt/Nitro runtime. The existing basic fixture provides:

  • a real Nuxt Test Utils fixture;
  • auth enabled with a deterministic session secret;
  • a local node:http mock Directus server;
  • real login/session cookies;
  • an existing refresh-through-SSR test;
  • the real /_directus/auth/session route, which resolves through readDirectusSessionSnapshot() and therefore the refresh coordinator.

We should build on that existing harness rather than introduce a separate E2E framework. The work in this issue should primarily extend modules/directus-client/__tests__/e2e.test.ts and its existing basic fixture.

Implementation plan

Goal

Prove the memory refresh coordinator behaves correctly through the complete runtime stack:

real HTTP request
→ built/running Nuxt/Nitro fixture
→ sealed session cookie
→ auth/session resolution
→ ensureFreshDirectusSession()
→ memory refresh coordinator
→ mock Directus /auth/refresh
→ published/shared refresh result
→ rotated cookie in HTTP response

Do not import coordinator functions directly in these tests. That is already covered by unit tests. These tests should only observe consumer-visible HTTP behavior.

The main guarantees we want to prove are:

1. overlapping requests -> one upstream refresh
2. follower receives the rotated session
3. recently completed refresh -> stale cookie reuses result, no second refresh
4. transient failure does not poison future refreshes
5. terminal rejection clears the session

PR #280 is still open, so implement this on top of feat/coordinator or after #280 has merged, not against current main alone.


1. Reuse the existing basic fixture

Keep:

modules/directus-client/__tests__/fixtures/basic/

Do not create refresh, auth-e2e, or another duplicate fixture.

The existing fixture already enables Directus auth and points instance.baseUrl to DIRECTUS_E2E_URL.

I would make one small fixture change: configure the refresh storage explicitly as memory:

nitro: {
  storage: {
    "directus-auth-refresh": {
      driver: "memory"
    }
  }
}

Although Nitro's root storage is memory by default, making this explicit is valuable here because the test is specifically proving the memory backend selection path introduced by #280.

Do not expose coordinator internals or add a test-only coordinator config option.


2. Extend the existing mock Directus server, don't replace it

The current e2e.test.ts already starts a local HTTP server and mocks:

/items/pages
/auth/login
/auth/refresh
/auth/logout
/users/me
/auth/password/request
/auth/password/reset
/auth/magic-links/request
/auth/magic-links/redeem

That is already a perfectly adequate Directus mock.

Do not introduce MSW, H3 as a second server framework, a generic mock framework, or a full fake Directus application.

Add only the controls required for the new scenarios.

A reasonable local shape is:

type RefreshBehavior = "success" | "transient" | "terminal";

let refreshBehavior: RefreshBehavior = "success";
let refreshDelayMs = 0;
let refreshRequests = 0;
let loginExpires = 1;
let lastItemsAuthorization: string | undefined;

Keep successful refresh tokens deterministic:

access_token: "refreshed-access"
refresh_token: "refreshed-refresh"
expires: 60_000

For /auth/refresh:

success
  -> existing 200 response

transient
  -> HTTP 500
  -> normal Directus-shaped errors body

terminal
  -> HTTP 401
  -> Directus error code INVALID_TOKEN

For overlapping-request tests, support a short configurable delay before sending the refresh response.

Something straightforward such as:

if (refreshDelayMs > 0) {
  await new Promise((resolve) => setTimeout(resolve, refreshDelayMs));
}

is enough.

Do not create elaborate deferred/barrier infrastructure unless the simple delayed response proves flaky.


3. Add tiny HTTP helpers to the test file

Avoid repeating cookie/header parsing everywhere.

Add local helpers along these lines:

function getSessionCookie(response: Response): string | undefined {
  return response.headers.get("set-cookie")?.split(";", 1)[0];
}

function requestSession(cookie: string): Promise<Response> {
  return fetch(url("/_directus/auth/session"), {
    headers: { cookie }
  });
}

Generalize the existing:

loginWithExpiringAccessToken()

slightly so tests can choose the access-token lifetime.

For example:

async function loginWithAccessToken(expires = 1): Promise<string>

Before the login request, set the mock's loginExpires value.

The current default safety window is 30 seconds, so:

expires = 1

forces refresh, while something like:

expires = 120_000

gives ample room to prove no refresh occurs. The configured default refresh safety window is 30,000 ms.


4. Reset only mutable mock behavior between tests

Add beforeEach.

Reset:

refreshBehavior = "success";
refreshDelayMs = 0;
loginExpires = 1;
lastItemsAuthorization = undefined;

I would not reset cumulative request counters. Instead, every test should capture:

const refreshCountBefore = refreshRequests;

and compare against that baseline.

This avoids accidental interference if any previous HTTP request finishes slightly later than expected.

Do not mark these tests .concurrent. They deliberately mutate the mock server's behavior.


Core coordinator E2E tests

These are the important ones.

5. Test: overlapping requests execute one refresh

This is the highest-value memory coordinator test.

Arrange:

const cookie = await loginWithAccessToken(1);
const refreshCountBefore = refreshRequests;
refreshDelayMs = 250;

Then issue two HTTP requests at essentially the same time, both carrying the exact same original cookie:

const [first, second] = await Promise.all([
  requestSession(cookie),
  requestSession(cookie)
]);

The delay exists specifically to keep the owner's /auth/refresh in flight long enough for the second Nitro request to encounter the existing memory flight.

Assert:

both HTTP responses are 200

refreshRequests === refreshCountBefore + 1

both response bodies contain:
  userId === "user-1"

both responses contain a Set-Cookie

neither returned cookie equals the original stale cookie

both returned cookie values are equal

That last assertion is important.

The owner seals the rotated session. The follower should receive the reusable completed flight and adopt that same sealed session. If both responses emit the same rotated cookie, we've tested more than just "Directus was called once": we've proved the follower actually received the owner result.

Do not weaken this test to

expect(refreshRequests).toBeLessThanOrEqual(2)

It must be exactly one.


6. Test: completed result is reused by a request carrying the stale cookie

This tests a different coordinator behavior.

Arrange:

const staleCookie = await loginWithAccessToken(1);
const refreshCountBefore = refreshRequests;

Perform the first request normally:

const first = await requestSession(staleCookie);

Capture its rotated cookie.

At this point:

R1 -> R2 happened
memory coordinator still holds completed R1 result

Now deliberately make another request with the old R1 cookie, not the newly returned R2 cookie:

const second = await requestSession(staleCookie);

Assert:

refreshRequests === refreshCountBefore + 1

not +2.

Also assert:

second request succeeds
second snapshot is user-1
second response sets the rotated cookie
second rotated cookie === first rotated cookie

This is the cleanest E2E proof of the 30-second completed-result reuse behavior from #280.

Do not E2E-test actual 30-second expiry. The unit tests already verify the precise TTL. Waiting 30 seconds in CI adds cost without testing an integration boundary.


7. Test: transient failure is shared briefly and then recoverable

This gives us a useful coordinator + auth-policy integration test.

Arrange:

const cookie = await loginWithAccessToken(1);
const refreshCountBefore = refreshRequests;

refreshBehavior = "transient";

Request:

GET /_directus/auth/session
Cookie: original session

Assert:

HTTP 503
refreshRequests === before + 1

Most importantly, assert the response does not clear the session cookie.

It is fine if there is no Set-Cookie at all. What must not be present is a deletion of directus_session.

Then change the mock:

refreshBehavior = "success";

Immediately retry with the same original cookie.

Because transient results live for one second, this immediate retry should still consume the shared transient result:

HTTP 503
refreshRequests remains before + 1

Then wait slightly longer than the actual one-second transient reuse window:

await new Promise((resolve) => setTimeout(resolve, 1_100));

Retry once more.

Assert:

HTTP 200
refreshRequests === before + 2

session snapshot is authenticated
rotated cookie is returned

This one test proves:

transient failure
→ cached briefly
→ doesn't clear session
→ doesn't cause immediate upstream burst
→ expires
→ later refresh can recover

That is excellent coverage for #260 through the actual Nitro boundary.

Do not use fake timers here. The Nuxt application is running outside the Vitest test execution context; fake timers would not control its clock consistently.


8. Test: terminal rejection clears the cookie

This isn't the coordinator's primary responsibility, but once the mock supports failure modes it's nearly free and verifies a dangerous boundary.

Arrange:

const cookie = await loginWithAccessToken(1);
refreshBehavior = "terminal";

Have Directus return:

401

with:

{
  "errors": [
    {
      "message": "Invalid token",
      "extensions": {
        "code": "INVALID_TOKEN"
      }
    }
  ]
}

Request:

/_directus/auth/session

Expected behavior:

snapshot becomes null / unauthenticated
Directus session cookie is cleared

Assert the Set-Cookie response contains deletion for the default directus_session cookie. The default cookie name is directus_session.

Do not over-specify the entire cookie header. Assert only meaningful semantics, e.g. cookie name + empty/deletion/max-age behavior.


Cheap additional wins

9. Test: a fresh access token does not refresh

This is almost free once login expiry is controllable.

Login with:

const cookie = await loginWithAccessToken(120_000);

Capture refresh count.

Call:

/_directus/auth/session

Assert:

response is authenticated
refreshRequests unchanged

This protects the refresh boundary itself:

fresh token -> coordinator isn't entered
expiring token -> coordinator is entered

That's useful because unnecessarily refreshing healthy sessions would materially increase the chance of token-rotation races.


10. Test: authenticated server requests use the newly refreshed access token

This is the extra test I would definitely include.

The fixture already exposes:

GET /api/directus-server

and it calls useDirectusServer(readItems(...), event).

Extend the mock /items/pages handler to record:

lastItemsAuthorization = request.headers.authorization;

Then:

login with expiring access token
call /api/directus-server using that cookie

Assert:

refresh happened exactly once

/items/pages was requested successfully

Authorization === "Bearer refreshed-access"

This verifies the actual end-to-end sequence:

incoming stale session
→ refresh
→ rotated access token
→ authenticated Directus request uses NEW token

That catches a class of integration bugs the coordinator unit tests cannot catch.


Things explicitly not to add in this phase

Keep this implementation focused.

Do not add:

Valkey / Redis
Docker / Testcontainers
another Nuxt fixture
another HTTP mocking library
browser/Playwright tests
30-second real-time TTL tests
access to coordinator internals from the fixture
test-only runtime coordinator APIs
generic mock Directus abstractions
Redis-equivalent distributed assertions
lease tests
malformed coordination-state tests
memory follower timeout tests

Those either belong to the existing unit tests or the later Valkey E2E phase.

The repository's own testing guidance says E2E tests should exercise consumer-visible behavior requiring real Nuxt infrastructure, while pure mechanics stay in focused unit tests.


Expected file changes

Ideally this remains only:

modules/directus-client/__tests__/e2e.test.ts
modules/directus-client/__tests__/fixtures/basic/nuxt.config.ts

I would not extract the mock Directus server yet.

The existing server is already local to this suite. Adding a few mutable controls and request observations is less code than introducing another helper abstraction.

If the Valkey work later needs the same mock server from a separate E2E suite, that is the moment to extract it into something like:

modules/directus-client/__tests__/helpers/mock-directus.ts

Don't pre-abstract for that now.


Test order I would give the agent

Implement in this order:

  1. Explicitly configure directus-auth-refresh as memory in the fixture.
  2. Add controllable refresh behavior/delay to the existing mock.
  3. Add the overlapping-request test.
  4. Add stale-cookie completed-result reuse.
  5. Add transient failure → cached transient → recovery.
  6. Add terminal failure cookie clearing.
  7. Add fresh-token/no-refresh.
  8. Add refreshed-token Authorization propagation.
  9. Run the existing E2E suite and make sure none of the existing login, SSR, Turnstile, magic-link, or composable tests were weakened.

The first two coordinator tests are the merge-critical output of this work. The rest are cheap integration wins enabled by the same harness.

Validation commands

The agent should finish by running:

pnpm exec vitest run modules/directus-client/__tests__/e2e.test.ts

then:

pnpm exec vitest run modules/directus-client/__tests__

then:

pnpm --filter @onderwijsin/nuxt-directus-client typecheck

and finally the normal repository validation relevant to the changed package.

Do not "fix" flaky concurrency tests by loosening assertions. If the two-request test ever observes two upstream refreshes, treat that as either a real coordinator bug or a test synchronization problem and determine which one.

Definition of done

I would consider the memory E2E work complete when we can prove, through real HTTP/Nitro execution:

Scenario Required observation
Single expiring session one refresh, rotated cookie
Two overlapping requests with same cookie exactly one upstream refresh
Follower request receives same rotated cookie as owner
Old cookie immediately reused no second upstream refresh
Transient Directus failure session preserved
Immediate transient retry no new upstream refresh
Retry after ~1s new refresh occurs and succeeds
Terminal rejection session cookie cleared
Fresh access token no refresh
Server Directus call after refresh uses Bearer refreshed-access

That gives us strong coverage of the memory coordinator's actual correctness while adding very little infrastructure.

Related: #280, #290

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions