85 lines
3.7 KiB
TypeScript
85 lines
3.7 KiB
TypeScript
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);
|
|
});
|
|
});
|