Files
atproto/packages/bsky/tests/views/age-assurance-v2.test.ts
2026-03-23 18:10:16 +01:00

804 lines
23 KiB
TypeScript

import crypto from 'node:crypto'
import { once } from 'node:events'
import { Server, createServer } from 'node:http'
import { AddressInfo } from 'node:net'
import express, { Application, json } from 'express'
import {
AppBskyAgeassuranceBegin,
AppBskyAgeassuranceDefs,
AppBskyAgeassuranceGetState,
AtpAgent,
ageAssuranceRuleIDs as ruleIds,
ids,
} from '@atproto/api'
import { SeedClient, TestNetwork, basicSeed } from '@atproto/dev-env'
import {
type KWSWebhookAgeVerified,
serializeKWSAgeVerifiedStatus,
} from '../../src/api/age-assurance/kws/age-verified'
import {
KWSExternalPayloadVersion,
serializeKWSExternalPayloadV1,
serializeKWSExternalPayloadV2,
} from '../../src/api/age-assurance/kws/external-payload'
import { KwsWebhookBody } from '../../src/api/kws/types'
type Database = TestNetwork['bsky']['db']
const BSKY_REDIRECT_URL = 'http://bsky'
jest.mock('../../dist/api/age-assurance/const.js', () => {
const AGE_ASSURANCE_CONFIG: AppBskyAgeassuranceDefs.Config = {
regions: [
{
countryCode: 'AA',
regionCode: undefined,
minAccessAge: 13,
rules: [
{
$type: ruleIds.IfAssuredOverAge,
age: 18,
access: 'full',
},
{
$type: ruleIds.Default,
access: 'safe',
},
],
},
{
countryCode: 'BB',
regionCode: undefined,
minAccessAge: 13,
rules: [
{
$type: ruleIds.IfAssuredOverAge,
age: 18,
access: 'full',
},
{
$type: ruleIds.Default,
access: 'safe',
},
],
},
],
}
return {
AGE_ASSURANCE_CONFIG,
}
})
jest.mock('../../dist/api/age-assurance/kws/const.js', () => {
const actual = jest.requireActual('../../dist/api/age-assurance/kws/const.js')
const KWS_V2_COUNTRIES = new Set(['AA'])
return {
...actual,
KWS_V2_COUNTRIES,
}
})
describe('age assurance v2 views', () => {
let network: TestNetwork
let db: Database
let agent: AtpAgent
let sc: SeedClient
let kws: MockKwsServer
const kwsOauthMock = jest.fn()
const kwsSendAgeVerifiedFlowEmailMock = jest.fn()
const kwsSendAdultVerifiedFlowEmailMock = jest.fn()
const actor = {
did: '',
email: '',
}
beforeAll(async () => {
kws = new MockKwsServer({
oauthMock: kwsOauthMock,
sendAgeVerifiedFlowEmailMock: kwsSendAgeVerifiedFlowEmailMock,
sendAdultVerifiedFlowEmailMock: kwsSendAdultVerifiedFlowEmailMock,
})
await kws.listen()
network = await TestNetwork.create({
dbPostgresSchema: 'bsky_views_age_assurance_v_two',
bsky: {
kws: {
apiKey: 'apiKey',
apiOrigin: kws.url,
authOrigin: kws.url,
clientId: 'clientId',
redirectUrl: BSKY_REDIRECT_URL,
userAgent: 'userAgent',
verificationSecret: kws.verificationSecret,
webhookSecret: kws.webhookSecret,
ageVerifiedWebhookSecret: kws.ageVerifiedWebhookSecret,
ageVerifiedRedirectSecret: kws.ageVerifiedRedirectSecret,
},
},
})
kws.setBskyBaseUrl(network.bsky.url)
db = network.bsky.db
agent = network.bsky.getAgent()
sc = network.getSeedClient()
await basicSeed(sc)
await network.processAll()
actor.did = sc.dids.alice
actor.email = sc.accounts[actor.did].email
})
beforeEach(async () => {
// Default mocks for KWS endpoints.
kwsOauthMock.mockImplementation(
(_req: express.Request, res: express.Response) =>
res.json({
access_token:
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.INVALID',
expires_in: 3600,
}),
)
kwsSendAgeVerifiedFlowEmailMock.mockImplementation(
(_req: express.Request, res: express.Response) => {
res.json({})
},
)
kwsSendAdultVerifiedFlowEmailMock.mockImplementation(
(_req: express.Request, res: express.Response) => {
res.json({})
},
)
})
afterEach(async () => {
jest.resetAllMocks()
await clearPrivateData(db)
await clearActorAgeAssurance(db)
})
afterAll(async () => {
await network.close()
await kws.stop()
})
const getState = async (params: AppBskyAgeassuranceGetState.QueryParams) => {
const { data } = await agent.app.bsky.ageassurance.getState(params, {
headers: await network.serviceHeaders(
actor.did,
ids.AppBskyAgeassuranceGetState,
),
})
return data
}
const beginAgeAssurance = async (
params: Omit<AppBskyAgeassuranceBegin.InputSchema, 'email' | 'language'> & {
email?: string
},
) => {
const { data } = await agent.app.bsky.ageassurance.begin(
{
...params,
email: params.email || sc.accounts[actor.did].email,
language: 'en',
},
{
headers: await network.serviceHeaders(
actor.did,
ids.AppBskyAgeassuranceBegin,
),
},
)
return data
}
describe('app.bsky.ageassurance.getState', () => {
it('initially returns defaults', async () => {
const { state, metadata } = await getState({
countryCode: 'US',
regionCode: undefined,
})
expect(metadata.accountCreatedAt).toBeDefined()
expect(state).toEqual({
lastInitatedAt: undefined,
status: 'unknown',
access: 'unknown',
})
})
})
describe('app.bsky.ageassurance.begin', () => {
it('fails if region not supported', async () => {
const call = beginAgeAssurance({
countryCode: 'XX',
})
await expect(call).rejects.toHaveProperty('error', 'RegionNotSupported')
})
it('fails if email is invalid', async () => {
const call = beginAgeAssurance({
email: 'invalid-email',
countryCode: 'XX',
})
await expect(call).rejects.toHaveProperty('error', 'InvalidEmail')
})
it('succeeds for V2 country', async () => {
const res = await beginAgeAssurance({
countryCode: 'AA',
})
await network.processAll()
const { state } = await getState({
countryCode: 'AA',
})
expect(kwsSendAgeVerifiedFlowEmailMock).toHaveBeenCalledTimes(1)
expect(res).toEqual(state)
expect(state.lastInitiatedAt).toBeDefined()
expect(state.status).toEqual('pending')
expect(state.access).toEqual('unknown')
})
it('succeeds for V1 country', async () => {
const res = await beginAgeAssurance({
countryCode: 'BB',
})
await network.processAll()
const { state } = await getState({
countryCode: 'BB',
})
expect(kwsSendAdultVerifiedFlowEmailMock).toHaveBeenCalledTimes(1)
expect(res).toEqual(state)
expect(state.lastInitiatedAt).toBeDefined()
expect(state.status).toEqual('pending')
expect(state.access).toEqual('unknown')
})
})
describe('external handlers', () => {
describe('V2 redirects', () => {
it('redirects with result=unknown if we fail to parse the status object', async () => {
const res = await kws.redirectV2({
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'AA',
}),
status: JSON.stringify({
verified: true,
verifiedMinimumAge: '18', // will fail parsing
}),
})
expect(res.status).toBe(302)
expect(res.headers.get('Location')).toBe(
`${BSKY_REDIRECT_URL}?result=unknown`,
)
})
it('redirects with result=unknown if status is not verified', async () => {
const res = await kws.redirectV2({
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'AA',
}),
status: serializeKWSAgeVerifiedStatus({
verified: false,
verifiedMinimumAge: 18,
}),
})
expect(res.status).toBe(302)
expect(res.headers.get('Location')).toBe(
`${BSKY_REDIRECT_URL}?actorDid=${encodeURIComponent(actor.did)}&result=unknown`,
)
})
// this also covers any other thrown errors
it('redirects with result=unknown if access check throws', async () => {
const res = await kws.redirectV2({
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'XX', // should never reach KWS anyway
}),
status: serializeKWSAgeVerifiedStatus({
verified: true,
verifiedMinimumAge: 18,
}),
})
expect(res.status).toBe(302)
expect(res.headers.get('Location')).toBe(
`${BSKY_REDIRECT_URL}?actorDid=${encodeURIComponent(actor.did)}&result=unknown`,
)
})
it('success', async () => {
await beginAgeAssurance({
countryCode: 'AA',
})
await network.processAll()
await kws.redirectV2({
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'AA',
}),
status: serializeKWSAgeVerifiedStatus({
verified: true,
verifiedMinimumAge: 18,
}),
})
await network.processAll()
const { state } = await getState({
countryCode: 'AA',
})
expect(state.lastInitiatedAt).toBeDefined()
expect(state.status).toEqual('assured')
expect(state.access).toEqual('full')
})
})
describe('V2 webhooks', () => {
it('returns 400 if we fail to parse the external payload', async () => {
const res = await kws.webhookV2({
name: 'age-verified',
time: new Date().toISOString(),
orgId: crypto.randomUUID(),
productId: crypto.randomUUID(),
payload: {
email: actor.email,
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'AA',
}),
status: {
verified: true,
// @ts-ignore testing invalid payload
verifiedMinimumAge: '18',
},
},
})
expect(res.status).toBe(400)
await expect(res.json()).resolves.toHaveProperty(
'error',
'Failed to parse KWS webhook body',
)
})
it('returns 400 if status is not verified', async () => {
const res = await kws.webhookV2({
name: 'age-verified',
time: new Date().toISOString(),
orgId: crypto.randomUUID(),
productId: crypto.randomUUID(),
payload: {
email: actor.email,
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'AA',
}),
status: {
verified: false,
verifiedMinimumAge: 18,
},
},
})
expect(res.status).toBe(400)
await expect(res.json()).resolves.toHaveProperty(
'error',
'Expected KWS webhook to have verified status',
)
})
it('returns 200, but AA state unchanged due to invalid region', async () => {
const res = await kws.webhookV2({
name: 'age-verified',
time: new Date().toISOString(),
orgId: crypto.randomUUID(),
productId: crypto.randomUUID(),
payload: {
email: actor.email,
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'XX',
}),
status: {
verified: true,
verifiedMinimumAge: 18,
},
},
})
await network.processAll()
expect(res.status).toBe(200)
const { state } = await getState({
countryCode: 'XX',
})
expect(state.status).toEqual('unknown') // we never began, so it's still unknown
})
it('success', async () => {
await beginAgeAssurance({
countryCode: 'AA',
})
await network.processAll()
await kws.webhookV2({
name: 'age-verified',
time: new Date().toISOString(),
orgId: crypto.randomUUID(),
productId: crypto.randomUUID(),
payload: {
email: actor.email,
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'AA',
}),
status: {
verified: true,
verifiedMinimumAge: 18,
},
},
})
await network.processAll()
const { state } = await getState({
countryCode: 'AA',
})
expect(state.lastInitiatedAt).toBeDefined()
expect(state.status).toEqual('assured')
expect(state.access).toEqual('full')
})
})
describe('V1 compat', () => {
it('works via webhook', async () => {
await beginAgeAssurance({
countryCode: 'BB',
})
await network.processAll()
await kws.webhookV1({
payload: {
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'BB',
}),
status: {
verified: true,
},
},
})
await network.processAll()
const { state } = await getState({
countryCode: 'BB',
})
expect(state.lastInitiatedAt).toBeDefined()
expect(state.status).toEqual('assured')
expect(state.access).toEqual('full')
})
it('works via redirect', async () => {
await beginAgeAssurance({
countryCode: 'BB',
})
await network.processAll()
await kws.redirectV1({
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'BB',
}),
status: JSON.stringify({
verified: true,
}),
})
await network.processAll()
const { state } = await getState({
countryCode: 'BB',
})
expect(state.lastInitiatedAt).toBeDefined()
expect(state.status).toEqual('assured')
expect(state.access).toEqual('full')
})
})
})
describe('misc', () => {
/*
* This is a silly test, but it did help me uncover a local data-plane
* implementation bug. Let's leave it here for additional safety.
*/
it('expects access to be safe', async () => {
await kws.redirectV2({
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'AA',
}),
status: serializeKWSAgeVerifiedStatus({
verified: true,
verifiedMinimumAge: 16,
}),
})
await network.processAll()
const { state } = await getState({
countryCode: 'AA',
})
expect(state.status).toEqual('assured')
expect(state.access).toEqual('safe')
})
/**
* We only block re-init if the user is in a `blocked` state, which is not
* testable using the local dataplane at the moment. The test below
* reflects v1 handling.
*
* Skipping for now, but this handling is implemented in v2.
*/
it.skip('cannot re-init from terminal state', async () => {
await kws.redirectV2({
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'AA',
}),
status: serializeKWSAgeVerifiedStatus({
verified: true,
verifiedMinimumAge: 18,
}),
})
await network.processAll()
const call = beginAgeAssurance({
countryCode: 'AA',
})
await expect(call).rejects.toHaveProperty('error', 'InvalidInitiation')
})
it('re-init from terminal state retains existing status', async () => {
await kws.redirectV2({
externalPayload: serializeKWSExternalPayloadV2({
version: KWSExternalPayloadVersion.V2,
actorDid: actor.did,
attemptId: crypto.randomUUID(),
countryCode: 'AA',
}),
status: serializeKWSAgeVerifiedStatus({
verified: true,
verifiedMinimumAge: 16,
}),
})
await network.processAll()
const { state } = await getState({
countryCode: 'AA',
})
expect(state.status).toEqual('assured')
expect(state.access).toEqual('safe')
const res = await beginAgeAssurance({
countryCode: 'AA',
})
expect(res.status).toEqual('assured')
expect(res.access).toEqual('safe')
})
/*
* This tests local dataplane behavior, but the actual prod implementation
* lives in the dataplane repo, obviously.
*/
it('dataplane converts v1 to v2 state at read time', async () => {
await beginAgeAssurance({
countryCode: 'BB',
})
await network.processAll()
await kws.webhookV1({
payload: {
externalPayload: serializeKWSExternalPayloadV1({
actorDid: actor.did,
attemptId: crypto.randomUUID(),
}),
status: {
verified: true,
},
},
})
await network.processAll()
const { state } = await getState({
countryCode: 'BB',
})
expect(state.lastInitiatedAt).toBeDefined()
expect(state.status).toEqual('assured')
expect(state.access).toEqual('full')
})
})
})
const clearPrivateData = async (db: Database) => {
await db.db.deleteFrom('private_data').execute()
}
const clearActorAgeAssurance = async (db: Database) => {
await db.db
.updateTable('actor')
.set({
ageAssuranceStatus: null,
ageAssuranceLastInitiatedAt: null,
ageAssuranceAccess: null,
ageAssuranceCountryCode: null,
ageAssuranceRegionCode: null,
})
.execute()
}
class MockKwsServer {
verificationSecret = 'verificationSecret' // unused here
webhookSecret = 'webhookSecret' // unused here
ageVerifiedWebhookSecret = 'ageVerifiedWebhookSecret'
ageVerifiedRedirectSecret = 'ageVerifiedRedirectSecret'
private app: Application
private server: Server
private bskyUrlBase = ''
constructor({
oauthMock,
sendAgeVerifiedFlowEmailMock,
sendAdultVerifiedFlowEmailMock,
}: {
oauthMock: jest.Mock
sendAgeVerifiedFlowEmailMock: jest.Mock
sendAdultVerifiedFlowEmailMock: jest.Mock
}) {
this.app = express()
.use(json())
.post('/auth/realms/kws/protocol/openid-connect/token', (_, res) =>
oauthMock(_, res),
)
.post('/v1/verifications/send-email', (req, res) => {
const body = req.body
if (body.userContext === 'age') {
return sendAgeVerifiedFlowEmailMock(req, res)
} else if (body.userContext === 'adult') {
return sendAdultVerifiedFlowEmailMock(req, res)
}
})
this.server = createServer(this.app)
}
async listen(port?: number) {
this.server.listen(port)
await once(this.server, 'listening')
}
async stop() {
this.server.close()
await once(this.server, 'close')
}
setBskyBaseUrl(url: string) {
this.bskyUrlBase = url
}
redirectV1({
externalPayload,
status,
}: {
externalPayload: string
status: string
}) {
const sig = crypto
.createHmac('sha256', this.verificationSecret)
.update(`${status}:${externalPayload}`)
.digest('hex')
const queryString = new URLSearchParams({
externalPayload,
signature: sig,
status,
}).toString()
return fetch(
`${this.bskyUrlBase}/external/kws/age-assurance-verification?${queryString}`,
{
method: 'GET',
redirect: 'manual',
},
)
}
redirectV2({
externalPayload,
status,
}: {
externalPayload: string
status: string
}) {
const sig = crypto
.createHmac('sha256', this.ageVerifiedRedirectSecret)
.update(`${status}:${externalPayload}`)
.digest('hex')
const queryString = new URLSearchParams({
externalPayload,
signature: sig,
status,
}).toString()
return fetch(
`${this.bskyUrlBase}/external/age-assurance/redirects/kws-age-verified?${queryString}`,
{
method: 'GET',
redirect: 'manual',
},
)
}
webhookV1(
body: Omit<KwsWebhookBody, 'payload'> & {
payload: Omit<KwsWebhookBody['payload'], 'externalPayload'> & {
externalPayload: string
}
},
): Promise<Response> {
const bodyBuffer = Buffer.from(JSON.stringify(body))
const timestamp = new Date().valueOf()
const sig = crypto
.createHmac('sha256', this.webhookSecret)
.update(`${timestamp}.${bodyBuffer}`)
.digest('hex')
return fetch(`${this.bskyUrlBase}/external/kws/age-assurance-webhook`, {
method: 'POST',
body: bodyBuffer,
headers: {
'x-kws-signature': `t=${timestamp},v1=${sig}`,
'Content-Type': 'application/json',
},
})
}
webhookV2(body: KWSWebhookAgeVerified): Promise<Response> {
const bodyBuffer = Buffer.from(JSON.stringify(body))
const timestamp = new Date().valueOf()
const sig = crypto
.createHmac('sha256', this.ageVerifiedWebhookSecret)
.update(`${timestamp}.${bodyBuffer}`)
.digest('hex')
return fetch(
`${this.bskyUrlBase}/external/age-assurance/webhooks/kws-age-verified`,
{
method: 'POST',
body: bodyBuffer,
headers: {
'x-kws-signature': `t=${timestamp},v1=${sig}`,
'Content-Type': 'application/json',
},
},
)
}
get url() {
const address = this.server.address() as AddressInfo
return `http://localhost:${address.port}`
}
}