teqo-backup/tests/unit/googleCalendarOAuth.unit.spec.ts

195 lines
7 KiB
TypeScript

// @vitest-environment node
import { afterEach, describe, expect, it } from 'vitest'
import {
buildGoogleCalendarOAuthAuthorizeUrl,
GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH,
GOOGLE_CALENDAR_OAUTH_SCOPES,
} from '@/lib/googleCalendarOAuth'
import {
exchangeGoogleCalendarOAuthCode,
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
GoogleCalendarOAuthError,
readGoogleCalendarOAuthCredentials,
} from '@/utilities/googleCalendarOAuth'
const CREDENTIALS = {
clientId: 'client-id.apps.googleusercontent.com',
clientSecret: 'client-secret',
}
const REDIRECT_URI = `https://jorgesolla1313.com.br${GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH}`
describe('buildGoogleCalendarOAuthAuthorizeUrl', () => {
it('pins the two literal minimum scopes (C149)', () => {
expect([...GOOGLE_CALENDAR_OAUTH_SCOPES]).toEqual([
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
])
})
it('builds the consent URL with offline access, forced consent and the state', () => {
const url = new URL(
buildGoogleCalendarOAuthAuthorizeUrl({
clientId: CREDENTIALS.clientId,
redirectUri: REDIRECT_URI,
state: 'state-123',
}),
)
expect(`${url.origin}${url.pathname}`).toBe('https://accounts.google.com/o/oauth2/v2/auth')
expect(url.searchParams.get('client_id')).toBe(CREDENTIALS.clientId)
expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI)
expect(url.searchParams.get('response_type')).toBe('code')
expect(url.searchParams.get('scope')).toBe(GOOGLE_CALENDAR_OAUTH_SCOPES.join(' '))
expect(url.searchParams.get('access_type')).toBe('offline')
expect(url.searchParams.get('prompt')).toBe('consent')
expect(url.searchParams.get('state')).toBe('state-123')
expect(url.search).not.toContain('client_secret')
})
})
describe('readGoogleCalendarOAuthCredentials', () => {
const original = {
id: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV],
secret: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV],
}
afterEach(() => {
if (original.id === undefined) delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
else process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = original.id
if (original.secret === undefined) delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
else process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = original.secret
})
it('fails closed (null) when either side is missing', () => {
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
expect(readGoogleCalendarOAuthCredentials()).toBeNull()
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'client-id'
expect(readGoogleCalendarOAuthCredentials()).toBeNull()
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'client-secret'
expect(readGoogleCalendarOAuthCredentials()).toBeNull()
})
it('returns the pair when both sides are set', () => {
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'client-id'
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'client-secret'
expect(readGoogleCalendarOAuthCredentials()).toEqual({
clientId: 'client-id',
clientSecret: 'client-secret',
})
})
})
describe('exchangeGoogleCalendarOAuthCode', () => {
it('posts the authorization code and returns the refresh token + scope', async () => {
const calls: Array<{ url: string; body?: string }> = []
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(input), body: typeof init?.body === 'string' ? init.body : '' })
return new Response(
JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
scope: GOOGLE_CALENDAR_OAUTH_SCOPES.join(' '),
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
const tokens = await exchangeGoogleCalendarOAuthCode({
code: 'code-123',
redirectUri: REDIRECT_URI,
credentials: CREDENTIALS,
fetchImpl,
})
expect(tokens).toEqual({
refreshToken: 'refresh-token',
scope: GOOGLE_CALENDAR_OAUTH_SCOPES.join(' '),
})
const params = new URLSearchParams(calls[0]?.body)
expect(calls[0]?.url).toBe('https://oauth2.googleapis.com/token')
expect(params.get('grant_type')).toBe('authorization_code')
expect(params.get('code')).toBe('code-123')
expect(params.get('redirect_uri')).toBe(REDIRECT_URI)
expect(params.get('client_id')).toBe(CREDENTIALS.clientId)
expect(params.get('client_secret')).toBe(CREDENTIALS.clientSecret)
})
it('maps a non-OK response to a safe error without echoing the body', async () => {
const fetchImpl = (async () =>
new Response(JSON.stringify({ error: 'invalid_grant', secret: 'leak-me' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})) as typeof fetch
const error = await exchangeGoogleCalendarOAuthCode({
code: 'code-123',
redirectUri: REDIRECT_URI,
credentials: CREDENTIALS,
fetchImpl,
}).catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(GoogleCalendarOAuthError)
expect((error as Error).message).not.toContain('leak-me')
expect((error as Error).message).not.toContain('invalid_grant')
})
it('rejects a partial grant that misses a required scope', async () => {
const fetchImpl = (async () =>
new Response(
JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
scope: 'https://www.googleapis.com/auth/calendar.events',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)) as typeof fetch
await expect(
exchangeGoogleCalendarOAuthCode({
code: 'code-123',
redirectUri: REDIRECT_URI,
credentials: CREDENTIALS,
fetchImpl,
}),
).rejects.toBeInstanceOf(GoogleCalendarOAuthError)
})
it('fails safely when Google does not return a refresh token', async () => {
const fetchImpl = (async () =>
new Response(JSON.stringify({ access_token: 'access-token' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})) as typeof fetch
await expect(
exchangeGoogleCalendarOAuthCode({
code: 'code-123',
redirectUri: REDIRECT_URI,
credentials: CREDENTIALS,
fetchImpl,
}),
).rejects.toBeInstanceOf(GoogleCalendarOAuthError)
})
it('maps a network failure to a safe error', async () => {
const fetchImpl = (async () => {
throw new Error('socket hang up')
}) as typeof fetch
await expect(
exchangeGoogleCalendarOAuthCode({
code: 'code-123',
redirectUri: REDIRECT_URI,
credentials: CREDENTIALS,
fetchImpl,
}),
).rejects.toBeInstanceOf(GoogleCalendarOAuthError)
})
})