feat: connect Twungeon to live Twitch

This commit is contained in:
2026-08-17 12:02:35 -07:00
parent 5bc70dd9b2
commit df984690ce
13 changed files with 523 additions and 55 deletions
+3 -1
View File
@@ -3,5 +3,7 @@ import { loadConfig } from '../../apps/backend/src/config.js'
describe('Channel Points configuration',()=>{
it('uses a safe local reward ID when Twitch is disabled',()=>expect(loadConfig({TWITCH_ENABLED:'false'}).resurrectionRewardId).toBe('local-resurrection'))
it('requires the reward ID when Twitch is enabled',()=>expect(()=>loadConfig({TWITCH_ENABLED:'true',TWITCH_CLIENT_ID:'id',TWITCH_CLIENT_SECRET:'secret',TWITCH_BROADCASTER_ID:'broadcaster',TWITCH_CHANNEL_LOGIN:'channel',TWITCH_BOT_ACCESS_TOKEN:'token',TWITCH_EXTENSION_SECRET:'extension'})).toThrow(/CHANNEL_POINTS_RESURRECTION_REWARD_ID/))
it('allows OAuth setup before live Twitch mode is enabled',()=>expect(loadConfig({TWITCH_ENABLED:'false',TWITCH_CLIENT_ID:'id',TWITCH_CLIENT_SECRET:'secret',TWITCH_CHANNEL_LOGIN:'labyricorn',PUBLIC_BASE_URL:'https://twungeon.example'})).toMatchObject({oauthConfigured:true,twitchRedirectUri:'https://twungeon.example/oauth/callback'}))
it('requires paired OAuth client credentials',()=>expect(()=>loadConfig({TWITCH_ENABLED:'false',TWITCH_CLIENT_ID:'id'})).toThrow(/configured together/))
it('requires the reward ID when Twitch is enabled',()=>expect(()=>loadConfig({TWITCH_ENABLED:'true',TWITCH_CLIENT_ID:'id',TWITCH_CLIENT_SECRET:'secret',TWITCH_BROADCASTER_ID:'broadcaster',TWITCH_CHANNEL_LOGIN:'channel',TWITCH_EXTENSION_SECRET:'extension',CHANNEL_POINTS_RESURRECTION_REWARD_ID:''})).toThrow(/CHANNEL_POINTS_RESURRECTION_REWARD_ID/))
})
+48
View File
@@ -0,0 +1,48 @@
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'})
})
})