From 598cb4b54a68e8e5103b12238004e509f6cfb326 Mon Sep 17 00:00:00 2001 From: Jose Truyol Date: Fri, 7 Aug 2026 13:10:35 -0700 Subject: [PATCH 1/5] fix: resolve organisation without depending on a user record CLI tokens are OAuth app authorizations with no linked user, and /authenticate returns identity flat at the top level with no organisation record, so start crashed in informLocalDev and create 404d on /users/me. Read the organisation from .raisely.json (falling back to /authenticate), fetch the org record separately for the localDevelopment flag and fail open on the expected 403, and skip the organisation switch when the session identifies no user instead of calling /users/null/move. --- src/actions/auth.js | 18 +++- src/actions/components.js | 45 ++++++++-- src/helpers.js | 23 ++++- tests/auth.test.js | 143 ++++++++++++++++++++++++++++++ tests/components.test.js | 181 ++++++++++++++++++++++++++++++++++++++ tests/helpers.test.js | 145 ++++++++++++++++++++++++++++++ 6 files changed, 545 insertions(+), 10 deletions(-) create mode 100644 tests/auth.test.js create mode 100644 tests/components.test.js create mode 100644 tests/helpers.test.js diff --git a/src/actions/auth.js b/src/actions/auth.js index df1ac27..42a290f 100644 --- a/src/actions/auth.js +++ b/src/actions/auth.js @@ -33,9 +33,14 @@ async function checkCorrectOrganisation(orgUuid, opts) { const authData = await api({ path: '/authenticate', }); - if (authData.organisationUuid !== organisationUuid) { + // /authenticate returns identity flat at the top level. It also has a + // `data` key, but that holds OAuth authorization metadata + // (type/scopes/appUuid/authorizationUuid), not identity, so it must not + // be treated as a response envelope. + const authBody = authData ?? {}; + if (authBody.organisationUuid !== organisationUuid) { log( - `This configuration is for organisation ${organisationUuid} but you are currently in organisation ${authData.organisationUuid}`, + `This configuration is for organisation ${organisationUuid} but you are currently in organisation ${authBody.organisationUuid}`, 'white' ); const response = await inquirer.prompt([ @@ -46,12 +51,19 @@ async function checkCorrectOrganisation(orgUuid, opts) { }, ]); if (response.confirm) { + if (!authBody.userUuid) { + log( + 'Your session does not identify a user, so the CLI cannot switch organisations for you. Switch organisation in the Raisely admin, then run raisely init again.', + 'red' + ); + return; + } const loader = ora( 'Switching to correct organisation ...' ).start(); try { await api({ - path: `/users/${authData.userUuid}/move`, + path: `/users/${authBody.userUuid}/move`, method: 'PUT', json: { data: { diff --git a/src/actions/components.js b/src/actions/components.js index 01e41bb..6132529 100644 --- a/src/actions/components.js +++ b/src/actions/components.js @@ -1,5 +1,6 @@ import api from './api.js'; import { loadBabelCore } from './babel.js'; +import { loadConfig } from '../config.js'; import path from 'path'; import fs from 'fs'; @@ -15,11 +16,45 @@ async function getComponent(uuid, opts = {}) { }); } +async function resolveOrganisationUuid() { + const config = await loadConfig({ allowEmpty: true }); + if (config.organisationUuid) { + return config.organisationUuid; + } + + try { + const authData = await api({ + path: '/authenticate', + }); + // /authenticate returns organisationUuid flat at the top level; its + // `data` key holds OAuth authorization metadata, not identity. + // `/users/me` is not an option here: CLI tokens are app + // authorizations with no user record, so it resolves `me` to the + // authorization uuid and 404s. + if (authData?.organisationUuid) { + return authData.organisationUuid; + } + } catch { + // fall through to actionable error below + } + + return null; +} + +const ORGANISATION_RESOLUTION_ERROR = [ + 'The CLI could not resolve your Raisely organisation.', + 'Try signing out and back in, then re-initialize this directory if needed:', + ' raisely logout', + ' raisely login', + ' raisely init', + 'If you already have a .raisely.json here, make sure it includes organisationUuid (re-run raisely init to refresh it).', +].join('\n'); + export async function createComponent({ name, apiUrl }, opts = {}) { - // fetch the organisation ID - const user = await api({ - path: '/users/me', - }); + const organisationUuid = await resolveOrganisationUuid(); + if (!organisationUuid) { + throw ORGANISATION_RESOLUTION_ERROR; + } return await api({ path: `/components?private=1`, @@ -27,7 +62,7 @@ export async function createComponent({ name, apiUrl }, opts = {}) { json: { data: { name, - organisationUuid: user.data.organisationUuid, + organisationUuid, }, }, }); diff --git a/src/helpers.js b/src/helpers.js index 6548f8e..ed388b7 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -87,8 +87,27 @@ export async function informLocalDev(config) { const authData = await api({ path: '/authenticate', }); - const organisation = authData.data.organisation; - if (!organisation.private || !organisation.private.localDevelopment) { + // /authenticate carries identity flat at the top level but no organisation + // record, so the localDevelopment flag has to be read from the org itself. + const organisationUuid = + config?.organisationUuid || authData?.organisationUuid; + + let organisation; + if (organisationUuid) { + try { + const orgResponse = await api({ + path: `/organisations/${organisationUuid}?private=1`, + }); + organisation = orgResponse?.data; + } catch (e) { + // No OAuth app scope grants reading an organisation record, so this + // is a 403 for any CLI login and only succeeds for admin tokens + // supplied via RAISELY_TOKEN. The flag is advisory, so skip the + // warning rather than blocking the command. + } + } + + if (!organisation?.private?.localDevelopment) { // this is fine, we can continue without warning return true; } diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..83a5862 --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const ora = vi.fn((label) => { + const loader = { + label, + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + warn: vi.fn(), + }; + loader.start.mockReturnValue(loader); + return loader; + }); + + return { + api: vi.fn(), + updateConfig: vi.fn(), + getCredentials: vi.fn(), + inquirerPrompt: vi.fn(), + log: vi.fn(), + error: vi.fn(), + ora, + }; +}); + +vi.mock('../src/actions/api.js', () => ({ + default: mocks.api, +})); + +vi.mock('../src/config.js', () => ({ + updateConfig: mocks.updateConfig, +})); + +vi.mock('../src/credentials.js', () => ({ + getCredentials: mocks.getCredentials, +})); + +vi.mock('../src/helpers.js', () => ({ + log: mocks.log, + error: mocks.error, +})); + +vi.mock('inquirer', () => ({ + default: { + prompt: mocks.inquirerPrompt, + }, +})); + +vi.mock('ora', () => ({ + default: mocks.ora, +})); + +import { getToken } from '../src/actions/auth.js'; + +/** + * Shape observed from the live API: identity is flat at the top level, and the + * `data` key holds OAuth authorization metadata rather than an envelope. + */ +function authenticateResponse({ organisationUuid, userUuid }) { + return { + campaigns: [], + roles: [], + userUuid, + userEmail: 'fundraiser@example.org', + organisationUuid, + data: { + type: 'oauth', + scopes: ['campaigns'], + appUuid: 'app-1', + authorizationUuid: 'authorization-1', + }, + }; +} + +describe('getToken', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCredentials.mockResolvedValue({ token: 'token-123' }); + }); + + test('does not offer to switch organisation when the authenticated organisation already matches the config', async () => { + mocks.api.mockResolvedValue( + authenticateResponse({ + organisationUuid: 'org-1', + userUuid: 'user-1', + }) + ); + + await getToken({}, { organisationUuid: 'org-1' }); + + expect(mocks.inquirerPrompt).not.toHaveBeenCalled(); + expect( + mocks.api.mock.calls.some(([opts]) => + opts.path.startsWith('/users/') + ) + ).toBe(false); + }); + + test('moves the signed-in user by uuid when the organisation genuinely differs', async () => { + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/authenticate') { + return authenticateResponse({ + organisationUuid: 'org-other', + userUuid: 'user-1', + }); + } + return {}; + }); + mocks.inquirerPrompt.mockResolvedValue({ confirm: true }); + + await getToken({}, { organisationUuid: 'org-1' }); + + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/users/user-1/move', + method: 'PUT', + json: { data: { organisationUuid: 'org-1' } }, + }) + ); + }); + + test('never builds a move path from an undefined user uuid', async () => { + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/authenticate') { + return authenticateResponse({ + organisationUuid: 'org-other', + userUuid: 'user-1', + }); + } + return {}; + }); + mocks.inquirerPrompt.mockResolvedValue({ confirm: true }); + + await getToken({}, { organisationUuid: 'org-1' }); + + expect( + mocks.api.mock.calls.some(([opts]) => + opts.path.includes('undefined') + ) + ).toBe(false); + }); +}); diff --git a/tests/components.test.js b/tests/components.test.js new file mode 100644 index 0000000..33eda91 --- /dev/null +++ b/tests/components.test.js @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + api: vi.fn(), + loadConfig: vi.fn(), +})); + +vi.mock('../src/actions/api.js', () => ({ + default: mocks.api, +})); + +vi.mock('../src/config.js', () => ({ + loadConfig: mocks.loadConfig, +})); + +import { createComponent } from '../src/actions/components.js'; + +const USERS_ME_404 = + 'https://api.raisely.com/v3/users/me (404) failed with message: user with uuid 5be64da0-xxxx... was not found'; + +describe('createComponent', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadConfig.mockResolvedValue({}); + }); + + test('throws a clear error when organisation cannot be resolved', async () => { + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/users/me') { + throw USERS_ME_404; + } + if (path === '/authenticate') { + return { data: {} }; + } + }); + + await expect(createComponent({ name: 'my-widget' })).rejects.toMatch( + /could not resolve your Raisely organisation/i + ); + await expect(createComponent({ name: 'my-widget' })).rejects.toMatch( + /raisely logout/ + ); + await expect(createComponent({ name: 'my-widget' })).rejects.toMatch( + /raisely login/ + ); + await expect(createComponent({ name: 'my-widget' })).rejects.toMatch( + /raisely init/ + ); + await expect(createComponent({ name: 'my-widget' })).rejects.not.toBe( + USERS_ME_404 + ); + expect( + mocks.api.mock.calls.some(([opts]) => opts.path === '/users/me') + ).toBe(false); + }); + + test('uses organisationUuid from config without calling /users/me', async () => { + mocks.loadConfig.mockResolvedValue({ + organisationUuid: 'org-from-config', + }); + + mocks.api.mockImplementation(async ({ path, method, json }) => { + if (path === '/users/me') { + throw new Error('/users/me should not be called'); + } + if (path === '/components?private=1' && method === 'POST') { + return { data: { uuid: 'component-1' } }; + } + throw new Error(`Unexpected api call: ${method} ${path}`); + }); + + await createComponent({ name: 'my-widget' }); + + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/components?private=1', + method: 'POST', + json: { + data: { + name: 'my-widget', + organisationUuid: 'org-from-config', + }, + }, + }) + ); + expect( + mocks.api.mock.calls.some(([opts]) => opts.path === '/users/me') + ).toBe(false); + }); + + test('falls back to organisationUuid from /authenticate when config has none', async () => { + mocks.loadConfig.mockResolvedValue({}); + + mocks.api.mockImplementation(async ({ path, method }) => { + if (path === '/users/me') { + throw new Error('/users/me should not be called'); + } + if (path === '/authenticate') { + return { organisationUuid: 'org-from-auth' }; + } + if (path === '/components?private=1' && method === 'POST') { + return { data: { uuid: 'component-1' } }; + } + throw new Error(`Unexpected api call: ${method} ${path}`); + }); + + await createComponent({ name: 'my-widget' }); + + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ path: '/authenticate' }) + ); + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/components?private=1', + method: 'POST', + json: { + data: { + name: 'my-widget', + organisationUuid: 'org-from-auth', + }, + }, + }) + ); + }); + + test('ignores the OAuth metadata under data when reading /authenticate', async () => { + mocks.loadConfig.mockResolvedValue({}); + + mocks.api.mockImplementation(async ({ path, method }) => { + if (path === '/authenticate') { + return { + userUuid: 'user-1', + organisationUuid: 'org-from-auth', + data: { + type: 'oauth', + scopes: ['campaigns'], + appUuid: 'app-1', + authorizationUuid: 'authorization-1', + }, + }; + } + if (path === '/components?private=1' && method === 'POST') { + return { data: { uuid: 'component-1' } }; + } + throw new Error(`Unexpected api call: ${method} ${path}`); + }); + + await createComponent({ name: 'my-widget' }); + + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/components?private=1', + method: 'POST', + json: { + data: { + name: 'my-widget', + organisationUuid: 'org-from-auth', + }, + }, + }) + ); + }); + + test('throws a clear error when /authenticate fails', async () => { + const authenticateError = + 'https://api.raisely.com/v3/authenticate (401) failed with message: Unauthorized'; + + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/authenticate') { + throw authenticateError; + } + }); + + await expect(createComponent({ name: 'my-widget' })).rejects.toMatch( + /could not resolve your Raisely organisation/i + ); + await expect(createComponent({ name: 'my-widget' })).rejects.not.toBe( + authenticateError + ); + }); +}); diff --git a/tests/helpers.test.js b/tests/helpers.test.js new file mode 100644 index 0000000..2a86404 --- /dev/null +++ b/tests/helpers.test.js @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + api: vi.fn(), + inquirerPrompt: vi.fn(), +})); + +vi.mock('../src/actions/api.js', () => ({ + default: mocks.api, +})); + +vi.mock('inquirer', () => ({ + default: { + prompt: mocks.inquirerPrompt, + }, +})); + +import { informLocalDev } from '../src/helpers.js'; + +/** + * Shape observed from the live API: identity is flat at the top level, the + * `data` key holds OAuth authorization metadata, and there is no organisation + * record anywhere in the payload. + */ +const AUTHENTICATE_RESPONSE = { + campaigns: [], + roles: [], + userUuid: 'user-1', + userEmail: 'fundraiser@example.org', + organisationUuid: 'org-1', + data: { + type: 'oauth', + scopes: ['campaigns'], + appUuid: 'app-1', + authorizationUuid: 'authorization-1', + }, +}; + +function mockApi({ organisation, organisationError }) { + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/authenticate') return AUTHENTICATE_RESPONSE; + if (path.startsWith('/organisations/')) { + if (organisationError) throw organisationError; + return { data: organisation }; + } + throw new Error(`Unexpected api call: ${path}`); + }); +} + +function captureConsole() { + const lines = []; + const spy = vi.spyOn(console, 'log').mockImplementation((message) => { + lines.push(String(message)); + }); + return { + lines, + restore() { + spy.mockRestore(); + }, + }; +} + +describe('informLocalDev', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('reads the localDevelopment flag from the organisation record, not from /authenticate', async () => { + mockApi({ organisation: { uuid: 'org-1', private: {} } }); + + await expect( + informLocalDev({ organisationUuid: 'org-1' }) + ).resolves.toBe(true); + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ path: '/organisations/org-1?private=1' }) + ); + expect(mocks.inquirerPrompt).not.toHaveBeenCalled(); + }); + + test('falls back to the organisation uuid from /authenticate when config has none', async () => { + mockApi({ organisation: { uuid: 'org-1', private: {} } }); + + await expect(informLocalDev({})).resolves.toBe(true); + expect(mocks.api).toHaveBeenCalledWith( + expect.objectContaining({ path: '/organisations/org-1?private=1' }) + ); + }); + + test('continues without warning when local development is not required', async () => { + mockApi({ organisation: { uuid: 'org-1' } }); + + await expect( + informLocalDev({ organisationUuid: 'org-1' }) + ).resolves.toBe(true); + expect(mocks.inquirerPrompt).not.toHaveBeenCalled(); + }); + + test('prompts when local development is required and user confirms', async () => { + mockApi({ + organisation: { + uuid: 'org-1', + private: { localDevelopment: true }, + }, + }); + mocks.inquirerPrompt.mockResolvedValue({ confirm: true }); + + await expect( + informLocalDev({ organisationUuid: 'org-1' }) + ).resolves.toBe(true); + expect(mocks.inquirerPrompt).toHaveBeenCalledOnce(); + }); + + test('aborts when local development is required and user declines', async () => { + mockApi({ + organisation: { + uuid: 'org-1', + private: { localDevelopment: true }, + }, + }); + mocks.inquirerPrompt.mockResolvedValue({ confirm: false }); + + await expect( + informLocalDev({ organisationUuid: 'org-1' }) + ).resolves.toBe(false); + expect(mocks.inquirerPrompt).toHaveBeenCalledOnce(); + }); + + test('continues when the organisation record cannot be fetched', async () => { + mockApi({ organisationError: 'organisation lookup failed' }); + + await expect( + informLocalDev({ organisationUuid: 'org-1' }) + ).resolves.toBe(true); + expect(mocks.inquirerPrompt).not.toHaveBeenCalled(); + }); + + test('does not crash when /authenticate carries no organisation record', async () => { + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/authenticate') return AUTHENTICATE_RESPONSE; + throw new Error('organisation unavailable'); + }); + + await expect(informLocalDev({})).resolves.toBe(true); + }); +}); From b55f425f5537afe9d1dfb5a0d9f06e3352cc939d Mon Sep 17 00:00:00 2001 From: Jose Truyol Date: Fri, 7 Aug 2026 13:10:56 -0700 Subject: [PATCH 2/5] fix: upload campaign page edits while start is running handleCampaignChange only handled files under /stylesheets, so saving a page JSON was dropped with no upload and no message, leaving the preview unchanged after a refresh with nothing to explain why. Route /pages/*.json through uploadPage, reusing the deploy guards: skip files that are invalid JSON, have no uuid, or belong to a campaign that is not configured, reporting each on the spinner. --- src/start.js | 67 +++++++++++++++++++++++++++++- tests/start.test.js | 99 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 2 deletions(-) diff --git a/src/start.js b/src/start.js index f2d9764..34f760a 100644 --- a/src/start.js +++ b/src/start.js @@ -20,6 +20,7 @@ import { } from './actions/layout.js'; import { uploadStyles } from './actions/campaigns.js'; +import { uploadPage } from './actions/pages.js'; import { updateComponentFile, updateComponentConfig, @@ -32,21 +33,80 @@ function startLoader(message, oraImpl = ora) { return oraImpl(message).start(); } +/** + * Upload a single page JSON, applying the same guards as `raisely deploy`: + * the page must have a uuid and belong to a configured campaign. + */ +async function uploadChangedPage( + filenameRaw, + relative, + { config, fsModule, uploadPageFn, oraFn, errorFn } +) { + const loader = startLoader(`Saving ${relative}`, oraFn); + + let pageData; + try { + pageData = JSON.parse(fsModule.readFileSync(filenameRaw, 'utf8')); + } catch (e) { + loader.fail(`${relative} is not valid JSON, skipping upload`); + return; + } + + if (!pageData.uuid) { + loader.fail(`${relative} has no uuid, skipping upload`); + return; + } + + const campaigns = config?.campaigns ?? []; + if ( + !pageData.campaignUuid || + !campaigns.includes(pageData.campaignUuid) + ) { + loader.fail( + `${relative} does not belong to a configured campaign, skipping upload` + ); + return; + } + + try { + await uploadPageFn(pageData); + loader.succeed(); + } catch (e) { + errorFn(e, loader); + } +} + export async function handleCampaignChange( filenameRaw, { campaignsDir, token, + config, + fsModule = fs, uploadStylesFn = uploadStyles, + uploadPageFn = uploadPage, validateCampaignSassFn = validateCampaignSass, oraFn = ora, + errorFn = error, } = {} ) { const relative = path.relative(campaignsDir, filenameRaw); const parts = relative.split(path.sep); - // Only handle stylesheet changes: /stylesheets/... - if (parts.length < 3 || parts[1] !== 'stylesheets') return; + if (parts.length < 3) return; const campaignPath = parts[0]; + + if (parts[1] === 'pages' && relative.endsWith('.json')) { + return await uploadChangedPage(filenameRaw, relative, { + config, + fsModule, + uploadPageFn, + oraFn, + errorFn, + }); + } + + // Anything else under a campaign other than stylesheets is not uploaded + if (parts[1] !== 'stylesheets') return; const loader = startLoader(`Saving ${relative}`, oraFn); const validation = await validateCampaignSassFn({ campaign: campaignPath, @@ -143,9 +203,12 @@ export function registerStartWatchers( await handleCampaignChange(filenameRaw, { campaignsDir, token: config.token, + config, uploadStylesFn: dependencies.uploadStylesFn, + uploadPageFn: dependencies.uploadPageFn, validateCampaignSassFn: dependencies.validateCampaignSassFn, oraFn: dependencies.oraFn, + errorFn: dependencies.errorFn, }); } ); diff --git a/tests/start.test.js b/tests/start.test.js index fdc3c71..5d75dff 100644 --- a/tests/start.test.js +++ b/tests/start.test.js @@ -29,6 +29,105 @@ function createOraHarness() { return { oraFn, loaders }; } +describe('start page uploads', () => { + function pageFs(contents) { + return { + readFileSync() { + return contents; + }, + }; + } + + test('saving a page uploads it', async () => { + const uploaded = []; + const { oraFn, loaders } = createOraHarness(); + + await handleCampaignChange( + '/repo/campaigns/acme/pages/home.json', + { + campaignsDir: '/repo/campaigns', + config: { campaigns: ['campaign-1'] }, + fsModule: pageFs( + '{"uuid":"page-1","campaignUuid":"campaign-1","title":"Home"}' + ), + uploadPageFn: async (pageData) => { + uploaded.push(pageData); + }, + oraFn, + } + ); + + assert.equal(uploaded.length, 1); + assert.equal(uploaded[0].uuid, 'page-1'); + assert.equal(loaders[0].succeeded, true); + }); + + test('skips a page belonging to a campaign that is not configured', async () => { + let uploadCalls = 0; + const { oraFn, loaders } = createOraHarness(); + + await handleCampaignChange( + '/repo/campaigns/acme/pages/home.json', + { + campaignsDir: '/repo/campaigns', + config: { campaigns: ['campaign-1'] }, + fsModule: pageFs( + '{"uuid":"page-1","campaignUuid":"other-campaign"}' + ), + uploadPageFn: async () => { + uploadCalls += 1; + }, + oraFn, + } + ); + + assert.equal(uploadCalls, 0); + assert.equal(loaders[0].succeeded, false); + }); + + test('skips a page with no uuid', async () => { + let uploadCalls = 0; + const { oraFn, loaders } = createOraHarness(); + + await handleCampaignChange( + '/repo/campaigns/acme/pages/home.json', + { + campaignsDir: '/repo/campaigns', + config: { campaigns: ['campaign-1'] }, + fsModule: pageFs('{"campaignUuid":"campaign-1"}'), + uploadPageFn: async () => { + uploadCalls += 1; + }, + oraFn, + } + ); + + assert.equal(uploadCalls, 0); + assert.equal(loaders[0].succeeded, false); + }); + + test('does not upload a half-written page that is invalid JSON', async () => { + let uploadCalls = 0; + const { oraFn, loaders } = createOraHarness(); + + await handleCampaignChange( + '/repo/campaigns/acme/pages/home.json', + { + campaignsDir: '/repo/campaigns', + config: { campaigns: ['campaign-1'] }, + fsModule: pageFs('{"uuid":"page-1",'), + uploadPageFn: async () => { + uploadCalls += 1; + }, + oraFn, + } + ); + + assert.equal(uploadCalls, 0); + assert.equal(loaders[0].succeeded, false); + }); +}); + describe('start per-save validation', () => { test('bad SCSS save fails inline and skips upload', async () => { let uploadCalls = 0; From da3f1d956bd5ecf0912e6ccdea9ffc863d6873c7 Mon Sep 17 00:00:00 2001 From: Jose Truyol Date: Fri, 7 Aug 2026 14:09:44 -0700 Subject: [PATCH 3/5] fix: abort the command when the organisation switch cannot happen The no-user branch logged and returned, so getToken carried on and the command ran against the organisation the user had just asked to leave. For deploy that means writing to the wrong organisation. Both failure branches now exit(-1) like the permission check above them, so neither continues and neither leaks a bare stack trace. The test named for an undefined user uuid was still passing userUuid: 'user-1', duplicating the case above it and never reaching the early return. It now omits userUuid, and a new case covers the move request failing. Both fail without the change above. --- src/actions/auth.js | 7 +++++-- tests/auth.test.js | 45 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/actions/auth.js b/src/actions/auth.js index 42a290f..31e7bc2 100644 --- a/src/actions/auth.js +++ b/src/actions/auth.js @@ -51,12 +51,15 @@ async function checkCorrectOrganisation(orgUuid, opts) { }, ]); if (response.confirm) { + // Continuing here would run the command against the + // organisation the user just declined, so abort rather than + // returning. if (!authBody.userUuid) { log( 'Your session does not identify a user, so the CLI cannot switch organisations for you. Switch organisation in the Raisely admin, then run raisely init again.', 'red' ); - return; + process.exit(-1); } const loader = ora( 'Switching to correct organisation ...' @@ -74,7 +77,7 @@ async function checkCorrectOrganisation(orgUuid, opts) { loader.succeed(); } catch (e) { error(e, loader); - throw e; + process.exit(-1); } } } diff --git a/tests/auth.test.js b/tests/auth.test.js index 83a5862..cc22c7e 100644 --- a/tests/auth.test.js +++ b/tests/auth.test.js @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; const mocks = vi.hoisted(() => { const ora = vi.fn((label) => { @@ -74,9 +74,20 @@ function authenticateResponse({ organisationUuid, userUuid }) { } describe('getToken', () => { + let exitSpy; + beforeEach(() => { vi.clearAllMocks(); mocks.getCredentials.mockResolvedValue({ token: 'token-123' }); + // Throwing keeps the rest of the function from running, the way a real + // process.exit would. + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit:${code}`); + }); + }); + + afterEach(() => { + exitSpy.mockRestore(); }); test('does not offer to switch organisation when the authenticated organisation already matches the config', async () => { @@ -120,24 +131,50 @@ describe('getToken', () => { ); }); - test('never builds a move path from an undefined user uuid', async () => { + test('aborts without building a move path when the session identifies no user', async () => { mocks.api.mockImplementation(async ({ path }) => { if (path === '/authenticate') { + // An app-token login: no userUuid at all. return authenticateResponse({ organisationUuid: 'org-other', - userUuid: 'user-1', }); } return {}; }); mocks.inquirerPrompt.mockResolvedValue({ confirm: true }); - await getToken({}, { organisationUuid: 'org-1' }); + await expect( + getToken({}, { organisationUuid: 'org-1' }) + ).rejects.toThrow('process.exit:-1'); + expect( + mocks.api.mock.calls.some(([opts]) => + opts.path.startsWith('/users/') + ) + ).toBe(false); expect( mocks.api.mock.calls.some(([opts]) => opts.path.includes('undefined') ) ).toBe(false); }); + + test('aborts instead of continuing when the move request fails', async () => { + mocks.api.mockImplementation(async ({ path }) => { + if (path === '/authenticate') { + return authenticateResponse({ + organisationUuid: 'org-other', + userUuid: 'user-1', + }); + } + throw new Error('move failed'); + }); + mocks.inquirerPrompt.mockResolvedValue({ confirm: true }); + + await expect( + getToken({}, { organisationUuid: 'org-1' }) + ).rejects.toThrow('process.exit:-1'); + + expect(mocks.error).toHaveBeenCalled(); + }); }); From 9b4ec03bae4005077ce5fa0a163465b6141f5ce4 Mon Sep 17 00:00:00 2001 From: Jose Truyol Date: Fri, 7 Aug 2026 14:46:50 -0700 Subject: [PATCH 4/5] ci: run the test suite on every pull request Runs pnpm test on Node 18, 20, and 22, covering the >=18 floor declared in engines, plus pushes to master and develop so merge results are checked too. Installs with --frozen-lockfile so a stale lockfile fails the run instead of resolving something different from local. --- .github/workflows/test.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d993fb3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Tests + +on: + pull_request: + push: + branches: [master, develop] + +concurrency: + group: tests-${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Node ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 18 is the floor declared in package.json engines + node: [18, 20, 22] + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10.7.1 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm test From 98408b4d4dc267a25436a9b3c4c992721b86b849 Mon Sep 17 00:00:00 2001 From: Jose Truyol Date: Fri, 7 Aug 2026 14:53:18 -0700 Subject: [PATCH 5/5] ci: bump actions off the deprecated node 20 runtime checkout, setup-node, and action-setup all ran on node 20, which runners deprecated in September 2025 and now force onto node 24, warning on every job. The explicit `cache: pnpm` input still works: setup-node v6 narrowed only the automatic packageManager-based caching to npm, which this workflow does not rely on. --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d993fb3..ad9c828 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,13 +20,13 @@ jobs: node: [18, 20, 22] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: version: 10.7.1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node }} cache: pnpm