feat: add internal admin flows and fixture runner

This commit is contained in:
hungtran24 2026-09-28 23:11:02 +07:00
parent 9cd27caa24
commit cfdc997211
16 changed files with 794 additions and 37 deletions

View File

@ -1,4 +1,8 @@
# Admin Web server only. SvelteKit reads apps/web/.env when run from apps/web/. # Admin Web server only. SvelteKit reads apps/web/.env when run from apps/web/.
# Docker Compose passes BACKEND_INTERNAL_URL directly to the web container instead. # Docker Compose passes BACKEND_INTERNAL_URL directly to the web container instead.
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 BACKEND_INTERNAL_URL=http://127.0.0.1:8000
# Label for this Web instance: development, test, or production. Set test when using ai_platform_test.
WEB_ENVIRONMENT=development
# Optional server-only key for the internal fixture runner (use a separate test tenant).
# LEAD_SCORING_FIXTURE_TENANT_KEY=
# Never put credentials or backend URLs in PUBLIC_* variables. # Never put credentials or backend URLs in PUBLIC_* variables.

View File

@ -5,6 +5,7 @@ COPY apps/web/package.json apps/web/bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY apps/web/ ./ COPY apps/web/ ./
COPY contracts/lead-scoring/v2/openapi.json /contracts/lead-scoring/v2/openapi.json COPY contracts/lead-scoring/v2/openapi.json /contracts/lead-scoring/v2/openapi.json
COPY contracts/lead-scoring/v2/fixtures/run-request.json /contracts/lead-scoring/v2/fixtures/run-request.json
RUN bun run build RUN bun run build
FROM oven/bun:1.4.0 AS runtime FROM oven/bun:1.4.0 AS runtime

View File

@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js'; import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import ListChecks from '@lucide/svelte/icons/list-checks'; import ListChecks from '@lucide/svelte/icons/list-checks';
import FlaskConical from '@lucide/svelte/icons/flask-conical';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import ChartNoAxesCombined from '@lucide/svelte/icons/chart-no-axes-combined'; import KeyRound from '@lucide/svelte/icons/key-round';
let { email }: { email: string } = $props(); let { email }: { email: string } = $props();
const sidebar = useSidebar(); const sidebar = useSidebar();
@ -39,30 +39,25 @@
</Sidebar.Header> </Sidebar.Header>
<Sidebar.Content class="py-3"> <Sidebar.Content class="py-3">
<Sidebar.Group> <Sidebar.Group>
<Sidebar.GroupLabel>DỊCH VỤ AI</Sidebar.GroupLabel> <Sidebar.GroupLabel>NỀN TẢNG</Sidebar.GroupLabel>
<Sidebar.Menu> <Sidebar.Menu>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<span <Sidebar.MenuButton
class="flex min-h-9 items-center gap-2 px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:justify-center" tooltipContent="Mã truy cập"
title="Lead Scoring" isActive={page.url.pathname.startsWith('/platform/tenant-keys')}
> >
<ChartNoAxesCombined class="size-4 shrink-0" /> {#snippet child({ props })}
<span class="group-data-[collapsible=icon]:hidden">Lead Scoring</span> <a
</span> {...props}
</Sidebar.MenuItem> href="/platform/tenant-keys"
<Sidebar.MenuItem> aria-current={page.url.pathname.startsWith('/platform/tenant-keys')
<span ? 'page'
class="flex min-h-9 items-center gap-2 px-2 text-sm text-muted-foreground group-data-[collapsible=icon]:justify-center" : undefined}
title="Churn: chưa có đặc tả" >
> <KeyRound /> <span>Mã truy cập</span>
<ChartNoAxesCombined class="size-4 shrink-0 opacity-50" /> </a>
<span class="group-data-[collapsible=icon]:hidden">Churn</span> {/snippet}
<Badge </Sidebar.MenuButton>
variant="outline"
class="ml-auto text-[10px] group-data-[collapsible=icon]:hidden"
>Chưa có đặc tả</Badge
>
</span>
</Sidebar.MenuItem> </Sidebar.MenuItem>
</Sidebar.Menu> </Sidebar.Menu>
</Sidebar.Group> </Sidebar.Group>
@ -88,6 +83,24 @@
{/snippet} {/snippet}
</Sidebar.MenuButton> </Sidebar.MenuButton>
</Sidebar.MenuItem> </Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton
tooltipContent="Thử quy tắc"
isActive={page.url.pathname.startsWith('/lead-scoring/fixtures')}
>
{#snippet child({ props })}
<a
{...props}
href="/lead-scoring/fixtures"
aria-current={page.url.pathname.startsWith('/lead-scoring/fixtures')
? 'page'
: undefined}
>
<FlaskConical /> <span>Thử quy tắc</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton <Sidebar.MenuButton
tooltipContent="Cấu hình AI" tooltipContent="Cấu hình AI"

View File

@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import { env } from '$env/dynamic/private';
import { fixtureRunnerConfigured, submitScoringFixture } from './fixture-runner';
import type { RequestEvent } from '@sveltejs/kit';
vi.mock('$env/dynamic/private', () => ({
env: {
BACKEND_INTERNAL_URL: 'http://backend:8000',
LEAD_SCORING_FIXTURE_TENANT_KEY: 'test-only-tenant-key',
},
}));
describe('server-only fixture transport', () => {
it('sends the canonical fixture with a new request ID and no V-Key', async () => {
const fetch = vi
.fn()
.mockResolvedValue(Response.json({ status: 'pending' }, { status: 202 }));
const event = { fetch } as unknown as RequestEvent;
expect(fixtureRunnerConfigured()).toBe(true);
await submitScoringFixture(event);
await submitScoringFixture(event);
expect(fetch).toHaveBeenCalledTimes(2);
const ids = fetch.mock.calls.map(([url, init]) => {
expect(url).toBe('http://backend:8000/v1/lead-scoring/runs');
expect(init.headers.authorization).toBe('Bearer test-only-tenant-key');
const body = JSON.parse(init.body);
expect(init.headers['Idempotency-Key']).toBe(body.request_id);
expect(init.headers).not.toHaveProperty('X-Tenant-VKey');
expect(init.cache).toBe('no-store');
expect(init.body).not.toContain(env.LEAD_SCORING_FIXTURE_TENANT_KEY);
expect(body.subject_ref).toBe('ems-lead-123');
expect(body.scoring_config.ai_weight).toBe(0);
return body.request_id;
});
expect(ids[0]).not.toBe(ids[1]);
expect(ids.every((id: string) => /^[0-9a-f-]{36}$/.test(id))).toBe(true);
});
});

View File

@ -0,0 +1,37 @@
import { env } from '$env/dynamic/private';
import type { RequestEvent } from '@sveltejs/kit';
import type { components } from '$lib/generated/api';
import fixture from '../../../../../contracts/lead-scoring/v2/fixtures/run-request.json';
export type RunRequest = components['schemas']['RunRequest'];
export class FixtureRunnerNotConfigured extends Error {}
export function fixtureRunnerConfigured(): boolean {
return Boolean(env.LEAD_SCORING_FIXTURE_TENANT_KEY?.trim());
}
export async function submitScoringFixture(event: RequestEvent): Promise<Response> {
const base = env.BACKEND_INTERNAL_URL;
const tenantKey = env.LEAD_SCORING_FIXTURE_TENANT_KEY?.trim();
if (!tenantKey) throw new FixtureRunnerNotConfigured();
if (!base || !/^https?:\/\//.test(base)) {
throw new Error('BACKEND_INTERNAL_URL must be configured on the server');
}
const request: RunRequest = {
...(fixture as RunRequest),
request_id: crypto.randomUUID(),
};
return event.fetch(`${base.replace(/\/$/, '')}/v1/lead-scoring/runs`, {
method: 'POST',
cache: 'no-store',
headers: {
accept: 'application/json',
authorization: `Bearer ${tenantKey}`,
'content-type': 'application/json',
'Idempotency-Key': request.request_id,
},
body: JSON.stringify(request),
});
}

View File

@ -1,7 +1,14 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { backendJson, clearAdminSession } from '$lib/server/backend'; import { backendJson, clearAdminSession } from '$lib/server/backend';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
const environmentLabels: Record<string, string> = {
test: 'TEST · dữ liệu thử',
development: 'DEV · dữ liệu phát triển',
production: 'PROD · dữ liệu thật',
};
export const load: LayoutServerLoad = async (event) => { export const load: LayoutServerLoad = async (event) => {
if (!event.cookies.get('ap_session')) redirect(303, '/login'); if (!event.cookies.get('ap_session')) redirect(303, '/login');
const { response, data } = await backendJson<{ email: string }>(event, '/v1/admin/me'); const { response, data } = await backendJson<{ email: string }>(event, '/v1/admin/me');
@ -9,5 +16,8 @@ export const load: LayoutServerLoad = async (event) => {
clearAdminSession(event); clearAdminSession(event);
redirect(303, '/login'); redirect(303, '/login');
} }
return { admin: data }; return {
admin: data,
environment: environmentLabels[env.WEB_ENVIRONMENT ?? ''] ?? 'Môi trường chưa xác định',
};
}; };

View File

@ -17,8 +17,9 @@
<Sidebar.Trigger aria-label="Ẩn hoặc hiện điều hướng" /> <Sidebar.Trigger aria-label="Ẩn hoặc hiện điều hướng" />
<Separator orientation="vertical" class="h-5" /> <Separator orientation="vertical" class="h-5" />
<span class="text-sm font-semibold">DotB AI Platform</span> <span class="text-sm font-semibold">DotB AI Platform</span>
<span class="ml-auto hidden text-xs text-muted-foreground sm:inline" <span
>Quản trị nội bộ</span class="ml-auto rounded-md border border-border bg-muted px-2 py-1 text-xs font-semibold text-foreground"
>{data.environment}</span
> >
</header> </header>
<div class="mx-auto w-full max-w-7xl px-5 py-8 md:px-8 md:py-10" id="main-content"> <div class="mx-auto w-full max-w-7xl px-5 py-8 md:px-8 md:py-10" id="main-content">

View File

@ -4,10 +4,12 @@ import type { Actions, PageServerLoad } from './$types';
export type AIProfile = { export type AIProfile = {
version: string; version: string;
provider: 'aihub' | 'openai_compatible';
model: string; model: string;
system_prompt: string; system_prompt: string;
timeout_seconds: number; timeout_seconds: number;
active: boolean; active: boolean;
base_url: string | null;
}; };
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
@ -23,19 +25,41 @@ export const actions: Actions = {
default: async (event) => { default: async (event) => {
const form = await event.request.formData(); const form = await event.request.formData();
const version = String(form.get('version') ?? '').trim(); const version = String(form.get('version') ?? '').trim();
const provider = String(form.get('provider') ?? 'aihub');
const base_url =
provider === 'openai_compatible' ? String(form.get('base_url') ?? '').trim() : null;
const model = String(form.get('model') ?? '').trim(); const model = String(form.get('model') ?? '').trim();
const system_prompt = String(form.get('system_prompt') ?? '').trim(); const system_prompt = String(form.get('system_prompt') ?? '').trim();
const timeout_seconds = Number(form.get('timeout_seconds')); const timeout_seconds = Number(form.get('timeout_seconds'));
if (!version || !model || !system_prompt || !Number.isFinite(timeout_seconds)) { if (
return fail(400, { message: 'Điền đầy đủ phiên bản, model, prompt và timeout.' }); !version ||
!model ||
!system_prompt ||
!Number.isFinite(timeout_seconds) ||
(provider === 'openai_compatible' && !base_url) ||
!['aihub', 'openai_compatible'].includes(provider)
) {
return fail(400, {
message:
'Nhập đủ phiên bản, nhà cung cấp, URL (nếu dùng OpenAI-compatible), model và prompt.',
});
} }
const response = await backend(event, '/v1/admin/ai-profiles', { const response = await backend(event, '/v1/admin/ai-profiles', {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ version, model, system_prompt, timeout_seconds }), body: JSON.stringify({
version,
provider,
base_url,
model,
system_prompt,
timeout_seconds,
}),
}); });
if (response.status === 409) if (response.status === 409)
return fail(409, { message: 'Phiên bản đã tồn tại với nội dung khác.' }); return fail(409, { message: 'Phiên bản đã tồn tại với nội dung khác.' });
if (response.status === 422)
return fail(422, { message: 'URL không hợp lệ hoặc nhà cung cấp chưa sẵn sàng.' });
if (!response.ok) return fail(response.status, { message: 'Không lưu được cấu hình AI.' }); if (!response.ok) return fail(response.status, { message: 'Không lưu được cấu hình AI.' });
return { message: 'Đã kích hoạt phiên bản mới.', success: true }; return { message: 'Đã kích hoạt phiên bản mới.', success: true };
}, },

View File

@ -9,6 +9,11 @@
import * as Resizable from '$lib/components/ui/resizable/index.js'; import * as Resizable from '$lib/components/ui/resizable/index.js';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let selectedProvider = $state<PageProps['data']['profiles'][number]['provider']>('aihub');
const providerLabels: Record<PageProps['data']['profiles'][number]['provider'], string> = {
aihub: 'AIHub',
openai_compatible: 'OpenAI-compatible',
};
</script> </script>
{#snippet versionList()} {#snippet versionList()}
@ -24,9 +29,14 @@
{#each data.profiles as profile (profile.version)} {#each data.profiles as profile (profile.version)}
<li class="flex flex-wrap items-center gap-2 py-3 first:pt-0 last:pb-0"> <li class="flex flex-wrap items-center gap-2 py-3 first:pt-0 last:pb-0">
<strong class="text-sm">{profile.version}</strong> <strong class="text-sm">{profile.version}</strong>
<Badge variant="outline">{providerLabels[profile.provider]}</Badge>
<span class="min-w-0 break-all text-sm text-muted-foreground" <span class="min-w-0 break-all text-sm text-muted-foreground"
>{profile.model}</span >{profile.model}</span
> >
{#if profile.base_url}<span
class="w-full break-all text-xs text-muted-foreground"
>{profile.base_url}</span
>{/if}
{#if profile.active}<Badge variant="secondary" class="ml-auto" {#if profile.active}<Badge variant="secondary" class="ml-auto"
>Đang dùng</Badge >Đang dùng</Badge
>{/if} >{/if}
@ -42,9 +52,7 @@
<Card.Root class="h-full"> <Card.Root class="h-full">
<Card.Header> <Card.Header>
<h2 class="text-base font-semibold">Tạo và kích hoạt phiên bản</h2> <h2 class="text-base font-semibold">Tạo và kích hoạt phiên bản</h2>
<Card.Description <Card.Description>Mỗi lần đổi cấu hình, tạo phiên bản mới.</Card.Description>
>Tạo phiên bản mới thay vì sửa lịch sử của các lần chấm điểm đã chạy.</Card.Description
>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<form method="POST" class="grid gap-4"> <form method="POST" class="grid gap-4">
@ -58,11 +66,38 @@
/> />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for={`${prefix}-model`}>Model AIHub</Label><Input <Label for={`${prefix}-provider`}>Nhà cung cấp</Label>
<select
id={`${prefix}-provider`}
name="provider"
bind:value={selectedProvider}
required
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-2 focus-visible:outline-ring"
>
<option value="aihub">{providerLabels.aihub}</option>
<option value="openai_compatible">{providerLabels.openai_compatible}</option
>
</select>
</div>
{#if selectedProvider === 'openai_compatible'}
<div class="grid gap-2">
<Label for={`${prefix}-base-url`}>URL OpenAI-compatible</Label>
<Input
id={`${prefix}-base-url`}
name="base_url"
type="url"
required
maxlength={2048}
placeholder="http://127.0.0.1:8317"
/>
</div>
{/if}
<div class="grid gap-2">
<Label for={`${prefix}-model`}>Model của nhà cung cấp</Label><Input
id={`${prefix}-model`} id={`${prefix}-model`}
name="model" name="model"
required required
value="openai/gpt-4o-mini" placeholder="Nhập model của nhà cung cấp"
/> />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
@ -79,7 +114,7 @@
name="timeout_seconds" name="timeout_seconds"
type="number" type="number"
min="1" min="1"
max="60" max="120"
value="12" value="12"
required required
/> />
@ -100,8 +135,7 @@
<p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Cấu hình</p> <p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Cấu hình</p>
<h1 class="text-3xl font-bold">Cấu hình AI</h1> <h1 class="text-3xl font-bold">Cấu hình AI</h1>
<p class="mt-2 max-w-prose text-sm text-muted-foreground"> <p class="mt-2 max-w-prose text-sm text-muted-foreground">
Quản lý model, prompt và thời gian chờ của AI Platform. Omni sở hữu quy tắc và trọng số; Chọn nhà cung cấp, nhập URL nếu dùng OpenAI-compatible và chọn model cho Lead Scoring.
thông tin xác thực nhà cung cấp, gồm V-Key, do AIHub quản lý và không nhập tại đây.
</p> </p>
</header> </header>
<div class="grid gap-5 lg:hidden"> <div class="grid gap-5 lg:hidden">

View File

@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import { backend } from '$lib/server/backend';
import { actions } from './+page.server';
vi.mock('$lib/server/backend', () => ({ backend: vi.fn(), backendJson: vi.fn() }));
const backendMock = vi.mocked(backend);
type ActionEvent = Parameters<NonNullable<typeof actions.default>>[0];
describe('AI profile provider selection', () => {
it('sends only a selected provider and profile settings to backend', async () => {
const form = new FormData();
form.set('version', 'profile-v2');
form.set('provider', 'openai_compatible');
form.set('base_url', 'http://127.0.0.1:8317');
form.set('model', 'gemini-3.5-flash-lite');
form.set('system_prompt', 'Only use provided evidence.');
form.set('timeout_seconds', '30');
const event = {
request: { formData: async () => form },
} as unknown as ActionEvent;
backendMock.mockResolvedValueOnce(new Response('{}', { status: 200 }));
const action = actions.default;
if (!action) throw new Error('AI profile action missing');
expect(await action(event)).toMatchObject({ success: true });
expect(backendMock).toHaveBeenCalledWith(
event,
'/v1/admin/ai-profiles',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({
version: 'profile-v2',
provider: 'openai_compatible',
base_url: 'http://127.0.0.1:8317',
model: 'gemini-3.5-flash-lite',
system_prompt: 'Only use provided evidence.',
timeout_seconds: 30,
}),
}),
);
expect(backendMock.mock.calls[0][2]?.body).not.toContain('api_key');
});
});

View File

@ -0,0 +1,64 @@
import { fail } from '@sveltejs/kit';
import { backend, clearAdminSession } from '$lib/server/backend';
import {
fixtureRunnerConfigured,
FixtureRunnerNotConfigured,
submitScoringFixture,
} from '$lib/server/fixture-runner';
import type { components } from '$lib/generated/api';
import type { Actions, PageServerLoad } from './$types';
type RunSummary = components['schemas']['RunSummary'];
export const load: PageServerLoad = async () => ({
fixtureRunnerConfigured: fixtureRunnerConfigured(),
});
export const actions: Actions = {
default: async (event) => {
event.setHeaders({ 'cache-control': 'no-store' });
const adminSession = await backend(event, '/v1/admin/me');
if (adminSession.status === 401) {
clearAdminSession(event);
return fail(401, { message: 'Phiên quản trị đã hết hạn. Hãy đăng nhập lại.' });
}
if (!adminSession.ok) {
return fail(502, { message: 'Không thể xác minh phiên quản trị.' });
}
if (!fixtureRunnerConfigured()) {
return fail(503, {
message: 'Chưa có mã truy cập cho mẫu này trên server.',
});
}
let response: Response;
try {
response = await submitScoringFixture(event);
} catch (error) {
if (error instanceof FixtureRunnerNotConfigured) {
return fail(503, {
message: 'Chưa có mã truy cập cho mẫu này trên server.',
});
}
return fail(502, { message: 'Không gửi được dữ liệu mẫu.' });
}
if (response.status === 401) {
return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.' });
}
if (!response.ok) {
return fail(response.status, { message: 'Không nhận được dữ liệu mẫu.' });
}
try {
const run = (await response.json()) as RunSummary;
return {
success: true,
run_id: run.run_id,
status: run.status,
message: 'Đã gửi dữ liệu mẫu.',
};
} catch {
return fail(502, { message: 'AI Platform trả về phản hồi không hợp lệ.' });
}
},
};

View File

@ -0,0 +1,48 @@
<script lang="ts">
import type { PageProps } from './$types';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
let { data, form }: PageProps = $props();
</script>
<svelte:head><title>Thử quy tắc · AI Platform</title></svelte:head>
<header class="mb-8">
<p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Kiểm thử</p>
<h1 class="text-3xl font-bold">Thử tính điểm theo quy tắc</h1>
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
Gửi dữ liệu mẫu để kiểm tra điểm quy tắc. Trang này chưa thử model AI.
</p>
</header>
<Card.Root class="max-w-2xl">
<Card.Header>
<h2 class="text-base font-semibold">Dữ liệu mẫu</h2>
</Card.Header>
<Card.Content class="grid gap-4">
{#if !data.fixtureRunnerConfigured}
<p class="text-sm text-muted-foreground" role="status">
Chưa có mã truy cập cho mẫu này trên server.
</p>
{/if}
{#if form?.message}
<p
class={form.success ? 'text-sm text-primary' : 'text-sm text-destructive'}
role={form.success ? 'status' : 'alert'}
>
{form.message}
</p>
{/if}
{#if form?.success && form.run_id}
<p class="text-sm">
Trạng thái: {form.status}.
<a
class="font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-2"
href={`/lead-scoring/runs/${form.run_id}`}>Xem lần chấm điểm</a
>
</p>
{/if}
<form method="POST">
<Button type="submit" disabled={!data.fixtureRunnerConfigured}>Gửi dữ liệu mẫu</Button>
</form>
</Card.Content>
</Card.Root>

View File

@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { backend, clearAdminSession } from '$lib/server/backend';
import { fixtureRunnerConfigured, submitScoringFixture } from '$lib/server/fixture-runner';
import { actions, load } from './+page.server';
vi.mock('$lib/server/backend', () => ({
backend: vi.fn(),
clearAdminSession: vi.fn(),
}));
vi.mock('$lib/server/fixture-runner', () => ({
fixtureRunnerConfigured: vi.fn(),
submitScoringFixture: vi.fn(),
FixtureRunnerNotConfigured: class extends Error {},
}));
const backendMock = vi.mocked(backend);
const configuredMock = vi.mocked(fixtureRunnerConfigured);
const submitMock = vi.mocked(submitScoringFixture);
const clearSessionMock = vi.mocked(clearAdminSession);
const runId = 'f4a5d7c8-f55f-47d2-b2ea-611a2b78e59b';
type ActionEvent = Parameters<NonNullable<typeof actions.default>>[0];
function eventFor(): { event: ActionEvent; setHeaders: ReturnType<typeof vi.fn> } {
const setHeaders = vi.fn();
return { event: { setHeaders } as unknown as ActionEvent, setHeaders };
}
beforeEach(() => {
vi.clearAllMocks();
configuredMock.mockReturnValue(true);
backendMock.mockResolvedValue(new Response('{}', { status: 200 }));
});
describe('fixture runner page', () => {
it('shows whether a server-only fixture key is configured', async () => {
configuredMock.mockReturnValue(false);
const page = await load({} as Parameters<typeof load>[0]);
expect(page).toEqual({ fixtureRunnerConfigured: false });
});
it('submits the fixture only after checking the admin session', async () => {
submitMock.mockResolvedValueOnce(
Response.json({ run_id: runId, status: 'pending' }, { status: 202 }),
);
const action = actions.default;
if (!action) throw new Error('fixture action missing');
const { event, setHeaders } = eventFor();
const result = await action(event);
expect(result).toMatchObject({ success: true, run_id: runId, status: 'pending' });
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
expect(submitMock).toHaveBeenCalledWith(event);
expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan(
submitMock.mock.invocationCallOrder[0],
);
});
it('rejects an expired admin session without submitting', async () => {
backendMock.mockResolvedValueOnce(new Response('{}', { status: 401 }));
const action = actions.default;
if (!action) throw new Error('fixture action missing');
const { event } = eventFor();
const result = await action(event);
expect(result).toMatchObject({ status: 401 });
expect(clearSessionMock).toHaveBeenCalledWith(event);
expect(submitMock).not.toHaveBeenCalled();
});
it('rejects an unconfigured fixture key without submitting', async () => {
configuredMock.mockReturnValue(false);
const action = actions.default;
if (!action) throw new Error('fixture action missing');
const result = await action(eventFor().event);
expect(result).toMatchObject({ status: 503 });
expect(submitMock).not.toHaveBeenCalled();
});
it('does not expose a rejected tenant key in the response', async () => {
submitMock.mockResolvedValueOnce(new Response('{}', { status: 401 }));
const action = actions.default;
if (!action) throw new Error('fixture action missing');
const result = await action(eventFor().event);
expect(result).toMatchObject({ status: 502 });
expect(JSON.stringify(result)).not.toContain('Bearer');
});
});

View File

@ -0,0 +1,84 @@
import { error, fail } from '@sveltejs/kit';
import { backendJson } from '$lib/server/backend';
import type { components } from '$lib/generated/api';
import type { Actions, PageServerLoad } from './$types';
type TenantKeySummary = components['schemas']['TenantKeySummary'];
type TenantKeyList = components['schemas']['TenantKeyList'];
type TenantKeySecret = components['schemas']['TenantKeySecret'];
export const load: PageServerLoad = async (event) => {
const { response, data } = await backendJson<TenantKeyList>(event, '/v1/admin/tenant-keys');
if (!response.ok || !data) error(502, 'Không tải được danh sách tenant key.');
return { tenants: data.tenants };
};
function mutationMessage(status: number, action: 'create' | 'rotate' | 'revoke'): string {
if (status === 409 && action === 'create')
return 'Tenant này đã có key. Hãy xoay vòng key hiện tại.';
if (status === 404) return 'Không tìm thấy tenant key.';
if (status === 422) return 'Tenant ID không hợp lệ.';
if (status === 401 || status === 403) return 'Phiên quản trị hết hạn hoặc CSRF không hợp lệ.';
return 'Không thể cập nhật tenant key. Vui lòng thử lại.';
}
export const actions: Actions = {
create: async (event) => {
event.setHeaders({ 'cache-control': 'no-store' });
const form = await event.request.formData();
const tenant_id = String(form.get('tenant_id') ?? '').trim();
if (!tenant_id || tenant_id.length > 128) {
return fail(400, { message: 'Nhập Tenant ID từ 1 đến 128 ký tự.' });
}
const { response, data } = await backendJson<TenantKeySecret>(
event,
'/v1/admin/tenant-keys',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ tenant_id }),
},
);
if (!response.ok || !data) {
return fail(response.status, { message: mutationMessage(response.status, 'create') });
}
return {
success: true,
message: 'Đã tạo tenant key.',
tenant_id: data.tenant_id,
tenant_key: data.tenant_key,
};
},
rotate: async (event) => {
event.setHeaders({ 'cache-control': 'no-store' });
const form = await event.request.formData();
const tenant_id = String(form.get('tenant_id') ?? '');
if (!tenant_id) return fail(400, { message: 'Thiếu Tenant ID.' });
const path = `/v1/admin/tenant-keys/${encodeURIComponent(tenant_id)}/rotate` as const;
const { response, data } = await backendJson<TenantKeySecret>(event, path, {
method: 'POST',
});
if (!response.ok || !data) {
return fail(response.status, { message: mutationMessage(response.status, 'rotate') });
}
return {
success: true,
message: `Đã xoay vòng key cho ${data.tenant_id}.`,
tenant_id: data.tenant_id,
tenant_key: data.tenant_key,
};
},
revoke: async (event) => {
const form = await event.request.formData();
const tenant_id = String(form.get('tenant_id') ?? '');
if (!tenant_id) return fail(400, { message: 'Thiếu Tenant ID.' });
const path = `/v1/admin/tenant-keys/${encodeURIComponent(tenant_id)}` as const;
const { response, data } = await backendJson<TenantKeySummary>(event, path, {
method: 'DELETE',
});
if (!response.ok || !data) {
return fail(response.status, { message: mutationMessage(response.status, 'revoke') });
}
return { success: true, message: `Đã thu hồi key của ${data.tenant_id}.` };
},
};

View File

@ -0,0 +1,159 @@
<script lang="ts">
import type { PageProps } from './$types';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
let { data, form }: PageProps = $props();
let copied = $state(false);
let copyError = $state(false);
async function copyKey(value: string) {
try {
await navigator.clipboard.writeText(value);
copied = true;
copyError = false;
} catch {
copied = false;
copyError = true;
}
}
function confirmAction(event: SubmitEvent, action: 'rotate' | 'revoke', tenant: string) {
const message =
action === 'rotate'
? `Thay mã cho ${tenant}? Mã cũ sẽ hết hiệu lực ngay.`
: `Thu hồi mã của ${tenant}? Tổ chức này sẽ không gọi được API.`;
if (!window.confirm(message)) event.preventDefault();
}
</script>
<svelte:head><title>Mã truy cập · AI Platform</title></svelte:head>
<header class="mb-8">
<p class="mb-2 text-xs font-semibold text-primary">Platform / Truy cập</p>
<h1 class="text-3xl font-bold">Mã truy cập của tổ chức</h1>
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
Tạo mã cho tổ chức gửi yêu cầu chấm điểm.
</p>
</header>
{#if form?.message}
<p
role={form.success ? 'status' : 'alert'}
class={form.success ? 'mb-5 text-sm text-primary' : 'mb-5 text-sm text-destructive'}
>
{form.message}
</p>
{/if}
{#if form?.tenant_key}
<section
aria-labelledby="new-key-title"
class="mb-6 rounded-lg border border-destructive/40 bg-card p-5"
>
<h2 id="new-key-title" class="text-base font-semibold">Sao chép mã vừa tạo</h2>
<p class="mt-2 text-sm text-destructive">
Sao chép mã này. Nếu làm mất, hãy dùng “Thay mã” ở danh sách bên dưới.
</p>
<div class="mt-4 flex flex-wrap items-center gap-3">
<code class="min-w-0 flex-1 break-all rounded-md bg-muted px-3 py-2 text-sm"
>{form.tenant_key}</code
>
<Button type="button" variant="outline" onclick={() => copyKey(form.tenant_key)}>
Sao chép mã
</Button>
</div>
{#if copied}<p class="mt-2 text-sm text-primary" role="status">Đã sao chép mã.</p>{/if}
{#if copyError}<p class="mt-2 text-sm text-destructive" role="alert">
Chọn và sao chép mã thủ công.
</p>{/if}
</section>
{/if}
<div class="grid gap-6 lg:grid-cols-[minmax(18rem,0.75fr)_minmax(0,1.25fr)]">
<Card.Root>
<Card.Header>
<h2 class="text-base font-semibold">Tạo mã truy cập</h2>
<Card.Description>Dành cho tổ chức chưa có mã.</Card.Description>
</Card.Header>
<Card.Content>
<form method="POST" action="?/create" class="grid gap-3">
<label for="tenant-id" class="text-sm font-medium">Mã tổ chức</label>
<input
id="tenant-id"
name="tenant_id"
required
maxlength="128"
autocomplete="off"
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
/>
<Button type="submit" class="mt-1 w-fit">Tạo mã</Button>
</form>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<h2 class="text-base font-semibold">Tổ chức đã có mã</h2>
</Card.Header>
<Card.Content>
{#if data.tenants.length === 0}
<p class="text-sm text-muted-foreground">Chưa có tổ chức nào được cấp mã.</p>
{:else}
<ul class="divide-y divide-border">
{#each data.tenants as tenant (tenant.tenant_id)}
<li class="grid gap-3 py-4 first:pt-0 last:pb-0">
<div class="flex flex-wrap items-center gap-2">
<code class="min-w-0 flex-1 break-all text-sm"
>{tenant.tenant_id}</code
>
<Badge variant={tenant.active ? 'secondary' : 'outline'}>
{tenant.active ? 'Đang hoạt động' : 'Đã thu hồi'}
</Badge>
</div>
<p class="text-xs text-muted-foreground">
Tạo {new Date(tenant.created_at).toLocaleString('vi-VN')} · Cập nhật {new Date(
tenant.updated_at,
).toLocaleString('vi-VN')}
</p>
<div class="flex flex-wrap gap-2">
<form
method="POST"
action="?/rotate"
onsubmit={(event) =>
confirmAction(event, 'rotate', tenant.tenant_id)}
>
<input
type="hidden"
name="tenant_id"
value={tenant.tenant_id}
/>
<Button type="submit" variant="outline" size="sm">
Thay mã
</Button>
</form>
{#if tenant.active}
<form
method="POST"
action="?/revoke"
onsubmit={(event) =>
confirmAction(event, 'revoke', tenant.tenant_id)}
>
<input
type="hidden"
name="tenant_id"
value={tenant.tenant_id}
/>
<Button type="submit" variant="destructive" size="sm">
Thu hồi
</Button>
</form>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { backend, backendJson } from '$lib/server/backend';
import { actions } from './+page.server';
type TenantKeyRequestEvent = Parameters<NonNullable<typeof actions.create>>[0];
vi.mock('$lib/server/backend', () => ({
backend: vi.fn(),
backendJson: vi.fn(),
}));
function eventFor(values: Record<string, string>): {
event: TenantKeyRequestEvent;
setHeaders: ReturnType<typeof vi.fn>;
} {
const setHeaders = vi.fn();
return {
event: {
request: new Request('http://localhost/platform/tenant-keys', {
method: 'POST',
body: new URLSearchParams(values),
}),
setHeaders,
} as unknown as TenantKeyRequestEvent,
setHeaders,
};
}
const backendJsonMock = vi.mocked(backendJson);
const backendMock = vi.mocked(backend);
beforeEach(() => {
vi.clearAllMocks();
});
describe('tenant key server actions', () => {
it('returns a newly-created key only in the action result', async () => {
backendJsonMock.mockResolvedValueOnce({
response: { ok: true, status: 201 } as Response,
data: { tenant_id: 'tenant-a', tenant_key: 'tnt-once' },
});
const action = actions.create;
if (!action) throw new Error('create action missing');
const { event, setHeaders } = eventFor({ tenant_id: 'tenant-a' });
const result = await action(event);
expect(result).toMatchObject({ tenant_id: 'tenant-a', tenant_key: 'tnt-once' });
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
expect(backendJsonMock).toHaveBeenCalledWith(
expect.anything(),
'/v1/admin/tenant-keys',
expect.objectContaining({ method: 'POST' }),
);
});
it('rotates a key through the Admin API and returns the new value once', async () => {
backendJsonMock.mockResolvedValueOnce({
response: { ok: true, status: 200 } as Response,
data: { tenant_id: 'tenant-a', tenant_key: 'tnt-rotated' },
});
const action = actions.rotate;
if (!action) throw new Error('rotate action missing');
const { event, setHeaders } = eventFor({ tenant_id: 'tenant-a' });
const result = await action(event);
expect(result).toMatchObject({ tenant_id: 'tenant-a', tenant_key: 'tnt-rotated' });
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
expect(backendJsonMock).toHaveBeenCalledWith(
expect.anything(),
'/v1/admin/tenant-keys/tenant-a/rotate',
{ method: 'POST' },
);
});
it('reports an existing tenant conflict without returning a key', async () => {
backendJsonMock.mockResolvedValueOnce({
response: { ok: false, status: 409 } as Response,
data: null,
});
const action = actions.create;
if (!action) throw new Error('create action missing');
const result = await action(eventFor({ tenant_id: 'tenant-a' }).event);
expect(result).toMatchObject({
status: 409,
data: { message: expect.stringContaining('đã có key') },
});
});
it('sends revoke only through the backend server helper', async () => {
backendJsonMock.mockResolvedValueOnce({
response: { ok: true, status: 200 } as Response,
data: {
tenant_id: 'tenant/a',
active: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z',
},
});
const action = actions.revoke;
if (!action) throw new Error('revoke action missing');
const result = await action(eventFor({ tenant_id: 'tenant/a' }).event);
expect(result).toMatchObject({ success: true });
expect(backendJsonMock).toHaveBeenCalledWith(
expect.anything(),
'/v1/admin/tenant-keys/tenant%2Fa',
{ method: 'DELETE' },
);
expect(backendMock).not.toHaveBeenCalled();
});
});