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
10 changes: 10 additions & 0 deletions core/accounts/src/owner-connections.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { managedInstallationContext } = require('./installation-authority');
const { SERVICES, service, credentialsFor, verificationInput, connectionView, connectionList, REASONS } = require('../../../shared/contracts/src/connections');

function createOwnerConnections({ store, access, invoke, clock = Date.now, paycomSetup = null }) {
const onboarding = require('./onboarding-store').createOnboardingStore(store, clock);
function context(session) {
const { organization } = access.requireDspOwner(session);
const selected = managedInstallationContext(store, organization.id);
Expand Down Expand Up @@ -36,6 +37,15 @@ function createOwnerConnections({ store, access, invoke, clock = Date.now, payco
return { items: listed.items.filter(item => {
const owner = require('../../../shared/plugin-sdk/catalog').catalog().find(plugin => plugin.services.includes(item.service));
return !owner || require('./plugins').available(store, selected.organization.id, owner.id);
}).map(item => {
// A confirmed save can outlive a lost check acknowledgement. Its
// durable onboarding request still owns the pending verification.
if (item.service === 'paycom' && item.state === 'not_verified') {
const pending = onboarding.latest(selected.organization.id);
if (['queued', 'running'].includes(pending?.status)) return { ...item, state: 'checking', reason: null };
if (pending?.status === 'failed') return { ...item, state: 'temporarily_unavailable', reason: 'auth_unavailable' };
}
return item;
}) };
}
if (result.status !== 'accepted') throw new Error();
Expand Down
27 changes: 19 additions & 8 deletions core/accounts/src/owner-paycom-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { createAccessInstallationActivationAuthority } = require('./installation-
const { createOnboardingStore } = require('./onboarding-store');
function fail(code, status = 409) { throw new AccessError(code, status); }
function createOwnerPaycomSetup({ store, access, invoke, clock = Date.now,
beginVerification = null, readReadiness = null,
enroll = (key, input) => invoke(key, 'paycom.setup', input) }) {
const requests = createOnboardingStore(store, clock);
function context(session) {
Expand Down Expand Up @@ -40,7 +41,8 @@ function createOwnerPaycomSetup({ store, access, invoke, clock = Date.now,
|| !['failed', 'ready', 'verifying', 'waiting_for_provider_auth'].includes(selected.installation.status)) return result;
let readiness;
try {
const response = await invoke(selected.manifest.runtime.key, 'paycom.setup', {
const response = readReadiness ? await readReadiness(selected.manifest.runtime.key)
: await invoke(selected.manifest.runtime.key, 'paycom.setup', {
command: 'status', requestId: row.id, step: 'readiness', manifest: selected.manifest,
manifestAuthority: selected.manifestAuthority, parameters: {},
});
Expand Down Expand Up @@ -134,13 +136,22 @@ function createOwnerPaycomSetup({ store, access, invoke, clock = Date.now,
const row = requests.latest(selected.organization.id);
const readiness = await status(session);
if (!readiness.canRetry) fail('installation_operation_not_allowed');
const current = context(session);
const latest = requests.latest(current.organization.id);
if (latest?.id !== row?.id || latest?.fence !== row?.fence || latest?.status !== 'failed'
|| current.installation.revision !== selected.installation.revision
|| current.installation.status !== selected.installation.status
|| JSON.stringify(current.manifest) !== JSON.stringify(selected.manifest)
|| store.activeLifecycleJob(current.organization.id)) fail('installation_operation_in_progress');
const guard = () => {
const current = context(session);
const latest = requests.latest(current.organization.id);
if (latest?.id !== row?.id || latest?.fence !== row?.fence || latest?.status !== 'failed'
|| current.installation.revision !== selected.installation.revision
|| current.installation.status !== selected.installation.status
|| JSON.stringify(current.manifest) !== JSON.stringify(selected.manifest)
|| store.activeLifecycleJob(current.organization.id)) fail('installation_operation_in_progress');
};
guard();
// An owner retry starts a fresh check before the worker sees the request.
if (beginVerification) {
try { await beginVerification(selected.manifest.runtime.key); }
catch { fail('auth_unavailable', 503); }
guard();
}
if (['ready', 'verifying', 'waiting_for_provider_auth'].includes(selected.installation.status)) {
store.transaction(() => {
const row = requests.latest(selected.organization.id);
Expand Down
7 changes: 5 additions & 2 deletions core/api/directory-platform.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,14 @@ async function startDirectoryApi({ paths, installation, host, port = 4310, addre
? require('../../host/plugins/directory-lifecycle').createDirectoryInstallation({ paths, manager: runtime.manager, execution, store }) : null;
const plugins = require('../accounts/src/plugins').createPluginService({ store, access, invoke, installationCoordinator,
settingsPort: runtime.manager.pluginBackend ? (id,pluginId,request) => runtime.manager.pluginBackend.request(id,'plugin.settings',{pluginId,request}) : null });
const verification = runtime.manager.pluginBackend ? require('../../host/controller/paycom-verification')
.createPaycomVerification({ backend: runtime.manager.pluginBackend }) : null;
const paycomSetup = createOwnerPaycomSetup({ store, access, invoke,
...(runtime.manager.pluginBackend ? { enroll: require('../../host/controller/paycom-enrollment')
.createPaycomEnrollment({ backend: runtime.manager.pluginBackend }) } : {}) });
.createPaycomEnrollment({ backend: runtime.manager.pluginBackend, verification }),
beginVerification: verification.start, readReadiness: verification.readiness } : {}) });
const connections = createOwnerConnections({ store, access, invoke, paycomSetup });
const onboarding = createOwnerOnboardingWorker({ store, invoke, backends: [BACKEND] });
const onboarding = createOwnerOnboardingWorker({ store, invoke, backends: [BACKEND], testProvider: verification?.poll });
const backups = new ManualBackups({ paths, store, access });
const deletions = new (require('../../host/controller/deletion').DirectoryDeletion)({ paths, store,
manager: runtime.manager, backups, execution, onError });
Expand Down
8 changes: 6 additions & 2 deletions core/auth-broker/tests/enrollment.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,23 @@ const input = () => ({ command: 'enroll', requestId: 'setup_' + 'b'.repeat(32),
test('direct enrollment forwards only to the selected DSP and confirms the vault acknowledgement', async () => {
const calls = [];
const enroll = createPaycomEnrollment({ backend: { request: async (...args) => {
calls.push(args); return { ok: true, status: 'configured' };
calls.push(args); return args[2].action === 'enroll-paycom' ? { ok: true, status: 'configured' }
: { ok: true, status: 'accepted', connection: { service: 'paycom', configured: true, state: 'checking', checkedAt: null, reason: null, retryAt: null } };
} } });
assert.deepEqual(await enroll(dsp, input()), { contractVersion: 1, ok: true, status: 'succeeded', data: { configured: true } });
assert.equal(calls[0][0], dsp); assert.equal(calls[0][1], 'auth.request');
assert.equal(calls[0][2].action, 'enroll-paycom'); assert.equal(calls[0][2].intent, 'create');
assert.ok(calls[0][3].signal instanceof AbortSignal);
assert.equal((await enroll(dsp, { ...input(), expiresAt: Date.now() - 1 })).status, 'invalid_input');
assert.equal(calls.length, 1);
assert.equal(calls.length, 2);
assert.equal(calls[1][0], dsp);
assert.deepEqual(calls[1][2], { action: 'connections', input: { command: 'test', service: 'paycom' } });
});

test('only an explicit missing profile permits create fallback for replacement', async () => {
let status = 'profile_not_configured'; const intents = [];
const enroll = createPaycomEnrollment({ backend: { request: async (_id, _op, request) => {
if (request.action !== 'enroll-paycom') throw new Error('check transport unavailable');
intents.push(request.intent);
return request.intent === 'create' ? { ok: true, status: 'configured' } : { ok: false, status };
} } });
Expand Down
72 changes: 72 additions & 0 deletions core/auth-broker/tests/paycom-verification.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createPaycomVerification } = require('../../../host/controller/paycom-verification');
const { createPaycomEnrollment } = require('../../../host/controller/paycom-enrollment');
const dsp = 'dsp_' + 'a'.repeat(32);
const blank = service => ({ service, configured: false, state: 'not_connected', checkedAt: null, reason: null, retryAt: null });
function fixture() {
const calls = [];
let paycom = { ...blank('paycom'), configured: true, state: 'not_verified' };
const backend = { request: async (id, operation, request) => {
assert.equal(id, dsp); assert.equal(operation, 'auth.request'); calls.push(request);
if (request.action === 'enroll-paycom') return { ok: true, status: 'configured' };
if (request.input.command === 'list') return { ok: true, status: 'found', items: [blank('cortex'), paycom] };
paycom = { ...paycom, state: 'checking', reason: null };
return { ok: true, status: 'accepted', connection: paycom };
} };
return { calls, backend, verification: createPaycomVerification({ backend }),
set: value => { paycom = { ...paycom, ...value }; } };
}

test('onboarding starts an unsent check, polls it without duplicate tests, and consumes fresh evidence', async () => {
const f = fixture();
for (let i = 0; i < 3; i++) assert.equal((await f.verification.poll(dsp)).status, 'running');
const checkedAt = new Date().toISOString();
f.set({ state: 'connected', checkedAt });
assert.deepEqual((await f.verification.poll(dsp)).data,
{ profileId: 'paycom-main', provider: 'paycom', status: 'authenticated', testedAt: checkedAt });
assert.equal(f.calls.filter(call => call.input?.command === 'test').length, 1);
assert.equal(JSON.stringify(f.calls).includes('credentials'), false);
});

test('interrupted and stale checks recover, but rejected credentials do not trigger login loops', async () => {
for (const state of [
{ state: 'temporarily_unavailable', reason: 'check_interrupted' },
{ state: 'connected', checkedAt: '2000-01-01T00:00:00.000Z' },
]) {
const f = fixture(); f.set(state);
assert.equal((await f.verification.poll(dsp)).status, 'running');
assert.equal(f.calls.filter(call => call.input?.command === 'test').length, 1);
}
const f = fixture(); f.set({ state: 'credentials_rejected', reason: 'invalid_credentials' });
for (let i = 0; i < 3; i++) assert.equal((await f.verification.poll(dsp)).status, 'invalid_credentials');
assert.ok(f.calls.every(call => call.input.command === 'list'));
});

test('a busy broker check is observed and malformed or unavailable responses never report success', async () => {
const f = fixture(); f.set({ state: 'checking' });
const original = f.backend.request;
f.backend.request = (...args) => args[2].input.command === 'test'
? { ok: false, status: 'session_busy' } : original(...args);
assert.equal((await f.verification.start(dsp)).state, 'checking');
for (const response of [{ ok: false, status: 'service_unavailable' }, { ok: true, status: 'found', items: [] }]) {
f.backend.request = async () => response;
assert.equal((await f.verification.poll(dsp)).ok, false);
}
});

test('a lost check acknowledgement preserves the confirmed save and onboarding joins the running check', async () => {
const f = fixture(); const original = f.backend.request;
f.backend.request = async (...args) => {
const response = await original(...args);
if (args[2].input?.command === 'test') throw new Error('lost check response');
return response;
};
const enroll = createPaycomEnrollment({ backend: f.backend, verification: f.verification });
assert.equal((await enroll(dsp, { command: 'enroll', requestId: 'setup_' + 'b'.repeat(32), expiresAt: Date.now() + 30000,
intent: 'create', credentials: { clientCode: 'synthetic', username: 'synthetic', password: 'synthetic-secret',
pin1: 'one', pin2: 'two', pin3: 'three', pin4: 'four', pin5: 'five' } })).ok, true);
assert.equal((await f.verification.poll(dsp)).status, 'running');
assert.equal(f.calls.filter(call => call.input?.command === 'test').length, 1);
});
4 changes: 3 additions & 1 deletion core/installations/src/owner-onboarding.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { providerEvidence } = require('./activation');
const { setupFailure } = require('../../../shared/contracts/src/paycom-setup');
function fail(code = 'installation_not_ready') { throw Object.assign(new Error(code), { code }); }
function createOwnerOnboardingWorker({ store, invoke, backends = ['oci_container_v1', 'native_service_v1'], clock = Date.now,
testProvider = null,
delay = ms => new Promise(resolve => setTimeout(resolve, ms)) }) {
const requests = createOnboardingStore(store, clock);
async function run(id, workerId) {
Expand Down Expand Up @@ -38,7 +39,8 @@ function createOwnerOnboardingWorker({ store, invoke, backends = ['oci_container
let step = 'test';
for (;;) {
guard();
const result = await invoke(selected.manifest.runtime.key, 'paycom.setup', {
const result = step === 'test' && testProvider ? await testProvider(selected.manifest.runtime.key)
: await invoke(selected.manifest.runtime.key, 'paycom.setup', {
command, requestId, step, manifest: selected.manifest,
manifestAuthority: selected.manifestAuthority, parameters: {},
});
Expand Down
14 changes: 12 additions & 2 deletions dashboard/examples/frontend-preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,15 @@ async function main() {
},
};
const runtimePlugins = new Map();
const paycomManifest=require('../../tests/fixtures/paycom-plugin.json');
// Core-only UI checks need catalog metadata but do not install a DSP fixture.
// Plugin browser checks use the explicitly installed DSP test package.
let paycomFixtureRoot = null;
try { paycomFixtureRoot = path.dirname(require.resolve('dispatch-dsp/plugins/paycom/dispatch-plugin.json')); }
catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error; }
const paycomManifest = paycomFixtureRoot ? require(path.join(paycomFixtureRoot, 'dispatch-plugin.json'))
: require('../../tests/fixtures/paycom-plugin.json');
require('../../shared/plugin-sdk/catalog').configureCatalog(() => [paycomManifest]);
require('dispatch-protocol/plugin-sdk/catalog').configureCatalog(() => [paycomManifest]);
const settingsDefinition=paycomManifest.settings;
const settingsFor=id=>require('../../core/plugins/settings-store').settingsStore(path.join(root,id),'paycom');
const published=path.join(root,'published/paycom.sqlite3');
Expand Down Expand Up @@ -294,10 +302,12 @@ async function main() {
const server = createDashboardServer({ client, access, updates, backups, paycomSetup, connections, plugins, releasePopup, turnstile, invitationDelivery,
pluginAssets: async ({ pluginId, revision }) => {
if (pluginId !== 'paycom') throw new Error('plugin_unavailable');
if (!paycomFixtureRoot) throw new Error('dsp_test_fixture_required');
const directory = path.join(root, 'frontend', pluginId);
if (!fs.existsSync(path.join(directory, 'index.js'))) {
const { buildFrontend } = await import('../../tooling/build-plugin-frontend.mjs');
await buildFrontend({ pluginRoot: path.resolve(__dirname, '../../plugins', pluginId), output: directory });
await buildFrontend({ pluginRoot: paycomFixtureRoot,
output: directory, toolsRoot: path.resolve(__dirname, '..') });
}
return { id: pluginId, version: paycomManifest.version, revision, javascript: fs.readFileSync(path.join(directory, 'index.js'), 'utf8'), stylesheet: fs.readFileSync(path.join(directory, 'styles.css'), 'utf8') };
},
Expand Down
2 changes: 1 addition & 1 deletion dashboard/frontend/src/pages/Connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export function Connections() {
? values
: {},
);
if (action === "test") {
if (action === "test" || action === "save") {
queryClient.setQueryData<ConnectionsData>(
["connections", membership?.organizationId],
(previous) =>
Expand Down
2 changes: 1 addition & 1 deletion dashboard/public/assets/frontend.js

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion dashboard/tests/browser/connections-persistence.spec.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ async function login(page, f) {

for (const mobile of [false, true]) test(`form credentials reach a directory DSP vault without a runtime slot (${mobile ? 'mobile' : 'desktop'})`, async ({ page }) => {
const f = await createConnectionsStack({ directoryEnrollment: true });
let finishPaycom;
const errors = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
Expand All @@ -37,6 +38,9 @@ for (const mobile of [false, true]) test(`form credentials reach a directory DSP
await cortex.getByRole('button', { name: 'Update credentials' }).click();
await expect(dialog.getByLabel('Amazon password')).toHaveValue('');
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
let paycomAttempts = 0;
const pendingPaycom = new Promise(resolve => { finishPaycom = resolve; });
f.state.authentication = async () => { paycomAttempts++; await pendingPaycom; return { status: 'authenticated' }; };
await page.getByRole('button', { name: 'Connect Paycom', exact: true }).click();
const paycom = { clientCode: 'form-client', username: 'form-paycom-owner', password: 'form-paycom-secret',
pin1: 'one', pin2: 'two', pin3: 'three', pin4: 'four', pin5: 'five' };
Expand All @@ -49,16 +53,24 @@ for (const mobile of [false, true]) test(`form credentials reach a directory DSP
expect((await paycomSaved).status()).toBe(202);
expect(f.state.runtimeEnrollments).toBe(0);
await expect(dialog).toHaveCount(0);
const paycomCard = page.locator('[data-slot="card"]').filter({ has: page.getByText('Paycom', { exact: true }) });
await expect(paycomCard.getByText('Checking session', { exact: true })).toBeVisible();
await expect(paycomCard.getByText('Not verified', { exact: true })).toHaveCount(0);
expect(paycomAttempts).toBe(1);
await page.screenshot({ path: `/tmp/dispatch-paycom-checking-${mobile ? 'mobile' : 'desktop'}.png`, fullPage: true });
finishPaycom();
await expect(paycomCard.getByText('Connected', { exact: true })).toBeVisible();
await f.restartBroker();
expect(f.state.broker.vault.readForAdapter('paycom-main').credentials).toEqual(paycom);
await page.reload();
await expect(paycomCard.getByText('Connected', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Update credentials', exact: true })).toHaveCount(2);
expect(await page.evaluate(() => JSON.stringify([localStorage, sessionStorage]))).not.toContain(credentials.password);
expect(await page.evaluate(() => JSON.stringify([localStorage, sessionStorage]))).not.toContain(paycom.password);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy();
await page.screenshot({ path: `/tmp/dispatch-save-verified-${mobile ? 'mobile' : 'desktop'}.png`, fullPage: true });
expect(errors).toEqual([]);
} finally { await page.close(); await f.close(); }
} finally { finishPaycom?.(); await page.close(); await f.close(); }
});

for (const mobile of [false, true]) test(`an unconfirmed Paycom save stays dismissible during a stalled status check (${mobile ? 'mobile' : 'desktop'})`, async ({ page }) => {
Expand Down
Loading