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
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue