225 lines
9.4 KiB
TypeScript
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')
|
|
}
|
|
})
|