Skip to content
Open
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
21 changes: 21 additions & 0 deletions src/__tests__/api.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/// <reference types="jest" />
import { addSignatureFn } from '../lib/api/helper/interceptors';

const packageJSON = require('../../package.json');

describe('API request interceptor', () => {
it('adds fdk cli version header before service requests are signed', async () => {
const interceptor = addSignatureFn({});
const config: any = {
url: 'https://api.fyndx1.de/service/panel/authentication/v1.0/oauth/token',
method: 'get',
headers: {},
};

const signedConfig = await interceptor(config);

expect(signedConfig.headers['x-fp-cli']).toBe(`${packageJSON.version}`);
expect(signedConfig.headers['x-fp-date']).toBeDefined();
expect(signedConfig.headers['x-fp-signature']).toBeDefined();
});
});
231 changes: 231 additions & 0 deletions src/__tests__/auth.device.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/// <reference types="jest" />
import rimraf from 'rimraf';
import Auth from '../lib/Auth';
import ApiClient from '../lib/api/ApiClient';
import Env from '../lib/Env';
import configStore, { CONFIG_KEYS } from '../lib/Config';
import OrganizationService from '../lib/api/services/organization.service';
import CommandError from '../lib/CommandError';
import Logger from '../lib/Logger';

const openMock = jest.fn();

jest.mock('open', () => ({
__esModule: true,
default: (...args) => openMock(...args),
}));

jest.mock('../helper/formatter', () => ({
OutputFormatter: {
link: (value: string) => value,
command: (value: string) => value,
},
successBox: ({ text }: { text: string }) => text,
}));

jest.mock('configstore', () => {
const Store = jest.requireActual('configstore');
return class MockConfigstore {
store = new Store('test-cli', undefined, {
configPath: './auth-device-test-cli.json',
});
get(key: string) {
return this.store.get(key);
}
set(key: string, value) {
this.store.set(key, value);
}
delete(key) {
this.store.delete(key);
}
clear() {
this.store.clear();
}
};
});

describe('Auth device flow', () => {
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
configStore.clear();
process.exitCode = 0;

jest.spyOn(Env, 'verifyAndSanitizeEnvValue').mockResolvedValue('api.fyndx1.de');
jest.spyOn(Env, 'getEnvValue').mockReturnValue('api.fyndx1.de');
jest.spyOn(Logger, 'info').mockImplementation(() => {});
jest.spyOn(OrganizationService, 'getOrganizationDetails').mockResolvedValue({
data: { _id: 'org-1', name: 'Test Org' },
} as any);
openMock.mockResolvedValue(undefined);
});

afterEach(() => {
process.exitCode = 0;
});

afterAll(() => {
rimraf.sync('./auth-device-test-cli.json');
});

it('falls back to legacy when client-config endpoint is unavailable', async () => {
jest.spyOn(ApiClient, 'get').mockResolvedValue({
status: 404,
data: {},
} as any);

await expect((Auth as any).getAuthFlowConfig('api.fyndx1.de')).resolves.toEqual({
auth_mode_type: 'legacy',
});
expect(process.exitCode).toBe(0);
});

it('does not fall back to legacy when client-config returns an unexpected error', async () => {
const error = {
message: 'Internal server error',
response: { status: 500 },
};
jest.spyOn(ApiClient, 'get').mockRejectedValue(error);

await expect((Auth as any).getAuthFlowConfig('api.fyndx1.de')).rejects.toBe(error);
});

it('uses regional Skywarp URLs for device login when region is provided', async () => {
const getSpy = jest.spyOn(ApiClient, 'get').mockResolvedValue({
data: {
client_id: 'fdk-cli',
auth_mode_type: 'device_code',
},
} as any);

const postSpy = jest.spyOn(ApiClient, 'post');
postSpy
.mockResolvedValueOnce({
data: {
device_code: 'device-code-region',
verification_uri_complete:
'https://partners.fyndx1.de/partners/organizations/?device_id=device-code-region&region=asia-south1%2Fdevelopment',
interval: 0,
expires_in: 10,
},
} as any)
.mockResolvedValueOnce({
data: {
auth_token: {
access_token: 'region-token',
expires_in: 3600,
current_user: { first_name: 'A', last_name: 'B', emails: [] },
},
organization: { _id: 'org-1', name: 'Test Org' },
},
} as any);

await Auth.login({ host: 'api.fyndx1.de', region: 'asia-south1/development' });

expect(getSpy).toHaveBeenCalledWith(
'https://api.fyndx1.de/region/asia-south1/development/service/panel/authentication/v1.0/oauth/client-config',
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
},
}),
expect.objectContaining({
validateStatus: expect.any(Function),
}),
);
expect(postSpy).toHaveBeenNthCalledWith(
1,
'https://api.fyndx1.de/region/asia-south1/development/service/panel/authentication/v1.0/oauth/device_authorization',
expect.objectContaining({
data: expect.objectContaining({
requested_region: 'asia-south1/development',
}),
}),
);
expect(postSpy).toHaveBeenNthCalledWith(
2,
'https://api.fyndx1.de/region/asia-south1/development/service/panel/authentication/v1.0/oauth/token',
expect.objectContaining({
data: expect.objectContaining({
device_code: 'device-code-region',
}),
}),
);
expect(openMock).toHaveBeenCalledWith(
'https://partners.fyndx1.de/partners/organizations/?device_id=device-code-region&region=asia-south1%2Fdevelopment',
);
expect(configStore.get(CONFIG_KEYS.AUTH_TOKEN).access_token).toBe('region-token');
});

it('uses device flow and opens verification URL unchanged', async () => {
const getSpy = jest.spyOn(ApiClient, 'get').mockResolvedValue({
data: {
client_id: 'fdk-cli',
auth_mode_type: 'device_code',
},
} as any);

const postSpy = jest.spyOn(ApiClient, 'post');
postSpy
.mockResolvedValueOnce({
data: {
device_code: 'device-code-1',
verification_uri_complete: 'https://partners.fyndx1.de/partners/organizations/?device_id=device-code-basic',
interval: 0,
expires_in: 10,
},
} as any)
.mockResolvedValueOnce({
data: {
auth_token: {
access_token: 'token-1',
expires_in: 3600,
current_user: { first_name: 'A', last_name: 'B', emails: [] },
},
organization: { _id: 'org-1', name: 'Test Org' },
},
} as any);

await Auth.login({ host: 'api.fyndx1.de' });

expect(getSpy).toHaveBeenCalled();
expect(postSpy).toHaveBeenCalledTimes(2);
expect(openMock).toHaveBeenCalledTimes(1);
const openedUrl = openMock.mock.calls[0][0] as string;
expect(openedUrl).toBe('https://partners.fyndx1.de/partners/organizations/?device_id=device-code-basic');
expect(configStore.get(CONFIG_KEYS.AUTH_TOKEN).access_token).toBe('token-1');
});

it('maps expired_token polling response to CommandError', async () => {
jest.spyOn(ApiClient, 'get').mockResolvedValue({
data: {
client_id: 'fdk-cli',
auth_mode_type: 'device_code',
},
} as any);

const postSpy = jest.spyOn(ApiClient, 'post');
postSpy
.mockResolvedValueOnce({
data: {
device_code: 'device-code-2',
verification_uri_complete:
'https://partners.fyndx1.de/partners/organizations/?device_id=device-code-expired',
interval: 0,
expires_in: 10,
},
} as any)
.mockRejectedValueOnce({
response: { data: { error: 'expired_token' } },
});

try {
await Auth.login({ host: 'api.fyndx1.de' });
fail('Expected Auth.login to throw for expired_token');
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect(error.message).toBe('Device code expired. Please run `fdk login` again.');
expect(error.code).toBe('400');
}
});
});
58 changes: 58 additions & 0 deletions src/__tests__/url.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
jest.mock('../lib/Config', () => {
const actual = jest.requireActual<typeof import('../lib/Config')>(
'../lib/Config',
);
const { CONFIG_KEYS } = actual;
return {
__esModule: true,
default: {
get: (key: string) => {
if (key === CONFIG_KEYS.API_VERSION) return '1.0';
if (key === CONFIG_KEYS.CURRENT_ENV_VALUE) {
return 'api.test.example.com';
}
return undefined;
},
},
CONFIG_KEYS: actual.CONFIG_KEYS,
};
});

import { URLS } from '../lib/api/services/url';

describe('URLS OAuth (device flow)', () => {
const authBase =
'https://api.test.example.com/service/panel/authentication/v1.0';
const regionalAuthBase =
'https://api.test.example.com/region/asia-south1/development/service/panel/authentication/v1.0';

it('OAUTH_CLIENT_CONFIG builds panel client-config URL', () => {
expect(URLS.OAUTH_CLIENT_CONFIG()).toBe(
`${authBase}/oauth/client-config`,
);
});

it('OAUTH_DEVICE_AUTHORIZATION builds device authorization URL', () => {
expect(URLS.OAUTH_DEVICE_AUTHORIZATION()).toBe(
`${authBase}/oauth/device_authorization`,
);
});

it('OAUTH_DEVICE_TOKEN builds OAuth token URL', () => {
expect(URLS.OAUTH_DEVICE_TOKEN()).toBe(`${authBase}/oauth/token`);
});

it('OAuth URLs support regional Skywarp paths', () => {
const options = { region: 'asia-south1/development' };

expect(URLS.OAUTH_CLIENT_CONFIG(options)).toBe(
`${regionalAuthBase}/oauth/client-config`,
);
expect(URLS.OAUTH_DEVICE_AUTHORIZATION(options)).toBe(
`${regionalAuthBase}/oauth/device_authorization`,
);
expect(URLS.OAUTH_DEVICE_TOKEN(options)).toBe(
`${regionalAuthBase}/oauth/token`,
);
});
});
5 changes: 4 additions & 1 deletion src/helper/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export const ENVIRONMENT_COMMANDS = ['env'];
export const AUTHENTICATION_COMMANDS = ['auth', 'login', 'logout'];
export const EXTENSION_COMMANDS = ['init', 'get', 'set', 'pull-env'];
export const MAX_RETRY = 5;
export const FDK_CLI_CLIENT_ID = 'fdk-cli';
export const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
export const DEVICE_AUTH_SCOPES = ['organization/*'];
export const THEME_TYPE = {
vue2: 'vue2',
react: 'react',
Expand Down Expand Up @@ -111,4 +114,4 @@ export const PROJECT_REPOS = {
[TEMPLATES['node-vue'].name]: TEMPLATES['node-vue'].repo,
[TEMPLATES['node-react'].name]: TEMPLATES['node-react'].repo,
[TEMPLATES['payment-node-react'].name]: TEMPLATES['payment-node-react'].repo
};
};
Loading
Loading