관리자 빈 화면 진단

This commit is contained in:
Yun Chan 2026-07-03 20:45:41 +09:00
parent 0324df7c4d
commit 50524f14ae
6 changed files with 556 additions and 12 deletions

View file

@ -158,6 +158,9 @@ async function mockAdminSession(
super_admin: boolean;
onboarding_completed_at: number | null;
}> = {},
options: {
adminUsers?: unknown[];
} = {},
) {
const seenAdminEndpoints = new Set<string>();
const json = (body: unknown) => JSON.stringify(body);
@ -210,7 +213,7 @@ async function mockAdminSession(
await fulfillJson({
source: "database",
durable: true,
users: [],
users: options.adminUsers ?? [],
});
return;
}
@ -659,6 +662,48 @@ test.describe("admin route guards", () => {
.toBe(0);
await expect(page.getByRole("heading", { name: "역할, 그룹, 접근 범위" })).toBeInViewport();
});
test("shows admin data diagnostics instead of a blank main pane", async ({ page }) => {
await mockAdminSession(
page,
{
user_id: "diagnostic-admin",
email: "diagnostic-admin@twentyoz.kr",
display_name: "Diagnostic Admin",
role: "admin",
admin_access: true,
super_admin: true,
onboarding_completed_at: 1_782_900_000,
},
{
adminUsers: [
{
user_id: "bad-user",
email: "bad-user@hs.ac.kr",
display_name: "Bad User",
role: "learner",
admin_access: false,
super_admin: false,
account_status: "pending",
affiliation: null,
cohort_ids: null,
active_sessions: null,
created_at: null,
last_seen_at: null,
source: "database",
},
],
},
);
await page.goto("/admin/users");
await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible();
await expect(page.locator(".ad-diagnostic")).toContainText("관리자 데이터 진단");
await expect(page.locator(".ad-diagnostic")).toContainText("cohort_ids");
await expect(page.locator(".ad-diagnostic")).toContainText("active_sessions");
await expect(page.getByText("Bad User")).toBeVisible();
});
});
test.describe("admin route", () => {

View file

@ -1,4 +1,13 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import {
Component,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ErrorInfo,
type ReactNode,
} from "react";
import { AppShell } from "../components/shell/AppShell";
import { Badge, Button, Dot, Icon, Kicker, ProgressBar } from "../components/ui";
import {
@ -31,6 +40,11 @@ type AccessTab = "roles" | "groups" | "matrix";
type TicketStatusFilter = AdminSupportTicket["status"] | "all";
type TicketCategoryFilter = AdminSupportTicket["category"] | "all";
type TicketPriorityFilter = AdminSupportTicket["priority"] | "all";
type AdminDataWarning = {
key: string;
message: string;
detail: string;
};
const MAX_RENDERED_USERS = 40;
const MAX_RESOLVED_TICKET_HISTORY = 6;
@ -330,6 +344,201 @@ function parseCohorts(value: string): string[] {
.filter(Boolean);
}
function asRecord(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function stringField(
source: Record<string, unknown>,
field: string,
fallback: string,
warnings: AdminDataWarning[],
label: string,
): string {
const value = source[field];
if (typeof value === "string") return value;
if (value == null) {
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 값이 비어 있어 기본값으로 표시합니다.`,
detail: `${field}=null`,
});
return fallback;
}
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 타입이 잘못되어 문자열로 변환했습니다.`,
detail: `${field}=${typeof value}`,
});
return String(value);
}
function booleanField(
source: Record<string, unknown>,
field: string,
fallback: boolean,
warnings: AdminDataWarning[],
label: string,
): boolean {
const value = source[field];
if (typeof value === "boolean") return value;
if (value == null) {
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 값이 비어 있어 ${fallback ? "true" : "false"}로 표시합니다.`,
detail: `${field}=null`,
});
return fallback;
}
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 타입이 잘못되어 bool 기본값으로 표시합니다.`,
detail: `${field}=${typeof value}`,
});
return fallback;
}
function numberField(
source: Record<string, unknown>,
field: string,
fallback: number,
warnings: AdminDataWarning[],
label: string,
): number {
const value = source[field];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field}가 문자열이라 숫자로 변환했습니다.`,
detail: `${field}=string`,
});
return parsed;
}
}
warnings.push({
key: `${label}.${field}`,
message: `${label} 응답에서 ${field} 숫자 값이 없어 ${fallback}으로 표시합니다.`,
detail: `${field}=${value == null ? "null" : typeof value}`,
});
return fallback;
}
function cohortIdsField(
source: Record<string, unknown>,
warnings: AdminDataWarning[],
label: string,
): string[] {
const value = source.cohort_ids;
if (!Array.isArray(value)) {
warnings.push({
key: `${label}.cohort_ids`,
message: `${label} 응답에서 cohort_ids가 배열이 아니라 빈 코호트로 표시합니다.`,
detail: `cohort_ids=${value == null ? "null" : typeof value}`,
});
return [];
}
const normalized = value.filter((item): item is string => typeof item === "string");
if (normalized.length !== value.length) {
warnings.push({
key: `${label}.cohort_ids.items`,
message: `${label} 응답에서 cohort_ids 내부에 문자열이 아닌 값이 있어 제외했습니다.`,
detail: `kept=${normalized.length}, raw=${value.length}`,
});
}
return normalized;
}
function roleField(
source: Record<string, unknown>,
warnings: AdminDataWarning[],
label: string,
): AdminManagedUser["role"] {
const value = source.role;
if (value === "learner" || value === "teacher" || value === "admin") return value;
warnings.push({
key: `${label}.role`,
message: `${label} 응답에서 role 값이 계약과 달라 학습자로 표시합니다.`,
detail: `role=${String(value)}`,
});
return "learner";
}
function accountStatusField(
source: Record<string, unknown>,
warnings: AdminDataWarning[],
label: string,
): AdminManagedUser["account_status"] {
const value = source.account_status;
if (value === "pending" || value === "approved" || value === "suspended") return value;
warnings.push({
key: `${label}.account_status`,
message: `${label} 응답에서 account_status 값이 계약과 달라 승인 대기로 표시합니다.`,
detail: `account_status=${String(value)}`,
});
return "pending";
}
function userSourceField(
source: Record<string, unknown>,
parentSource: AdminUsersResponse["source"],
): AdminManagedUser["source"] {
const value = source.source;
return value === "server_session_registry" || value === "database" ? value : parentSource;
}
function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): {
response: AdminUsersResponse;
warnings: AdminDataWarning[];
} {
const warnings: AdminDataWarning[] = [];
const root = asRecord(rawResponse);
const source: AdminUsersResponse["source"] =
root.source === "server_session_registry" ? "server_session_registry" : "database";
const durable = typeof root.durable === "boolean" ? root.durable : source === "database";
const rawUsers = Array.isArray(root.users) ? root.users : [];
if (!Array.isArray(root.users)) {
warnings.push({
key: "admin.users",
message: "/admin/users 응답에서 users 배열이 없어 빈 목록으로 표시합니다.",
detail: `users=${root.users == null ? "null" : typeof root.users}`,
});
}
const users = rawUsers.map((item, index): AdminManagedUser => {
const sourceRecord = asRecord(item);
const label = `users[${index}]`;
const userId = stringField(sourceRecord, "user_id", `unknown-${index + 1}`, warnings, label);
return {
user_id: userId,
email: stringField(sourceRecord, "email", userId, warnings, label),
display_name: stringField(sourceRecord, "display_name", userId, warnings, label),
role: roleField(sourceRecord, warnings, label),
admin_access: booleanField(sourceRecord, "admin_access", false, warnings, label),
super_admin: booleanField(sourceRecord, "super_admin", false, warnings, label),
account_status: accountStatusField(sourceRecord, warnings, label),
cohort_ids: cohortIdsField(sourceRecord, warnings, label),
affiliation: stringField(sourceRecord, "affiliation", "", warnings, label),
active_sessions: numberField(sourceRecord, "active_sessions", 0, warnings, label),
created_at: numberField(sourceRecord, "created_at", 0, warnings, label),
last_seen_at: numberField(sourceRecord, "last_seen_at", 0, warnings, label),
source: userSourceField(sourceRecord, source),
};
});
return {
response: {
source,
durable,
users,
},
warnings,
};
}
function isOnline(seconds?: number | null): boolean {
if (!seconds || seconds <= 0) return false;
return Date.now() / 1000 - seconds < 15 * 60;
@ -355,6 +564,187 @@ function PageHeader({ kicker, title, description, children }: PageHeaderProps) {
);
}
function runtimeAssetLabel(): string {
if (typeof document === "undefined") return "unknown";
const script = Array.from(document.scripts)
.map((item) => item.getAttribute("src") ?? "")
.find((src) => src.includes("/assets/index-") && src.endsWith(".js"));
if (!script) return "unknown";
return script.split("/").pop() ?? script;
}
function AdminDiagnosticPanel({
title,
body,
details,
}: {
title: string;
body: string;
details: Array<[string, string]>;
}) {
return (
<section className="ad-diagnostic" role="alert">
<div>
<Kicker> </Kicker>
<h1>{title}</h1>
<p>{body}</p>
</div>
<dl>
{details.map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</section>
);
}
interface AdminErrorBoundaryProps {
section: AdminSection;
children: ReactNode;
}
interface AdminErrorBoundaryState {
error: Error | null;
errorInfo: ErrorInfo | null;
}
class AdminErrorBoundary extends Component<AdminErrorBoundaryProps, AdminErrorBoundaryState> {
state: AdminErrorBoundaryState = { error: null, errorInfo: null };
static getDerivedStateFromError(error: Error): AdminErrorBoundaryState {
return { error, errorInfo: null };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState({ errorInfo });
console.error("[admin-render-error]", error, errorInfo.componentStack);
}
componentDidUpdate(prevProps: AdminErrorBoundaryProps) {
if (prevProps.section !== this.props.section && this.state.error) {
this.setState({ error: null, errorInfo: null });
}
}
render() {
if (this.state.error) {
return (
<AdminDiagnosticPanel
title="관리자 화면을 표시하지 못했습니다"
body="렌더 중 예외가 발생했습니다. 빈 화면 대신 아래 진단값을 남깁니다."
details={[
["section", this.props.section],
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
["error", this.state.error.message || this.state.error.name],
["asset", runtimeAssetLabel()],
["componentStack", this.state.errorInfo?.componentStack?.trim() || "not captured"],
]}
/>
);
}
return this.props.children;
}
}
function AdminBlankContentProbe({ section }: { section: AdminSection }) {
const markerRef = useRef<HTMLSpanElement | null>(null);
const [diagnostic, setDiagnostic] = useState<Array<[string, string]> | null>(null);
useEffect(() => {
setDiagnostic(null);
const timer = window.setTimeout(() => {
const root = markerRef.current?.closest(".ad-root") as HTMLElement | null;
if (!root) return;
const text = root.innerText.replace(/\s+/g, " ").trim();
const rootRect = root.getBoundingClientRect();
const visibleNodes = Array.from(
root.querySelectorAll<HTMLElement>(
"h1,h2,p,button,input,select,article,section,.ad-head,.ad-panel,.ad-section,.ad-users-note,.ad-status",
),
).filter((element) => {
if (element.classList.contains("ad-diagnostic")) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 1 &&
rect.height > 1 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
);
});
if (text.length === 0 || rootRect.height < 12 || visibleNodes.length === 0) {
setDiagnostic([
["section", section],
["path", window.location.pathname],
["rootHeight", `${Math.round(rootRect.height)}px`],
["visibleNodes", String(visibleNodes.length)],
["scrollY", String(Math.round(window.scrollY))],
["asset", runtimeAssetLabel()],
]);
}
}, 800);
return () => window.clearTimeout(timer);
}, [section]);
return (
<>
<span ref={markerRef} className="ad-blank-probe" aria-hidden="true" />
{diagnostic ? (
<AdminDiagnosticPanel
title="관리자 본문이 비어 있습니다"
body="라우트는 열렸지만 본문 DOM이 표시 가능한 콘텐츠를 만들지 못했습니다."
details={diagnostic}
/>
) : null}
</>
);
}
function AdminSectionRenderer({
section,
diagnostics,
renderOverview,
renderUsers,
renderAccess,
renderTickets,
}: {
section: AdminSection;
diagnostics: ReactNode;
renderOverview: () => ReactNode;
renderUsers: () => ReactNode;
renderAccess: () => ReactNode;
renderTickets: () => ReactNode;
}) {
let content: ReactNode = null;
if (section === "overview") content = renderOverview();
if (section === "users") content = renderUsers();
if (section === "access") content = renderAccess();
if (section === "tickets") content = renderTickets();
return (
<div className="ad-root" data-admin-section={section}>
{diagnostics}
{content ?? (
<AdminDiagnosticPanel
title="관리자 라우트 섹션을 찾지 못했습니다"
body="라우터가 알 수 없는 관리자 섹션을 전달했습니다."
details={[
["section", section],
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
["asset", runtimeAssetLabel()],
]}
/>
)}
<AdminBlankContentProbe section={section} />
</div>
);
}
interface AdminProps {
section?: AdminSection;
}
@ -368,6 +758,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
const [usersData, setUsersData] = useState<AdminUsersResponse | null>(null);
const [usersLoading, setUsersLoading] = useState(true);
const [usersError, setUsersError] = useState<string | null>(null);
const [usersDiagnostics, setUsersDiagnostics] = useState<AdminDataWarning[]>([]);
const [userDrafts, setUserDrafts] = useState<Record<string, UserDraft>>({});
const [savingUserId, setSavingUserId] = useState<string | null>(null);
const [newUser, setNewUser] = useState<NewUserDraft>(EMPTY_NEW_USER);
@ -413,10 +804,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
setUsersError(null);
try {
const next = await adminUsersApi.list();
setUsersData(next);
const { response: normalized, warnings } = normalizeAdminUsersResponse(next);
setUsersData(normalized);
setUsersDiagnostics(warnings);
setUserDrafts((current) =>
Object.fromEntries(
next.users.map((user) => [
normalized.users.map((user) => [
user.user_id,
current[user.user_id] ?? {
display_name: user.display_name,
@ -431,6 +824,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
);
} catch (err) {
setUsersError(err instanceof Error ? err.message : "사용자 목록을 불러오지 못했습니다.");
setUsersDiagnostics([]);
} finally {
setUsersLoading(false);
}
@ -804,6 +1198,25 @@ export default function Admin({ section = "overview" }: AdminProps) {
}, [userSearch, users]);
const hasUserSearch = userSearch.trim().length > 0;
const visibleUsers = filteredUsers.slice(0, MAX_RENDERED_USERS);
const renderAdminDiagnostics = () =>
usersDiagnostics.length > 0 ? (
<AdminDiagnosticPanel
title="관리자 데이터 진단"
body="/admin/users 응답 일부가 화면 계약과 달라 기본값으로 보정했습니다. 빈 화면 대신 어떤 필드가 문제인지 표시합니다."
details={[
["section", section],
["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
["asset", runtimeAssetLabel()],
...usersDiagnostics.slice(0, 8).map((warning): [string, string] => [
warning.key,
`${warning.message} (${warning.detail})`,
]),
...(usersDiagnostics.length > 8
? [["more", `${usersDiagnostics.length - 8}개 추가 진단 생략`] as [string, string]]
: []),
]}
/>
) : null;
const renderOverview = () => (
<>
@ -1875,12 +2288,16 @@ export default function Admin({ section = "overview" }: AdminProps) {
return (
<AppShell contextLabel="운영 콘솔" navRole="admin" wide>
<div className="ad-root">
{section === "overview" ? renderOverview() : null}
{section === "users" ? renderUsers() : null}
{section === "access" ? renderAccess() : null}
{section === "tickets" ? renderTickets() : null}
</div>
<AdminErrorBoundary section={section}>
<AdminSectionRenderer
section={section}
diagnostics={renderAdminDiagnostics()}
renderOverview={renderOverview}
renderUsers={renderUsers}
renderAccess={renderAccess}
renderTickets={renderTickets}
/>
</AdminErrorBoundary>
</AppShell>
);
}

View file

@ -21,6 +21,83 @@
pointer-events: none;
}
.ad-blank-probe {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.ad-diagnostic {
display: grid;
gap: 14px;
padding: 18px;
border: 1px solid color-mix(in srgb, var(--warn-solid) 42%, var(--hair));
border-radius: var(--radius);
background:
linear-gradient(180deg, color-mix(in srgb, var(--warn-tint) 48%, var(--bg-surface)), var(--bg-surface));
box-shadow: var(--shadow-sm);
}
.ad-diagnostic h1 {
margin: 6px 0 0;
color: var(--text-strong);
font-size: var(--fs-h2);
line-height: 1.28;
letter-spacing: 0;
}
.ad-diagnostic p {
margin: 6px 0 0;
max-width: 760px;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.55;
}
.ad-diagnostic dl {
display: grid;
grid-template-columns: minmax(130px, 0.28fr) minmax(0, 1fr);
gap: 0;
margin: 0;
overflow: hidden;
border: 1px solid var(--hair);
border-radius: var(--radius);
background: var(--bg-surface);
}
.ad-diagnostic dl > div {
display: grid;
grid-template-columns: minmax(130px, 0.28fr) minmax(0, 1fr);
grid-column: 1 / -1;
min-width: 0;
}
.ad-diagnostic dt,
.ad-diagnostic dd {
min-width: 0;
margin: 0;
padding: 9px 11px;
border-bottom: 1px solid var(--hair);
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.5;
overflow-wrap: anywhere;
}
.ad-diagnostic dl > div:last-child dt,
.ad-diagnostic dl > div:last-child dd {
border-bottom: 0;
}
.ad-diagnostic dt {
color: var(--text-muted);
font-family: var(--font-num);
font-weight: 700;
background: var(--bg-surface-2);
}
.ad-head {
display: flex;
align-items: flex-end;

View file

@ -30,7 +30,7 @@
- M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속.
- 페르소나 저작/P4~P7 적재 2차: `data/personas/P4.json`~`P7.json`을 저장소 관리 `PersonaCard`로 읽어 `materialize_seed_personas()`와 seed fallback catalog에 포함했다. 검증: `python -m pytest app/test_persona_review.py -q` 22 passed, 로컬 dev DB materialize 결과 `approved_codes=P1,P2,P3,P4,P5,P6,P7`, 인증된 로컬 `GET /personas``P1..P7` 반환.
- Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다.
- 관리자 기본 진입 경로와 route scroll 복원: `initialPathForUser()`가 pending/onboarding/admin-entitled landing을 소유한다. 관리자 콘솔 접근권이 있는 계정은 primary role이 learner/teacher여도 `/` 또는 로그인 완료 뒤 `/admin`으로 들어간다. `App.tsx`는 pathname 변경마다 document scroll을 0으로 복원해 긴 관리자 화면에서 짧은 `/admin/access`로 이동해도 sticky shell만 보이는 빈 화면을 만들지 않는다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "resets scroll"` 1 passed, custom domain 새 asset 확인.
- 관리자 기본 진입 경로와 blank diagnostics: `initialPathForUser()`가 pending/onboarding/admin-entitled landing을 소유한다. 관리자 콘솔 접근권이 있는 계정은 primary role이 learner/teacher여도 `/` 또는 로그인 완료 뒤 `/admin`으로 들어간다. `App.tsx`는 pathname 변경마다 document scroll을 0으로 복원해 긴 관리자 화면에서 짧은 `/admin/access`로 이동해도 sticky shell만 보이는 빈 화면을 만들지 않는다. 관리자 화면은 `/admin/users` payload 계약 위반을 안전하게 정규화하고 `관리자 데이터 진단`으로 깨진 필드/path/asset을 표시하며, route render error와 0-height main도 진단 패널로 대체한다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"` 4 passed, custom domain 새 asset 확인.
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다.
- 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010``ENVIRONMENT=dev`로 떠 있다.

View file

@ -961,8 +961,9 @@
<tr><td>Engine session reuse</td><td><code>engine_gateway.test_gateway_model</code></td><td>27 tests OK; shared engine contract, JSON Schema + golden fixture validation, Node.js artifact conformance runner, <code>GenerateResponse</code> response validation, structured payload fallback parser, current-turn prompt split, direct evaluator/live-coach parser ownership, SSE frames/decoder, live session_id reuse, <code>gateway-default</code> 기본 라우팅 sentinel 정규화, missing-user 400 guard, ephemeral close fixed</td></tr>
<tr><td>P1/H4 masking gate</td><td><code>app/test_pii_masking_eval.py app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py</code> / <code>PLAYWRIGHT_PORT=5244 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "Korean PII"</code></td><td>47 passed; 최신 보정 회귀 59 passed + DB-backed browser PII E2E 1 passed. phone/email/RRN 및 한국어 NAME/ORG raw 값이 generate/stream/evaluator payload와 client <code>text_masked</code>에 남지 않음. Optional ko recognizer fake span은 <code>[NAME]</code>/<code>[ORG]</code>로 마스킹되고 같은 문장의 phone은 후단 regex가 처리하며, adapter 실패 시에도 regex fallback이 유지된다. Synthetic ko fixture 16/16 pass, 자연 발화형 이름 라벨·자기소개와 negative control 포함, input/report schema validation, summary-only report(<code>evidence_text_included=false</code>), entity recall 1.0, forbidden substring removal 1.0, unexpected entity violations 0. 최신 브라우저 stream E2E는 한국어 이름·기관·전화번호 발화가 DB-backed <code>GET /sessions/{id}</code> 상세와 <code>/review</code> 모두에서 raw 값 없이 <code>[NAME]</code>/<code>[ORG]</code>/<code>[PHONE]</code>으로 남는지 확인한다.</td></tr>
<tr><td>P2a RBAC/audit/visibility</td><td><code>app.test_rbac_idor</code></td><td>11 tests OK; other learner 403, read_session audit, evaluator-only hidden, teacher session review read allowed, super_admin learner review principal is promoted to admin for evaluation/worksheet/review-status loaders, admin_access-only learner remains blocked, and learner worksheet write remains 403</td></tr>
<tr><td>Admin access delegation</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_admin_ops.py app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_admin_console_even_with_stale_access_flag app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_all_role_spaces_without_super_admin app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_access_flag_without_admin_role_does_not_enter_learner_space -q</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --grep "approved admin without onboarding"</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"</code> / <code>npm run typecheck</code></td><td>13 backend focused passed + admin onboarding guard E2E 2 passed + latest admin route guards 2 passed + web typecheck/build passed. 실제 <code>admin</code> role은 <code>admin_access=false</code> 세션이어도 <code>can_access_role(Role.ADMIN)</code>으로 관리자 API를 열고, 승인된 관리자 또는 <code>admin_access</code> 계정은 <code>/admin*</code>에서 학습자 온보딩으로 우회하지 않는다. 최초 진입 경로도 <code>initialPathForUser</code>가 소유해 primary role이 learner/teacher인 관리자 권한 계정이 <code>/</code> 또는 로그인 완료 후 <code>/admin</code>으로 들어간다. 관리자 sidebar와 기존 workspace 전환 계약은 유지된다.</td></tr>
<tr><td>Admin access delegation</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_admin_ops.py app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_admin_console_even_with_stale_access_flag app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_all_role_spaces_without_super_admin app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_access_flag_without_admin_role_does_not_enter_learner_space -q</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --grep "approved admin without onboarding"</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"</code> / <code>npm run typecheck</code></td><td>13 backend focused passed + admin onboarding guard E2E 2 passed + latest admin route guards 4 passed + web typecheck/build passed. 실제 <code>admin</code> role은 <code>admin_access=false</code> 세션이어도 <code>can_access_role(Role.ADMIN)</code>으로 관리자 API를 열고, 승인된 관리자 또는 <code>admin_access</code> 계정은 <code>/admin*</code>에서 학습자 온보딩으로 우회하지 않는다. 최초 진입 경로도 <code>initialPathForUser</code>가 소유해 primary role이 learner/teacher인 관리자 권한 계정이 <code>/</code> 또는 로그인 완료 후 <code>/admin</code>으로 들어간다. 관리자 sidebar와 기존 workspace 전환 계약은 유지된다.</td></tr>
<tr><td>SPA route scroll reset</td><td><code>npm run typecheck</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "resets scroll"</code> / Pages deploy</td><td>Typecheck passed and focused E2E 1 passed. <code>App.tsx</code> resets document scroll to top on <code>pathname</code> changes, so moving from a long admin page to short <code>/admin/access</code> no longer preserves stale <code>scrollY</code> and shows only the sticky topbar/sidebar over an empty main area. Probe proof: previous <code>scrollY=1000</code>, after clicking <code>권한</code> <code>scrollY=0</code>, heading <code>역할, 그룹, 접근 범위</code> in viewport. Live custom domain now serves <code>assets/index-DBWK9mZg.js</code>/<code>assets/index-liGoZae_.css</code>.</td></tr>
<tr><td>Admin silent-blank diagnostics</td><td><code>npm run typecheck</code> / <code>npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"</code></td><td>Typecheck passed and route-guard E2E 4 passed. Admin now normalizes malformed <code>/admin/users</code> payload fields such as <code>cohort_ids=null</code>, <code>active_sessions=null</code>, and <code>created_at=null</code> instead of throwing during render. The screen shows <code>관리자 데이터 진단</code> with section/path/asset and broken field keys, while route render errors and zero-height main content fall back to a diagnostic panel instead of a blank shell.</td></tr>
<tr><td>OAuth/SAML admin landing</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_auth_providers.py -q</code> / public runtime restart</td><td>36 passed; OAuth/SAML callback now normalizes generic saved <code>next</code> paths such as <code>/learn</code> and <code>/teach</code> to <code>/admin</code> for admin-entitled users while preserving deep links such as <code>/learn/session/...</code>. Live proof after commits <code>998acb44</code>/<code>6b6241f4</code> and latest scroll-fix deploy <code>a5a2e2ea</code>: local/public <code>/health</code> returned <code>prod</code>, <code>db=true</code>, <code>engine=true</code>; custom domain now serves <code>assets/index-DBWK9mZg.js</code>/<code>assets/index-liGoZae_.css</code>; unauthenticated <code>/personas</code> remains <code>401</code>.</td></tr>
<tr><td>Session read-model DB readiness</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / <code>https://api-vignette.chanpaca.net/health</code></td><td>2026-06-29 prod 503 원인은 운영 DB의 <code>app.turns.provider_events</code> 컬럼 누락이었다. 운영 DB hotfix 후 <code>/teacher/dashboard</code> code path는 <code>source=database</code>로 복구됐다. 현재 <code>db.healthcheck()</code>, runtime table readiness, deploy preflight DB mode는 <code>app.turns</code> 음성 메타 컬럼 5개(<code>audio_ref</code>, <code>silence_ms</code>, <code>speech_rate</code>, <code>barge_in</code>, <code>provider_events</code>), <code>app.session_review_status</code> worksheet 컬럼, <code>app.safety_events</code> 필수 컬럼을 함께 검증한다. 2026-06-30에는 Docker Desktop/DB 중단으로 public API 530/error code 1033이 재발했지만, Docker Desktop/DB 재기동 뒤 public health가 <code>environment=prod</code>, <code>db=true</code>, <code>engine=true</code>로 복구됐다.</td></tr>
<tr><td>Admin usage persistence</td><td><code>python -m pytest app/test_admin_ops.py app/test_runtime_policy.py -q</code> / authenticated local public-API smoke</td><td>26 passed; <code>/admin/usage</code> returns 200 with <code>source=database</code>, <code>durable=true</code>. Cloudflare blocked raw Python public smoke with 1010, so app-level HTTP was verified against <code>127.0.0.1:8001</code> using the same prod process.</td></tr>

View file

@ -726,6 +726,10 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`):
역할 전환용 `roleHomePath`는 그대로 역할별 홈(`/learn`, `/teach`, `/admin`)만 소유한다.
- SPA 라우트 전환은 `App.tsx``pathname` 변경마다 문서 스크롤을 맨 위로 복원한다. 긴 관리자 목록에서
짧은 `/admin/access` 정책 화면으로 이동할 때 이전 `scrollY`가 유지되어 sticky 셸만 보이는 빈 화면을 막기 위한 계약이다.
- 관리자 콘솔은 shell만 남고 본문이 비는 silent failure를 허용하지 않는다. `/admin/users` 응답의
`cohort_ids`, `active_sessions`, `created_at` 같은 필드가 런타임 계약과 다르면 프론트가 안전한 기본값으로
정규화하고 `관리자 데이터 진단` 패널에 깨진 필드와 asset/path를 표시한다. 렌더 예외나 본문 0-height 상태도
route-scoped diagnostic panel로 노출해 운영자가 원인 없이 빈칸만 보지 않게 한다.
- OAuth/SAML callback은 저장된 `next`가 일반 진입 경로(`/`, `/learn`, `/teach`, `/login`, `/onboarding`)이고
로그인 사용자가 관리자 콘솔 접근권을 가지면 `/admin`으로 정규화한다. 단, `/learn/session/...` 같은 깊은
링크는 사용자가 의도적으로 연 URL일 수 있으므로 보존한다.