mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
// @vitest-environment node
|
|
//
|
|
// C149 — the OAuth handshake boundary: the start action (role + state cookie
|
|
// + authorize URL) and the callback route (state validation, role re-check,
|
|
// code exchange, connection recording). `next/headers` is mocked with an
|
|
// in-memory cookie jar so the real signing/verification runs; `fetch` is
|
|
// stubbed at the token endpoint so no request leaves the process. Session
|
|
// resolution is mocked (the pattern of googleCalendarSyncAction.int.spec.ts);
|
|
// everything else — Payload access, the engine hooks, the DB — runs for real.
|
|
|
|
import { NextRequest } from 'next/server'
|
|
import { getPayload, type Payload } from 'payload'
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
|
|
import config from '@/payload.config'
|
|
|
|
const { cookieJar } = vi.hoisted(() => ({ cookieJar: new Map<string, string>() }))
|
|
|
|
vi.mock('next/headers', () => ({
|
|
cookies: async () => ({
|
|
get: (name: string) => (cookieJar.has(name) ? { name, value: cookieJar.get(name) } : undefined),
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value)
|
|
},
|
|
}),
|
|
headers: async () => new Headers(),
|
|
}))
|
|
|
|
vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
|
|
const original = await importOriginal<typeof import('@/utilities/campaignActionContext')>()
|
|
return { ...original, getCampaignActionContext: vi.fn() }
|
|
})
|
|
|
|
// The callback's follow-up reconciliation is not this spec's subject (the
|
|
// engine has its own file) and, against the shared singleton, a full pass
|
|
// would write snapshots onto whichever row the loader picked — including a
|
|
// parallel spec's. Stub only that call; every other export stays real.
|
|
vi.mock('@/utilities/googleCalendarSync', async (importOriginal) => {
|
|
const original = await importOriginal<typeof import('@/utilities/googleCalendarSync')>()
|
|
return { ...original, runCampaignCalendarSync: vi.fn() }
|
|
})
|
|
|
|
import {
|
|
disconnectGoogleCalendarOAuth,
|
|
startGoogleCalendarOAuth,
|
|
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
|
import { GET as googleOAuthCallback } from '@/app/(campaign)/campanha/agenda/google-oauth/callback/route'
|
|
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
|
import {
|
|
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
|
|
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
|
|
} from '@/utilities/googleCalendarOAuth'
|
|
import { recordGoogleCalendarOAuthConnection } from '@/utilities/googleCalendarSync'
|
|
|
|
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
|
import { serializeGoogleCalendarSyncSpec } from '../helpers/googleCalendarSyncLock'
|
|
|
|
serializeGoogleCalendarSyncSpec()
|
|
|
|
let payload: Payload
|
|
const campaignFixtures = installCampaignFixtures({
|
|
getPayload: () => payload,
|
|
setPayload: (nextPayload) => {
|
|
payload = nextPayload
|
|
},
|
|
})
|
|
|
|
const mockedGetContext = vi.mocked(getCampaignActionContext)
|
|
|
|
const OAUTH_ENV_KEYS = [
|
|
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
|
|
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
|
|
] as const
|
|
const originalEnv = new Map(OAUTH_ENV_KEYS.map((key) => [key, process.env[key]]))
|
|
|
|
const setOAuthClientEnv = (): void => {
|
|
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'test-client-id'
|
|
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'test-client-secret'
|
|
}
|
|
|
|
const restoreEnv = (): void => {
|
|
for (const key of OAUTH_ENV_KEYS) {
|
|
const value = originalEnv.get(key)
|
|
if (value === undefined) delete process.env[key]
|
|
else process.env[key] = value
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Token endpoint + an empty calendar API, so the callback's follow-up pass
|
|
* completes without network regardless of which row the loader picked.
|
|
*/
|
|
const googleFetchStub = (refreshToken: string): typeof fetch =>
|
|
(async (input: RequestInfo | URL) => {
|
|
const url = String(input)
|
|
if (url === 'https://oauth2.googleapis.com/token') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
access_token: 'access-token',
|
|
refresh_token: refreshToken,
|
|
scope:
|
|
'https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.calendarlist.readonly',
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
)
|
|
}
|
|
if (url.includes('/events/watch')) {
|
|
return new Response(
|
|
JSON.stringify({ id: 'watch-1', resourceId: 'resource-1', expiration: null }),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
)
|
|
}
|
|
if (url.includes('/channels/stop')) return new Response(null, { status: 204 })
|
|
if (url.includes('/calendar/v3/')) {
|
|
return new Response(JSON.stringify({ items: [] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
throw new Error(`Unexpected fetch to ${url}`)
|
|
}) as typeof fetch
|
|
|
|
const callbackRequest = (params: Record<string, string>): NextRequest =>
|
|
new NextRequest(
|
|
`http://localhost:3000/campanha/agenda/google-oauth/callback?${new URLSearchParams(params).toString()}`,
|
|
)
|
|
|
|
const rowsWithToken = (token: string) =>
|
|
payload.find({
|
|
collection: 'googleCalendarSync',
|
|
where: { oauthRefreshToken: { equals: token } },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
|
|
describe('C149 — OAuth actions and callback', () => {
|
|
beforeAll(async () => {
|
|
payload = await getPayload({ config: await config })
|
|
})
|
|
|
|
afterEach(async () => {
|
|
restoreEnv()
|
|
cookieJar.clear()
|
|
vi.unstubAllGlobals()
|
|
vi.clearAllMocks()
|
|
|
|
// The connection may have landed on a parallel spec's row (the loader
|
|
// prefers a configured row): clear the fields, never delete that row.
|
|
const touched = await payload.find({
|
|
collection: 'googleCalendarSync',
|
|
where: { oauthRefreshToken: { like: 'c149-refresh%' } },
|
|
depth: 0,
|
|
pagination: false,
|
|
overrideAccess: true,
|
|
})
|
|
for (const doc of touched.docs) {
|
|
await payload.update({
|
|
collection: 'googleCalendarSync',
|
|
id: doc.id,
|
|
data: {
|
|
oauthRefreshToken: null,
|
|
oauthScope: null,
|
|
oauthConnectedAt: null,
|
|
oauthErrorAt: null,
|
|
oauthError: null,
|
|
},
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
}
|
|
// Empty rows (no calendar, no token) are only produced by this file's
|
|
// start attempts — safe to delete.
|
|
const emptyRows = await payload.find({
|
|
collection: 'googleCalendarSync',
|
|
where: {
|
|
and: [{ calendarId: { exists: false } }, { oauthRefreshToken: { exists: false } }],
|
|
},
|
|
depth: 0,
|
|
pagination: false,
|
|
overrideAccess: true,
|
|
})
|
|
for (const doc of emptyRows.docs) {
|
|
await payload.delete({ collection: 'googleCalendarSync', id: doc.id, overrideAccess: true })
|
|
}
|
|
})
|
|
|
|
afterAll(() => {
|
|
restoreEnv()
|
|
})
|
|
|
|
it('start (coordinator) returns the consent URL and the callback records the connection', async () => {
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
mockedGetContext.mockResolvedValue({ payload, actor: coordinator })
|
|
setOAuthClientEnv()
|
|
|
|
const start = await startGoogleCalendarOAuth()
|
|
expect(start.ok).toBe(true)
|
|
if (!start.ok) throw new Error('start must succeed')
|
|
|
|
const authorizeUrl = new URL(start.authorizeUrl)
|
|
expect(`${authorizeUrl.origin}${authorizeUrl.pathname}`).toBe(
|
|
'https://accounts.google.com/o/oauth2/v2/auth',
|
|
)
|
|
expect(authorizeUrl.searchParams.get('access_type')).toBe('offline')
|
|
expect(authorizeUrl.searchParams.get('prompt')).toBe('consent')
|
|
expect(authorizeUrl.searchParams.get('redirect_uri')).toMatch(
|
|
/\/campanha\/agenda\/google-oauth\/callback$/,
|
|
)
|
|
const state = authorizeUrl.searchParams.get('state')
|
|
expect(state).toBeTruthy()
|
|
expect(cookieJar.has('campaign-google-oauth-state')).toBe(true)
|
|
|
|
const refreshToken = 'c149-refresh-callback-success'
|
|
vi.stubGlobal('fetch', googleFetchStub(refreshToken))
|
|
|
|
const response = await googleOAuthCallback(
|
|
callbackRequest({ code: 'code-1', state: state as string }),
|
|
)
|
|
expect(response.status).toBe(307)
|
|
expect(response.headers.get('location')).toContain('/campanha/agenda')
|
|
|
|
const rows = await rowsWithToken(refreshToken)
|
|
expect(rows.docs).toHaveLength(1)
|
|
expect(rows.docs[0]?.oauthConnectedAt).toBeTruthy()
|
|
// The state cookie is single-use.
|
|
expect(cookieJar.get('campaign-google-oauth-state')).toBe('')
|
|
})
|
|
|
|
it('start refuses an advisor without writing anything', async () => {
|
|
const advisor = await campaignFixtures().createCampaignUser('advisor')
|
|
mockedGetContext.mockResolvedValue({ payload, actor: advisor })
|
|
setOAuthClientEnv()
|
|
|
|
const start = await startGoogleCalendarOAuth()
|
|
|
|
expect(start.ok).toBe(false)
|
|
if (start.ok) throw new Error('start must be refused')
|
|
expect(start.message).toContain('candidato ou coordenação')
|
|
expect(cookieJar.has('campaign-google-oauth-state')).toBe(false)
|
|
})
|
|
|
|
it('start fails closed when the OAuth client env is missing', async () => {
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
mockedGetContext.mockResolvedValue({ payload, actor: coordinator })
|
|
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
|
|
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
|
|
|
|
const start = await startGoogleCalendarOAuth()
|
|
|
|
expect(start.ok).toBe(false)
|
|
if (start.ok) throw new Error('start must fail')
|
|
expect(start.message).toContain('não está configurada no servidor')
|
|
})
|
|
|
|
it('callback refuses an invalid state without recording anything', async () => {
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
mockedGetContext.mockResolvedValue({ payload, actor: coordinator })
|
|
setOAuthClientEnv()
|
|
vi.stubGlobal('fetch', googleFetchStub('c149-refresh-should-not-exist'))
|
|
|
|
const response = await googleOAuthCallback(
|
|
callbackRequest({ code: 'code-1', state: 'forged-state' }),
|
|
)
|
|
|
|
expect(response.status).toBe(307)
|
|
expect((await rowsWithToken('c149-refresh-should-not-exist')).docs).toHaveLength(0)
|
|
})
|
|
|
|
it('callback treats access_denied as a silent redirect', async () => {
|
|
const response = await googleOAuthCallback(callbackRequest({ error: 'access_denied' }))
|
|
|
|
expect(response.status).toBe(307)
|
|
expect(response.headers.get('location')).toContain('/campanha/agenda')
|
|
})
|
|
|
|
it('disconnect clears the stored connection', async () => {
|
|
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
|
mockedGetContext.mockResolvedValue({ payload, actor: coordinator })
|
|
setOAuthClientEnv()
|
|
|
|
const refreshToken = 'c149-refresh-disconnect'
|
|
await recordGoogleCalendarOAuthConnection(payload, { refreshToken, scope: null })
|
|
expect((await rowsWithToken(refreshToken)).docs).toHaveLength(1)
|
|
|
|
const result = await disconnectGoogleCalendarOAuth()
|
|
|
|
expect(result.ok).toBe(true)
|
|
expect((await rowsWithToken(refreshToken)).docs).toHaveLength(0)
|
|
})
|
|
})
|