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/...orhttp://127.0.0.1:PORT/...return URL to the app in the console. Plainhttpis accepted for these two hosts only. - The page must be
localhostorhttps. Sign-in needs Web Crypto, which browsers expose only there. A LAN address such ashttp://192.168.1.20:5173does not work. - The test server. Point the SDK at it with
issuerand 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:
| Option | In a test |
|---|---|
fetch | A function that answers the few requests you expect. |
storage | new MemoryStore(), so tests do not share state and nothing touches the browser. |
navigate | A 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:
| Field | Content |
|---|---|
id, at, ms | A counter, the start time (epoch milliseconds) and the duration. |
sdk | Which SDK call caused it, for example api.feed() or token (refresh_token). |
method, url | The request line. |
requestHeaders, requestBody | Headers (lower-case names) and the body: JSON and form bodies as objects, files described, never included. |
status, ok | The status (0 when there was no response: network or CORS) and whether it was 2xx. |
responseHeaders, responseBody | The answer. Media files are not read. |
error | The 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
httpendpoints. The SDK refuses it withdiscovery_invalid; copy the shape above. - Sharing one
MemoryStorebetween tests. Create one per test. - Using
memorystorage in the browser. It cannot finish a redirect sign-in; it is for tests. - Testing with a production App ID. Use the test server.