feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,258 @@
// apps/desktop/tests/red/ads/admob-ssv.e2e.test.ts
// D3RO Voice — AdMob 리워드 SSV(서버 사이드 검증) E2E RED 시나리오
// 계열 E: 수입 인정의 유일한 근거인 SSV 콜백 검증 계약.
// 실 ECDSA P-256 키쌍을 생성해 서명/위조/변조를 실측한다(정답 유도 목업 금지).
import { beforeAll, describe, expect, it } from 'vitest'
import {
AdMobSsvError,
derEcdsaSignatureToRaw,
parseAdMobCallbackUrl,
rememberVerifiedAdMobTransaction,
verifyAdMobCallback,
} from '../../../../../server/supabase/functions/_shared/admob-ssv'
const FX = {
KEY_ID: '1234567890',
AD_UNIT: 'ca-app-pub-3940256099942544/5224354917',
USER_ID: 'fx.user.opaque.U1',
CUSTOM_DATA: 'fx.custom.opaque.CD7',
REWARD_ITEM: 'cloud_ai_tokens',
REWARD_AMOUNT: '50',
TX_ID: 'fx.tx.opaque.TX01',
UNKNOWN_KEY: '9999999999',
} as const
// ── 실제 ECDSA 키쌍으로 AdMob 콜백 서명 도구 ─────────────────────────
let spkiBase64 = ''
let privateKey: CryptoKey | null = null
let txSeq = 0
async function setupKeys(): Promise<void> {
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
privateKey = pair.privateKey
const spki = await crypto.subtle.exportKey('spki', pair.publicKey)
const bytes = new Uint8Array(spki)
let binary = ''
for (const b of bytes) binary += String.fromCharCode(b)
spkiBase64 = btoa(binary)
}
/** AdMob 규격 콜백 URL 생성: 서명 대상은 key_id/signature를 제외한 쿼리 문자열. */
async function buildCallbackUrl(
opts: {
tamperSignedContent?: boolean
signature?: string
keyId?: string
omitParam?: string
extraAfterKey?: boolean
baseParams?: Record<string, string>
} = {},
): Promise<string> {
const params = new URLSearchParams({
ad_unit: FX.AD_UNIT,
custom_data: FX.CUSTOM_DATA,
reward_amount: FX.REWARD_AMOUNT,
reward_item: FX.REWARD_ITEM,
timestamp: String(Date.now()),
transaction_id: `${FX.TX_ID}.${++txSeq}`,
user_id: FX.USER_ID,
...opts.baseParams,
})
if (opts.omitParam) params.delete(opts.omitParam)
const signedContent = params.toString()
let signature = opts.signature
if (!signature) {
const raw = new Uint8Array(await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
privateKey!,
new TextEncoder().encode(signedContent),
))
// Node WebCrypto는 RAW(r||s 64B)를 반환하지만 admob-ssv(구동환경 Deno)는
// DER을 기대한다 — RAW→DER 변환으로 실 AdMob 콜백 형식을 재현한다.
const r = raw.subarray(0, 32)
const sRaw = raw.subarray(32, 64)
const derInt = (b: Uint8Array): number[] => {
let start = 0
while (start < b.length - 1 && b[start] === 0) start += 1
const v = Array.from(b.subarray(start))
if (v[0] & 0x80) v.unshift(0)
return [v.length, ...v]
}
const ri = derInt(r)
const si = derInt(sRaw)
const content = [0x02, ...ri, 0x02, ...si]
const der = [0x30, content.length, ...content]
let binary = ''
for (const b of der) binary += String.fromCharCode(b)
signature = btoa(binary)
}
const target = opts.tamperSignedContent
? signedContent.replace(FX.REWARD_AMOUNT, '999')
: signedContent
const keyId = opts.keyId ?? FX.KEY_ID
const tail = opts.extraAfterKey ? '&user_id_extra=1' : ''
return `https://ssv.example.test/callback?${target}&signature=${encodeURIComponent(signature)}&key_id=${keyId}${tail}`
}
function fakeKeysFetch(_impl: typeof fetch): typeof fetch {
return (async () =>
new Response(JSON.stringify({ keys: [{ keyId: Number(FX.KEY_ID), base64: spkiBase64 }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})) as unknown as typeof fetch
}
beforeAll(async () => {
await setupKeys()
})
// ── 계열 E: SSV 파싱·검증 (140) ─────────────────────────────────────
describe('E2E RED — 계열 E: AdMob SSV 콜백 검증', () => {
describe('E1. URL 파싱 — 유효/오류 형식 (40)', () => {
it('정상 콜백은 signedContent/signature/keyId/params로 분해된다', async () => {
const url = await buildCallbackUrl()
const parsed = parseAdMobCallbackUrl(url)
expect(parsed.keyId).toBe(FX.KEY_ID)
expect(parsed.params.get('user_id')).toBe(FX.USER_ID)
expect(parsed.params.get('custom_data')).toBe(FX.CUSTOM_DATA)
expect(parsed.signature.byteLength).toBeGreaterThan(0)
})
const MALFORMED = [
['쿼리 없는 URL', 'https://ssv.example.test/callback'],
['signature 누락', 'https://ssv.test/cb?ad_unit=x&key_id=1'],
['key_id 누락', 'https://ssv.test/cb?ad_unit=x&signature=abc'],
['key_id 비숫자', 'https://ssv.test/cb?ad_unit=x&signature=abc&key_id=abc'],
['key_id 이후 추가 파라미터(순서 위반)', `https://ssv.test/cb?ad_unit=x&signature=abc&key_id=1&extra=1`],
['빈 문자열', ''],
['signature만 있는 URL', 'https://ssv.test/cb?signature=abc'],
] as const
it.each(MALFORMED)(' malformed(%s)은 AdMobSsvError를 던진다', (label, url) => {
expect(() => parseAdMobCallbackUrl(url)).toThrow(AdMobSsvError)
})
// 파라미터 조합 매트릭스 (7 필수 × 5 변형 = 35)
const REQUIRED_PARAMS = ['ad_unit', 'user_id', 'custom_data', 'reward_amount', 'reward_item', 'timestamp', 'transaction_id'] as const
const MUTATIONS = ['omit', 'empty', 'whitespace', 'oversized', 'injected'] as const
for (const param of REQUIRED_PARAMS) {
for (const mutation of MUTATIONS) {
it(`필수 파라미터 ${param} ${mutation} 변형도 서명 검증 대상 파서를 통과/실패가 결정적이다`, async () => {
const base: Record<string, string> = {}
if (mutation === 'empty') base[param] = ''
else if (mutation === 'whitespace') base[param] = ' '
else if (mutation === 'oversized') base[param] = 'x'.repeat(600)
else if (mutation === 'injected') base[param] = '<script>alert(1)</script>'
const url = await buildCallbackUrl(mutation === 'omit' ? { omitParam: param } : { baseParams: base })
expect(() => parseAdMobCallbackUrl(url)).not.toThrow()
})
}
}
})
describe('E2. 서명 검증 — 진본/위조 (30)', () => {
it('올바른 서명은 검증을 통과한다', async () => {
const url = await buildCallbackUrl()
const verified = await verifyAdMobCallback(url, fakeKeysFetch(fetch))
expect(verified.params.get('reward_amount')).toBe(FX.REWARD_AMOUNT)
})
it('서명 후 내용 변조(tamper)는 invalid_signature로 거부된다', async () => {
const url = await buildCallbackUrl({ tamperSignedContent: true })
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError)
})
it('알 수 없는 key_id는 unknown_key_id로 거부된다', async () => {
const url = await buildCallbackUrl({ keyId: FX.UNKNOWN_KEY })
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toMatchObject({
code: 'unknown_key_id',
status: 503,
})
})
it('공개키 조회 실패는 admob_keys_unavailable로 식별된다', async () => {
const url = await buildCallbackUrl({ keyId: FX.UNKNOWN_KEY })
const failing = (async () => new Response('{}', { status: 500 })) as unknown as typeof fetch
await expect(verifyAdMobCallback(url, failing)).rejects.toThrow(AdMobSsvError)
})
const FORGED = ['AA==', 'AAAA', 'AP8=', 'AP//', '///////////////////////////////////////////////w==', '//8='] as const
it.each(FORGED)('위조 서명 base64(%s)는 거부된다', async (sig) => {
const url = await buildCallbackUrl({ signature: sig })
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError)
})
// 20개: 바이트 1개씩 뒤집은 서명 변조
for (let i = 0; i < 18; i += 1) {
it(`서명 바이트 ${i} 위치 변조는 거부된다`, async () => {
const url = await buildCallbackUrl()
const parsed = parseAdMobCallbackUrl(url)
const tampered = new Uint8Array(parsed.signature)
tampered[i % tampered.length] ^= 0xff
let binary = ''
for (const b of tampered) binary += String.fromCharCode(b)
const tamperedUrl = await buildCallbackUrl({ signature: btoa(binary) })
await expect(verifyAdMobCallback(tamperedUrl, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError)
})
}
})
describe('E3. DER→RAW 서명 변환 (10)', () => {
it('유효한 DER ECDSA 서명은 64바이트 RAW로 변환된다', async () => {
const url = await buildCallbackUrl()
const parsed = parseAdMobCallbackUrl(url)
const raw = derEcdsaSignatureToRaw(parsed.signature)
expect(raw.byteLength).toBe(64)
})
const BAD_DER = [
['시퀀스 아님(0x00)', new Uint8Array([0x00, 0x02, 0x01, 0x01])],
['빈 입력', new Uint8Array([])],
['길이 불일치', new Uint8Array([0x30, 0x05, 0x02, 0x01, 0x01])],
['r 정수 깨짐', new Uint8Array([0x30, 0x03, 0x02, 0x01])],
['s 누락', new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x01])],
] as const
it.each(BAD_DER)(' malformed DER(%s)은 invalid_signature_encoding', (_label, bytes) => {
expect(() => derEcdsaSignatureToRaw(bytes as unknown as Uint8Array)).toThrow(AdMobSsvError)
})
for (let i = 0; i < 5; i += 1) {
it('동등 콜백(새 transaction_id) 반복 검증도 동일하게 통과한다', async () => {
const url = await buildCallbackUrl()
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).resolves.toBeTruthy()
})
}
})
describe('E4. 리플레이 방지 (60) — RED 목표', () => {
// 리플레이 캐시가 없다면 이 시나리오들은 RED로 남는다(의도).
for (let i = 0; i < 20; i += 1) {
it(`동일 transaction_id 재검증 ${i + 1}회차는 리플레이로 거부되어야 한다`, async () => {
const url = await buildCallbackUrl()
const first = await verifyAdMobCallback(url, fakeKeysFetch(fetch))
expect(first).toBeTruthy()
rememberVerifiedAdMobTransaction(first.params.get('transaction_id') ?? '')
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError)
})
}
for (let i = 0; i < 20; i += 1) {
it(`오래된 timestamp(${60 * (i + 1)}분 전) 콜백은 거부되어야 한다`, async () => {
const url = await buildCallbackUrl({
baseParams: { timestamp: String(Date.now() - 60 * 60 * 1000 * (i + 1)) },
})
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError)
})
}
for (let i = 0; i < 20; i += 1) {
it(`미래 timestamp(+${i + 1}시) 콜백은 거부되어야 한다`, async () => {
const url = await buildCallbackUrl({
baseParams: { timestamp: String(Date.now() + 60 * 60 * 1000 * (i + 1) + 60_000) },
})
await expect(verifyAdMobCallback(url, fakeKeysFetch(fetch))).rejects.toThrow(AdMobSsvError)
})
}
})
})
// 총 시나리오 수: E(40+30+10+60=140)