Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
448 lines
22 KiB
JavaScript
448 lines
22 KiB
JavaScript
// Sign in with Steam (docs/website/STEAM_SIGNIN.md): the OpenID 2.0 provider's
|
|
// checks (§3), the callback through the real SSO controller, D394 (key required),
|
|
// D395 (never signs up) and the module hooks behind D392/D396 (MODULE_API 1.17.0).
|
|
// Steam itself is a stubbed global fetch; no request leaves the process.
|
|
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
|
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
process.env.APP_BASE_URL = 'https://site.example'
|
|
|
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const SteamProvider = require('../src/auth/providers/steam.provider')
|
|
const registry = require('../src/auth/providers/registry')
|
|
const ssoCtrl = require('../src/router/v1/auth/sso.controller')
|
|
const ssoState = require('../src/auth/ssoState')
|
|
const token = require('../src/auth/token')
|
|
const identityHooks = require('../src/auth/identityHooks')
|
|
const loader = require('../src/modules/loader')
|
|
const users = require('../src/model/users/users.model')
|
|
const activity = require('../src/model/activity/activity.model')
|
|
const authProviders = require('../src/model/authProviders/authProviders.model')
|
|
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
|
|
const settings = require('../src/model/settings/settings.model')
|
|
const mobileBridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model')
|
|
const providersCtrl = require('../src/router/v1/admin/authProviders.controller')
|
|
const accountCtrl = require('../src/router/v1/auth/account.controller')
|
|
const db = require('../src/utils/db')
|
|
|
|
after(() => db.close())
|
|
|
|
const STEAM_ID = '76561197960287930'
|
|
const CALLBACK = 'https://site.example/api/v1/auth/sso/steam/callback'
|
|
const STEAM_ROW = { id: 'steam', kind: 'steam', name: 'Steam', enabled: 1, client_id: null, client_secret_enc: 'enc', client_secret: 'WEBAPIKEY' }
|
|
|
|
// ── A stand-in for steamcommunity.com and api.steampowered.com ──────────────
|
|
|
|
const realFetch = global.fetch
|
|
let steam // { valid, verifyStatus, verifyThrows, summary, summaryStatus, calls }
|
|
function installSteam(overrides = {}) {
|
|
steam = {
|
|
valid: true,
|
|
verifyStatus: 200,
|
|
verifyThrows: false,
|
|
summary: { steamid: STEAM_ID, personaname: 'CoolGuy99', avatarfull: 'https://avatars.steamstatic.com/abc_full.jpg' },
|
|
summaryStatus: 200,
|
|
calls: [],
|
|
...overrides,
|
|
}
|
|
global.fetch = async (url, init = {}) => {
|
|
const href = String(url)
|
|
steam.calls.push({ href, init })
|
|
if (href === SteamProvider.OP_ENDPOINT) {
|
|
if (steam.verifyThrows) throw new Error('ECONNRESET')
|
|
const body = new URLSearchParams(init.body)
|
|
steam.lastVerifyBody = body
|
|
return {
|
|
ok: steam.verifyStatus === 200,
|
|
status: steam.verifyStatus,
|
|
text: async () => `ns:http://specs.openid.net/auth/2.0\nis_valid:${steam.valid}\n`,
|
|
}
|
|
}
|
|
if (href.startsWith('https://api.steampowered.com/')) {
|
|
return {
|
|
ok: steam.summaryStatus === 200,
|
|
status: steam.summaryStatus,
|
|
json: async () => ({ response: { players: steam.summary ? [steam.summary] : [] } }),
|
|
}
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`)
|
|
}
|
|
}
|
|
afterEach(() => { global.fetch = realFetch })
|
|
|
|
// What Steam appends to our return_to after a successful sign-in.
|
|
function assertion(state, overrides = {}) {
|
|
const claimed = `https://steamcommunity.com/openid/id/${STEAM_ID}`
|
|
return {
|
|
state,
|
|
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
|
'openid.mode': 'id_res',
|
|
'openid.op_endpoint': SteamProvider.OP_ENDPOINT,
|
|
'openid.claimed_id': claimed,
|
|
'openid.identity': claimed,
|
|
'openid.return_to': SteamProvider.returnToFor(CALLBACK, state),
|
|
'openid.response_nonce': '2026-10-19T12:00:00Zabc',
|
|
'openid.assoc_handle': '1234567890',
|
|
'openid.signed': 'signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle',
|
|
'openid.sig': 'c2lnbmF0dXJl',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
// ── The provider (§3) ───────────────────────────────────────────────────────
|
|
|
|
test('the authorization URL is an OpenID 2.0 checkid_setup with our state inside return_to', () => {
|
|
const url = new URL(new SteamProvider({}).getAuthorizationUrl('nonce-1', { redirectUri: CALLBACK }))
|
|
assert.equal(url.origin + url.pathname, SteamProvider.OP_ENDPOINT)
|
|
assert.equal(url.searchParams.get('openid.mode'), 'checkid_setup')
|
|
assert.equal(url.searchParams.get('openid.return_to'), `${CALLBACK}?state=nonce-1`)
|
|
assert.equal(url.searchParams.get('openid.realm'), 'https://site.example')
|
|
assert.equal(url.searchParams.get('openid.claimed_id'), 'http://specs.openid.net/auth/2.0/identifier_select')
|
|
assert.equal(url.searchParams.get('client_id'), null, 'Steam has no client id')
|
|
})
|
|
|
|
test('parseClaimedId accepts only a 17-digit id on steamcommunity.com', () => {
|
|
assert.equal(SteamProvider.parseClaimedId(`https://steamcommunity.com/openid/id/${STEAM_ID}`), STEAM_ID)
|
|
assert.equal(SteamProvider.parseClaimedId('http://steamcommunity.com/openid/id/76561197960287930'), null)
|
|
assert.equal(SteamProvider.parseClaimedId('https://steamcommunity.com/openid/id/123'), null)
|
|
assert.equal(SteamProvider.parseClaimedId('https://evil.example/openid/id/76561197960287930'), null)
|
|
assert.equal(SteamProvider.parseClaimedId(`https://steamcommunity.com/openid/id/${STEAM_ID}/x`), null)
|
|
assert.equal(SteamProvider.parseClaimedId(undefined), null)
|
|
})
|
|
|
|
test('a valid assertion is verified with Steam and yields the id, persona name and avatar', async () => {
|
|
installSteam()
|
|
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
|
const profile = await p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' })
|
|
assert.deepEqual(profile, {
|
|
subject: STEAM_ID, email: null, emailVerified: false, name: 'CoolGuy99',
|
|
displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg',
|
|
})
|
|
// The verification echoed every openid field back with mode check_authentication,
|
|
// and did not forward our own `state`.
|
|
assert.equal(steam.lastVerifyBody.get('openid.mode'), 'check_authentication')
|
|
assert.equal(steam.lastVerifyBody.get('openid.sig'), 'c2lnbmF0dXJl')
|
|
assert.equal(steam.lastVerifyBody.get('state'), null)
|
|
})
|
|
|
|
for (const [label, overrides, state] of [
|
|
['a wrong op_endpoint', { 'openid.op_endpoint': 'https://evil.example/openid/login' }, 's1'],
|
|
['a return_to that is not ours', { 'openid.return_to': 'https://evil.example/cb?state=s1' }, 's1'],
|
|
['a claimed id that is not a Steam id', { 'openid.claimed_id': 'https://steamcommunity.com/openid/id/1', 'openid.identity': 'https://steamcommunity.com/openid/id/1' }, 's1'],
|
|
['an identity that differs from the claimed id', { 'openid.identity': 'https://steamcommunity.com/openid/id/76561197960287931' }, 's1'],
|
|
['mode cancel', { 'openid.mode': 'cancel' }, 's1'],
|
|
]) {
|
|
test(`${label} is refused before Steam is even asked`, async () => {
|
|
installSteam()
|
|
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
|
await assert.rejects(
|
|
p.handleCallback({ query: assertion(state, overrides), redirectUri: CALLBACK, state }),
|
|
SteamProvider.SteamAssertionError,
|
|
)
|
|
assert.equal(steam.calls.length, 0)
|
|
})
|
|
}
|
|
|
|
test('an assertion replayed into another sign-in fails: its return_to carries the old state', async () => {
|
|
installSteam()
|
|
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
|
// Captured from a sign-in whose state was s-old, replayed against a tx whose state is s-new.
|
|
const replay = { ...assertion('s-old'), state: 's-new' }
|
|
await assert.rejects(p.handleCallback({ query: replay, redirectUri: CALLBACK, state: 's-new' }), { reason: 'return_to' })
|
|
})
|
|
|
|
test('check_authentication saying is_valid:false refuses the sign-in', async () => {
|
|
installSteam({ valid: false })
|
|
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
|
await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' })
|
|
})
|
|
|
|
test('Steam unreachable for verification is a refusal, never a pass', async () => {
|
|
installSteam({ verifyThrows: true })
|
|
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
|
await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' })
|
|
installSteam({ verifyStatus: 503 })
|
|
await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' })
|
|
})
|
|
|
|
test('a failed Web API lookup still signs in, with the bare id (D394)', async () => {
|
|
installSteam({ summaryStatus: 403 })
|
|
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
|
const profile = await p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' })
|
|
assert.equal(profile.subject, STEAM_ID)
|
|
assert.equal(profile.displayName, null)
|
|
assert.equal(profile.avatarUrl, null)
|
|
})
|
|
|
|
test('an avatar that is not on a Steam CDN is dropped', async () => {
|
|
installSteam({ summary: { steamid: STEAM_ID, personaname: 'X', avatarfull: 'https://evil.example/a.jpg' } })
|
|
const profile = await new SteamProvider({ clientSecret: 'K' }).handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' })
|
|
assert.equal(profile.displayName, 'X')
|
|
assert.equal(profile.avatarUrl, null)
|
|
})
|
|
|
|
// ── Registry and Admin → Auth (D394) ────────────────────────────────────────
|
|
|
|
test('Steam is a built-in that needs only a Web API key', () => {
|
|
assert.ok(registry.isBuiltin('steam'))
|
|
assert.deepEqual(registry.validateConfig({ kind: 'steam' }), { valid: false, missing: ['api_key'] })
|
|
assert.deepEqual(registry.validateConfig({ kind: 'steam', client_secret_enc: 'e' }), { valid: true, missing: [] })
|
|
assert.equal(registry.neverProvisions('steam'), true)
|
|
assert.equal(registry.neverProvisions('google'), false)
|
|
})
|
|
|
|
function mockRes() {
|
|
return {
|
|
statusCode: 200, redirectedTo: null, cookies: {}, cleared: [],
|
|
status(c) { this.statusCode = c; return this },
|
|
json(b) { this.body = b; return this },
|
|
redirect(u) { this.redirectedTo = u; return this },
|
|
cookie(n, v) { this.cookies[n] = v; return this },
|
|
clearCookie(n) { this.cleared.push(n); return this },
|
|
}
|
|
}
|
|
|
|
test('enabling Steam with no key is refused with a sentence; with one it saves', async () => {
|
|
let saved = null
|
|
authProviders.get = async () => null
|
|
authProviders.save = async (id, fields) => { saved = { id, ...fields }; return { id, kind: 'steam', name: 'Steam', enabled: 1, client_secret_enc: 'e' } }
|
|
activity.log = async () => {}
|
|
|
|
const refused = mockRes()
|
|
await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true }, user: { username: 'admin' } }, refused)
|
|
assert.equal(refused.statusCode, 400)
|
|
assert.match(refused.body.message, /Steam Web API key/)
|
|
assert.equal(saved, null)
|
|
|
|
const ok = mockRes()
|
|
await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true, secret: 'WEBAPIKEY', clientId: 'ignored' }, user: { username: 'admin' } }, ok)
|
|
assert.equal(ok.statusCode, 200)
|
|
assert.equal(saved.kind, 'steam')
|
|
assert.equal(saved.secret, 'WEBAPIKEY')
|
|
assert.equal(saved.clientId, undefined, 'Steam keeps no client id')
|
|
assert.equal(ok.body.hasSecret, true)
|
|
})
|
|
|
|
test('re-enabling Steam with a key already stored needs no new one', async () => {
|
|
authProviders.get = async () => ({ id: 'steam', kind: 'steam', client_secret_enc: 'e' })
|
|
authProviders.save = async () => ({ id: 'steam', kind: 'steam', name: 'Steam', enabled: 1, client_secret_enc: 'e' })
|
|
activity.log = async () => {}
|
|
const res = mockRes()
|
|
await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true }, user: { username: 'admin' } }, res)
|
|
assert.equal(res.statusCode, 200)
|
|
})
|
|
|
|
// ── The callback through the real controller ────────────────────────────────
|
|
|
|
let logged
|
|
let linkArgs
|
|
let refreshed
|
|
let created
|
|
let hooks
|
|
beforeEach(() => {
|
|
logged = []
|
|
linkArgs = null
|
|
refreshed = null
|
|
created = null
|
|
hooks = { onIdentityLinking: [], onIdentityLinked: [] }
|
|
activity.log = async (evt) => { logged.push(evt) }
|
|
authProviders.getWithSecret = async () => ({ ...STEAM_ROW })
|
|
registry.instantiate = (row) => new SteamProvider({ ...row, clientSecret: row.client_secret })
|
|
userIdentities.findByProviderSubject = async () => null
|
|
userIdentities.link = async (args) => { linkArgs = args; return 1 }
|
|
userIdentities.refreshProfile = async (id, p) => { refreshed = { id, ...p }; return 1 }
|
|
users.getById = async (id) => ({ id, username: 'alice', role: 'player', status: 'active' })
|
|
users.recordLogin = async () => {}
|
|
users.createUser = async (args) => { created = args; return { id: 99, ...args } }
|
|
settings.getRegistrationMode = async () => 'both' // SSO sign-up ON: Steam must still refuse
|
|
loader.identityHooks = (name) => hooks[name]
|
|
installSteam()
|
|
})
|
|
|
|
function steamReq(tx, overrides = {}) {
|
|
return {
|
|
params: { provider: 'steam' },
|
|
cookies: { [ssoState.TX_COOKIE]: tx.txToken },
|
|
query: assertion(tx.nonce, overrides),
|
|
ip: '127.0.0.1', protocol: 'https', get: () => 'site.example', headers: {},
|
|
}
|
|
}
|
|
|
|
test('a linked Steam account signs in through the one session service, and its name is refreshed', async () => {
|
|
userIdentities.findByProviderSubject = async (provider, subject) =>
|
|
(provider === 'steam' && subject === STEAM_ID ? { id: 3, user_id: 7, provider: 'steam' } : null)
|
|
const tx = ssoState.createTx({ provider: 'steam', mode: 'login', returnTo: '/account' })
|
|
const res = mockRes()
|
|
await ssoCtrl.callback(steamReq(tx), res)
|
|
assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie set')
|
|
assert.equal(res.redirectedTo, '/account')
|
|
assert.equal(logged.at(-1).action, 'auth.sso.login')
|
|
assert.deepEqual(refreshed, { id: 3, displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg' })
|
|
})
|
|
|
|
test('an unlinked Steam id signs nobody in and creates nobody, even with SSO sign-up on (D395)', async () => {
|
|
const tx = ssoState.createTx({ provider: 'steam', mode: 'login', returnTo: '/account' })
|
|
const res = mockRes()
|
|
await ssoCtrl.callback(steamReq(tx), res)
|
|
assert.equal(res.redirectedTo, '/account/login?sso_error=not_linked')
|
|
assert.equal(created, null)
|
|
assert.equal(linkArgs, null)
|
|
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
|
|
})
|
|
|
|
test('the same refusal holds on the mobile bridge (D395)', async () => {
|
|
mobileBridge.getSession = async () => ({
|
|
session_id: 'm1', status: 'pending', redirect_uri: 'runicgateway://sso', state: 'app-state',
|
|
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
})
|
|
const tx = ssoState.createTx({ provider: 'steam', mode: 'mobile', mobileSessionId: 'm1' })
|
|
const res = mockRes()
|
|
await ssoCtrl.callback(steamReq(tx), res)
|
|
assert.equal(res.redirectedTo, 'runicgateway://sso?error=not_linked&state=app-state')
|
|
assert.equal(created, null)
|
|
})
|
|
|
|
test('a linked Steam account signs in to the app through the mobile bridge (M9)', async () => {
|
|
userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 7, provider: 'steam' })
|
|
mobileBridge.getSession = async () => ({
|
|
session_id: 'm2', status: 'pending', redirect_uri: 'runicgateway://sso', state: 'app-state',
|
|
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
})
|
|
mobileBridge.issueAuthCode = async () => ({ code: 'one-time-code' })
|
|
const tx = ssoState.createTx({ provider: 'steam', mode: 'mobile', mobileSessionId: 'm2' })
|
|
const res = mockRes()
|
|
await ssoCtrl.callback(steamReq(tx), res)
|
|
assert.equal(res.redirectedTo, 'runicgateway://sso?code=one-time-code&state=app-state')
|
|
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'the app gets a code, never a cookie')
|
|
})
|
|
|
|
test('a forged callback (Steam says is_valid:false) is a refused sign-in, not a server error', async () => {
|
|
installSteam({ valid: false })
|
|
userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 7 })
|
|
const tx = ssoState.createTx({ provider: 'steam', mode: 'login' })
|
|
const res = mockRes()
|
|
await ssoCtrl.callback(steamReq(tx), res)
|
|
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
|
|
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
|
|
})
|
|
|
|
test('cancelling on steamcommunity.com reads as denied', async () => {
|
|
const tx = ssoState.createTx({ provider: 'steam', mode: 'login' })
|
|
const res = mockRes()
|
|
await ssoCtrl.callback({ ...steamReq(tx), query: { state: tx.nonce, 'openid.mode': 'cancel' } }, res)
|
|
assert.equal(res.redirectedTo, '/admin/login?sso_error=denied')
|
|
assert.equal(steam.calls.length, 0)
|
|
})
|
|
|
|
test('a callback with a state that is not the tx is refused before Steam is asked', async () => {
|
|
const tx = ssoState.createTx({ provider: 'steam', mode: 'login' })
|
|
const res = mockRes()
|
|
const req = steamReq(tx)
|
|
req.query.state = 'tampered'
|
|
await ssoCtrl.callback(req, res)
|
|
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
|
|
assert.equal(steam.calls.length, 0)
|
|
})
|
|
|
|
// ── Linking, and the module hooks (MODULE_API 1.17.0) ───────────────────────
|
|
|
|
test('linking Steam stores the id with its name and avatar, then tells the modules once (D392)', async () => {
|
|
const told = []
|
|
hooks.onIdentityLinked = [{ id: 'rust', hook: async (link) => { told.push(link) } }]
|
|
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
|
const res = mockRes()
|
|
await ssoCtrl.callback(steamReq(tx), res)
|
|
assert.deepEqual(linkArgs, {
|
|
userId: 5, provider: 'steam', subject: STEAM_ID, email: null,
|
|
displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg',
|
|
})
|
|
assert.equal(res.redirectedTo, '/account?linked=steam')
|
|
assert.deepEqual(told, [{ userId: 5, provider: 'steam', subject: STEAM_ID, displayName: 'CoolGuy99' }])
|
|
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session')
|
|
})
|
|
|
|
test('a module refusing a link writes nothing and the player reads "in use" (D396)', async () => {
|
|
const told = []
|
|
hooks.onIdentityLinking = [{ id: 'rust', hook: async () => ({ ok: false, reason: 'in_use', message: 'held by bob' }) }]
|
|
hooks.onIdentityLinked = [{ id: 'rust', hook: async (link) => { told.push(link) } }]
|
|
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
|
const res = mockRes()
|
|
await ssoCtrl.callback(steamReq(tx), res)
|
|
assert.equal(res.redirectedTo, '/account?link_error=in_use')
|
|
assert.equal(linkArgs, null)
|
|
assert.deepEqual(told, [])
|
|
assert.equal(logged.at(-1).action, 'auth.sso.link.refused')
|
|
assert.doesNotMatch(res.redirectedTo, /bob/, 'a module\'s own words never reach the URL')
|
|
})
|
|
|
|
test('a module that throws or stalls refuses the link (fails closed)', async () => {
|
|
hooks.onIdentityLinking = [{ id: 'rust', hook: async () => { throw new Error('db down') } }]
|
|
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
|
const res = mockRes()
|
|
await ssoCtrl.callback(steamReq(tx), res)
|
|
assert.equal(res.redirectedTo, '/account?link_error=refused')
|
|
assert.equal(linkArgs, null)
|
|
})
|
|
|
|
test('a linked hook that fails never undoes the link', async () => {
|
|
hooks.onIdentityLinked = [{ id: 'rust', hook: async () => { throw new Error('boom') } }]
|
|
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
|
const res = mockRes()
|
|
await ssoCtrl.callback(steamReq(tx), res)
|
|
assert.equal(res.redirectedTo, '/account?linked=steam')
|
|
assert.ok(linkArgs)
|
|
})
|
|
|
|
test('linking a Steam account already yours refreshes it and asks no module', async () => {
|
|
let asked = false
|
|
hooks.onIdentityLinking = [{ id: 'rust', hook: async () => { asked = true; return { ok: true } } }]
|
|
userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 5, provider: 'steam' })
|
|
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
|
const res = mockRes()
|
|
await ssoCtrl.callback(steamReq(tx), res)
|
|
assert.equal(res.redirectedTo, '/account?linked=steam')
|
|
assert.equal(asked, false)
|
|
assert.equal(linkArgs, null)
|
|
assert.equal(refreshed.id, 3)
|
|
})
|
|
|
|
test('checkLink stops at the first refusal and hands a hook a frozen copy', async () => {
|
|
let seen
|
|
hooks.onIdentityLinking = [
|
|
{ id: 'a', hook: async (link) => { seen = link; return { ok: true } } },
|
|
{ id: 'b', hook: async () => ({ ok: false, reason: 'anything else' }) },
|
|
{ id: 'c', hook: async () => { throw new Error('never reached') } },
|
|
]
|
|
const answer = await identityHooks.checkLink({ userId: 1, provider: 'steam', subject: STEAM_ID, extra: 'x' })
|
|
assert.deepEqual(answer, { ok: false, reason: 'refused', module: 'b' })
|
|
assert.ok(Object.isFrozen(seen))
|
|
assert.deepEqual(Object.keys(seen), ['userId', 'provider', 'subject'])
|
|
})
|
|
|
|
test('with no module hooks a link is checked exactly as before', async () => {
|
|
assert.deepEqual(await identityHooks.checkLink({ userId: 1, provider: 'google', subject: 's' }), { ok: true })
|
|
})
|
|
|
|
// ── Unlinking one of several (D393 leaves module links alone) ───────────────
|
|
|
|
test('unlink with ?id= removes that one identity only', async () => {
|
|
let args
|
|
userIdentities.unlink = async (...a) => { args = a; return 1 }
|
|
const res = mockRes()
|
|
await accountCtrl.unlinkIdentity({ params: { provider: 'steam' }, query: { id: '12' }, user: { id: 5 } }, res)
|
|
assert.deepEqual(args, [5, 'steam', 12])
|
|
assert.deepEqual(res.body, { unlinked: true })
|
|
})
|
|
|
|
test('unlink without an id removes every identity of the provider, as before', async () => {
|
|
let args
|
|
userIdentities.unlink = async (...a) => { args = a; return 2 }
|
|
const res = mockRes()
|
|
await accountCtrl.unlinkIdentity({ params: { provider: 'google' }, query: {}, user: { id: 5 } }, res)
|
|
assert.deepEqual(args, [5, 'google', null])
|
|
})
|