feat(admin): 예전/최신 어드민 통합 — 실데이터 복원 + 인증 아키텍처 정리
예전 배포본(HEAD)의 상세 기능을 새 아키텍처(인증=.NET 백엔드, 데이터=Supabase) 위에 실데이터로 복원. 예전 HEAD는 서명 쿠키를 거부하고 위조 쿠키는 통과시키는 인증 결함이 있었고, 삭제된 페이지 다수는 백엔드 호출 0인 하드코딩 목업이었음. 인증/세션 - 로그인 이메일 전용화(username 폐지), 에러 키별 안내 메시지 - ADMIN_COOKIE_SECURE 옵션: TLS 없는 LAN HTTP 배포에서 Secure 쿠키 유실로 로그인이 유지되지 않던 문제 해결 (login/logout route, admin-session, compose, .env.example) - Supabase 미설정 시 우아한 저하: isSupabaseAdminConfigured + UnavailableAdminPanel 기능 복원 (실데이터) - Release Hub: Forgejo API 실데이터(다운로드 수/SHA-256 체크섬/릴리스 이력) - Ad Monetization: 데스크톱 미디에이션 10개 어댑터 로스터(fail-closed) + ad_reward_claims 통계 - License Issuer: 서버사이드 Ed25519 서명(/api/admin/license, super_admin 전용), 개인키는 ADMIN_LICENSE_PRIVATE_KEY env로만, 발급 감사를 .NET AdminAuditEntries에 기록 - Service Models: STT 7종/LLM 5종 프리셋 드롭다운 + 자동채움 - 대시보드 ARR/MRR KPI: Supabase 구독 실집계(티어 월단가 기반) - 사용자 상세 티어별 기능 배지(pro_plus 조건부) .NET - SuperAdminOnly 정책 추가, /api/admin/license-audit 엔드포인트, LicenseAuditDto
This commit is contained in:
parent
a9c9a1ca6e
commit
5a34f66981
66 changed files with 4471 additions and 3501 deletions
289
apps/admin/__tests__/admin-edge.local.integration.mjs
Normal file
289
apps/admin/__tests__/admin-edge.local.integration.mjs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const url = process.env.D3RO_LOCAL_SUPABASE_URL?.trim() ?? ''
|
||||
const anonKey = process.env.D3RO_LOCAL_SUPABASE_ANON_KEY?.trim() ?? ''
|
||||
const serviceRoleKey = process.env.D3RO_LOCAL_SUPABASE_SERVICE_ROLE_KEY?.trim() ?? ''
|
||||
if (!url.startsWith('http://127.0.0.1:55321') || anonKey.length < 20 || serviceRoleKey.length < 20) {
|
||||
throw new Error('D3RO local Supabase credentials for port 55321 are required')
|
||||
}
|
||||
|
||||
const clientOptions = { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false } }
|
||||
const service = createClient(url, serviceRoleKey, clientOptions)
|
||||
const suffix = randomUUID()
|
||||
const createdUserIds = []
|
||||
let assertions = 0
|
||||
|
||||
function checked(value, message) {
|
||||
assert.ok(value, message)
|
||||
assertions += 1
|
||||
}
|
||||
|
||||
async function createActor(label, role) {
|
||||
const email = `d3ro.edge.${suffix}.${label}@example.com`
|
||||
const password = `D3ro-${suffix}-${label}-Strong!`
|
||||
const { data, error } = await service.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: true,
|
||||
app_metadata: { role },
|
||||
user_metadata: { name: `Edge ${label}` }
|
||||
})
|
||||
assert.equal(error, null, `${label} creation failed: ${error?.message ?? ''}`)
|
||||
createdUserIds.push(data.user.id)
|
||||
const profile = await service.from('profiles').update({ role }).eq('id', data.user.id)
|
||||
assert.equal(profile.error, null, `${label} profile setup failed`)
|
||||
|
||||
const login = createClient(url, anonKey, clientOptions)
|
||||
const session = await login.auth.signInWithPassword({ email, password })
|
||||
assert.equal(session.error, null, `${label} sign-in failed`)
|
||||
return { id: data.user.id, email, token: session.data.session.access_token }
|
||||
}
|
||||
|
||||
async function invoke(functionName, options = {}) {
|
||||
const headers = { apikey: anonKey, Accept: 'application/json' }
|
||||
if (options.token) headers.Authorization = `Bearer ${options.token}`
|
||||
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey
|
||||
const response = await fetch(`${url}/functions/v1/${functionName}${options.query ?? ''}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
})
|
||||
const text = await response.text()
|
||||
let payload = null
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
payload = { invalidJson: text }
|
||||
}
|
||||
return { status: response.status, payload }
|
||||
}
|
||||
|
||||
function requireStatus(result, status, code, label) {
|
||||
assert.equal(result.status, status, `${label}: ${JSON.stringify(result.payload)}`)
|
||||
if (code) assert.equal(result.payload?.error, code, `${label}: wrong stable error code`)
|
||||
assertions += code ? 2 : 1
|
||||
}
|
||||
|
||||
try {
|
||||
const manager = await createActor('manager', 'manager')
|
||||
const staleManager = await createActor('stale-manager', 'manager')
|
||||
const admin = await createActor('admin', 'admin')
|
||||
const superAdmin = await createActor('super', 'super_admin')
|
||||
const target = await createActor('target', 'user')
|
||||
|
||||
const anonymous = await invoke('admin-users')
|
||||
checked(anonymous.status === 401, `anonymous request must be denied, received ${anonymous.status}`)
|
||||
|
||||
requireStatus(
|
||||
await invoke('admin-users', { token: target.token }),
|
||||
403,
|
||||
'admin_forbidden',
|
||||
'ordinary user list access',
|
||||
)
|
||||
const staleProfileDemotion = await service.from('profiles').update({ role: 'user' }).eq('id', staleManager.id)
|
||||
assert.equal(staleProfileDemotion.error, null, 'stale manager profile demotion failed')
|
||||
const staleAuthDemotion = await service.auth.admin.updateUserById(staleManager.id, { app_metadata: { role: 'user' } })
|
||||
assert.equal(staleAuthDemotion.error, null, 'stale manager auth demotion failed')
|
||||
requireStatus(
|
||||
await invoke('admin-users', { token: staleManager.token }),
|
||||
403,
|
||||
'admin_forbidden',
|
||||
'demoted manager stale token read',
|
||||
)
|
||||
requireStatus(
|
||||
await invoke('admin-users', { token: manager.token, query: '?page=NaN&limit=20' }),
|
||||
400,
|
||||
'invalid_pagination',
|
||||
'NaN pagination',
|
||||
)
|
||||
requireStatus(
|
||||
await invoke('admin-users', { token: manager.token, query: '?debug=true' }),
|
||||
400,
|
||||
'unexpected_query_parameter',
|
||||
'unknown query parameter',
|
||||
)
|
||||
const detail = await invoke('admin-users', { token: manager.token, query: `?userId=${target.id}` })
|
||||
requireStatus(detail, 200, null, 'manager user detail')
|
||||
checked(detail.payload?.account?.email === target.email, 'user detail must come from the real auth account')
|
||||
|
||||
requireStatus(
|
||||
await invoke('admin-users', {
|
||||
token: manager.token,
|
||||
method: 'PATCH',
|
||||
idempotencyKey: randomUUID(),
|
||||
body: { userId: target.id, newRole: 'manager', memo: 'manager escalation attempt' },
|
||||
}),
|
||||
403,
|
||||
'admin_forbidden',
|
||||
'manager role mutation',
|
||||
)
|
||||
|
||||
const roleKey = randomUUID()
|
||||
const roleBody = { userId: target.id, newRole: 'manager', memo: 'verified support assignment' }
|
||||
const roleChanged = await invoke('admin-users', {
|
||||
token: admin.token,
|
||||
method: 'PATCH',
|
||||
idempotencyKey: roleKey,
|
||||
body: roleBody,
|
||||
})
|
||||
requireStatus(roleChanged, 200, null, 'admin role mutation')
|
||||
checked(roleChanged.payload?.newRole === 'manager', 'role response must match the requested role')
|
||||
const roleReplay = await invoke('admin-users', {
|
||||
token: admin.token,
|
||||
method: 'PATCH',
|
||||
idempotencyKey: roleKey,
|
||||
body: roleBody,
|
||||
})
|
||||
requireStatus(roleReplay, 200, null, 'role idempotent replay')
|
||||
checked(JSON.stringify(roleReplay.payload) === JSON.stringify(roleChanged.payload), 'idempotent response changed')
|
||||
const { count: roleAuditCount } = await service.from('audit_log').select('id', { count: 'exact', head: true })
|
||||
.eq('admin_id', admin.id).eq('action', 'user.role_change').eq('target_id', target.id)
|
||||
checked(roleAuditCount === 1, 'role replay duplicated audit rows')
|
||||
|
||||
requireStatus(
|
||||
await invoke('admin-users', {
|
||||
token: admin.token,
|
||||
method: 'PATCH',
|
||||
idempotencyKey: roleKey,
|
||||
body: { ...roleBody, memo: 'different request using the same key' },
|
||||
}),
|
||||
409,
|
||||
'idempotency_conflict',
|
||||
'role idempotency mismatch',
|
||||
)
|
||||
requireStatus(
|
||||
await invoke('admin-users', {
|
||||
token: admin.token,
|
||||
method: 'PATCH',
|
||||
idempotencyKey: randomUUID(),
|
||||
body: { userId: target.id, newRole: 'admin', memo: 'unauthorized privileged role assignment' },
|
||||
}),
|
||||
403,
|
||||
'super_admin_required',
|
||||
'admin privileged role assignment',
|
||||
)
|
||||
|
||||
const superRole = await invoke('admin-users', {
|
||||
token: superAdmin.token,
|
||||
method: 'PATCH',
|
||||
idempotencyKey: randomUUID(),
|
||||
body: { userId: target.id, newRole: 'admin', memo: 'approved privileged role assignment' },
|
||||
})
|
||||
requireStatus(superRole, 200, null, 'super admin privileged role assignment')
|
||||
|
||||
const subscriptionRemoval = await service.from('subscriptions').delete().eq('user_id', target.id)
|
||||
assert.equal(subscriptionRemoval.error, null, 'target subscription fixture removal failed')
|
||||
const subscriptionKey = randomUUID()
|
||||
const subscriptionBody = {
|
||||
userId: target.id,
|
||||
tier: 'pro',
|
||||
status: 'active',
|
||||
overageCredits: 25,
|
||||
adminNote: 'verified support grant',
|
||||
memo: 'approved manual subscription grant',
|
||||
}
|
||||
const subscriptionCreated = await invoke('admin-subscriptions', {
|
||||
token: admin.token,
|
||||
method: 'POST',
|
||||
idempotencyKey: subscriptionKey,
|
||||
body: subscriptionBody,
|
||||
})
|
||||
requireStatus(subscriptionCreated, 201, null, 'subscription creation')
|
||||
checked(!JSON.stringify(subscriptionCreated.payload).includes('provider_resource_id'), 'subscription mutation exposed provider resource id')
|
||||
checked(!JSON.stringify(subscriptionCreated.payload).includes('payple_payer_id'), 'subscription mutation exposed Payple payer id')
|
||||
const subscriptionReplay = await invoke('admin-subscriptions', {
|
||||
token: admin.token,
|
||||
method: 'POST',
|
||||
idempotencyKey: subscriptionKey,
|
||||
body: subscriptionBody,
|
||||
})
|
||||
requireStatus(subscriptionReplay, 201, null, 'subscription create replay')
|
||||
checked(JSON.stringify(subscriptionReplay.payload) === JSON.stringify(subscriptionCreated.payload), 'subscription replay response changed')
|
||||
|
||||
const updated = await invoke('admin-subscriptions', {
|
||||
token: manager.token,
|
||||
method: 'PATCH',
|
||||
query: `?userId=${target.id}`,
|
||||
idempotencyKey: randomUUID(),
|
||||
body: { tier: 'pro_plus', overageCredits: 50, memo: 'verified entitlement correction' },
|
||||
})
|
||||
requireStatus(updated, 200, null, 'manager subscription update')
|
||||
checked(updated.payload?.subscription?.tier === 'pro_plus', 'updated tier must be returned from the RPC')
|
||||
|
||||
requireStatus(
|
||||
await invoke('admin-subscriptions', {
|
||||
token: manager.token,
|
||||
method: 'DELETE',
|
||||
query: `?userId=${target.id}`,
|
||||
idempotencyKey: randomUUID(),
|
||||
body: { memo: 'manager delete attempt' },
|
||||
}),
|
||||
403,
|
||||
'admin_forbidden',
|
||||
'manager subscription delete',
|
||||
)
|
||||
requireStatus(
|
||||
await invoke('admin-subscriptions', {
|
||||
token: manager.token,
|
||||
method: 'PATCH',
|
||||
query: `?userId=${target.id}`,
|
||||
idempotencyKey: randomUUID(),
|
||||
body: { overageCredits: -1, memo: 'invalid negative credit update' },
|
||||
}),
|
||||
400,
|
||||
'invalid_overageCredits',
|
||||
'negative overage credits',
|
||||
)
|
||||
const { data: unchanged } = await service.from('subscriptions').select('overage_credits').eq('user_id', target.id).single()
|
||||
checked(unchanged?.overage_credits === 50, 'failed update changed subscription data')
|
||||
|
||||
const paymentHistory = await invoke('admin-payments', { token: manager.token, query: `?userId=${target.id}` })
|
||||
requireStatus(paymentHistory, 200, null, 'database payment history')
|
||||
checked(Array.isArray(paymentHistory.payload?.providerEvents), 'provider event ledger must be returned as an array')
|
||||
checked(!JSON.stringify(paymentHistory.payload).includes('payload_digest'), 'payment payload digest must not be exposed')
|
||||
checked(!JSON.stringify(paymentHistory.payload).includes('provider_resource_id'), 'provider resource identifier must not be exposed')
|
||||
requireStatus(
|
||||
await invoke('admin-payments', { token: manager.token, query: `?userId=${target.id}&source=payple` }),
|
||||
501,
|
||||
'payple_live_history_not_configured',
|
||||
'live Payple history',
|
||||
)
|
||||
|
||||
const sensitiveAudit = await service.from('audit_log').insert({
|
||||
admin_id: admin.id,
|
||||
action: 'subscription.redaction_test',
|
||||
target_type: 'subscription',
|
||||
target_id: target.id,
|
||||
before_data: { tier: 'pro', payple_payer_id: 'payer-secret' },
|
||||
after_data: { tier: 'pro_plus', provider_resource_id: 'resource-secret', payload: { raw: 'provider-payload' } },
|
||||
memo: 'verify audit response redaction',
|
||||
})
|
||||
assert.equal(sensitiveAudit.error, null, 'sensitive audit fixture insertion failed')
|
||||
const audit = await invoke('admin-audit-log', { token: manager.token, query: `?target_id=${target.id}&limit=100` })
|
||||
requireStatus(audit, 200, null, 'audit log read')
|
||||
checked(audit.payload?.logs?.some((entry) => entry.action === 'user.role_change'), 'role audit row is missing')
|
||||
checked(audit.payload?.logs?.some((entry) => entry.action === 'subscription.update'), 'subscription audit row is missing')
|
||||
const encodedAudit = JSON.stringify(audit.payload)
|
||||
checked(!encodedAudit.includes('payer-secret') && !encodedAudit.includes('resource-secret') && !encodedAudit.includes('provider-payload'), 'audit response exposed provider identifiers or payload')
|
||||
|
||||
const deleted = await invoke('admin-subscriptions', {
|
||||
token: admin.token,
|
||||
method: 'DELETE',
|
||||
query: `?userId=${target.id}`,
|
||||
idempotencyKey: randomUUID(),
|
||||
body: { memo: 'approved subscription retirement' },
|
||||
})
|
||||
requireStatus(deleted, 200, null, 'admin subscription soft delete')
|
||||
checked(deleted.payload?.subscription?.tier === 'free' && deleted.payload?.subscription?.status === 'expired', 'soft delete response is invalid')
|
||||
|
||||
console.log(`admin Edge local integration: ${assertions} assertions passed`)
|
||||
} finally {
|
||||
if (createdUserIds.length > 0) {
|
||||
await service.from('audit_log').delete().in('admin_id', createdUserIds)
|
||||
await service.from('admin_operation_requests').delete().in('actor_id', createdUserIds)
|
||||
for (const userId of createdUserIds.reverse()) await service.auth.admin.deleteUser(userId)
|
||||
}
|
||||
}
|
||||
194
apps/admin/__tests__/admin-management.local.integration.mjs
Normal file
194
apps/admin/__tests__/admin-management.local.integration.mjs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const url = process.env.D3RO_LOCAL_SUPABASE_URL?.trim() ?? ''
|
||||
const anonKey = process.env.D3RO_LOCAL_SUPABASE_ANON_KEY?.trim() ?? ''
|
||||
const serviceRoleKey = process.env.D3RO_LOCAL_SUPABASE_SERVICE_ROLE_KEY?.trim() ?? ''
|
||||
if (!url.startsWith('http://127.0.0.1:55321') || anonKey.length < 20 || serviceRoleKey.length < 20) {
|
||||
throw new Error('D3RO local Supabase credentials for port 55321 are required')
|
||||
}
|
||||
|
||||
const options = { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false } }
|
||||
const service = createClient(url, serviceRoleKey, options)
|
||||
const anonymous = createClient(url, anonKey, options)
|
||||
const suffix = randomUUID()
|
||||
const createdUserIds = []
|
||||
let assertions = 0
|
||||
|
||||
function checked(value, message) {
|
||||
assert.ok(value, message)
|
||||
assertions += 1
|
||||
}
|
||||
|
||||
async function createActor(label, role) {
|
||||
const email = `d3ro.admin.${suffix}.${label}@example.com`
|
||||
const { data, error } = await service.auth.admin.createUser({
|
||||
email,
|
||||
password: `D3ro-${suffix}-${label}-Strong!`,
|
||||
email_confirm: true,
|
||||
app_metadata: { role },
|
||||
user_metadata: { name: label }
|
||||
})
|
||||
assert.equal(error, null, `${label} creation failed: ${error?.message ?? ''}`)
|
||||
createdUserIds.push(data.user.id)
|
||||
const profileUpdate = await service.from('profiles').update({ role }).eq('id', data.user.id)
|
||||
assert.equal(profileUpdate.error, null, `${label} profile role setup failed`)
|
||||
return { id: data.user.id, email }
|
||||
}
|
||||
|
||||
function requireSuccess(result, label) {
|
||||
assert.equal(result.error, null, `${label}: ${result.error?.message ?? 'unknown error'}`)
|
||||
checked(result.data?.success === true, `${label}: success response missing`)
|
||||
return result.data
|
||||
}
|
||||
|
||||
function requireFailure(result, pattern, label) {
|
||||
assert.ok(result.error, `${label}: unexpectedly succeeded`)
|
||||
assert.match(`${result.error.code ?? ''} ${result.error.message ?? ''}`, pattern, `${label}: wrong error`)
|
||||
assertions += 1
|
||||
}
|
||||
|
||||
try {
|
||||
const superAdmin = await createActor('super', 'super_admin')
|
||||
const admin = await createActor('admin', 'admin')
|
||||
const manager = await createActor('manager', 'manager')
|
||||
const target = await createActor('target', 'user')
|
||||
|
||||
requireFailure(await anonymous.rpc('admin_change_user_role_v1', {
|
||||
p_actor_email: admin.email,
|
||||
p_idempotency_key: randomUUID(),
|
||||
p_target_user_id: target.id,
|
||||
p_new_role: 'manager',
|
||||
p_memo: 'anonymous bypass attempt'
|
||||
}), /permission denied|42501/i, 'anonymous RPC execution')
|
||||
|
||||
requireFailure(await service.rpc('admin_change_user_role_v1', {
|
||||
p_actor_email: manager.email,
|
||||
p_idempotency_key: randomUUID(),
|
||||
p_target_user_id: target.id,
|
||||
p_new_role: 'manager',
|
||||
p_memo: 'manager escalation attempt'
|
||||
}), /insufficient_admin_role|42501/i, 'manager role mutation')
|
||||
|
||||
const roleKey = randomUUID()
|
||||
const roleArgs = {
|
||||
p_actor_email: admin.email,
|
||||
p_idempotency_key: roleKey,
|
||||
p_target_user_id: target.id,
|
||||
p_new_role: 'manager',
|
||||
p_memo: 'verified customer support assignment'
|
||||
}
|
||||
const roleChanged = requireSuccess(await service.rpc('admin_change_user_role_v1', roleArgs), 'admin role change')
|
||||
const roleReplayed = requireSuccess(await service.rpc('admin_change_user_role_v1', roleArgs), 'role idempotent replay')
|
||||
checked(JSON.stringify(roleChanged) === JSON.stringify(roleReplayed), 'role replay response changed')
|
||||
|
||||
const [{ data: targetProfile }, { data: targetAuth }, { count: roleAuditCount }] = await Promise.all([
|
||||
service.from('profiles').select('role').eq('id', target.id).single(),
|
||||
service.auth.admin.getUserById(target.id),
|
||||
service.from('audit_log').select('id', { count: 'exact', head: true }).eq('admin_id', admin.id).eq('action', 'user.role_change').eq('target_id', target.id)
|
||||
])
|
||||
checked(targetProfile?.role === 'manager', 'profile role was not updated')
|
||||
checked(targetAuth?.user?.app_metadata?.role === 'manager', 'auth app_metadata role was not updated')
|
||||
checked(roleAuditCount === 1, 'idempotent role replay duplicated audit rows')
|
||||
|
||||
requireFailure(await service.rpc('admin_change_user_role_v1', {
|
||||
...roleArgs,
|
||||
p_memo: 'different request with reused key'
|
||||
}), /idempotency_key_reused|22023/i, 'role idempotency mismatch')
|
||||
|
||||
requireFailure(await service.rpc('admin_change_user_role_v1', {
|
||||
p_actor_email: admin.email,
|
||||
p_idempotency_key: randomUUID(),
|
||||
p_target_user_id: target.id,
|
||||
p_new_role: 'admin',
|
||||
p_memo: 'unauthorized privileged assignment'
|
||||
}), /super_admin_required|42501/i, 'admin privileged role assignment')
|
||||
|
||||
requireSuccess(await service.rpc('admin_change_user_role_v1', {
|
||||
p_actor_email: superAdmin.email,
|
||||
p_idempotency_key: randomUUID(),
|
||||
p_target_user_id: target.id,
|
||||
p_new_role: 'admin',
|
||||
p_memo: 'approved privileged assignment'
|
||||
}), 'super admin privileged assignment')
|
||||
|
||||
await service.from('subscriptions').delete().eq('user_id', target.id)
|
||||
const subscriptionKey = randomUUID()
|
||||
const createArgs = {
|
||||
p_actor_email: admin.email,
|
||||
p_idempotency_key: subscriptionKey,
|
||||
p_action: 'create',
|
||||
p_user_id: target.id,
|
||||
p_tier: 'pro',
|
||||
p_status: 'active',
|
||||
p_overage_credits: 25,
|
||||
p_admin_note: 'verified support grant',
|
||||
p_memo: 'approved manual subscription grant'
|
||||
}
|
||||
const createdSubscription = requireSuccess(await service.rpc('admin_mutate_subscription_v1', createArgs), 'subscription create')
|
||||
checked(!JSON.stringify(createdSubscription).includes('provider_resource_id'), 'subscription RPC exposed provider resource id')
|
||||
checked(!JSON.stringify(createdSubscription).includes('payple_payer_id'), 'subscription RPC exposed Payple payer id')
|
||||
requireSuccess(await service.rpc('admin_mutate_subscription_v1', createArgs), 'subscription create replay')
|
||||
const { count: createAuditCount } = await service.from('audit_log').select('id', { count: 'exact', head: true })
|
||||
.eq('admin_id', admin.id).eq('action', 'subscription.create').eq('target_id', target.id)
|
||||
checked(createAuditCount === 1, 'subscription replay duplicated audit rows')
|
||||
|
||||
requireSuccess(await service.rpc('admin_mutate_subscription_v1', {
|
||||
p_actor_email: manager.email,
|
||||
p_idempotency_key: randomUUID(),
|
||||
p_action: 'update',
|
||||
p_user_id: target.id,
|
||||
p_tier: 'pro_plus',
|
||||
p_status: 'active',
|
||||
p_overage_credits: 50,
|
||||
p_memo: 'customer support entitlement correction'
|
||||
}), 'manager subscription update')
|
||||
const [{ data: updatedSubscription }, { data: updatedProfile }] = await Promise.all([
|
||||
service.from('subscriptions').select('tier, overage_credits').eq('user_id', target.id).single(),
|
||||
service.from('profiles').select('tier').eq('id', target.id).single()
|
||||
])
|
||||
checked(updatedSubscription?.tier === 'pro_plus' && updatedSubscription?.overage_credits === 50, 'subscription update was not persisted')
|
||||
checked(updatedProfile?.tier === 'pro_plus', 'profile tier was not synchronized')
|
||||
|
||||
requireFailure(await service.rpc('admin_mutate_subscription_v1', {
|
||||
p_actor_email: manager.email,
|
||||
p_idempotency_key: randomUUID(),
|
||||
p_action: 'delete',
|
||||
p_user_id: target.id,
|
||||
p_memo: 'manager delete attempt'
|
||||
}), /admin_role_required|42501/i, 'manager subscription delete')
|
||||
|
||||
requireFailure(await service.rpc('admin_mutate_subscription_v1', {
|
||||
p_actor_email: admin.email,
|
||||
p_idempotency_key: randomUUID(),
|
||||
p_action: 'update',
|
||||
p_user_id: target.id,
|
||||
p_overage_credits: -1,
|
||||
p_memo: 'invalid negative credit mutation'
|
||||
}), /invalid_overage_credits|22023/i, 'negative credits validation')
|
||||
const { data: unchanged } = await service.from('subscriptions').select('overage_credits').eq('user_id', target.id).single()
|
||||
checked(unchanged?.overage_credits === 50, 'failed mutation changed subscription data')
|
||||
|
||||
requireSuccess(await service.rpc('admin_mutate_subscription_v1', {
|
||||
p_actor_email: admin.email,
|
||||
p_idempotency_key: randomUUID(),
|
||||
p_action: 'delete',
|
||||
p_user_id: target.id,
|
||||
p_memo: 'approved subscription retirement'
|
||||
}), 'admin subscription soft delete')
|
||||
const [{ data: deletedSubscription }, { data: deletedProfile }] = await Promise.all([
|
||||
service.from('subscriptions').select('tier, status').eq('user_id', target.id).single(),
|
||||
service.from('profiles').select('tier').eq('id', target.id).single()
|
||||
])
|
||||
checked(deletedSubscription?.tier === 'free' && deletedSubscription?.status === 'expired', 'soft delete state is invalid')
|
||||
checked(deletedProfile?.tier === 'free', 'soft delete did not synchronize profile tier')
|
||||
|
||||
console.log(`admin management integration: ${assertions} assertions passed`)
|
||||
} finally {
|
||||
if (createdUserIds.length > 0) {
|
||||
await service.from('audit_log').delete().in('admin_id', createdUserIds)
|
||||
await service.from('admin_operation_requests').delete().in('actor_id', createdUserIds)
|
||||
for (const userId of createdUserIds.reverse()) await service.auth.admin.deleteUser(userId)
|
||||
}
|
||||
}
|
||||
101
apps/admin/__tests__/admin.local.browser.e2e.mjs
Normal file
101
apps/admin/__tests__/admin.local.browser.e2e.mjs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const baseUrl = process.env.D3RO_ADMIN_E2E_URL?.trim() ?? ''
|
||||
if (!baseUrl.startsWith('https://127.0.0.1:')) throw new Error('Local HTTPS admin E2E URL is required')
|
||||
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true })
|
||||
await page.addInitScript(() => {
|
||||
window.alert = () => undefined
|
||||
window.confirm = () => true
|
||||
window.prompt = () => 'browser verified endpoint deletion'
|
||||
})
|
||||
const consoleErrors = []
|
||||
const serverErrors = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 500) serverErrors.push(`${response.status()} ${response.url()}`)
|
||||
})
|
||||
|
||||
try {
|
||||
await page.goto(`${baseUrl}/`, { waitUntil: 'networkidle' })
|
||||
await page.getByLabel('Admin Identifier (Username / Email)').fill('admin.browser.e2e@example.com')
|
||||
await page.getByLabel('Password').fill('Admin-browser-e2e-strong-password!')
|
||||
await Promise.all([
|
||||
page.waitForURL(`${baseUrl}/`),
|
||||
page.getByRole('button', { name: 'Sign In as Super Admin' }).click()
|
||||
])
|
||||
await page.getByText('BACKEND CONNECTED').waitFor()
|
||||
|
||||
const endpointPayload = await page.evaluate(async () => {
|
||||
const response = await fetch('/api/admin/backend/endpoints', { cache: 'no-store' })
|
||||
return { status: response.status, body: await response.json() }
|
||||
})
|
||||
assert.equal(endpointPayload.status, 200)
|
||||
assert.ok(Array.isArray(endpointPayload.body))
|
||||
assert.ok(endpointPayload.body.every((endpoint) => endpoint.apiKey === '' || endpoint.apiKey === '••••••••'))
|
||||
|
||||
await page.goto(`${baseUrl}/models`, { waitUntil: 'networkidle' })
|
||||
await page.getByRole('tab', { name: /LLM & Reasoning Model Endpoints/ }).click()
|
||||
const addModelButton = page.getByRole('button', { name: /Add LLM Model Endpoint/ })
|
||||
if (await addModelButton.count() === 0) {
|
||||
const cookies = await page.context().cookies()
|
||||
const bodyText = (await page.locator('body').innerText()).slice(0, 1200)
|
||||
throw new Error(`models diagnostic url=${page.url()} cookies=${cookies.map((cookie) => `${cookie.name}:${cookie.secure}:${cookie.sameSite}`).join(',')} body=${bodyText}`)
|
||||
}
|
||||
await addModelButton.click()
|
||||
await page.getByLabel('Model ID').fill('browser-e2e-model')
|
||||
await page.getByLabel('Display Name').fill('Browser E2E Model')
|
||||
await page.getByLabel('Endpoint URL').fill('https://models.example.com/v1')
|
||||
await page.getByLabel('Audit memo').last().fill('browser verified endpoint creation')
|
||||
const createResponse = page.waitForResponse((response) => response.url().includes('/api/admin/backend/endpoints') && response.request().method() === 'POST')
|
||||
await page.getByRole('button', { name: 'Save Endpoint' }).click()
|
||||
const created = await createResponse
|
||||
assert.equal(created.status(), 200, `model creation failed: ${await created.text()}`)
|
||||
await page.getByText('Browser E2E Model').waitFor()
|
||||
|
||||
const modelRow = page.locator('tr').filter({ hasText: 'browser-e2e-model' })
|
||||
const deleteResponse = page.waitForResponse((response) => response.url().includes('/api/admin/backend/endpoints/') && response.request().method() === 'DELETE')
|
||||
await modelRow.getByRole('button', { name: 'Delete' }).click()
|
||||
const deleted = await deleteResponse
|
||||
assert.equal(deleted.status(), 200, `model deletion failed: ${await deleted.text()}`)
|
||||
await page.getByText('Browser E2E Model').waitFor({ state: 'detached' })
|
||||
|
||||
await page.goto(`${baseUrl}/users`, { waitUntil: 'networkidle' })
|
||||
const customerRow = page.locator('tr').filter({ hasText: 'customer.browser.e2e@example.com' })
|
||||
await customerRow.waitFor()
|
||||
const customerHref = await customerRow.getByRole('link').first().getAttribute('href')
|
||||
assert.match(customerHref ?? '', /^\/users\/[0-9a-f-]{36}$/)
|
||||
const customerId = customerHref.split('/').pop()
|
||||
|
||||
await page.goto(`${baseUrl}${customerHref}`, { waitUntil: 'networkidle' })
|
||||
await page.getByRole('button', { name: 'Change Role' }).click()
|
||||
const roleDialog = page.getByRole('dialog', { name: /CHANGE USER ROLE/ })
|
||||
await roleDialog.waitFor()
|
||||
await roleDialog.getByRole('combobox').click()
|
||||
await page.getByRole('option', { name: 'manager' }).click()
|
||||
await page.getByPlaceholder('Reason for role change (required)...').fill('browser verified support assignment')
|
||||
await page.getByRole('button', { name: 'Change Role', exact: true }).last().click()
|
||||
await page.getByText('MANAGER').first().waitFor()
|
||||
|
||||
await page.goto(`${baseUrl}/subscriptions/${customerId}`, { waitUntil: 'networkidle' })
|
||||
await page.getByRole('combobox', { name: 'Tier' }).click()
|
||||
await page.getByRole('option', { name: 'PRO', exact: true }).click()
|
||||
await page.getByLabel('Memo (required for audit log)').fill('browser verified subscription update')
|
||||
await page.getByRole('button', { name: 'Update' }).click()
|
||||
await page.getByText('Operation successful').waitFor()
|
||||
|
||||
await page.goto(`${baseUrl}/audit-log`, { waitUntil: 'networkidle' })
|
||||
await page.getByText('user.role_change').first().waitFor()
|
||||
await page.getByText('subscription.update').first().waitFor()
|
||||
await page.screenshot({ path: 'scratch/admin-browser-e2e.png', fullPage: true })
|
||||
|
||||
assert.deepEqual(serverErrors, [], `server errors: ${serverErrors.join(', ')}`)
|
||||
assert.deepEqual(consoleErrors, [], `console errors: ${consoleErrors.join(', ')}`)
|
||||
console.log('admin browser E2E: login, JWT proxy, model create/delete, users, role, subscription, audit GREEN')
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
57
apps/admin/__tests__/browser-fixture.local.mjs
Normal file
57
apps/admin/__tests__/browser-fixture.local.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const action = process.argv[2]
|
||||
const url = process.env.D3RO_LOCAL_SUPABASE_URL?.trim() ?? ''
|
||||
const serviceRoleKey = process.env.D3RO_LOCAL_SUPABASE_SERVICE_ROLE_KEY?.trim() ?? ''
|
||||
if (!url.startsWith('http://127.0.0.1:55321') || serviceRoleKey.length < 20) {
|
||||
throw new Error('D3RO local Supabase service credentials are required')
|
||||
}
|
||||
|
||||
const client = createClient(url, serviceRoleKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }
|
||||
})
|
||||
const adminEmail = 'admin.browser.e2e@example.com'
|
||||
const targetEmail = 'customer.browser.e2e@example.com'
|
||||
|
||||
async function matchingUsers() {
|
||||
const { data, error } = await client.auth.admin.listUsers({ page: 1, perPage: 1000 })
|
||||
if (error) throw error
|
||||
return data.users.filter((user) => user.email === adminEmail || user.email === targetEmail)
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
const users = await matchingUsers()
|
||||
const ids = users.map((user) => user.id)
|
||||
if (ids.length === 0) return
|
||||
await client.from('audit_log').delete().in('admin_id', ids)
|
||||
await client.from('admin_operation_requests').delete().in('actor_id', ids)
|
||||
for (const user of users) await client.auth.admin.deleteUser(user.id)
|
||||
}
|
||||
|
||||
if (action === 'cleanup') {
|
||||
await cleanup()
|
||||
console.log('admin browser fixture cleaned')
|
||||
} else if (action === 'setup') {
|
||||
await cleanup()
|
||||
const admin = await client.auth.admin.createUser({
|
||||
email: adminEmail,
|
||||
password: 'Admin-browser-e2e-strong-password!',
|
||||
email_confirm: true,
|
||||
app_metadata: { role: 'super_admin' },
|
||||
user_metadata: { name: 'Browser E2E Admin' }
|
||||
})
|
||||
if (admin.error) throw admin.error
|
||||
const target = await client.auth.admin.createUser({
|
||||
email: targetEmail,
|
||||
password: 'Customer-browser-e2e-strong-password!',
|
||||
email_confirm: true,
|
||||
app_metadata: { role: 'user' },
|
||||
user_metadata: { name: 'Browser E2E Customer' }
|
||||
})
|
||||
if (target.error) throw target.error
|
||||
const roleUpdate = await client.from('profiles').update({ role: 'super_admin' }).eq('id', admin.data.user.id)
|
||||
if (roleUpdate.error) throw roleUpdate.error
|
||||
console.log(`admin browser fixture ready target=${target.data.user.id}`)
|
||||
} else {
|
||||
throw new Error('Expected setup or cleanup')
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue