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