Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/b2c-tooling-sdk/test/auth/api-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/

import {expect} from 'chai';
import {ApiKeyStrategy} from '@salesforce/b2c-tooling-sdk/auth';

describe('auth/api-key', () => {
describe('ApiKeyStrategy', () => {
it('getAuthorizationHeader returns Bearer token when headerName is Authorization', async () => {
const auth = new ApiKeyStrategy('my-key', 'Authorization');
const header = await auth.getAuthorizationHeader();
expect(header).to.equal('Bearer my-key');
});

it('getAuthorizationHeader returns raw key when headerName is not Authorization', async () => {
const auth = new ApiKeyStrategy('my-key', 'x-api-key');
const header = await auth.getAuthorizationHeader();
expect(header).to.equal('my-key');
});

it('fetch injects configured header and preserves existing headers', async () => {
const originalFetch = globalThis.fetch;
try {
let seenAuth: string | null = null;
let seenCustom: string | null = null;

globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seenAuth = headers.get('Authorization');
seenCustom = headers.get('x-custom');
return new Response('ok', {status: 200});
}) as typeof fetch;

const auth = new ApiKeyStrategy('my-key', 'Authorization');
const res = await auth.fetch('https://example.com', {headers: {'x-custom': '1'}});

expect(res.status).to.equal(200);
expect(seenCustom).to.equal('1');
expect(seenAuth).to.equal('Bearer my-key');
} finally {
globalThis.fetch = originalFetch;
}
});

it('fetch injects raw key for non-Authorization headerName', async () => {
const originalFetch = globalThis.fetch;
try {
let seenKey: string | null = null;

globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seenKey = headers.get('x-api-key');
return new Response('ok', {status: 200});
}) as typeof fetch;

const auth = new ApiKeyStrategy('my-key', 'x-api-key');
await auth.fetch('https://example.com');

expect(seenKey).to.equal('my-key');
} finally {
globalThis.fetch = originalFetch;
}
});
});
});
42 changes: 42 additions & 0 deletions packages/b2c-tooling-sdk/test/auth/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/

import {expect} from 'chai';
import {BasicAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth';

describe('auth/basic', () => {
describe('BasicAuthStrategy', () => {
it('getAuthorizationHeader returns a Basic header with base64 encoded user:pass', async () => {
const auth = new BasicAuthStrategy('user', 'pass');
const header = await auth.getAuthorizationHeader();
expect(header).to.equal(`Basic ${Buffer.from('user:pass').toString('base64')}`);
});

it('fetch injects Authorization header and preserves existing headers', async () => {
const originalFetch = globalThis.fetch;
try {
let seenHeader: string | null = null;
let seenCustom: string | null = null;

globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seenHeader = headers.get('Authorization');
seenCustom = headers.get('x-custom');
return new Response('ok', {status: 200});
}) as typeof fetch;

const auth = new BasicAuthStrategy('user', 'pass');
const res = await auth.fetch('https://example.com', {headers: {'x-custom': '1'}});

expect(res.status).to.equal(200);
expect(seenCustom).to.equal('1');
expect(seenHeader).to.equal(`Basic ${Buffer.from('user:pass').toString('base64')}`);
} finally {
globalThis.fetch = originalFetch;
}
});
});
});
35 changes: 35 additions & 0 deletions packages/b2c-tooling-sdk/test/auth/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/

import {expect} from 'chai';
import {
ALL_AUTH_METHODS,
ApiKeyStrategy,
BasicAuthStrategy,
ImplicitOAuthStrategy,
OAuthStrategy,
checkAvailableAuthMethods,
decodeJWT,
resolveAuthStrategy,
} from '@salesforce/b2c-tooling-sdk/auth';

describe('auth/index', () => {
it('exports core strategies and helpers from the auth entrypoint', () => {
expect(ALL_AUTH_METHODS).to.be.an('array');
expect(ApiKeyStrategy).to.be.a('function');
expect(BasicAuthStrategy).to.be.a('function');
expect(ImplicitOAuthStrategy).to.be.a('function');
expect(OAuthStrategy).to.be.a('function');
expect(checkAvailableAuthMethods).to.be.a('function');
expect(resolveAuthStrategy).to.be.a('function');
expect(decodeJWT).to.be.a('function');
});

it('ALL_AUTH_METHODS is stable and can be iterated', () => {
const methods = [...ALL_AUTH_METHODS];
expect(methods).to.include('implicit');
});
});
174 changes: 174 additions & 0 deletions packages/b2c-tooling-sdk/test/auth/oauth-implicit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/

import {expect} from 'chai';
import {ImplicitOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth';

type TokenResponse = {
accessToken: string;
expires: Date;
scopes: string[];
};

function futureDate(minutes: number): Date {
return new Date(Date.now() + minutes * 60 * 1000);
}

describe('auth/oauth-implicit', () => {
const originalFetch = globalThis.fetch;

afterEach(() => {
globalThis.fetch = originalFetch;
});

it('adds Authorization and x-dw-client-id headers on fetch', async () => {
const clientId = 'implicit-client-headers';
const strategy = new ImplicitOAuthStrategy({clientId, scopes: ['a']});

(strategy as unknown as {implicitFlowLogin: () => Promise<TokenResponse>}).implicitFlowLogin = async () => ({
accessToken: 'tok-1',
expires: futureDate(30),
scopes: ['a'],
});

let seenAuth: string | null = null;
let seenClientId: string | null = null;

globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seenAuth = headers.get('Authorization');
seenClientId = headers.get('x-dw-client-id');
return new Response('ok', {status: 200});
};

const res = await strategy.fetch('https://example.com/test');
expect(res.status).to.equal(200);
expect(seenAuth).to.equal('Bearer tok-1');
expect(seenClientId).to.equal(clientId);

strategy.invalidateToken();
});

it('retries once on 401 after invalidating the cached token', async () => {
const clientId = 'implicit-client-401';
const strategy = new ImplicitOAuthStrategy({clientId, scopes: ['a']});

let tokenCalls = 0;
(strategy as unknown as {implicitFlowLogin: () => Promise<TokenResponse>}).implicitFlowLogin = async () => {
tokenCalls++;
return {
accessToken: tokenCalls === 1 ? 'tok-1' : 'tok-2',
expires: futureDate(30),
scopes: ['a'],
};
};

const seenAuth: string[] = [];
let fetchCalls = 0;

globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) => {
fetchCalls++;
const headers = new Headers(init?.headers);
seenAuth.push(headers.get('Authorization') ?? '');

if (fetchCalls === 1) {
return new Response('unauthorized', {status: 401});
}

return new Response('ok', {status: 200});
};

const res = await strategy.fetch('https://example.com/test');
expect(res.status).to.equal(200);

expect(fetchCalls).to.equal(2);
expect(tokenCalls).to.equal(2);
expect(seenAuth[0]).to.equal('Bearer tok-1');
expect(seenAuth[1]).to.equal('Bearer tok-2');

strategy.invalidateToken();
});

it('reuses cached token when scopes and expiry are valid', async () => {
const clientId = 'implicit-client-cache';
const strategy = new ImplicitOAuthStrategy({clientId, scopes: ['a']});

let tokenCalls = 0;
(strategy as unknown as {implicitFlowLogin: () => Promise<TokenResponse>}).implicitFlowLogin = async () => {
tokenCalls++;
return {
accessToken: 'tok-cache',
expires: futureDate(30),
scopes: ['a'],
};
};

const t1 = await strategy.getTokenResponse();
const t2 = await strategy.getTokenResponse();

expect(tokenCalls).to.equal(1);
expect(t2.accessToken).to.equal(t1.accessToken);

strategy.invalidateToken();
});

it('re-authenticates when cached token is missing required scopes', async () => {
const clientId = 'implicit-client-scopes';
const strategy = new ImplicitOAuthStrategy({clientId, scopes: ['a', 'b']});

let tokenCalls = 0;
(strategy as unknown as {implicitFlowLogin: () => Promise<TokenResponse>}).implicitFlowLogin = async () => {
tokenCalls++;
return {
accessToken: tokenCalls === 1 ? 'tok-missing-scope' : 'tok-all-scopes',
expires: futureDate(30),
scopes: tokenCalls === 1 ? ['a'] : ['a', 'b'],
};
};

const t1 = await strategy.getTokenResponse();
const t2 = await strategy.getTokenResponse();

expect(tokenCalls).to.equal(2);
expect(t1.accessToken).to.equal('tok-missing-scope');
expect(t2.accessToken).to.equal('tok-all-scopes');

strategy.invalidateToken();
});

it('deduplicates concurrent token requests using pending auth mutex', async () => {
const clientId = 'implicit-client-pending';
const strategy = new ImplicitOAuthStrategy({clientId, scopes: ['a']});

let resolveToken: ((t: TokenResponse) => void) | undefined;
let tokenCalls = 0;

(strategy as unknown as {implicitFlowLogin: () => Promise<TokenResponse>}).implicitFlowLogin = async () => {
tokenCalls++;
return await new Promise<TokenResponse>((resolve) => {
resolveToken = resolve;
});
};

globalThis.fetch = async () => new Response('ok', {status: 200});

const p1 = strategy.fetch('https://example.com/test');
const p2 = strategy.fetch('https://example.com/test');

if (!resolveToken) {
throw new Error('Expected token request to be started');
}

resolveToken({accessToken: 'tok-pending', expires: futureDate(30), scopes: ['a']});

const [r1, r2] = await Promise.all([p1, p2]);
expect(r1.status).to.equal(200);
expect(r2.status).to.equal(200);
expect(tokenCalls).to.equal(1);

strategy.invalidateToken();
});
});
Loading
Loading