Compare commits

...

1 Commits

Author SHA1 Message Date
google-labs-jules[bot] 54534012bd Refactor Google Sheets integration for better modularity and testability
- Decoupled Google Sheets API interaction into `GoogleSheetsClient`.
- Decoupled OAuth2 device code flow logic and state into `DeviceAuthProvider`.
- Refactored `sheets.service.ts` to use these new components while maintaining the existing public API.
- Added comprehensive unit tests for `GoogleSheetsClient` and `DeviceAuthProvider`.
- Improved type safety by using `OAuth2Client` instead of `any`.
- Ensured consistency with original behavior by including `responseRanges` in `batchUpdate`.

Co-authored-by: cpvalente <34649812+cpvalente@users.noreply.github.com>
2026-02-22 18:15:38 +00:00
5 changed files with 458 additions and 221 deletions
@@ -0,0 +1,179 @@
import { Credentials, OAuth2Client } from 'google-auth-library';
import { logger } from '../../classes/Logger.js';
import { LogOrigin, MaybeString, AuthenticationStatus } from 'ontime-types';
import { consoleSubdued } from '../../utils/console.js';
import { type ClientSecret } from './sheets.utils.js';
const codesUrl = 'https://oauth2.googleapis.com/device/code';
const tokenUrl = 'https://oauth2.googleapis.com/token';
const grantType = 'urn:ietf:params:oauth:grant-type:device_code';
const sheetScope = 'https://www.googleapis.com/auth/spreadsheets';
type CodesResponse = {
device_code: string;
expires_in: number;
interval: number;
user_code: string;
verification_url: string;
};
export class DeviceAuthProvider {
private currentAuthClient: OAuth2Client | null = null;
private currentAuthUrl: MaybeString = null;
private currentAuthCode: MaybeString = null;
private pollInterval: NodeJS.Timeout | null = null;
private cleanupTimeout: NodeJS.Timeout | null = null;
constructor() {}
getStatus(): AuthenticationStatus {
if (this.cleanupTimeout) {
return 'pending';
}
return this.currentAuthClient ? 'authenticated' : 'not_authenticated';
}
getAuthClient(): OAuth2Client | null {
return this.currentAuthClient;
}
getPendingData() {
return {
verification_url: this.currentAuthUrl,
user_code: this.currentAuthCode,
};
}
revoke() {
this.reset();
}
private reset() {
this.currentAuthClient = null;
this.currentAuthUrl = null;
this.currentAuthCode = null;
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
if (this.cleanupTimeout) {
clearTimeout(this.cleanupTimeout);
this.cleanupTimeout = null;
}
}
async authenticate(clientSecret: ClientSecret, onAuthenticated: (client: OAuth2Client) => Promise<void>) {
const { device_code, expires_in, interval, user_code, verification_url } = await this.getDeviceCodes(clientSecret);
this.currentAuthUrl = verification_url;
this.currentAuthCode = user_code;
this.verifyConnection(clientSecret, device_code, interval, expires_in, onAuthenticated);
return { verification_url, user_code };
}
private async getDeviceCodes(clientSecret: ClientSecret): Promise<CodesResponse> {
const response = await fetch(codesUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: clientSecret.installed.client_id,
scope: sheetScope,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch device codes: ${response.status} ${response.statusText} - ${errorText}`);
}
const deviceCodes: CodesResponse = await response.json();
return deviceCodes;
}
private verifyConnection(
clientSecret: ClientSecret,
device_code: string,
interval: number,
expires_in: number,
onAuthenticated: (client: OAuth2Client) => Promise<void>,
) {
logger.info(LogOrigin.Server, 'Start polling for auth...');
this.pollInterval = setInterval(() => this.pollForAuth(clientSecret, device_code, onAuthenticated), interval * 1000);
if (this.cleanupTimeout) {
clearTimeout(this.cleanupTimeout);
}
this.cleanupTimeout = setTimeout(() => {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
this.cleanupTimeout = null;
}, expires_in * 1000);
}
private async pollForAuth(
clientSecret: ClientSecret,
device_code: string,
onAuthenticated: (client: OAuth2Client) => Promise<void>,
) {
try {
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: clientSecret.installed.client_id,
client_secret: clientSecret.installed.client_secret,
device_code,
grant_type: grantType,
}),
});
if (response.status === 428) {
consoleSubdued('User not auth yet');
return;
}
if (!response.ok) {
logger.error(LogOrigin.Server, `Authentication poll failed with code: ${response.status}`);
return;
}
const auth: Credentials = await response.json();
logger.info(LogOrigin.Server, 'Successfully Authenticated');
const client = new OAuth2Client({
clientId: clientSecret.installed.client_id,
clientSecret: clientSecret.installed.client_secret,
});
client.setCredentials({
refresh_token: auth.refresh_token,
access_token: auth.access_token,
scope: auth.scope,
token_type: auth.token_type,
});
this.currentAuthClient = client;
if (this.cleanupTimeout) {
clearTimeout(this.cleanupTimeout);
this.cleanupTimeout = null;
}
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
await onAuthenticated(client);
} catch (error) {
logger.error(LogOrigin.Server, `Authentication poll error: ${(error as Error).message}`);
}
}
}
@@ -0,0 +1,41 @@
import { sheets, type sheets_v4 } from '@googleapis/sheets';
import { OAuth2Client } from 'google-auth-library';
export class GoogleSheetsClient {
private sheets: sheets_v4.Sheets;
constructor(auth: OAuth2Client) {
this.sheets = sheets({ version: 'v4', auth });
}
async getSpreadsheet(spreadsheetId: string) {
return this.sheets.spreadsheets.get({
spreadsheetId,
includeGridData: false,
});
}
async getValues(spreadsheetId: string, range: string) {
return this.sheets.spreadsheets.values.get({
spreadsheetId,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range,
});
}
async batchUpdate(
spreadsheetId: string,
requests: sheets_v4.Schema$Request[],
responseRanges?: string[],
) {
return this.sheets.spreadsheets.batchUpdate({
spreadsheetId,
requestBody: {
includeSpreadsheetInResponse: false,
responseRanges,
requests,
},
});
}
}
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { DeviceAuthProvider } from '../DeviceAuthProvider.js';
describe('DeviceAuthProvider', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('should start with not_authenticated status', () => {
const provider = new DeviceAuthProvider();
expect(provider.getStatus()).toBe('not_authenticated');
});
it('should initiate authentication and poll', async () => {
const provider = new DeviceAuthProvider();
const mockClientSecret = {
installed: {
client_id: 'id',
client_secret: 'secret',
auth_uri: '',
token_uri: '',
auth_provider_x509_cert_url: '',
},
};
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
device_code: 'dc',
expires_in: 60,
interval: 5,
user_code: 'uc',
verification_url: 'vurl',
}),
});
const onAuth = vi.fn();
const result = await provider.authenticate(mockClientSecret, onAuth);
expect(result).toEqual({
verification_url: 'vurl',
user_code: 'uc',
});
expect(provider.getStatus()).toBe('pending');
expect(fetch).toHaveBeenCalledTimes(1);
// Poll 1: not yet authorized
(fetch as any).mockResolvedValueOnce({
status: 428,
ok: false,
});
vi.advanceTimersByTime(5000);
// Poll happens in background
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(provider.getStatus()).toBe('pending');
// Poll 2: success
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
access_token: 'at',
refresh_token: 'rt',
scope: 'scope',
token_type: 'Bearer',
}),
});
vi.advanceTimersByTime(5000);
await vi.waitFor(() => expect(onAuth).toHaveBeenCalled());
expect(provider.getStatus()).toBe('authenticated');
});
it('should stop polling after expiration', async () => {
const provider = new DeviceAuthProvider();
const mockClientSecret = {
installed: {
client_id: 'id',
client_secret: 'secret',
auth_uri: '',
token_uri: '',
auth_provider_x509_cert_url: '',
},
};
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
device_code: 'dc',
expires_in: 10,
interval: 5,
user_code: 'uc',
verification_url: 'vurl',
}),
});
await provider.authenticate(mockClientSecret, vi.fn());
expect(provider.getStatus()).toBe('pending');
vi.advanceTimersByTime(11000);
expect(provider.getStatus()).toBe('not_authenticated');
});
it('should reset on revoke', async () => {
const provider = new DeviceAuthProvider();
const mockClientSecret = {
installed: {
client_id: 'id',
client_secret: 'secret',
auth_uri: '',
token_uri: '',
auth_provider_x509_cert_url: '',
},
};
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
device_code: 'dc',
expires_in: 60,
interval: 5,
user_code: 'uc',
verification_url: 'vurl',
}),
});
await provider.authenticate(mockClientSecret, vi.fn());
provider.revoke();
expect(provider.getStatus()).toBe('not_authenticated');
expect(provider.getAuthClient()).toBeNull();
});
});
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { sheets } from '@googleapis/sheets';
import { GoogleSheetsClient } from '../GoogleSheetsClient.js';
vi.mock('@googleapis/sheets', () => ({
sheets: vi.fn(),
}));
describe('GoogleSheetsClient', () => {
let mockSheets: any;
beforeEach(() => {
mockSheets = {
spreadsheets: {
get: vi.fn(),
values: {
get: vi.fn(),
},
batchUpdate: vi.fn(),
},
};
(sheets as any).mockReturnValue(mockSheets);
});
it('should call spreadsheets.get', async () => {
const client = new GoogleSheetsClient({} as any);
mockSheets.spreadsheets.get.mockResolvedValue({ data: {} });
await client.getSpreadsheet('id');
expect(mockSheets.spreadsheets.get).toHaveBeenCalledWith({
spreadsheetId: 'id',
includeGridData: false,
});
});
it('should call spreadsheets.values.get', async () => {
const client = new GoogleSheetsClient({} as any);
mockSheets.spreadsheets.values.get.mockResolvedValue({ data: {} });
await client.getValues('id', 'range');
expect(mockSheets.spreadsheets.values.get).toHaveBeenCalledWith({
spreadsheetId: 'id',
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range: 'range',
});
});
it('should call spreadsheets.batchUpdate', async () => {
const client = new GoogleSheetsClient({} as any);
mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ data: {} });
await client.batchUpdate('id', [{ updateCells: {} }], ['range']);
expect(mockSheets.spreadsheets.batchUpdate).toHaveBeenCalledWith({
spreadsheetId: 'id',
requestBody: {
includeSpreadsheetInResponse: false,
responseRanges: ['range'],
requests: [{ updateCells: {} }],
},
});
});
});
+40 -221
View File
@@ -20,8 +20,8 @@ import {
} from 'ontime-types';
import { ImportMap, getErrorMessage } from 'ontime-utils';
import { sheets, type sheets_v4 } from '@googleapis/sheets';
import { Credentials, OAuth2Client } from 'google-auth-library';
import { type sheets_v4 } from '@googleapis/sheets';
import { OAuth2Client } from 'google-auth-library';
import { logger } from '../../classes/Logger.js';
import { parseRundowns } from '../rundown/rundown.parser.js';
@@ -29,42 +29,18 @@ import { parseRundowns } from '../rundown/rundown.parser.js';
import { getCurrentRundown, getProjectCustomFields, processRundown } from '../rundown/rundown.dao.js';
import { parseExcel } from '../excel/excel.parser.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import { consoleSubdued } from '../../utils/console.js';
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheets.utils.js';
import { catchCommonImportXlsxError } from './googleApi.utils.js';
import { DeviceAuthProvider } from './DeviceAuthProvider.js';
import { GoogleSheetsClient } from './GoogleSheetsClient.js';
const sheetScope = 'https://www.googleapis.com/auth/spreadsheets';
const codesUrl = 'https://oauth2.googleapis.com/device/code';
const tokenUrl = 'https://oauth2.googleapis.com/token';
const grantType = 'urn:ietf:params:oauth:grant-type:device_code';
let currentAuthClient: OAuth2Client | null = null;
let currentClientSecret: ClientSecret | null = null;
let currentAuthUrl: MaybeString = null;
let currentAuthCode: MaybeString = null;
const authProvider = new DeviceAuthProvider();
let currentSheetId: MaybeString = null;
let pollInterval: NodeJS.Timeout | null = null;
let cleanupTimeout: NodeJS.Timeout | null = null;
function reset() {
currentAuthClient = null;
currentClientSecret = null;
currentAuthUrl = null;
currentAuthCode = null;
authProvider.revoke();
currentSheetId = null;
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
if (cleanupTimeout) {
clearTimeout(cleanupTimeout);
cleanupTimeout = null;
}
}
/**
@@ -95,148 +71,20 @@ export function handleClientSecret(clientSecret: string): ClientSecret {
return clientSecretObject;
}
// https://developers.google.com/identity/protocols/oauth2/limited-input-device#success-response
type CodesResponse = {
device_code: string;
expires_in: number;
interval: number;
user_code: string;
verification_url: string;
};
/**
* Establishes connection with Google Auth server and retrieves device codes
*/
async function getDeviceCodes(clientSecret: ClientSecret): Promise<CodesResponse> {
const response = await fetch(codesUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: clientSecret.installed.client_id,
scope: sheetScope,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch device codes: ${response.status} ${response.statusText} - ${errorText}`);
}
const deviceCodes: CodesResponse = await response.json();
return deviceCodes;
}
/**
* Gets credentials from Google Auth server
*/
function verifyConnection(
clientSecret: ClientSecret,
device_code: string,
interval: number,
expires_in: number,
postAction: () => Promise<any>,
) {
logger.info(LogOrigin.Server, 'Start polling for auth...');
// create poller to check for auth
pollInterval = setInterval(pollForAuth, interval * 1000);
// schedule to clear the poller when we know the token is no longer valid
if (cleanupTimeout) {
clearTimeout(cleanupTimeout);
cleanupTimeout = null;
}
cleanupTimeout = setTimeout(() => {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}, expires_in * 1000);
async function pollForAuth() {
try {
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: clientSecret.installed.client_id,
client_secret: clientSecret.installed.client_secret,
device_code,
grant_type: grantType,
}),
});
// server returns 428 if user hasnt yet completed the auth process
if (response.status === 428) {
consoleSubdued('User not auth yet');
return;
}
if (!response.ok) {
logger.error(LogOrigin.Server, `Authentication poll failed with code: ${response.status}`);
return;
}
const auth: Credentials = await response.json();
logger.info(LogOrigin.Server, 'Successfully Authenticated');
const client = new OAuth2Client({
clientId: clientSecret.installed.client_id,
clientSecret: clientSecret.installed.client_secret,
});
client.setCredentials({
refresh_token: auth.refresh_token,
access_token: auth.access_token,
scope: auth.scope,
token_type: auth.token_type,
});
// save client and cancel tasks
currentAuthClient = client;
if (cleanupTimeout) {
clearTimeout(cleanupTimeout);
cleanupTimeout = null;
}
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
await postAction();
} catch (error) {
logger.error(LogOrigin.Server, `Authentication poll error: ${(error as Error).message}`);
}
}
}
export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: string } {
if (!currentSheetId) {
throw new Error('No sheet ID');
}
if (cleanupTimeout) {
return { authenticated: 'pending', sheetId: currentSheetId };
}
return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated', sheetId: currentSheetId };
return { authenticated: authProvider.getStatus(), sheetId: currentSheetId };
}
async function verifySheet(
sheetId = currentSheetId,
authClient = currentAuthClient,
sheetId: string,
authClient: OAuth2Client,
): Promise<{ worksheetOptions: string[] }> {
if (!sheetId || !authClient) {
throw new Error('Missing sheet ID or authentication');
}
try {
const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({
spreadsheetId: sheetId,
includeGridData: false,
});
const client = new GoogleSheetsClient(authClient);
const spreadsheets = await client.getSpreadsheet(sheetId);
const worksheets: string[] = [];
spreadsheets.data.sheets?.forEach((sheet) => {
@@ -261,26 +109,17 @@ export async function handleInitialConnection(
clientSecret: ClientSecret,
sheetId: string,
): Promise<{ verification_url: string; user_code: string }> {
currentClientSecret = clientSecret;
// we know there is an ongoing process if there is a timeout for cleanup
// if there is an ongoing process, we return its data
if (cleanupTimeout) {
if (!currentAuthUrl || !currentAuthCode) {
throw new Error('No ongoing connection');
}
return { verification_url: currentAuthUrl, user_code: currentAuthCode };
}
const { device_code, expires_in, interval, user_code, verification_url } = await getDeviceCodes(currentClientSecret);
currentAuthUrl = verification_url;
currentAuthCode = user_code;
currentSheetId = sheetId;
// schedule verifying token and the existence of the sheetID
verifyConnection(currentClientSecret, device_code, interval, expires_in, verifySheet);
if (authProvider.getStatus() === 'pending') {
const pendingData = authProvider.getPendingData();
if (!pendingData.verification_url || !pendingData.user_code) {
throw new Error('No ongoing connection');
}
return pendingData as { verification_url: string; user_code: string };
}
return { verification_url, user_code };
return authProvider.authenticate(clientSecret, (client) => verifySheet(sheetId, client).then(() => {}));
}
/**
@@ -288,22 +127,21 @@ export async function handleInitialConnection(
* @returns
*/
export async function getWorksheetOptions(sheetId: string): ReturnType<typeof verifySheet> {
if (!currentAuthClient) {
const authClient = authProvider.getAuthClient();
if (!authClient) {
throw new Error('Not authenticated');
}
currentSheetId = sheetId;
return verifySheet(sheetId);
return verifySheet(sheetId, authClient);
}
async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ worksheetId: number; range: string }> {
if (!currentAuthClient) {
throw new Error('Not authenticated');
}
const spreadsheets = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.get({
spreadsheetId: sheetId,
});
async function verifyWorksheet(
client: GoogleSheetsClient,
sheetId: string,
worksheet: string,
): Promise<{ worksheetId: number; range: string }> {
const spreadsheets = await client.getSpreadsheet(sheetId);
if (spreadsheets.status !== 200) {
throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`);
@@ -320,13 +158,7 @@ async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ wo
if (!selectedWorksheet) {
throw new Error('Could not find worksheet');
}
/*
The first spreadsheet provided by google sheet has an id = 0,
so !0 returns true, the only other number that returns true in this setup is NaN,
so if x !== 0 && x !== NaN, then !x returns false, we indeed want !NaN to return true,
but we would like !0 to return false, reason why is also checked that the id is not 0,
because if it is 0, then I should not enter the condition.
*/
if (
!selectedWorksheet.properties ||
(!selectedWorksheet.properties.sheetId && selectedWorksheet.properties.sheetId !== 0)
@@ -342,18 +174,15 @@ async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ wo
}
export async function upload(sheetId: string, options: ImportMap) {
if (!currentAuthClient) {
const authClient = authProvider.getAuthClient();
if (!authClient) {
throw new Error('Not authenticated');
}
const client = new GoogleSheetsClient(authClient);
const { worksheetId, range } = await verifyWorksheet(sheetId, options.worksheet);
const { worksheetId, range } = await verifyWorksheet(client, sheetId, options.worksheet);
const readResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({
spreadsheetId: sheetId,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range,
});
const readResponse = await client.getValues(sheetId, range);
if (readResponse.status !== 200 || !readResponse.data.values) {
throw new Error(`Sheet read failed: ${readResponse.statusText}`);
@@ -429,14 +258,7 @@ export async function upload(sheetId: string, options: ImportMap) {
throw new Error(`Sheet write failed to correctly parse rundown: ${e}`);
}
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
spreadsheetId: sheetId,
requestBody: {
includeSpreadsheetInResponse: false,
responseRanges: [range],
requests: updateRundown,
},
});
const writeResponse = await client.batchUpdate(sheetId, updateRundown, [range]);
if (writeResponse.status === 200) {
logger.info(LogOrigin.Server, `Sheet write ${writeResponse.statusText}`);
@@ -459,18 +281,15 @@ export async function download(
customFields: CustomFields;
summary: RundownSummary;
}> {
if (!currentAuthClient) {
const authClient = authProvider.getAuthClient();
if (!authClient) {
throw new Error('Not authenticated');
}
const client = new GoogleSheetsClient(authClient);
const { range } = await verifyWorksheet(sheetId, options.worksheet);
const { range } = await verifyWorksheet(client, sheetId, options.worksheet);
const googleResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({
spreadsheetId: sheetId,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range,
});
const googleResponse = await client.getValues(sheetId, range);
if (googleResponse.status !== 200) {
throw new Error(`Sheet read failed: ${googleResponse.statusText}`);