49 lines
3.0 KiB
TypeScript
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'})
|
|
})
|
|
})
|