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
36 changes: 2 additions & 34 deletions bin/pos-cli-env-refresh-token.js
Original file line number Diff line number Diff line change
@@ -1,55 +1,23 @@
import { program } from '../lib/program.js';
import logger from '../lib/logger.js';
import Portal from '../lib/portal.js';
import { readPassword } from '../lib/utils/password.js';
import { fetchSettings } from '../lib/settings.js';
import { storeEnvironment, deviceAuthorizationFlow } from '../lib/environments.js';
import refreshToken from '../lib/envs/refreshToken.js';
import ServerError from '../lib/ServerError.js';

const saveToken = (settings, token) => {
storeEnvironment(Object.assign(settings, { token: token }));
logger.Success(`Environment ${settings.url} as ${settings.environment} has been added successfully.`);
};

const login = async (email, password, url) => {
return Portal.login(email, password, url)
.then(response => {
if (response) return Promise.resolve(response[0].token);
});
};

program
.name('pos-cli env refresh-token')
.arguments('[environment]', 'name of environment. Example: staging')
.action(async (environment, _params) => {
try {

const authData = await fetchSettings(environment);

if (!authData.email){
token = await deviceAuthorizationFlow(authData.url);
} else {
logger.Info(
`Please make sure that you have a permission to deploy. \n You can verify it here: ${Portal.url()}/me/permissions`,
{ hideTimestamp: true }
);

const password = await readPassword();
logger.Info(`Asking ${Portal.url()} for access token...`);

token = await login(authData.email, password, authData.url);
}

if (token) saveToken({...authData, environment}, token);

await refreshToken(environment, authData);
} catch (e) {
if (ServerError.isNetworkError(e))
await ServerError.handler(e);
else
await logger.Error(e);
process.exit(1);
}

});

program.parse(process.argv);
2 changes: 1 addition & 1 deletion lib/ServerError.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ const ServerError = {

unauthorized: async request => {
logger.Debug(`Unauthorized: ${JSON.stringify(request, null, 2)}`);
await logger.Error('You are unauthorized to do this operation. Check if your Token/URL or email/password are correct.', {
await logger.Error('You are unauthorized to do this operation. Check if your Token/URL or email/password are correct.\nTo refresh your token, run: pos-cli env refresh-token <environment>', {
hideTimestamp: true,
exit: shouldExit(request)
});
Expand Down
40 changes: 40 additions & 0 deletions lib/envs/refreshToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Portal from '../portal.js';
import logger from '../logger.js';
import { readPassword } from '../utils/password.js';
import { storeEnvironment, deviceAuthorizationFlow } from '../environments.js';

const login = async (email, password, url) => {
return Portal.login(email, password, url)
.then(response => {
if (response) return Promise.resolve(response[0].token);
});
};

const refreshToken = async (environment, authData) => {
let token;

if (!authData.email) {
token = await deviceAuthorizationFlow(authData.url);
} else {
logger.Info(
`Please make sure that you have a permission to deploy. \n You can verify it here: ${Portal.url()}/me/permissions`,
{ hideTimestamp: true }
);

const password = await readPassword();
logger.Info(`Asking ${Portal.url()} for access token...`);

token = await login(authData.email, password, authData.url);
}

if (token) {
storeEnvironment({ ...authData, environment, token });
logger.Success(`Token for ${authData.url} as ${environment} has been refreshed successfully.`);
} else {
logger.Warn('Could not obtain a new token. Your existing token has not been changed.');
}

return token;
};

export default refreshToken;
8 changes: 6 additions & 2 deletions test/unit/ServerError.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe('ServerError', () => {
expect(logger.Error).toHaveBeenCalledWith('NotFound: https://example.com/api/nonexistent');
});

test('handles 401 unauthorized', () => {
test('handles 401 unauthorized with refresh-token hint', () => {
const request = {
statusCode: 401,
options: { uri: 'https://example.com/api/deploy' }
Expand All @@ -210,7 +210,11 @@ describe('ServerError', () => {
ServerError.responseHandler(request);

expect(logger.Error).toHaveBeenCalledWith(
'You are unauthorized to do this operation. Check if your Token/URL or email/password are correct.',
expect.stringContaining('You are unauthorized to do this operation.'),
expect.objectContaining({ hideTimestamp: true })
);
expect(logger.Error).toHaveBeenCalledWith(
expect.stringContaining('pos-cli env refresh-token'),
expect.objectContaining({ hideTimestamp: true })
);
});
Expand Down
134 changes: 55 additions & 79 deletions test/unit/env-refresh-token-unit.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { vi, describe, test, expect, afterEach, beforeEach } from 'vitest';
import { vi, describe, test, expect, afterEach, beforeEach, beforeAll } from 'vitest';
import fs from 'fs';
import { storeEnvironment } from '#lib/environments.js';
import os from 'os';
import path from 'path';
import { settingsFromDotPos } from '#lib/settings.js';

vi.mock('open', () => ({
default: vi.fn(() => Promise.resolve())
Expand Down Expand Up @@ -32,6 +34,7 @@ vi.mock('#lib/logger.js', () => ({
Success: vi.fn(),
Debug: vi.fn(),
Info: vi.fn(),
Warn: vi.fn(),
Error: vi.fn()
}
}));
Expand All @@ -40,24 +43,30 @@ vi.mock('#lib/utils/password.js', () => ({
readPassword: vi.fn(() => Promise.resolve('test-password'))
}));

let deviceAuthorizationFlow;
let refreshToken;
let mockLogger;
let mockPortal;
let originalCwd;
let tempDir;

beforeEach(async () => {
const envModule = await import('#lib/environments.js');
deviceAuthorizationFlow = envModule.deviceAuthorizationFlow;
beforeAll(async () => {
const refreshMod = await import('#lib/envs/refreshToken.js');
refreshToken = refreshMod.default;

const loggerModule = await import('#lib/logger.js');
mockLogger = loggerModule.default;

const portalModule = await import('#lib/portal.js');
mockPortal = portalModule.default;
});

beforeEach(() => {
originalCwd = process.cwd();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pos-cli-test-'));
process.chdir(tempDir);

// Reset all mocks
vi.clearAllMocks();

// Reset the mock implementation to default success
mockPortal.requestDeviceAuthorization.mockResolvedValue({
verification_uri_complete: 'http://example.com/xxxx',
device_code: 'device_code',
Expand All @@ -66,115 +75,82 @@ beforeEach(async () => {
});

afterEach(() => {
try {
fs.unlinkSync('.pos');
} catch {
// File might not exist, ignore
}
process.chdir(originalCwd);
fs.rmSync(tempDir, { recursive: true, force: true });
});

describe('env refresh-token', () => {
test('refreshes token using device authorization flow', async () => {
// Setup: create initial environment
test('refreshes token using device authorization flow and saves to .pos', async () => {
const environment = 'staging';
storeEnvironment({
environment: environment,
url: 'https://staging.example.com',
token: 'old-token-12345',
email: undefined
});

// Execute: refresh token
const token = await deviceAuthorizationFlow('https://staging.example.com');
const authData = { url: 'https://staging.example.com', token: 'old-token-12345', email: undefined };
const token = await refreshToken(environment, authData);

// Verify
expect(token).toBe('refreshed-token-12345');
expect(mockPortal.requestDeviceAuthorization).toHaveBeenCalledWith('staging.example.com');

const settings = settingsFromDotPos(environment);
expect(settings.token).toBe('refreshed-token-12345');
expect(mockLogger.Success).toHaveBeenCalledWith(expect.stringContaining('refreshed successfully'));
});

test('refreshes token using email/password flow and saves to .pos', async () => {
const environment = 'staging';
const authData = { url: 'https://staging.example.com', token: 'old-token-12345', email: 'user@example.com' };
const token = await refreshToken(environment, authData);

expect(token).toBe('refreshed-token-12345');
expect(mockPortal.login).toHaveBeenCalledWith('user@example.com', 'test-password', 'https://staging.example.com');
expect(mockPortal.requestDeviceAuthorization).not.toHaveBeenCalled();

const settings = settingsFromDotPos(environment);
expect(settings.token).toBe('refreshed-token-12345');
});

test('warns when token cannot be obtained', async () => {
mockPortal.login.mockResolvedValue(null);

const environment = 'staging';
const authData = { url: 'https://staging.example.com', token: 'old-token-12345', email: 'user@example.com' };
const token = await refreshToken(environment, authData);

expect(token).toBeUndefined();
expect(mockLogger.Warn).toHaveBeenCalledWith(expect.stringContaining('Could not obtain a new token'));
expect(mockLogger.Success).not.toHaveBeenCalled();
});

test('displays error when instance is not registered in partner portal', async () => {
// Setup: mock 404 error from partner portal
mockPortal.requestDeviceAuthorization.mockRejectedValue({
statusCode: 404,
options: { uri: 'https://partners.platformos.com/oauth/authorize_device' },
message: 'Not Found'
});

const environment = 'unregistered';
storeEnvironment({
environment: environment,
url: 'https://unregistered-instance.example.com',
token: 'old-token',
email: undefined
});

// Execute and verify error is thrown
await expect(
deviceAuthorizationFlow('https://unregistered-instance.example.com')
).rejects.toMatchObject({
const authData = { url: 'https://unregistered-instance.example.com', token: 'old-token', email: undefined };
await expect(refreshToken('unregistered', authData)).rejects.toMatchObject({
statusCode: 404
});

// Verify error message was logged
expect(mockLogger.Error).toHaveBeenCalledWith(
expect.stringContaining('Instance https://unregistered-instance.example.com is not registered in the Partner Portal'),
expect.objectContaining({ hideTimestamp: true, exit: false })
);
expect(mockLogger.Error).toHaveBeenCalledWith(
expect.stringContaining('Please double-check if the instance URL is correct'),
expect.objectContaining({ hideTimestamp: true, exit: false })
);
});

test('does not display custom error for non-404 errors', async () => {
// Setup: mock 500 error from partner portal
mockPortal.requestDeviceAuthorization.mockRejectedValue({
statusCode: 500,
options: { uri: 'https://partners.platformos.com/oauth/authorize_device' },
message: 'Internal Server Error'
});

const environment = 'errored';
storeEnvironment({
environment: environment,
url: 'https://errored-instance.example.com',
token: 'old-token',
email: undefined
});

// Execute and verify error is thrown
await expect(
deviceAuthorizationFlow('https://errored-instance.example.com')
).rejects.toMatchObject({
const authData = { url: 'https://errored-instance.example.com', token: 'old-token', email: undefined };
await expect(refreshToken('errored', authData)).rejects.toMatchObject({
statusCode: 500
});

// Verify our custom error message was NOT displayed
expect(mockLogger.Error).not.toHaveBeenCalledWith(
expect.stringContaining('is not registered in the Partner Portal'),
expect.anything()
);
});

test('refreshes token using email/password flow', async () => {
// Setup: create environment with email
const environment = 'staging';
storeEnvironment({
environment: environment,
url: 'https://staging.example.com',
token: 'old-token-12345',
email: 'user@example.com'
});

// Execute: login with email/password
const Portal = await import('#lib/portal.js');
const token = await Portal.default.login('user@example.com', 'test-password', 'https://staging.example.com');

// Verify
expect(token).toEqual([{ token: 'refreshed-token-12345' }]);
expect(mockPortal.login).toHaveBeenCalledWith('user@example.com', 'test-password', 'https://staging.example.com');

// Verify device authorization was NOT called for email/password flow
expect(mockPortal.requestDeviceAuthorization).not.toHaveBeenCalled();
});
});
Loading