From 748b0a311534f4f05c95ff208cf1877173ec8b9f Mon Sep 17 00:00:00 2001 From: Maciej Krajowski-Kukiel Date: Wed, 18 Mar 2026 09:33:54 +0100 Subject: [PATCH 1/3] fix pos-cli env refresh --- bin/pos-cli-env-refresh-token.js | 36 +----- lib/ServerError.js | 2 +- lib/envs/refreshToken.js | 40 +++++++ test/unit/ServerError.test.js | 8 +- test/unit/env-refresh-token-unit.test.js | 134 ++++++++++------------- 5 files changed, 104 insertions(+), 116 deletions(-) create mode 100644 lib/envs/refreshToken.js diff --git a/bin/pos-cli-env-refresh-token.js b/bin/pos-cli-env-refresh-token.js index 22b73087..73eff094 100644 --- a/bin/pos-cli-env-refresh-token.js +++ b/bin/pos-cli-env-refresh-token.js @@ -1,47 +1,16 @@ 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); @@ -49,7 +18,6 @@ program await logger.Error(e); process.exit(1); } - }); program.parse(process.argv); diff --git a/lib/ServerError.js b/lib/ServerError.js index 3ae65d54..7b0db93c 100644 --- a/lib/ServerError.js +++ b/lib/ServerError.js @@ -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 ', { hideTimestamp: true, exit: shouldExit(request) }); diff --git a/lib/envs/refreshToken.js b/lib/envs/refreshToken.js new file mode 100644 index 00000000..4086dc61 --- /dev/null +++ b/lib/envs/refreshToken.js @@ -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; diff --git a/test/unit/ServerError.test.js b/test/unit/ServerError.test.js index 58abed0a..7e05ae2f 100644 --- a/test/unit/ServerError.test.js +++ b/test/unit/ServerError.test.js @@ -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' } @@ -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 }) ); }); diff --git a/test/unit/env-refresh-token-unit.test.js b/test/unit/env-refresh-token-unit.test.js index 5cc32ef5..a744cbf8 100644 --- a/test/unit/env-refresh-token-unit.test.js +++ b/test/unit/env-refresh-token-unit.test.js @@ -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()) @@ -32,6 +34,7 @@ vi.mock('#lib/logger.js', () => ({ Success: vi.fn(), Debug: vi.fn(), Info: vi.fn(), + Warn: vi.fn(), Error: vi.fn() } })); @@ -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', @@ -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(); - }); }); From 367f36fb5be9343143f1b255ba25cd0c3632b0c4 Mon Sep 17 00:00:00 2001 From: Maciej Krajowski-Kukiel Date: Wed, 18 Mar 2026 13:22:45 +0100 Subject: [PATCH 2/3] fix constant setting --- bin/pos-cli-archive.js | 4 ++-- bin/pos-cli-constants-list.js | 4 ++-- bin/pos-cli-constants-set.js | 4 ++-- bin/pos-cli-constants-unset.js | 4 ++-- test/unit/queries.test.js | 23 +++++++++++++++++++++++ 5 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 test/unit/queries.test.js diff --git a/bin/pos-cli-archive.js b/bin/pos-cli-archive.js index 7096dca5..e912e4b2 100644 --- a/bin/pos-cli-archive.js +++ b/bin/pos-cli-archive.js @@ -2,10 +2,10 @@ import { program } from '../lib/program.js'; import { run as auditRun } from '../lib/audit.js'; -import archive from '../lib/archive.js'; +import { makeArchive } from '../lib/archive.js'; const createArchive = async (env) => { - const numberOfFiles = await archive.makeArchive(env, { withoutAssets: false }); + const numberOfFiles = await makeArchive(env, { withoutAssets: false }); if (numberOfFiles == 0) throw 'Archive failed to create.'; }; diff --git a/bin/pos-cli-constants-list.js b/bin/pos-cli-constants-list.js index bdd3ce07..7f699507 100644 --- a/bin/pos-cli-constants-list.js +++ b/bin/pos-cli-constants-list.js @@ -2,7 +2,7 @@ import { program } from '../lib/program.js'; import Gateway from '../lib/proxy.js'; -import queries from '../lib/graph/queries.js'; +import { getConstants } from '../lib/graph/queries.js'; import { fetchSettings } from '../lib/settings.js'; import logger from '../lib/logger.js'; @@ -26,7 +26,7 @@ program const gateway = new Gateway(authData); gateway - .graph({query: queries.getConstants()}) + .graph({query: getConstants()}) .then(success) .catch(console.log); }); diff --git a/bin/pos-cli-constants-set.js b/bin/pos-cli-constants-set.js index b90301dc..ac4e20bd 100644 --- a/bin/pos-cli-constants-set.js +++ b/bin/pos-cli-constants-set.js @@ -3,7 +3,7 @@ import { program } from '../lib/program.js'; import Gateway from '../lib/proxy.js'; import { existence as validateExistence } from '../lib/validators/index.js'; -import queries from '../lib/graph/queries.js'; +import { setConstant } from '../lib/graph/queries.js'; import { fetchSettings } from '../lib/settings.js'; import logger from '../lib/logger.js'; @@ -36,7 +36,7 @@ program const gateway = new Gateway(authData); gateway - .graph({query: queries.setConstant(params.name, params.value)}) + .graph({query: setConstant(params.name, params.value)}) .then(success) .catch(error); }); diff --git a/bin/pos-cli-constants-unset.js b/bin/pos-cli-constants-unset.js index fc305904..488458d3 100755 --- a/bin/pos-cli-constants-unset.js +++ b/bin/pos-cli-constants-unset.js @@ -3,7 +3,7 @@ import { program } from '../lib/program.js'; import Gateway from '../lib/proxy.js'; import { existence as validateExistence } from '../lib/validators/index.js'; -import queries from '../lib/graph/queries.js'; +import { unsetConstant } from '../lib/graph/queries.js'; import { fetchSettings } from '../lib/settings.js'; import logger from '../lib/logger.js'; @@ -37,7 +37,7 @@ program const gateway = new Gateway(authData); gateway - .graph({query: queries.unsetConstant(params.name)}) + .graph({query: unsetConstant(params.name)}) .then(success) .catch(error); }); diff --git a/test/unit/queries.test.js b/test/unit/queries.test.js new file mode 100644 index 00000000..527edf96 --- /dev/null +++ b/test/unit/queries.test.js @@ -0,0 +1,23 @@ +/** + * Unit tests for lib/graph/queries.js + * Verifies queries produce valid GraphQL strings. + */ +import { describe, test, expect } from 'vitest'; +import { getConstants, setConstant, unsetConstant } from '../../lib/graph/queries.js'; + +describe('graph/queries', () => { + test('getConstants returns a query for all constants', () => { + const query = getConstants(); + expect(query).toContain('query getConstants'); + expect(query).toContain('constants(per_page: 99)'); + expect(query).toContain('results { name, value, updated_at }'); + }); + + test('setConstant builds a mutation with interpolated name and value', () => { + expect(setConstant('API_KEY', 'secret123')).toContain('constant_set(name: "API_KEY", value: "secret123")'); + }); + + test('unsetConstant builds a mutation with interpolated name', () => { + expect(unsetConstant('API_KEY')).toContain('constant_unset(name: "API_KEY")'); + }); +}); From c1bbc22db25a51945b14eca0c1bbc232d811959c Mon Sep 17 00:00:00 2001 From: Maciej Krajowski-Kukiel Date: Wed, 18 Mar 2026 14:35:59 +0100 Subject: [PATCH 3/3] fix ci --- test/unit/generators.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/unit/generators.test.js b/test/unit/generators.test.js index f12fc173..89ef5fbb 100644 --- a/test/unit/generators.test.js +++ b/test/unit/generators.test.js @@ -21,6 +21,12 @@ const execCommand = (cmd, opts = {}) => { return resolve({ stdout, stderr, code }); }); + // Close stdin immediately so generators that prompt for missing required + // arguments receive EOF instead of hanging waiting for user input. + if (child.stdin) { + child.stdin.end(); + } + if (opts.timeout) { setTimeout(() => { child.kill();