Testing and debugging

Develop against localhost and the test server, unit-test your sign-in code without a network using a fake fetch and a memory store, and trace every request the SDK makes.

You can run and test an app built on the SDK without touching real accounts: develop against localhost and the test server, and unit-test your own code against a fake fetch. When something goes wrong, onTrace shows every request the SDK made.

Local development

  • Return URLs. Add a http://localhost:PORT/... or http://127.0.0.1:PORT/... return URL to the app in the console. Plain http is accepted for these two hosts only.
  • The page must be localhost or https. Sign-in needs Web Crypto, which browsers expose only there. A LAN address such as http://192.168.1.20:5173 does not work.
  • The test server. Point the SDK at it with issuer and use an App ID created on that server:
import { UnetId } from "@unet/id-web";

const dev = location.hostname === "localhost"; // or your build tool's development flag

export const unet = new UnetId({
  clientId: "unet_xxx", // the App ID of the server you use
  redirectUri: `${location.origin}/auth/callback`,
  issuer: dev ? "https://test-api.unet.ovh/oidc" : "https://api.unet.ovh/oidc",
});

Unit tests without a network

The client takes everything it touches from its options, so a test can replace all of it:

OptionIn a test
fetchA function that answers the few requests you expect.
storagenew MemoryStore(), so tests do not share state and nothing touches the browser.
navigateA function that records the address instead of leaving the page.

The fake has to answer the discovery request the way a real server does: the document names the issuer you configured and keeps its endpoints on the issuer's origin over https. The SDK rejects anything else.

// unet.test.ts, run with: node --test
import assert from "node:assert/strict";
import { test } from "node:test";
import { InsufficientScope, MemoryStore, UnetId } from "@unet/id-web";

const ISSUER = "https://id.test/oidc";

function fakeUnet(apiReply?: { status: number; body: unknown }) {
  const navigations: string[] = [];
  const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });

  const fakeFetch = (async (input: RequestInfo | URL) => {
    const url = String(input);
    if (url.endsWith("/.well-known/openid-configuration")) {
      return json({
        issuer: ISSUER,
        authorization_endpoint: `${ISSUER}/auth`,
        token_endpoint: `${ISSUER}/token`,
        userinfo_endpoint: `${ISSUER}/me`,
        revocation_endpoint: `${ISSUER}/token/revocation`,
      });
    }
    if (url === `${ISSUER}/token`) return json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 });
    if (url === `${ISSUER}/me`) return json({ sub: "k3Zr", username: "alice", display_name: "Alice" });
    if (url.startsWith("https://id.test/open/v1/") && apiReply) return json(apiReply.body, apiReply.status);
    return json({ error: "not_found" }, 404);
  }) as typeof fetch;

  const unet = new UnetId({
    clientId: "unet_test",
    redirectUri: "https://app.test/auth/callback",
    issuer: ISSUER,
    storage: new MemoryStore(),
    fetch: fakeFetch,
    navigate: (url) => navigations.push(url),
  });

  // sign in the way a page would: start, then come back with the state the SDK made
  async function signIn(returnTo = "/") {
    await unet.signIn({ returnTo });
    const state = new URL(navigations.at(-1)!).searchParams.get("state");
    return unet.handleRedirectCallback(`https://app.test/auth/callback?code=C&state=${state}`);
  }
  return { unet, signIn, navigations };
}

test("signs in and exposes the user", async () => {
  const { unet, signIn } = fakeUnet();
  const { user, returnTo } = await signIn("/dashboard");
  assert.equal(user.username, "alice");
  assert.equal(returnTo, "/dashboard");
  assert.equal(unet.isSignedIn(), true);
});

test("a missing permission is an InsufficientScope", async () => {
  const { unet, signIn } = fakeUnet({ status: 403, body: { error: "insufficient_scope" } });
  await signIn();
  await assert.rejects(unet.api.like(1), (e) => e instanceof InsufficientScope);
});

Both tests run in plain Node 20 or newer, which has Web Crypto and fetch built in. The same pattern works in Vitest or Jest; only the assertion functions change.

Testing your return page

Put the logic of the return page in a function that takes the client and the address, and test that function:

import { AccessDenied, type UnetId } from "@unet/id-web";

export async function finishSignIn(unet: UnetId, url: string): Promise<string> {
  try {
    const { returnTo } = await unet.handleRedirectCallback(url);
    return returnTo;
  } catch (e) {
    if (e instanceof AccessDenied) return "/";
    throw e;
  }
}

handleRedirectCallback(url) accepts the address as an argument, so a test does not need a browser or a fake location. To test denial, call it with ?error=access_denied&state=... using the state from the recorded sign-in address.

Tracing requests

Pass onTrace to see every HTTP request the SDK makes: discovery, token exchange, profile, API calls, revocation.

import { UnetId } from "@unet/id-web";

const unet = new UnetId({
  clientId: "unet_xxx",
  redirectUri: `${location.origin}/auth/callback`,
  onTrace: (t) => console.log(t.sdk, t.method, t.url, t.status, `${t.ms} ms`),
});

Each trace has:

FieldContent
id, at, msA counter, the start time (epoch milliseconds) and the duration.
sdkWhich SDK call caused it, for example api.feed() or token (refresh_token).
method, urlThe request line.
requestHeaders, requestBodyHeaders (lower-case names) and the body: JSON and form bodies as objects, files described, never included.
status, okThe status (0 when there was no response: network or CORS) and whether it was 2xx.
responseHeaders, responseBodyThe answer. Media files are not read.
errorThe message, when the request failed.

Tokens, codes, code verifiers and secrets are shortened in traces (eyJhbG…(1032 chars, redacted)), so a trace is safe to paste into a bug report. A faulty onTrace function never breaks the SDK.

Common mistakes

  • A fake discovery document with the wrong issuer or http endpoints. The SDK refuses it with discovery_invalid; copy the shape above.
  • Sharing one MemoryStore between tests. Create one per test.
  • Using memory storage in the browser. It cannot finish a redirect sign-in; it is for tests.
  • Testing with a production App ID. Use the test server.

What next