d3ro-voice/server/supabase/functions/_shared/admin-contract.test.ts
2026-08-29 18:33:45 +09:00

225 lines
9.4 KiB
TypeScript

import {
AdminPublicError,
adminErrorResponse,
adminRpcError,
parsePagination,
parseRoleChangeRequest,
parseSubscriptionMutationRequest,
readAdminJson,
sanitizeAuditRecord,
validateQueryKeys,
validateRoleRpcResult,
validateSubscriptionRpcResult,
} from './admin-contract.ts'
import { corsHeaders } from './cors.ts'
const OPERATION_ID = '11111111-2222-4333-8444-555555555555'
const USER_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
function expectAdminError(action: () => unknown, code: string, status: number): void {
let received: unknown
try {
action()
} catch (error) {
received = error
}
assert(received instanceof AdminPublicError, `expected AdminPublicError for ${code}`)
assert(received.code === code, `expected ${code}, received ${received.code}`)
assert(received.status === status, `expected ${status}, received ${received.status}`)
}
Deno.test('admin role mutation requires strict UUID, fields, role and audit memo', () => {
const parsed = parseRoleChangeRequest({
userId: USER_ID,
newRole: 'manager',
memo: ' verified support assignment ',
}, OPERATION_ID)
assert(parsed.userId === USER_ID, 'target UUID must be preserved')
assert(parsed.newRole === 'manager', 'managed role must be preserved')
assert(parsed.memo === 'verified support assignment', 'memo must be normalized')
expectAdminError(
() => parseRoleChangeRequest({ userId: USER_ID, newRole: 'owner', memo: 'valid reason' }, OPERATION_ID),
'invalid_newRole',
400,
)
expectAdminError(
() => parseRoleChangeRequest({ userId: USER_ID, newRole: 'manager', memo: 'ok', rawSql: 'select 1' }, OPERATION_ID),
'unexpected_request_field',
400,
)
expectAdminError(
() => parseRoleChangeRequest({ userId: USER_ID, newRole: 'manager', memo: 'valid reason' }, 'not-a-uuid'),
'valid_idempotency_key_required',
400,
)
})
Deno.test('subscription mutations enforce action-specific fields and numeric/date bounds', () => {
const created = parseSubscriptionMutationRequest('create', {
userId: USER_ID,
tier: 'pro_plus',
status: 'active',
currentPeriodEnd: '2027-08-21T12:00:00+09:00',
overageCredits: 100,
adminNote: 'VIP recovery',
memo: 'verified account recovery',
}, OPERATION_ID)
assert(created.currentPeriodEnd === '2027-08-21T03:00:00.000Z', 'period end must normalize to UTC')
assert(created.overageCredits === 100, 'valid bounded credits must pass')
const deleted = parseSubscriptionMutationRequest(
'delete',
{ memo: 'verified subscription removal' },
OPERATION_ID,
USER_ID,
)
assert(deleted.action === 'delete' && deleted.userId === USER_ID, 'delete target must come from query')
expectAdminError(
() => parseSubscriptionMutationRequest('create', { userId: USER_ID, memo: 'valid reason' }, OPERATION_ID),
'tier_required',
400,
)
expectAdminError(
() => parseSubscriptionMutationRequest('update', { memo: 'valid reason', overageCredits: 1_000_001 }, OPERATION_ID, USER_ID),
'invalid_overageCredits',
400,
)
expectAdminError(
() => parseSubscriptionMutationRequest('delete', { memo: 'valid reason', hardDelete: true }, OPERATION_ID, USER_ID),
'unexpected_request_field',
400,
)
})
Deno.test('admin query parsing rejects NaN, unbounded pages and unknown parameters', () => {
const valid = new URL('https://example.test/admin-users?page=2&limit=100')
validateQueryKeys(valid, ['page', 'limit'])
const page = parsePagination(valid)
assert(page.from === 100 && page.to === 199, 'bounded pagination range must be exact')
expectAdminError(
() => parsePagination(new URL('https://example.test/admin-users?page=NaN&limit=20')),
'invalid_pagination',
400,
)
expectAdminError(
() => parsePagination(new URL('https://example.test/admin-users?page=1&limit=101')),
'invalid_pagination',
400,
)
expectAdminError(
() => validateQueryKeys(new URL('https://example.test/admin-users?debug=true'), ['page']),
'unexpected_query_parameter',
400,
)
})
Deno.test('admin RPC errors and responses expose only stable public contracts', async () => {
const unknown = adminRpcError({ code: 'XX000', message: 'password=secret; private SQL stack' })
assert(unknown.status === 500 && unknown.code === 'admin_operation_failed', 'unknown RPC detail must be sanitized')
const conflict = adminRpcError({ code: '22023', message: 'idempotency_key_reused_with_different_request' })
assert(conflict.status === 409 && conflict.code === 'idempotency_conflict', 'idempotency conflict must be stable')
const response = adminErrorResponse(new Error('database host and secret'), corsHeaders)
assert(response.status === 500, 'unexpected error must be a server failure')
assert(await response.text() === '{"error":"internal_error"}', 'raw exception text must never reach clients')
const roleRequest = parseRoleChangeRequest({ userId: USER_ID, newRole: 'manager', memo: 'valid reason' }, OPERATION_ID)
const roleResult = validateRoleRpcResult({ success: true, userId: USER_ID, newRole: 'manager' }, roleRequest)
assert(roleResult.success === true, 'valid role RPC result must pass')
expectAdminError(
() => validateRoleRpcResult({ success: true, userId: USER_ID, newRole: 'admin' }, roleRequest),
'invalid_admin_rpc_response',
502,
)
const subscriptionRequest = parseSubscriptionMutationRequest(
'update',
{ tier: 'pro', memo: 'valid reason' },
OPERATION_ID,
USER_ID,
)
const subscriptionResult = validateSubscriptionRpcResult({
success: true,
subscription: { user_id: USER_ID, tier: 'pro', status: 'active' },
}, subscriptionRequest)
assert(subscriptionResult.success === true, 'valid subscription RPC result must pass')
})
Deno.test('audit response redacts provider identifiers and secrets recursively', () => {
const sanitized = sanitizeAuditRecord({
id: 7,
before_data: {
tier: 'pro',
payple_payer_id: 'payer-secret',
nested: { provider_resource_id: 'resource-secret', status: 'active' },
},
after_data: {
api_key: 'api-secret',
provider: 'payple',
payload: { card: 'raw-provider-payload' },
},
})
const encoded = JSON.stringify(sanitized)
assert(!encoded.includes('payer-secret'), 'Payple payer id must be removed')
assert(!encoded.includes('resource-secret'), 'provider resource id must be removed')
assert(!encoded.includes('api-secret'), 'API key must be removed')
assert(!encoded.includes('raw-provider-payload'), 'raw provider payload must be removed')
assert(encoded.includes('"tier":"pro"') && encoded.includes('"provider":"payple"'), 'safe audit fields must remain')
})
Deno.test('admin JSON reader enforces content type, object shape and byte limit', async () => {
const valid = await readAdminJson(new Request('https://example.test/admin-users', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ memo: 'valid reason' }),
}))
assert(valid.memo === 'valid reason', 'valid JSON object must be returned')
let received: unknown
try {
await readAdminJson(new Request('https://example.test/admin-users', {
method: 'PATCH',
headers: { 'Content-Type': 'text/plain' },
body: '{}',
}))
} catch (error) {
received = error
}
assert(received instanceof AdminPublicError && received.status === 415, 'non-JSON request must fail closed')
})
Deno.test('admin Edge handlers use atomic RPC writes and contain no legacy direct/provider fallback', async () => {
const [users, subscriptions, payments, audit] = await Promise.all([
Deno.readTextFile(new URL('../admin-users/index.ts', import.meta.url)),
Deno.readTextFile(new URL('../admin-subscriptions/index.ts', import.meta.url)),
Deno.readTextFile(new URL('../admin-payments/index.ts', import.meta.url)),
Deno.readTextFile(new URL('../admin-audit-log/index.ts', import.meta.url)),
])
assert(payments.includes('overage_credits, admin_note'),
'payment history subscription must include the strict mobile subscription fields')
assert(users.includes("rpc('admin_change_user_role_v1'"), 'role mutation must use atomic RPC')
assert(subscriptions.includes("rpc('admin_mutate_subscription_v1'"), 'subscription mutation must use atomic RPC')
for (const [name, source] of [['users', users], ['subscriptions', subscriptions]] as const) {
assert(!source.includes(".update({"), `${name} must not directly update tables`)
assert(!source.includes(".insert("), `${name} must not directly insert tables`)
assert(!source.includes('writeAuditLog'), `${name} audit must be inside the RPC transaction`)
assert(!source.includes('auth.admin.updateUserById'), `${name} must not perform a separate auth mutation`)
}
assert(payments.includes('payple_live_history_not_configured'), 'live Payple history must fail closed')
assert(!payments.includes('paypleAuth'), 'payment reads must not fetch Payple directly')
assert(!payments.includes('paypleHistory'), 'raw provider payload must not be returned')
for (const source of [users, subscriptions, payments, audit]) {
assert(source.includes('verifyCurrentAdminActor'), 'every admin read must re-check the current database role')
assert(!source.includes('error.message'), 'raw database errors must not be serialized')
assert(!source.includes('JSON.stringify({ error: message })'), 'raw exceptions must not be serialized')
}
})