import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { GOOGLE_OAUTH_SCOPES } from '../src/services/google-sheets'; import { buildGoogleAuthorizationUrl, googleConnectionMetadata, normaliseGoogleValues, oauthFlowMatches, oauthStateHash, parseBoundedGoogleRange, } from '../src/services/google-sheets'; describe('Google OAuth proof and redaction', () => { it('binds state and PKCE without putting the verifier in the authorization URL', () => { const state = 'state-secret'; const verifier = 'verifier-secret'; const url = new URL(buildGoogleAuthorizationUrl({ clientId: 'client-id', redirectUri: 'https://pig.example/oauth/google/callback', state, challenge: 'challenge', })); assert.equal(url.searchParams.get('state'), state); assert.equal(url.searchParams.get('code_challenge'), 'challenge'); assert.equal(url.searchParams.get('code_challenge_method'), 'S256'); assert.equal(url.searchParams.get('scope'), GOOGLE_OAUTH_SCOPES.join(' ')); assert.equal(url.searchParams.get('access_type'), 'offline'); assert.equal(url.toString().includes(verifier), false); }); it('accepts only the matching, unexpired, just-consumed state', () => { const now = new Date('2026-08-13T12:00:00.000Z'); const flow = { stateHash: oauthStateHash('expected'), browserBindingHash: oauthStateHash('browser'), expiresAt: new Date('2026-08-13T12:01:00.000Z'), consumedAt: now, }; assert.equal(oauthFlowMatches(flow, 'expected', 'browser', now), true); assert.equal(oauthFlowMatches(flow, 'attacker', 'browser', now), false); assert.equal(oauthFlowMatches(flow, 'expected', 'other-browser', now), false); assert.equal(oauthFlowMatches({ ...flow, consumedAt: null }, 'expected', 'browser', now), false); assert.equal(oauthFlowMatches({ ...flow, expiresAt: now }, 'expected', 'browser', now), false); }); it('never serializes encrypted tokens in connection metadata', () => { const metadata = googleConnectionMetadata(true, { userId: '00000000-0000-4000-8000-000000000001', refreshTokenEncrypted: 'v1.refresh.secret', accessTokenEncrypted: 'v1.access.secret', accessTokenExpiresAt: new Date(), scopes: [...GOOGLE_OAUTH_SCOPES], connectedAt: new Date('2026-08-13T12:00:00.000Z'), updatedAt: new Date(), }); const serialized = JSON.stringify(metadata); assert.equal(serialized.includes('v1.refresh.secret'), false); assert.equal(serialized.includes('v1.access.secret'), false); assert.deepEqual(Object.keys(metadata), ['configured', 'connected', 'connectedAt', 'scopes']); }); }); describe('Google Sheets range and value boundaries', () => { it('requires an explicit rectangular range within A14 limits and the selected grid', () => { assert.deepEqual(parseBoundedGoogleRange('a1:CV2001', { rowCount: 3_000, columnCount: 100 }), { a1: 'A1:CV2001', rows: 2_001, columns: 100, }); assert.throws(() => parseBoundedGoogleRange('A:Z')); assert.throws(() => parseBoundedGoogleRange('A1:C2002')); assert.throws(() => parseBoundedGoogleRange('A1:C10', { rowCount: 9, columnCount: 3 })); }); it('normalizes formatted values into A14 rows while keeping text inert', () => { const table = normaliseGoogleValues([ ['external_id', 'name', 'active', 'score'], [7, '=IMPORTDATA("https://example.test")', true, 2.5], ], 4); assert.deepEqual(table.headers, ['external_id', 'name', 'active', 'score']); assert.deepEqual(table.rows, [['7', '=IMPORTDATA("https://example.test")', 'true', '2.5']]); assert.match(table.warnings.join(' '), /formula source was not imported/i); }); });