d3ro-voice/server/supabase/functions/payple-webhook/webhook-policy.test.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

160 lines
6.5 KiB
TypeScript

import {
type CorrelatedPayment,
decideWebhookTransition,
type WebhookTransitionInput,
} from './webhook-policy.ts'
import type { PayplePaymentLookupResult } from '../_shared/payple.ts'
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
function assertEquals(actual: unknown, expected: unknown, message: string): void {
const a = JSON.stringify(actual)
const e = JSON.stringify(expected)
if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`)
}
const OLD_ORDER = 'D3RO-20260801090000-user-oid1'
const CURRENT_ORDER = 'D3RO-20260901090000-user-oid2'
const NOW = new Date('2026-09-15T00:00:00.000Z')
function correlated(overrides: Partial<CorrelatedPayment> = {}): CorrelatedPayment {
return {
user_id: '00000000-0000-4000-8000-000000000001',
tier: 'pro',
operation_id: '00000000-0000-4000-8000-0000000000aa',
payer_id: 'payer-billing-key',
current_order_id: CURRENT_ORDER,
...overrides,
}
}
function lookup(orderId: string, overrides: Partial<PayplePaymentLookupResult> = {}): PayplePaymentLookupResult {
return {
PCD_PAY_RST: 'success',
PCD_PAY_CODE: 'PCHK0000',
PCD_PAY_MSG: 'ok',
PCD_PAY_OID: orderId,
PCD_PAY_TYPE: 'card',
PCD_PAYER_ID: 'payer-billing-key',
PCD_PAY_TOTAL: '9900',
PCD_PAY_TIME: '20260901090000',
...overrides,
}
}
function input(overrides: Partial<WebhookTransitionInput>): WebhookTransitionInput {
return {
kind: 'payment',
orderId: CURRENT_ORDER,
lookup: lookup(CURRENT_ORDER),
correlated: correlated(),
expectedAmount: 9900,
now: NOW,
...overrides,
}
}
Deno.test('payment of the current order grants the correlated tier for one period', () => {
const decision = decideWebhookTransition(input({}))
assert(decision.kind === 'apply', `expected apply, got ${decision.kind}`)
assertEquals(decision.amount, 9900, 'amount')
const args = decision.args
assertEquals(args.p_event_id, `payment:${CURRENT_ORDER}`, 'event id')
assertEquals(args.p_event_type, 'payment.completed', 'event type')
assertEquals(args.p_entitled, true, 'entitled')
assertEquals(args.p_tier, 'pro', 'tier')
assertEquals(args.p_status, 'active', 'status')
assertEquals(args.p_auto_renewing, true, 'auto renewing')
assertEquals(args.p_provider_order_id, CURRENT_ORDER, 'order id')
assertEquals(args.p_provider_resource_id, 'payer-billing-key', 'resource id')
// 20260901090000 KST = 2026-09-01T00:00:00Z
assertEquals(args.p_event_created_at, '2026-09-01T00:00:00.000Z', 'event time is payment time')
assertEquals(args.p_current_period_start, '2026-09-01T00:00:00.000Z', 'period start')
assertEquals(args.p_current_period_end, '2026-10-01T00:00:00.000Z', 'period end')
assertEquals(args.p_cancel_at, null, 'no cancel_at')
})
Deno.test('payment of an older registered order is still applied (ordering is the RPC cursor)', () => {
const decision = decideWebhookTransition(input({
orderId: OLD_ORDER,
lookup: lookup(OLD_ORDER, { PCD_PAY_TIME: '20260801090000' }),
}))
assert(decision.kind === 'apply', 'payments are not order-scoped')
})
Deno.test('payment whose reconciled amount differs from the tier price is rejected', () => {
for (const total of ['29900', 'not-a-number', undefined]) {
const decision = decideWebhookTransition(input({
lookup: lookup(CURRENT_ORDER, { PCD_PAY_TOTAL: total }),
}))
assertEquals(decision, { kind: 'reject', status: 422, error: 'payment_amount_mismatch' }, `total ${total}`)
}
const unpricedTier = decideWebhookTransition(input({ expectedAmount: undefined }))
assertEquals(unpricedTier.kind, 'reject', 'tier without a price cannot be paid')
})
Deno.test('lookup payer different from the correlated payer is rejected', () => {
const decision = decideWebhookTransition(input({
lookup: lookup(CURRENT_ORDER, { PCD_PAYER_ID: 'payer-other' }),
}))
assertEquals(decision, { kind: 'reject', status: 401, error: 'payment_owner_mismatch' }, 'owner')
})
Deno.test('cancellation not confirmed by the Payple lookup is rejected', () => {
for (const state of [undefined, '승인완료']) {
const decision = decideWebhookTransition(input({
kind: 'cancellation',
lookup: lookup(CURRENT_ORDER, { PCD_PAY_STATE: state }),
}))
assertEquals(decision, { kind: 'reject', status: 409, error: 'cancellation_not_confirmed' }, `state ${state}`)
}
})
Deno.test('confirmed cancellation of the current order revokes entitlement now', () => {
for (const state of ['승인취소완료', 'canceled']) {
const decision = decideWebhookTransition(input({
kind: 'cancellation',
lookup: lookup(CURRENT_ORDER, { PCD_PAY_STATE: state, PCD_PAY_TOTAL: '1' }),
}))
assert(decision.kind === 'apply', `expected apply for ${state}`)
const args = decision.args
assertEquals(args.p_event_id, `cancel:${CURRENT_ORDER}:${state}`, 'event id')
assertEquals(args.p_event_type, 'webhook.payment_canceled', 'event type')
assertEquals(args.p_entitled, false, 'not entitled')
assertEquals(args.p_tier, 'free', 'tier')
assertEquals(args.p_status, 'canceled', 'status')
assertEquals(args.p_auto_renewing, false, 'auto renewing')
assertEquals(args.p_event_created_at, NOW.toISOString(), 'event time is now')
assertEquals(args.p_current_period_start, null, 'period start untouched')
assertEquals(args.p_current_period_end, NOW.toISOString(), 'period ends now')
assertEquals(args.p_cancel_at, NOW.toISOString(), 'cancel_at now')
assertEquals(args.p_provider_order_id, CURRENT_ORDER, 'order id')
}
})
Deno.test('confirmed cancellation of an older order is ignored and keeps the paid period', () => {
const decision = decideWebhookTransition(input({
kind: 'cancellation',
orderId: OLD_ORDER,
lookup: lookup(OLD_ORDER, { PCD_PAY_STATE: '승인취소완료', PCD_PAY_TIME: '20260801090000' }),
}))
assertEquals(decision, {
kind: 'ignore',
reason: 'canceled_order_not_current',
eventId: `cancel:${OLD_ORDER}:승인취소완료`,
eventCreatedAt: NOW.toISOString(),
eventType: 'webhook.payment_canceled',
providerResourceId: 'payer-billing-key',
}, 'old order refund must not revoke the current subscription')
})
Deno.test('confirmed cancellation while no current order is on record is ignored', () => {
const decision = decideWebhookTransition(input({
kind: 'cancellation',
correlated: correlated({ current_order_id: null }),
lookup: lookup(CURRENT_ORDER, { PCD_PAY_STATE: '승인취소완료' }),
}))
assertEquals(decision.kind, 'ignore', 'fail safe: unknown current order never revokes')
})