calendar-oauth-connector-unit.test.mjs
127 lines 4.9 KB
Raw
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 10 days ago
1 /**
2 * Tier 1 — UNIT: calendar OAuth connector primitives.
3 */
4 import { describe, it, beforeEach, afterEach } from 'node:test';
5 import assert from 'node:assert/strict';
6 import fs from 'node:fs';
7 import path from 'node:path';
8 import { fileURLToPath } from 'node:url';
9 import {
10 createPkcePair,
11 createOAuthState,
12 constantTimeEqual,
13 computeCodeChallenge,
14 PKCE_METHOD_S256,
15 } from '../lib/companion-oauth-pkce.mjs';
16 import {
17 oauthTokenVaultRoundTrip,
18 writeOAuthTokenVault,
19 readOAuthTokenVault,
20 deleteOAuthTokenVault,
21 } from '../lib/calendar/oauth-token-vault.mjs';
22 import { normalizeGoogleEvent, normalizeGoogleEvents } from '../lib/calendar/google-event-normalizer.mjs';
23 import { buildSourceCalendarDefaults, SOURCE_CALENDAR_DEFAULTS } from '../lib/calendar/source-calendar-defaults.mjs';
24 import {
25 buildGoogleAuthorizationUrl,
26 CALENDAR_OAUTH_GOOGLE_AUTHORIZED,
27 isGoogleOAuthConnectorEnabled,
28 } from '../lib/calendar/google-oauth-connector.mjs';
29
30 const __dirname = path.dirname(fileURLToPath(import.meta.url));
31 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-calendar-oauth-unit');
32 const TEST_SECRET = 'x'.repeat(40);
33
34 describe('calendar oauth unit', () => {
35 it('compile-time gate is true (KN-INF-3a); authorizedOverride can force off in tests', () => {
36 assert.equal(CALENDAR_OAUTH_GOOGLE_AUTHORIZED, true);
37 assert.equal(isGoogleOAuthConnectorEnabled(), true);
38 assert.equal(isGoogleOAuthConnectorEnabled({ authorizedOverride: false }), false);
39 assert.equal(isGoogleOAuthConnectorEnabled({ authorizedOverride: true }), true);
40 });
41
42 it('PKCE S256 pair validates round-trip challenge', () => {
43 const pair = createPkcePair();
44 assert.equal(pair.method, PKCE_METHOD_S256);
45 assert.equal(computeCodeChallenge(pair.codeVerifier), pair.codeChallenge);
46 const state = createOAuthState();
47 assert.ok(state.length >= 32);
48 assert.equal(constantTimeEqual(state, state), true);
49 assert.equal(constantTimeEqual(state, createOAuthState()), false);
50 });
51
52 it('oauth token vault encrypt/decrypt round-trip and tamper fails', () => {
53 const payload = {
54 refresh_token: 'rt_test',
55 scope: 'openid',
56 token_type: 'Bearer',
57 obtained_at: '2026-07-05T00:00:00.000Z',
58 account_sub: 'sub-abc',
59 };
60 const round = oauthTokenVaultRoundTrip(TEST_SECRET, payload);
61 assert.deepEqual(round, payload);
62 });
63
64 it('oauth token vault file round-trip', () => {
65 fs.rmSync(tmpRoot, { recursive: true, force: true });
66 const dataDir = path.join(tmpRoot, 'data');
67 writeOAuthTokenVault(dataDir, 'conn_test12345678', TEST_SECRET, {
68 refresh_token: 'rt_file',
69 scope: 'openid',
70 token_type: 'Bearer',
71 obtained_at: '2026-07-05T00:00:00.000Z',
72 account_sub: 'sub-file',
73 });
74 const read = readOAuthTokenVault(dataDir, 'conn_test12345678', TEST_SECRET);
75 assert.equal(read.refresh_token, 'rt_file');
76 deleteOAuthTokenVault(dataDir, 'conn_test12345678');
77 assert.throws(() => readOAuthTokenVault(dataDir, 'conn_test12345678', TEST_SECRET));
78 });
79
80 it('google event normalizer maps fields and drops PII fields', () => {
81 const normalized = normalizeGoogleEvent({
82 id: 'evt-1',
83 summary: 'Standup',
84 start: { dateTime: '2026-06-18T17:00:00Z', timeZone: 'UTC' },
85 end: { dateTime: '2026-06-18T17:30:00Z', timeZone: 'UTC' },
86 status: 'confirmed',
87 location: 'Secret room',
88 description: 'Ignore me',
89 attendees: [{ email: '[email protected]' }],
90 });
91 assert.ok(normalized);
92 assert.equal(normalized.external_uid, 'evt-1');
93 assert.equal(normalized.summary, 'Standup');
94 assert.equal(normalized.recurrence_rule, null);
95 assert.equal(normalized.busy, true);
96 assert.notEqual(JSON.stringify(normalized).includes('Secret room'), true);
97 assert.notEqual(JSON.stringify(normalized).includes('[email protected]'), true);
98 });
99
100 it('normalizeGoogleEvents batch', () => {
101 const rows = normalizeGoogleEvents([
102 { id: 'a', start: { dateTime: '2026-06-18T17:00:00Z' }, end: { dateTime: '2026-06-18T18:00:00Z' } },
103 ]);
104 assert.equal(rows.length, 1);
105 });
106
107 it('source calendar defaults match D9', () => {
108 const defaults = buildSourceCalendarDefaults();
109 assert.deepEqual(defaults, SOURCE_CALENDAR_DEFAULTS);
110 assert.equal(defaults.enabled_for_agents, false);
111 assert.equal(defaults.agent_context_tier_max, 0);
112 });
113
114 it('authorization URL uses S256 and read-only scopes only', () => {
115 const url = buildGoogleAuthorizationUrl({
116 clientId: 'client',
117 redirectUri: 'https://hub.example/callback',
118 state: 'state-value',
119 codeChallenge: 'challenge',
120 });
121 const parsed = new URL(url);
122 assert.equal(parsed.searchParams.get('code_challenge_method'), PKCE_METHOD_S256);
123 assert.equal(parsed.searchParams.get('access_type'), 'offline');
124 assert.match(parsed.searchParams.get('scope') ?? '', /calendar.events.readonly/);
125 assert.doesNotMatch(parsed.searchParams.get('scope') ?? '', /calendar.events(?!\.readonly)/);
126 });
127 });
File History 1 commit
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 10 days ago