mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
449 lines
17 KiB
TypeScript
449 lines
17 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { exportPKCS8, exportSPKI, generateKeyPair, importSPKI, jwtVerify } from 'jose'
|
|
import { beforeAll, describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
buildServiceAccountAssertion,
|
|
createGoogleCalendarClient,
|
|
GOOGLE_CALENDAR_SCOPE,
|
|
GoogleCalendarApiError,
|
|
GoogleCalendarAuthError,
|
|
parseGoogleWatchExpiration,
|
|
type FetchLike,
|
|
type GoogleCalendarAuth,
|
|
type GoogleCalendarServiceAccountCredentials,
|
|
} from '@/utilities/googleCalendarClient'
|
|
|
|
describe('buildServiceAccountAssertion', () => {
|
|
let credentials: GoogleCalendarServiceAccountCredentials
|
|
let publicKeyPem: string
|
|
|
|
beforeAll(async () => {
|
|
const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true })
|
|
credentials = {
|
|
clientEmail: 'teqo-sa@projeto.iam.gserviceaccount.com',
|
|
privateKey: await exportPKCS8(privateKey),
|
|
}
|
|
publicKeyPem = await exportSPKI(publicKey)
|
|
})
|
|
|
|
it('signs an RS256 JWT with the service-account claims the token endpoint expects', async () => {
|
|
const assertion = await buildServiceAccountAssertion(credentials, 1_800_000_000)
|
|
const { payload } = await jwtVerify(assertion, await importSPKI(publicKeyPem, 'RS256'))
|
|
|
|
expect(payload.iss).toBe(credentials.clientEmail)
|
|
expect(payload.aud).toBe('https://oauth2.googleapis.com/token')
|
|
expect(payload.scope).toBe(GOOGLE_CALENDAR_SCOPE)
|
|
expect(payload.iat).toBe(1_800_000_000)
|
|
expect((payload.exp as number) - (payload.iat as number)).toBe(3600)
|
|
})
|
|
})
|
|
|
|
describe('parseGoogleWatchExpiration', () => {
|
|
it('parses the int64 expiration serialized as a JSON string', () => {
|
|
expect(parseGoogleWatchExpiration('1757900000000')).toBe(1757900000000)
|
|
})
|
|
|
|
it('accepts a finite number unchanged', () => {
|
|
expect(parseGoogleWatchExpiration(1757900000000)).toBe(1757900000000)
|
|
})
|
|
|
|
it('fails to null for absent, malformed or degenerate values (engine fallback path)', () => {
|
|
const invalid = [
|
|
undefined,
|
|
null,
|
|
'',
|
|
' ',
|
|
'abc',
|
|
'NaN',
|
|
0,
|
|
-1,
|
|
Number.NaN,
|
|
Number.POSITIVE_INFINITY,
|
|
// Beyond Date's range: toISOString() would throw — fallback, never crash.
|
|
8.64e15 + 1,
|
|
'8640000000000001',
|
|
{},
|
|
[],
|
|
]
|
|
for (const value of invalid) {
|
|
expect(parseGoogleWatchExpiration(value)).toBeNull()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('createGoogleCalendarClient', () => {
|
|
let credentials: GoogleCalendarServiceAccountCredentials
|
|
let serviceAccountAuth: GoogleCalendarAuth
|
|
|
|
beforeAll(async () => {
|
|
// The token endpoint is stubbed, but the client still SIGNS the JWT
|
|
// assertion before posting — a real key keeps the tests honest.
|
|
const { privateKey } = await generateKeyPair('RS256', { extractable: true })
|
|
credentials = {
|
|
clientEmail: 'teqo-sa@projeto.iam.gserviceaccount.com',
|
|
privateKey: await exportPKCS8(privateKey),
|
|
}
|
|
serviceAccountAuth = { kind: 'service-account', credentials }
|
|
})
|
|
|
|
const oauthAuth: GoogleCalendarAuth = {
|
|
kind: 'oauth',
|
|
credentials: {
|
|
clientId: 'client-id.apps.googleusercontent.com',
|
|
clientSecret: 'client-secret',
|
|
refreshToken: 'refresh-token',
|
|
},
|
|
}
|
|
|
|
const calendarId = 'c_abc@group.calendar.google.com'
|
|
|
|
/** Minimal fake transport: token endpoint + calendar REST in memory. */
|
|
const stubTransport = (
|
|
events: Array<Record<string, unknown>> = [],
|
|
watchResponse: Record<string, unknown> = {
|
|
id: 'watch-1',
|
|
resourceId: 'resource-1',
|
|
// C164 — events.watch serializes the int64 `expiration` as a JSON string.
|
|
expiration: '1757900000000',
|
|
},
|
|
) => {
|
|
let tokenRequests = 0
|
|
const calls: Array<{ url: string; method: string; body?: string; auth?: string }> = []
|
|
|
|
const fetchImpl: FetchLike = (async (input: RequestInfo | URL, _init?: RequestInit) => {
|
|
const url = String(input)
|
|
const method = (_init?.method ?? 'GET').toUpperCase()
|
|
const body = typeof _init?.body === 'string' ? _init.body : undefined
|
|
const auth = (_init?.headers as Record<string, string> | undefined)?.['Authorization']
|
|
calls.push({ url, method, body, auth })
|
|
|
|
if (url === 'https://oauth2.googleapis.com/token') {
|
|
tokenRequests += 1
|
|
return new Response(
|
|
JSON.stringify({ access_token: `token-${tokenRequests}`, expires_in: 3600 }),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
)
|
|
}
|
|
|
|
if (url.includes('/events/watch') && method === 'POST') {
|
|
return new Response(JSON.stringify(watchResponse), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
if (url.includes('/events') && method === 'POST') {
|
|
const event = JSON.parse(body ?? '{}') as Record<string, unknown>
|
|
events.push(event)
|
|
return new Response(JSON.stringify(event), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
if (url.includes('/events') && method === 'PUT') {
|
|
const event = JSON.parse(body ?? '{}') as Record<string, unknown>
|
|
const index = events.findIndex((e) => e.id === event.id)
|
|
if (index >= 0) events[index] = event
|
|
return new Response(JSON.stringify(event), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
if (url.includes('/events') && method === 'PATCH') {
|
|
const patch = JSON.parse(body ?? '{}') as Record<string, unknown>
|
|
const eventId = new URL(url).pathname.split('/').pop()
|
|
const index = events.findIndex((e) => e.id === eventId)
|
|
if (index >= 0) events[index] = { ...events[index], ...patch }
|
|
return new Response(JSON.stringify(events[index] ?? patch), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
if (url.includes('/events') && method === 'DELETE') {
|
|
const eventId = url.split('/').pop()
|
|
const index = events.findIndex((e) => e.id === eventId)
|
|
if (index >= 0) events.splice(index, 1)
|
|
return new Response(null, { status: 204 })
|
|
}
|
|
if (url.includes('/events')) {
|
|
return new Response(JSON.stringify({ items: events }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
return new Response('not found', { status: 404 })
|
|
}) as FetchLike
|
|
|
|
return { fetchImpl, calls, events, getTokenRequests: () => tokenRequests }
|
|
}
|
|
|
|
it('inserts with the deterministic id, bearer auth and JSON body', async () => {
|
|
const { fetchImpl, calls, events } = stubTransport()
|
|
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
|
|
|
|
await client.insertEvent(calendarId, { id: 'teqo1a', summary: 'S', description: 'D' })
|
|
|
|
expect(events).toHaveLength(1)
|
|
expect(events[0].id).toBe('teqo1a')
|
|
const insertCall = calls.find((call) => call.method === 'POST' && call.url.includes('/events'))
|
|
expect(insertCall?.url).toContain(`/calendars/${encodeURIComponent(calendarId)}/events`)
|
|
expect(insertCall?.auth).toBe('Bearer token-1')
|
|
})
|
|
|
|
it('lists with the window params and pagination', async () => {
|
|
const { fetchImpl, calls } = stubTransport()
|
|
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
|
|
|
|
await client.listEvents(calendarId, {
|
|
timeMin: '2026-01-01T00:00:00Z',
|
|
timeMax: '2026-12-31T00:00:00Z',
|
|
})
|
|
|
|
const listCall = calls.find((call) => call.method === 'GET')
|
|
expect(listCall?.url).toContain('timeMin=2026-01-01T00%3A00%3A00Z')
|
|
expect(listCall?.url).toContain('timeMax=2026-12-31T00%3A00%3A00Z')
|
|
expect(listCall?.url).toContain('maxResults=2500')
|
|
})
|
|
|
|
it('lists writable calendars with pagination, hiding deleted and falling back the summary (C150)', async () => {
|
|
const pages: Record<string, { items: Array<Record<string, unknown>>; nextPageToken?: string }> =
|
|
{
|
|
'': {
|
|
items: [
|
|
{ id: 'primary-id', summary: 'Conta da campanha', primary: true, accessRole: 'owner' },
|
|
{ id: 'campaign-id', summary: 'Agenda da Campanha', accessRole: 'writer' },
|
|
{ id: 'no-summary-id' },
|
|
{ id: 'deleted-id', summary: 'Antiga', deleted: true },
|
|
],
|
|
nextPageToken: 'page-2',
|
|
},
|
|
'page-2': {
|
|
items: [{ id: 'second-page-id', summary: 'Segunda página' }],
|
|
},
|
|
}
|
|
const calls: string[] = []
|
|
const fetchImpl: FetchLike = (async (input: RequestInfo | URL) => {
|
|
const url = String(input)
|
|
if (url === 'https://oauth2.googleapis.com/token') {
|
|
return new Response(JSON.stringify({ access_token: 'token-1', expires_in: 3600 }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
calls.push(url)
|
|
const pageToken = new URL(url).searchParams.get('pageToken') ?? ''
|
|
return new Response(JSON.stringify(pages[pageToken]), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}) as FetchLike
|
|
|
|
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
|
|
const calendars = await client.listCalendars()
|
|
|
|
expect(calendars).toEqual([
|
|
{ id: 'primary-id', summary: 'Conta da campanha', primary: true },
|
|
{ id: 'campaign-id', summary: 'Agenda da Campanha', primary: false },
|
|
{ id: 'no-summary-id', summary: 'no-summary-id', primary: false },
|
|
{ id: 'second-page-id', summary: 'Segunda página', primary: false },
|
|
])
|
|
expect(calls).toHaveLength(2)
|
|
const first = new URL(calls[0])
|
|
expect(first.pathname).toBe('/calendar/v3/users/me/calendarList')
|
|
expect(first.searchParams.get('minAccessRole')).toBe('writer')
|
|
expect(first.searchParams.get('showHidden')).toBe('true')
|
|
expect(first.searchParams.get('maxResults')).toBe('250')
|
|
expect(first.searchParams.get('fields')).toBe('items(id,summary,primary,deleted),nextPageToken')
|
|
expect(new URL(calls[1]).searchParams.get('pageToken')).toBe('page-2')
|
|
})
|
|
|
|
it('maps invalid_grant on the calendar list to GoogleCalendarAuthError (C150)', async () => {
|
|
const fetchImpl: FetchLike = (async () =>
|
|
new Response(JSON.stringify({ error: 'invalid_grant' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})) as FetchLike
|
|
|
|
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
|
|
await expect(client.listCalendars()).rejects.toBeInstanceOf(GoogleCalendarAuthError)
|
|
})
|
|
|
|
it('re-mints the token once on a 401 and retries the call', async () => {
|
|
let calendarCalls = 0
|
|
const fetchImpl: FetchLike = (async (input: RequestInfo | URL, _init?: RequestInit) => {
|
|
const url = String(input)
|
|
if (url === 'https://oauth2.googleapis.com/token') {
|
|
return new Response(JSON.stringify({ access_token: 'fresh-token', expires_in: 3600 }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
calendarCalls += 1
|
|
if (calendarCalls === 1) {
|
|
return new Response('unauthorized', { status: 401 })
|
|
}
|
|
return new Response(JSON.stringify({ items: [] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}) as FetchLike
|
|
|
|
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
|
|
const events = await client.listEvents(calendarId, {
|
|
timeMin: '2026-01-01T00:00:00Z',
|
|
timeMax: '2026-12-31T00:00:00Z',
|
|
})
|
|
|
|
expect(events).toEqual([])
|
|
expect(calendarCalls).toBe(2)
|
|
})
|
|
|
|
it('throws a typed error on a non-401 failure without leaking the body', async () => {
|
|
const fetchImpl: FetchLike = (async (input: RequestInfo | URL) => {
|
|
const url = String(input)
|
|
if (url === 'https://oauth2.googleapis.com/token') {
|
|
return new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 403 })
|
|
}
|
|
return new Response('ok', { status: 200 })
|
|
}) as FetchLike
|
|
|
|
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
|
|
await expect(
|
|
client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' }),
|
|
).rejects.toBeInstanceOf(GoogleCalendarApiError)
|
|
})
|
|
|
|
it('mints an access token with the OAuth refresh grant (C149)', async () => {
|
|
const { fetchImpl, calls } = stubTransport()
|
|
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
|
|
|
|
await client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' })
|
|
|
|
const tokenCall = calls.find((call) => call.url === 'https://oauth2.googleapis.com/token')
|
|
const params = new URLSearchParams(tokenCall?.body)
|
|
expect(params.get('grant_type')).toBe('refresh_token')
|
|
expect(params.get('refresh_token')).toBe('refresh-token')
|
|
expect(params.get('client_id')).toBe('client-id.apps.googleusercontent.com')
|
|
expect(params.get('client_secret')).toBe('client-secret')
|
|
const listCall = calls.find((call) => call.method === 'GET')
|
|
expect(listCall?.auth).toBe('Bearer token-1')
|
|
})
|
|
|
|
it('maps invalid_grant to GoogleCalendarAuthError (reconnectable)', async () => {
|
|
const fetchImpl: FetchLike = (async () =>
|
|
new Response(JSON.stringify({ error: 'invalid_grant' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})) as FetchLike
|
|
|
|
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
|
|
await expect(
|
|
client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' }),
|
|
).rejects.toBeInstanceOf(GoogleCalendarAuthError)
|
|
})
|
|
|
|
it('keeps invalid_client as an API error (reconnect would not fix it)', async () => {
|
|
const fetchImpl: FetchLike = (async () =>
|
|
new Response(JSON.stringify({ error: 'invalid_client' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})) as FetchLike
|
|
|
|
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
|
|
await expect(
|
|
client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' }),
|
|
).rejects.toBeInstanceOf(GoogleCalendarApiError)
|
|
})
|
|
|
|
it('re-mints the OAuth token once on a 401 and retries the call', async () => {
|
|
let calendarCalls = 0
|
|
let tokenRequests = 0
|
|
const fetchImpl: FetchLike = (async (input: RequestInfo | URL) => {
|
|
const url = String(input)
|
|
if (url === 'https://oauth2.googleapis.com/token') {
|
|
tokenRequests += 1
|
|
return new Response(
|
|
JSON.stringify({ access_token: `oauth-token-${tokenRequests}`, expires_in: 3600 }),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
)
|
|
}
|
|
calendarCalls += 1
|
|
if (calendarCalls === 1) return new Response('unauthorized', { status: 401 })
|
|
return new Response(JSON.stringify({ items: [] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}) as FetchLike
|
|
|
|
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
|
|
const events = await client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' })
|
|
|
|
expect(events).toEqual([])
|
|
expect(calendarCalls).toBe(2)
|
|
expect(tokenRequests).toBe(2)
|
|
})
|
|
|
|
it('patches partial fields with sendUpdates=none, preserving what was not sent (C165)', async () => {
|
|
const { fetchImpl, calls, events } = stubTransport([
|
|
{ id: 'foreign-1', summary: 'Antigo', description: 'descrição do usuário' },
|
|
])
|
|
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
|
|
|
|
await client.patchEvent(calendarId, 'foreign-1', { summary: 'Novo', location: '' })
|
|
|
|
const patchCall = calls.find((call) => call.method === 'PATCH')
|
|
expect(patchCall?.url).toContain(
|
|
`/calendars/${encodeURIComponent(calendarId)}/events/foreign-1`,
|
|
)
|
|
expect(patchCall?.url).toContain('sendUpdates=none')
|
|
expect(JSON.parse(patchCall?.body ?? '{}')).toEqual({ summary: 'Novo', location: '' })
|
|
// O PATCH preserva o que não foi enviado — a descrição do usuário fica.
|
|
expect(events[0]).toMatchObject({ summary: 'Novo', description: 'descrição do usuário' })
|
|
})
|
|
|
|
it('normalizes the watch expiration string into millis (C164)', async () => {
|
|
const { fetchImpl, calls } = stubTransport()
|
|
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
|
|
|
|
const channel = await client.watchEvents(calendarId, {
|
|
id: 'channel-1',
|
|
address: 'https://jorgesolla1313.com.br/campanha/agenda/google-webhook/secret',
|
|
token: 'secret',
|
|
ttlSeconds: 3600,
|
|
})
|
|
|
|
expect(channel).toEqual({
|
|
id: 'watch-1',
|
|
resourceId: 'resource-1',
|
|
expiration: 1757900000000,
|
|
})
|
|
const watchCall = calls.find((call) => call.url.includes('/events/watch'))
|
|
expect(watchCall?.method).toBe('POST')
|
|
expect(watchCall?.url).toBe(
|
|
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/watch`,
|
|
)
|
|
expect(JSON.parse(watchCall?.body ?? '{}')).toMatchObject({
|
|
id: 'channel-1',
|
|
type: 'web_hook',
|
|
params: { ttl: '3600' },
|
|
})
|
|
})
|
|
|
|
it('returns a null expiration when the watch response omits it (engine falls back to the TTL)', async () => {
|
|
const { fetchImpl } = stubTransport([], { id: 'watch-1', resourceId: 'resource-1' })
|
|
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
|
|
|
|
const channel = await client.watchEvents(calendarId, {
|
|
id: 'channel-1',
|
|
address: 'https://example.test/campanha/agenda/google-webhook/secret',
|
|
token: 'secret',
|
|
ttlSeconds: 3600,
|
|
})
|
|
|
|
expect(channel.expiration).toBeNull()
|
|
})
|
|
})
|