Files
zeekay 7f26cb96cd fix(clio): correct OAuth refresh + Custom Action nonce to real Clio v4 contract
Post-review corrections verified against Clio's own developer docs and the
official clio/example-third-party-application, fixing three ways the first
cut would have failed against real Clio:

1. OAuth: removed access_type=offline (a Google-ism Clio ignores — it would
   have yielded NO refresh_token). Clio returns a refresh_token on the
   authorization_code grant by default; added the real redirect_on_decline
   option instead. Wired the refresh path into the SPA (ensureClioToken →
   /oauth/refresh) so a long matter session survives token expiry — the
   refresh apparatus is no longer dead code.

2. Custom Action nonce: the nonce is NOT a locally-verifiable HMAC. It is a
   single-use, 60s code that the app must REPLAY to the Clio API, which
   validates server-side (403 on bad/stale). Removed the wrong local HMAC
   verify (and node:crypto from the service); /custom-action now parses the
   deep-link and redirects into the SPA carrying matter_id + the nonce, and
   getMatter replays it (custom_action_nonce query param) on the first
   authenticated call. Corrected the deep-link params to Clio's actual set
   (custom_action_id/user_id/subject_url/custom_action_nonce; subject_url is
   URL-encoded) — subject_id was never sent.

3. Documents: gate downloads on content_type (isTextExtractable) — PDF/DOCX
   (the bulk of legal docs) would have been fed to the model as mojibake,
   wasting the context budget. Binary docs now contribute name + type only.
   The MIME check matches the subtype so DOCX (whose vendor type contains the
   substring "xml") is correctly binary.

tsc clean, 89 vitest tests pass, esbuild build succeeds; re-verified the
browser bundle has zero secret/crypto refs.
2026-07-03 17:21:01 -07:00

59 lines
2.3 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { readEnv, customActionRedirect } from '../src/server';
describe('readEnv', () => {
const good = {
CLIO_CLIENT_ID: 'CID',
CLIO_CLIENT_SECRET: 'SECRET',
CLIO_REDIRECT_URI: 'https://clio.hanzo.ai/oauth/callback',
};
it('reads a complete environment with region + port defaults', () => {
const env = readEnv(good as any);
expect(env.clientId).toBe('CID');
expect(env.clientSecret).toBe('SECRET');
expect(env.redirectUri).toBe('https://clio.hanzo.ai/oauth/callback');
expect(env.region).toBe('us');
expect(env.port).toBe(8787);
});
it('honors CLIO_REGION and PORT overrides', () => {
const env = readEnv({ ...good, CLIO_REGION: 'eu', PORT: '9000' } as any);
expect(env.region).toBe('eu');
expect(env.port).toBe(9000);
});
it('throws fast when a required secret is missing (no silent default)', () => {
expect(() => readEnv({ CLIO_CLIENT_ID: 'x' } as any)).toThrow(/required/);
expect(() => readEnv({ CLIO_CLIENT_ID: 'x', CLIO_CLIENT_SECRET: 'y' } as any)).toThrow(/required/);
});
});
describe('customActionRedirect', () => {
it('redirects a matter deep-link to the SPA carrying matter_id + nonce', () => {
const search = new URLSearchParams({
custom_action_id: '7',
user_id: '42',
subject_url: encodeURIComponent('/api/v4/matters/321'),
custom_action_nonce: 'NONCE1',
});
const dest = customActionRedirect(search);
const u = new URL(dest, 'https://clio.hanzo.ai');
expect(u.pathname).toBe('/index.html');
expect(u.searchParams.get('matter_id')).toBe('321');
expect(u.searchParams.get('custom_action_nonce')).toBe('NONCE1');
});
it('omits the nonce param when Clio sent none', () => {
const search = new URLSearchParams({ subject_url: '/api/v4/matters/9' });
const u = new URL(customActionRedirect(search), 'https://clio.hanzo.ai');
expect(u.searchParams.get('matter_id')).toBe('9');
expect(u.searchParams.has('custom_action_nonce')).toBe(false);
});
it('returns empty for a non-matter deep-link (nothing to open)', () => {
expect(customActionRedirect(new URLSearchParams({ subject_url: '/api/v4/contacts/9' }))).toBe('');
expect(customActionRedirect(new URLSearchParams())).toBe('');
});
});