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:
Yun Chan 2026-08-23 23:38:08 +09:00
parent a9c9a1ca6e
commit 5a34f66981
66 changed files with 4471 additions and 3501 deletions

View 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()
}