import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { TwitchOAuthService } from '../../apps/backend/src/twitchOAuth.js' import { readStoredTwitchToken } from '../../packages/twitch-adapter/src/tokenStore.js' const directories: string[] = [] afterEach(async()=>{await Promise.all(directories.splice(0).map(path=>rm(path,{recursive:true,force:true})))}) async function harness(scopes=['chat:read','moderator:read:followers','channel:read:redemptions']) { const directory=await mkdtemp(join(tmpdir(),'twungeon-oauth-'));directories.push(directory) const tokenFile=join(directory,'token.json') const fetchMock=vi.fn(async(input:string|URL|Request)=>{ const url=String(input) if(url.endsWith('/token'))return new Response(JSON.stringify({access_token:'access',refresh_token:'refresh',expires_in:3600,scope:scopes,token_type:'bearer'}),{status:200,headers:{'content-type':'application/json'}}) if(url.endsWith('/validate'))return new Response(JSON.stringify({client_id:'client',login:'labyricorn',scopes,user_id:'1234',expires_in:3500}),{status:200,headers:{'content-type':'application/json'}}) return new Response(null,{status:404}) }) as typeof fetch const service=new TwitchOAuthService({clientId:'client',clientSecret:'secret',redirectUri:'https://twungeon.example/oauth/callback',expectedLogin:'labyricorn',tokenFile},fetchMock,()=>1_000) return {service,tokenFile,fetchMock} } describe('Twitch OAuth',()=>{ it('uses state, validates the broadcaster, and stores a refreshable token',async()=>{ const {service,tokenFile,fetchMock}=await harness() const authorization=new URL(service.startAuthorization()),state=authorization.searchParams.get('state')! expect(authorization.searchParams.get('redirect_uri')).toBe('https://twungeon.example/oauth/callback') expect(authorization.searchParams.get('scope')).toContain('moderator:read:followers') await expect(service.completeAuthorization('code',state)).resolves.toMatchObject({login:'labyricorn',userId:'1234'}) await expect(readStoredTwitchToken(tokenFile)).resolves.toMatchObject({accessToken:'access',refreshToken:'refresh',userId:'1234',clientId:'client'}) expect(fetchMock).toHaveBeenCalledTimes(2) await expect(service.status()).resolves.toMatchObject({configured:true,authorized:true}) }) it('rejects missing state before exchanging a code',async()=>{ const {service,fetchMock}=await harness() await expect(service.completeAuthorization('code','unknown')).rejects.toThrow(/state is missing or expired/) expect(fetchMock).not.toHaveBeenCalled() }) it('rejects a token missing a required scope',async()=>{ const {service,tokenFile}=await harness(['chat:read']) const state=new URL(service.startAuthorization()).searchParams.get('state')! await expect(service.completeAuthorization('code',state)).rejects.toThrow(/missing required scopes/) await expect(readStoredTwitchToken(tokenFile)).rejects.toMatchObject({code:'ENOENT'}) }) })