feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
225
server/supabase/functions/_shared/admin-contract.test.ts
Normal file
225
server/supabase/functions/_shared/admin-contract.test.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
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')
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue