import test from "node:test";
import assert from "node:assert/strict";
import { _internals, startPkceFlow } from "../auth/pkce-flow.js";
const { sameLengthEqual } = _internals;
// ---------------------------------------------------------------------------
// sameLengthEqual: timing-safe state comparison
// ---------------------------------------------------------------------------
test("sameLengthEqual matches identical strings", () => {
    assert.equal(sameLengthEqual("abcdef0123456789", "abcdef0123456789"), true);
});
test("sameLengthEqual rejects different equal-length strings", () => {
    assert.equal(sameLengthEqual("abcdef0123456789", "abcdef0123456780"), false);
});
test("sameLengthEqual rejects length mismatch without throwing", () => {
    assert.equal(sameLengthEqual("short", "longerstring"), false);
});
function mockFetch(handler) {
    const calls = [];
    const original = globalThis.fetch;
    globalThis.fetch = (async (url, init) => {
        const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
        // Pass loopback callbacks through to the real fetch — only the upstream
        // token endpoint is intercepted. Otherwise the test's own callback
        // request would loop back into the mock and never reach the bound server.
        if (urlStr.startsWith("http://127.0.0.1:") || urlStr.startsWith("http://localhost:")) {
            return original(url, init);
        }
        calls.push({ url: urlStr, init });
        return handler(urlStr, init);
    });
    return {
        calls,
        restore: () => {
            globalThis.fetch = original;
        },
    };
}
function tokenSuccess() {
    return new Response(JSON.stringify({
        access_token: "test-access",
        refresh_token: "test-refresh",
        expires_in: 3600,
        token_type: "Bearer",
        scope: "offline_access User.Read Mail.Read Calendars.Read Contacts.Read",
    }), { status: 200, headers: { "Content-Type": "application/json" } });
}
function extractStateFromAuthUrl(authUrl) {
    const url = new URL(authUrl);
    const state = url.searchParams.get("state");
    assert.ok(state, "auth URL must include state param");
    return state;
}
function extractRedirectUriPort(redirectUri) {
    const url = new URL(redirectUri);
    return Number(url.port);
}
test("PKCE happy path: state matches → tokens exchanged", async () => {
    const fetchMock = mockFetch(() => tokenSuccess());
    try {
        const flow = await startPkceFlow({
            clientId: "test-client",
            tenantId: "common",
            accountId: "acct-pkce-1",
        });
        const state = extractStateFromAuthUrl(flow.authUrl);
        const port = extractRedirectUriPort(flow.redirectUri);
        // Drive the callback synthetically, as Microsoft would after consent.
        const callbackResp = await fetch(`http://127.0.0.1:${port}/callback?code=fake-code&state=${state}`);
        assert.equal(callbackResp.status, 200);
        const result = await flow.result;
        assert.equal(result.tokenResponse.access_token, "test-access");
        assert.equal(result.tokenResponse.refresh_token, "test-refresh");
        assert.deepEqual(result.scopes.sort(), [
            "Calendars.Read",
            "Contacts.Read",
            "Mail.Read",
            "User.Read",
            "offline_access",
        ].sort());
        // Token endpoint was called with code + verifier + redirect_uri.
        assert.equal(fetchMock.calls.length, 1);
        const body = String(fetchMock.calls[0].init?.body ?? "");
        assert.match(body, /code=fake-code/);
        assert.match(body, /code_verifier=/);
        assert.match(body, /grant_type=authorization_code/);
    }
    finally {
        fetchMock.restore();
    }
});
test("PKCE state mismatch → flow rejects, token endpoint NOT called", async () => {
    const fetchMock = mockFetch(() => {
        throw new Error("token endpoint should not be called on state mismatch");
    });
    try {
        const flow = await startPkceFlow({
            clientId: "test-client",
            tenantId: "common",
            accountId: "acct-pkce-2",
        });
        // Subscribe BEFORE driving the callback — otherwise the rejection lands
        // before any handler is attached and Node flags it as unhandled.
        const rejection = assert.rejects(flow.result, /State mismatch/i);
        const port = extractRedirectUriPort(flow.redirectUri);
        const cbResp = await fetch(`http://127.0.0.1:${port}/callback?code=fake-code&state=wrong-state-value`);
        assert.equal(cbResp.status, 400);
        await rejection;
        assert.equal(fetchMock.calls.length, 0, "token endpoint must not be called");
    }
    finally {
        fetchMock.restore();
    }
});
test("PKCE OAuth error in callback → flow rejects, no token call", async () => {
    const fetchMock = mockFetch(() => {
        throw new Error("token endpoint should not be called on OAuth error");
    });
    try {
        const flow = await startPkceFlow({
            clientId: "test-client",
            tenantId: "common",
            accountId: "acct-pkce-3",
        });
        const rejection = assert.rejects(flow.result, /OAuth error: access_denied/);
        const state = extractStateFromAuthUrl(flow.authUrl);
        const port = extractRedirectUriPort(flow.redirectUri);
        const cbResp = await fetch(`http://127.0.0.1:${port}/callback?error=access_denied&error_description=user%20cancelled&state=${state}`);
        assert.equal(cbResp.status, 400);
        await rejection;
        assert.equal(fetchMock.calls.length, 0);
    }
    finally {
        fetchMock.restore();
    }
});
test("PKCE callback missing code → flow rejects", async () => {
    const fetchMock = mockFetch(() => {
        throw new Error("token endpoint should not be called");
    });
    try {
        const flow = await startPkceFlow({
            clientId: "test-client",
            tenantId: "common",
            accountId: "acct-pkce-4",
        });
        const rejection = assert.rejects(flow.result, /No authorization code received/);
        const state = extractStateFromAuthUrl(flow.authUrl);
        const port = extractRedirectUriPort(flow.redirectUri);
        const cbResp = await fetch(`http://127.0.0.1:${port}/callback?state=${state}`);
        assert.equal(cbResp.status, 400);
        await rejection;
        assert.equal(fetchMock.calls.length, 0);
    }
    finally {
        fetchMock.restore();
    }
});
test("PKCE auth URL has S256 challenge + correct scopes + prompt=select_account", async () => {
    // No mock needed — we assert URL structure and abort the flow without
    // exchanging tokens. Sending an OAuth error in the callback closes the
    // server and rejects flow.result loudly; we catch it explicitly.
    const flow = await startPkceFlow({
        clientId: "client-X",
        tenantId: "common",
        accountId: "acct-pkce-5",
    });
    const expectedRejection = assert.rejects(flow.result, /OAuth error/);
    try {
        const url = new URL(flow.authUrl);
        assert.equal(url.searchParams.get("client_id"), "client-X");
        assert.equal(url.searchParams.get("response_type"), "code");
        assert.equal(url.searchParams.get("code_challenge_method"), "S256");
        assert.equal(url.searchParams.get("prompt"), "select_account");
        const challenge = url.searchParams.get("code_challenge");
        assert.ok(challenge && challenge.length > 30, "challenge should be base64url-encoded SHA-256");
        const scope = url.searchParams.get("scope") ?? "";
        assert.match(scope, /offline_access/);
        assert.match(scope, /User\.Read/);
        assert.match(scope, /Mail\.Read/);
        assert.match(scope, /Calendars\.Read/);
        assert.match(scope, /Contacts\.Read/);
        // Abort the flow by sending an OAuth error. Server closes; result rejects.
        const port = extractRedirectUriPort(flow.redirectUri);
        const state = url.searchParams.get("state");
        await fetch(`http://127.0.0.1:${port}/callback?error=access_denied&state=${state}`);
    }
    finally {
        await expectedRejection;
    }
});
test("PKCE redirect URI uses ephemeral loopback port (not fixed 49152)", async () => {
    // No token-exchange mock — abort each flow with an OAuth error so the
    // server closes without invoking exchangeCode.
    const flow1 = await startPkceFlow({ clientId: "c", tenantId: "common", accountId: "a1" });
    const flow2 = await startPkceFlow({ clientId: "c", tenantId: "common", accountId: "a2" });
    const r1 = assert.rejects(flow1.result, /OAuth error/);
    const r2 = assert.rejects(flow2.result, /OAuth error/);
    try {
        const port1 = extractRedirectUriPort(flow1.redirectUri);
        const port2 = extractRedirectUriPort(flow2.redirectUri);
        assert.ok(port1 > 0 && port1 < 65536);
        assert.ok(port2 > 0 && port2 < 65536);
        assert.notEqual(port1, port2, "two parallel flows must allocate different ephemeral ports");
        const s1 = extractStateFromAuthUrl(flow1.authUrl);
        const s2 = extractStateFromAuthUrl(flow2.authUrl);
        await fetch(`http://127.0.0.1:${port1}/callback?error=access_denied&state=${s1}`);
        await fetch(`http://127.0.0.1:${port2}/callback?error=access_denied&state=${s2}`);
    }
    finally {
        await Promise.all([r1, r2]);
    }
});
//# sourceMappingURL=pkce-flow.test.js.map