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
5 changes: 3 additions & 2 deletions src/core/oauth-provider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
type TokenResponse = {
access_token: string;
refresh_token?: string;
expires_in: number; // in secondes
expires_in: number; // in seconds
token_type: string;
};

Expand All @@ -21,7 +21,7 @@ export class OAuth2Client {
private typeToken: string | null = null;
private accessToken: string | null = null;
private refreshToken: string | null = null;
private tokenExpiry: number = 0; // timestamp en ms
private tokenExpiry: number = 0; // timestamp in ms

/** Deduplicates concurrent token acquisition calls (thundering-herd protection). */
private pendingToken: Promise<void> | null = null;
Expand Down Expand Up @@ -51,6 +51,7 @@ export class OAuth2Client {
const params = new URLSearchParams({
grant_type: 'api_token',
api_token: this.clientSecret,
client_id: this.clientId,
});

const response = await fetch(this.tokenEndpoint, {
Expand Down
20 changes: 18 additions & 2 deletions tests/unit/core/oauth2-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,21 @@ describe('OAuth2Client', () => {
expect(typeof result).toBe('boolean');
});

it('should send Authorization: Basic header', async () => {
it('should send Authorization: Basic header and include client_id in body', async () => {
let capturedAuth: string | undefined;
let capturedBody: string | undefined;
nock('https://auth.upsun.com')
.post('/oauth2/token')
.reply(function () {
.reply(function (_uri, body) {
capturedAuth = this.req.headers['authorization'] as string;
capturedBody = body as string;
return [200, { access_token: 'tok', token_type: 'Bearer', expires_in: 3600 }];
});

await oauth2Client.exchangeCodeForToken();
expect(capturedAuth).toMatch(/^Basic /);
expect(capturedBody).toContain('client_id=test-client-id');
expect(capturedBody).toContain('grant_type=api_token');
});

it('should handle token exchange errors', async () => {
Expand Down Expand Up @@ -252,4 +256,16 @@ describe('OAuth2Client', () => {
expect(token).toBe('refreshed-via-separate-endpoint');
});
});

// ---------------------------------------------------------------------------
// refreshAccessToken defensive guard (private method, line 121)
// ---------------------------------------------------------------------------
describe('refreshAccessToken (defensive guard)', () => {
it('should throw when called without a stored refresh token', async () => {
// oauth2Client has no refresh token because no exchange has been performed
await expect((oauth2Client as any).refreshAccessToken()).rejects.toThrow(
'No refresh token available',
);
});
});
});
160 changes: 160 additions & 0 deletions tests/unit/upsun-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,164 @@ describe('UpsunClient', () => {
expect(typeof client.getUserId).toBe('function');
});
});

// ---------------------------------------------------------------------------
// createAuthRetryMiddleware — RFC 6750 §3.1 P1 fix
// ---------------------------------------------------------------------------
describe('createAuthRetryMiddleware', () => {
const AUTH_URL = 'https://auth.upsun.com';
const API_URL = 'https://api.upsun.com';
const TOKEN_PATH = '/oauth2/token';
const ME_PATH = '/users/me';

const mockUser = { id: 'test-user-id' };
const tokenReply = (token: string) => ({
access_token: token,
token_type: 'Bearer',
expires_in: 3600,
});

it('should pass through non-401 responses without retry', async () => {
nock(AUTH_URL).post(TOKEN_PATH).reply(200, tokenReply('token-1'));
nock(API_URL).get(ME_PATH).reply(200, mockUser);

const result = await client.users.me();
expect(result.id).toBe('test-user-id');
expect(nock.pendingMocks()).toHaveLength(0);
});

it('should call forceRefresh and retry with new token after 401 (P1 fix)', async () => {
// Initial token acquisition
nock(AUTH_URL).post(TOKEN_PATH).reply(200, tokenReply('token-1'));
// First API call → 401 (expired / revoked token)
nock(API_URL).get(ME_PATH).reply(401, { error: 'invalid_token' });
// forceRefresh() re-acquires a new token
nock(AUTH_URL).post(TOKEN_PATH).reply(200, tokenReply('token-2'));
// Retry with new token → success
nock(API_URL).get(ME_PATH).reply(200, mockUser);

const result = await client.users.me();
expect(result.id).toBe('test-user-id');
expect(nock.pendingMocks()).toHaveLength(0);
});

it('should not retry a second time when __upsunRetry guard is set', async () => {
nock(AUTH_URL).post(TOKEN_PATH).reply(200, tokenReply('token-1'));
nock(API_URL).get(ME_PATH).reply(401, { error: 'invalid_token' });
// forceRefresh is called once
nock(AUTH_URL).post(TOKEN_PATH).reply(200, tokenReply('token-2'));
// Retry still returns 401 — must not loop again
nock(API_URL).get(ME_PATH).reply(401, { error: 'invalid_token' });

await expect(client.users.me()).rejects.toThrow();
expect(nock.pendingMocks()).toHaveLength(0);
});

it('should return 401 without retry in Bearer-only mode (no auth client)', async () => {
const bearerClient = new UpsunClient();
bearerClient.setBearerToken('static-bearer-token');

// Only one request — no retry, no auth call
nock(API_URL).get(ME_PATH).reply(401, { error: 'invalid_token' });

await expect(bearerClient.users.me()).rejects.toThrow();
expect(nock.pendingMocks()).toHaveLength(0);
});
});

// ---------------------------------------------------------------------------
// getUserId
// ---------------------------------------------------------------------------
describe('getUserId', () => {
it('should fetch user ID from API and cache it for subsequent calls', async () => {
nock('https://auth.upsun.com').post('/oauth2/token').reply(200, {
access_token: 'token-1',
token_type: 'Bearer',
expires_in: 3600,
});
nock('https://api.upsun.com').get('/users/me').reply(200, { id: 'user-abc' });

const userId = await client.getUserId();
expect(userId).toBe('user-abc');

// Second call must use the cached value — no new network request
const cachedId = await client.getUserId();
expect(cachedId).toBe('user-abc');
expect(nock.pendingMocks()).toHaveLength(0);
});
});

// ---------------------------------------------------------------------------
// getToken
// ---------------------------------------------------------------------------
describe('getToken', () => {
it('should return bearer token when set via setBearerToken', async () => {
const unauthClient = new UpsunClient();
unauthClient.setBearerToken('my-bearer-token');

const token = await (unauthClient as any).getToken();
expect(token).toBe('my-bearer-token');
});

it('should throw when neither apiKey nor bearer token is configured', async () => {
const unauthClient = new UpsunClient();

await expect((unauthClient as any).getToken()).rejects.toThrow(
'No authentication method available',
);
});
});

// ---------------------------------------------------------------------------
// authenticate
// ---------------------------------------------------------------------------
describe('authenticate', () => {
it('should throw when no API key is configured', async () => {
const unauthClient = new UpsunClient();

await expect(unauthClient.authenticate()).rejects.toThrow('API Key is not defined');
});

it('should return true on successful token exchange', async () => {
nock('https://auth.upsun.com').post('/oauth2/token').reply(200, {
access_token: 'fresh-token',
token_type: 'Bearer',
expires_in: 3600,
});

await expect(client.authenticate()).resolves.toBe(true);
});
});

// ---------------------------------------------------------------------------
// cloneHeaders (private — tests via cast to any)
// ---------------------------------------------------------------------------
describe('cloneHeaders', () => {
it('should return empty object when headers is undefined', () => {
const result = (client as any).cloneHeaders(undefined);
expect(result).toEqual({});
});

it('should clone a Headers instance (keys normalised to lower-case)', () => {
const headers = new Headers({ 'Content-Type': 'application/json', 'X-Custom': 'value' });
const result = (client as any).cloneHeaders(headers);
expect(result['content-type']).toBe('application/json');
expect(result['x-custom']).toBe('value');
});

it('should clone an array of [key, value] header pairs', () => {
const headers: [string, string][] = [
['Authorization', 'Bearer test'],
['Accept', 'application/json'],
];
const result = (client as any).cloneHeaders(headers);
expect(result).toEqual({ Authorization: 'Bearer test', Accept: 'application/json' });
});

it('should clone a plain-object header map', () => {
const headers = { Authorization: 'Bearer test', 'Content-Type': 'application/json' };
const result = (client as any).cloneHeaders(headers);
expect(result).toEqual({ Authorization: 'Bearer test', 'Content-Type': 'application/json' });
});
});
});