Files
Twungeon/tests/domain/oauth.test.ts
T

49 lines
3.0 KiB
TypeScript

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'})
})
})