Files
zeekay a9718dfd67 feat(workday): Hanzo AI for Workday — read-only HCM app
@hanzo/workday: an embedded-app panel + read-only OAuth + API-proxy service
over Workday people data, built on @hanzo/ai + @hanzo/iam via api.hanzo.ai.
Mirrors packages/procore's pure-core shape.

- OAuth2 Authorization Code + refresh against the tenant token endpoint
  (…/ccx/oauth2/{tenant}/token) with HTTP Basic client auth (secret in the
  header, never the body); non-rotating refresh carried forward.
- Workday REST wrappers (staffing v6 workers/orgs/directReports, recruiting v4
  requisitions, absenceManagement v1 absence types) with limit/offset + the
  { total, data } envelope, plus RaaS custom-report reads ({ Report_Entry }).
- Pure people-context assembly with honest budget/truncation windowing.
- Five HR-guardrailed AI actions: summarize worker, draft job description,
  org & headcount, answer HR question, draft review feedback.
- Read-only by design: the proxy is GET-only (405 otherwise); write-back is
  documented as a gated future addition.
- 113 vitest tests; tsc --noEmit clean; esbuild build green.

Registers packages/workday in pnpm-workspace.yaml.
2026-07-04 01:55:15 -07:00

176 lines
6.9 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
basicAuthHeader,
authorizeUrl,
tokenUrl,
tokenExchange,
refreshExchange,
parseTokenResponse,
carryRefreshToken,
tokenExpiresAt,
isExpired,
} from '../src/workday-oauth.js';
const HOST = 'https://wd2-impl-services1.workday.com';
const TENANT = 'acme';
describe('oauth: basicAuthHeader', () => {
it('base64-encodes client_id:client_secret as HTTP Basic', () => {
const h = basicAuthHeader('cid', 'sec');
expect(h).toBe(`Basic ${Buffer.from('cid:sec').toString('base64')}`);
// Decodes back to the pair.
const decoded = Buffer.from(h.replace('Basic ', ''), 'base64').toString('utf8');
expect(decoded).toBe('cid:sec');
});
it('never leaks the raw secret in the header value', () => {
expect(basicAuthHeader('cid', 'SUPERSECRET')).not.toContain('SUPERSECRET');
});
});
describe('oauth: authorizeUrl', () => {
it('builds the tenant consent URL with the standard params', () => {
const url = new URL(
authorizeUrl({
host: HOST,
tenant: TENANT,
clientId: 'cid',
redirectUri: 'https://workday.hanzo.ai/oauth/callback',
state: 'st-1',
}),
);
expect(url.origin).toBe('https://wd2-impl-services1.workday.com');
expect(url.pathname).toBe('/ccx/oauth2/acme/authorize');
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('client_id')).toBe('cid');
expect(url.searchParams.get('redirect_uri')).toBe('https://workday.hanzo.ai/oauth/callback');
expect(url.searchParams.get('state')).toBe('st-1');
});
it('omits scope when empty', () => {
const url = new URL(
authorizeUrl({ host: HOST, tenant: TENANT, clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: [] }),
);
expect(url.searchParams.has('scope')).toBe(false);
});
it('space-joins scopes when present', () => {
const url = new URL(
authorizeUrl({ host: HOST, tenant: TENANT, clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: ['Staffing', 'Time_Off'] }),
);
expect(url.searchParams.get('scope')).toBe('Staffing Time_Off');
});
});
describe('oauth: tokenUrl', () => {
it('resolves the token endpoint on the tenant OAuth root', () => {
expect(tokenUrl(HOST, TENANT)).toBe('https://wd2-impl-services1.workday.com/ccx/oauth2/acme/token');
});
});
describe('oauth: tokenExchange', () => {
it('puts the client credentials in the Basic header, NOT the body', () => {
const req = tokenExchange({
host: HOST,
tenant: TENANT,
clientId: 'cid',
clientSecret: 'sec',
code: 'the-code',
redirectUri: 'https://workday.hanzo.ai/oauth/callback',
});
expect(req.url).toBe('https://wd2-impl-services1.workday.com/ccx/oauth2/acme/token');
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
expect(req.headers.Authorization).toBe(basicAuthHeader('cid', 'sec'));
const body = new URLSearchParams(req.body);
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('code')).toBe('the-code');
expect(body.get('redirect_uri')).toBe('https://workday.hanzo.ai/oauth/callback');
// Secret + id are NOT in the body (they ride in the Basic header).
expect(body.has('client_secret')).toBe(false);
expect(body.has('client_id')).toBe(false);
});
it('never puts the secret in the body string', () => {
const req = tokenExchange({ host: HOST, tenant: TENANT, clientId: 'c', clientSecret: 'SUPER', code: 'x', redirectUri: 'https://x/cb' });
expect(req.body).not.toContain('SUPER');
});
});
describe('oauth: refreshExchange', () => {
it('form-encodes the refresh grant with Basic client auth', () => {
const req = refreshExchange({ host: HOST, tenant: TENANT, clientId: 'cid', clientSecret: 'sec', refreshToken: 'r-1' });
expect(req.url).toBe('https://wd2-impl-services1.workday.com/ccx/oauth2/acme/token');
expect(req.headers.Authorization).toBe(basicAuthHeader('cid', 'sec'));
const body = new URLSearchParams(req.body);
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('r-1');
expect(body.has('code')).toBe(false);
expect(body.has('client_secret')).toBe(false);
});
});
describe('oauth: parseTokenResponse', () => {
it('parses a full token set (no created_at on Workday)', () => {
const t = parseTokenResponse({
access_token: 'at',
refresh_token: 'rt',
expires_in: 3600,
token_type: 'Bearer',
});
expect(t.access_token).toBe('at');
expect(t.refresh_token).toBe('rt');
expect(t.expires_in).toBe(3600);
expect(t.token_type).toBe('Bearer');
});
it('throws on a Workday error payload with the description', () => {
expect(() => parseTokenResponse({ error: 'invalid_grant', error_description: 'code used' })).toThrow(/code used/);
});
it('throws when access_token is missing or empty', () => {
expect(() => parseTokenResponse({})).toThrow(/missing access_token/);
expect(() => parseTokenResponse({ access_token: '' })).toThrow(/missing access_token/);
expect(() => parseTokenResponse(null)).toThrow(/empty token response/);
});
it('drops non-string/non-number optional fields defensively', () => {
const t = parseTokenResponse({ access_token: 'at', refresh_token: 123, expires_in: 'soon' });
expect(t.refresh_token).toBeUndefined();
expect(t.expires_in).toBeUndefined();
});
});
describe('oauth: carryRefreshToken (Workday does not rotate)', () => {
it('keeps the prior refresh token when the refresh response omits one', () => {
const prev = { access_token: 'old', refresh_token: 'keep-me', expires_in: 3600 };
const next = { access_token: 'new', expires_in: 3600 };
expect(carryRefreshToken(prev, next).refresh_token).toBe('keep-me');
expect(carryRefreshToken(prev, next).access_token).toBe('new');
});
it('keeps a rotated refresh token when the response does carry one', () => {
const prev = { access_token: 'old', refresh_token: 'old-r' };
const next = { access_token: 'new', refresh_token: 'new-r' };
expect(carryRefreshToken(prev, next).refresh_token).toBe('new-r');
});
});
describe('oauth: expiry math (mint-time based)', () => {
it('computes expiry from mintedAt + expires_in minus skew', () => {
const t = { access_token: 'a', expires_in: 3600 };
expect(tokenExpiresAt(t, 1000, 60)).toBe(1000 + 3600 - 60);
});
it('treats a missing expires_in as ttl 0', () => {
const t = { access_token: 'a' };
expect(tokenExpiresAt(t, 5000, 0)).toBe(5000);
});
it('reports not-expired before, expired at/after the skewed boundary', () => {
const t = { access_token: 'a', expires_in: 3600 };
const minted = 1000;
const boundary = minted + 3600 - 60;
expect(isExpired(t, minted, boundary - 1, 60)).toBe(false);
expect(isExpired(t, minted, boundary, 60)).toBe(true);
expect(isExpired(t, minted, boundary + 100, 60)).toBe(true);
});
});