diff --git a/apps/web/.env.example b/apps/web/.env.example index 3c2f5b9..5f30698 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,8 +1,10 @@ # 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. -BACKEND_INTERNAL_URL=http://127.0.0.1:8000 +# Make web-dev/dev derives BACKEND_INTERNAL_URL from BACKEND_PORT in the root .env. +# Docker Compose passes its internal backend URL directly to the web container. +# Only set this manually when running Bun directly without Make: +# 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= +# Optional server-only key for the internal fixture runner (use a separate test app). +# LEAD_SCORING_FIXTURE_APP_API_KEY= # Never put credentials or backend URLs in PUBLIC_* variables. diff --git a/apps/web/src/lib/components/AdminNavigation.svelte b/apps/web/src/lib/components/AdminNavigation.svelte index 47f4b50..2b214ea 100644 --- a/apps/web/src/lib/components/AdminNavigation.svelte +++ b/apps/web/src/lib/components/AdminNavigation.svelte @@ -43,18 +43,18 @@ {#snippet child({ props })} - Mã truy cập + Ứng dụng {/snippet} diff --git a/apps/web/src/lib/server/fixture-runner.test.ts b/apps/web/src/lib/server/fixture-runner.test.ts index f505852..f9fc752 100644 --- a/apps/web/src/lib/server/fixture-runner.test.ts +++ b/apps/web/src/lib/server/fixture-runner.test.ts @@ -1,40 +1,81 @@ import { describe, expect, it, vi } from 'vitest'; import { env } from '$env/dynamic/private'; -import { fixtureRunnerConfigured, submitScoringFixture } from './fixture-runner'; +import { + fixtureRunnerConfigured, + FixtureRunnerNotConfigured, + scoringFixture, + 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', + LEAD_SCORING_FIXTURE_APP_API_KEY: 'test-only-app-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); +function mockEvent() { + const fetch = vi.fn().mockResolvedValue(Response.json({ status: 'pending' }, { status: 202 })); + return { fetch, event: { fetch } as unknown as RequestEvent }; +} - await submitScoringFixture(event); - await submitScoringFixture(event); +describe('server-only fixture transport', () => { + it.each([false, true])('sends the canonical fixture with AI enabled: %s', async (useAI) => { + const { fetch, event } = mockEvent(); + expect(fixtureRunnerConfigured()).toBe(true); + await submitScoringFixture(event, useAI); + await submitScoringFixture(event, useAI); 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'); + expect(init.headers.authorization).toBe('Bearer test-only-app-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); + expect(init.redirect).toBe('error'); + expect(init.body).not.toContain(env.LEAD_SCORING_FIXTURE_APP_API_KEY); + expect(body).toEqual({ + ...scoringFixture, + request_id: expect.any(String), + ai_profile: useAI ? 'medium' : scoringFixture.ai_profile, + scoring_config: { + ...scoringFixture.scoring_config, + rule_weight: useAI ? 0.5 : 1, + ai_weight: useAI ? 0.5 : 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); + expect( + ids.every((id: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id), + ), + ).toBe(true); + expect(scoringFixture.scoring_config.ai_weight).toBe(0); + }); + + it('keeps the default request rules-only', async () => { + const { fetch, event } = mockEvent(); + await submitScoringFixture(event); + expect(JSON.parse(fetch.mock.calls[0][1].body).scoring_config.ai_weight).toBe(0); + }); + + it('rejects a missing app key before sending any request', async () => { + const { fetch, event } = mockEvent(); + const original = env.LEAD_SCORING_FIXTURE_APP_API_KEY; + try { + env.LEAD_SCORING_FIXTURE_APP_API_KEY = ' '; + expect(fixtureRunnerConfigured()).toBe(false); + await expect(submitScoringFixture(event, true)).rejects.toBeInstanceOf( + FixtureRunnerNotConfigured, + ); + expect(fetch).not.toHaveBeenCalled(); + } finally { + env.LEAD_SCORING_FIXTURE_APP_API_KEY = original; + } }); }); diff --git a/apps/web/src/lib/server/fixture-runner.ts b/apps/web/src/lib/server/fixture-runner.ts index 5926a85..b71ac71 100644 --- a/apps/web/src/lib/server/fixture-runner.ts +++ b/apps/web/src/lib/server/fixture-runner.ts @@ -4,31 +4,39 @@ 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 const scoringFixture = fixture as RunRequest; export class FixtureRunnerNotConfigured extends Error {} export function fixtureRunnerConfigured(): boolean { - return Boolean(env.LEAD_SCORING_FIXTURE_TENANT_KEY?.trim()); + return Boolean(env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim()); } -export async function submitScoringFixture(event: RequestEvent): Promise { +export async function submitScoringFixture(event: RequestEvent, useAI = false): Promise { const base = env.BACKEND_INTERNAL_URL; - const tenantKey = env.LEAD_SCORING_FIXTURE_TENANT_KEY?.trim(); - if (!tenantKey) throw new FixtureRunnerNotConfigured(); + const appKey = env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim(); + if (!appKey) 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), + ...scoringFixture, request_id: crypto.randomUUID(), + scoring_config: { + ...scoringFixture.scoring_config, + rule_weight: useAI ? 0.5 : 1, + ai_weight: useAI ? 0.5 : 0, + }, + ai_profile: useAI ? 'medium' : scoringFixture.ai_profile, }; return event.fetch(`${base.replace(/\/$/, '')}/v1/lead-scoring/runs`, { method: 'POST', cache: 'no-store', + redirect: 'error', headers: { accept: 'application/json', - authorization: `Bearer ${tenantKey}`, + authorization: `Bearer ${appKey}`, 'content-type': 'application/json', 'Idempotency-Key': request.request_id, }, diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts index d632d38..f253a9a 100644 --- a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts +++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts @@ -3,6 +3,7 @@ import { backend, clearAdminSession } from '$lib/server/backend'; import { fixtureRunnerConfigured, FixtureRunnerNotConfigured, + scoringFixture, submitScoringFixture, } from '$lib/server/fixture-runner'; import type { components } from '$lib/generated/api'; @@ -12,6 +13,7 @@ type RunSummary = components['schemas']['RunSummary']; export const load: PageServerLoad = async () => ({ fixtureRunnerConfigured: fixtureRunnerConfigured(), + fixture: scoringFixture, }); export const actions: Actions = { @@ -31,34 +33,42 @@ export const actions: Actions = { }); } + const useAI = (await event.request.formData()).get('use_ai') === 'true'; let response: Response; try { - response = await submitScoringFixture(event); + response = await submitScoringFixture(event, useAI); } 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.', + useAI, }); } - return fail(502, { message: 'Không gửi được dữ liệu mẫu.' }); + return fail(502, { message: 'Không gửi được dữ liệu mẫu.', useAI }); } if (response.status === 401) { - return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.' }); + return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.', useAI }); } if (!response.ok) { - return fail(response.status, { message: 'Không nhận được dữ liệu mẫu.' }); + return fail(response.status, { + message: `Backend từ chối dữ liệu mẫu (HTTP ${response.status}).`, + useAI, + }); } try { const run = (await response.json()) as RunSummary; return { success: true, + useAI, run_id: run.run_id, status: run.status, - message: 'Đã gửi dữ liệu mẫu.', + message: useAI + ? 'Đã gửi mẫu quy tắc và AI. Xem chi tiết để kiểm tra kết quả AI hoặc fallback.' + : 'Đã gửi mẫu chỉ chấm quy tắc.', }; } catch { - return fail(502, { message: 'AI Platform trả về phản hồi không hợp lệ.' }); + return fail(502, { message: 'AI Platform trả về phản hồi không hợp lệ.', useAI }); } }, }; diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte index 5cb5d1e..c77e81c 100644 --- a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte +++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte @@ -3,21 +3,22 @@ import { Button } from '$lib/components/ui/button/index.js'; import * as Card from '$lib/components/ui/card/index.js'; + import FixturePreview from './FixturePreview.svelte'; + let { data, form }: PageProps = $props(); + let useAI = $derived(form?.useAI ?? false); -Thử quy tắc · AI Platform +Thử chấm điểm · AI Platform

Lead Scoring / Kiểm thử

-

Thử tính điểm theo quy tắc

+

Thử chấm điểm với dữ liệu mẫu

- Gửi dữ liệu mẫu để kiểm tra điểm quy tắc. Trang này chưa thử model AI. + Xem hội thoại và quy tắc trước khi gửi. Dữ liệu dưới đây hoàn toàn giả lập.

- - -

Dữ liệu mẫu

-
+ +

Chế độ chấm điểm

{#if !data.fixtureRunnerConfigured}

@@ -34,15 +35,43 @@ {/if} {#if form?.success && form.run_id}

- Trạng thái: {form.status}. + Trạng thái lúc nhận: {form.status}. Xem lần chấm điểm

{/if} -
- + + +

+ {useAI + ? 'Quy tắc + AI · Quy tắc 50%, AI 50% · medium' + : 'Chỉ quy tắc · Quy tắc 100%, AI 0%'} +

+

+ {#if useAI} + AI dùng cấu hình đang active và có thể phát sinh chi phí. Với AIHub, mẫu chưa + gửi V-Key nên sẽ dự phòng bằng điểm quy tắc. Thiếu cấu hình hoặc provider lỗi + cũng có thể dẫn đến fallback; xem kết quả ở trang chi tiết. + {:else} + Không gọi model AI. Các quy tắc mẫu và dữ liệu khách hàng được giữ nguyên. + {/if} +

+
+ diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/FixturePreview.svelte b/apps/web/src/routes/(admin)/lead-scoring/fixtures/FixturePreview.svelte new file mode 100644 index 0000000..93e33c9 --- /dev/null +++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/FixturePreview.svelte @@ -0,0 +1,90 @@ + + +
+
+ +

Thông tin mẫu

+ +

Lead: {fixture.subject_ref}

+

Account: {fixture.account_id}

+
+ {#each Object.entries(fixture.snapshot.lead_profile.custom_fields ?? {}) as [key, value] (key)} +
{key}
+
{JSON.stringify(value)}
+ {/each} +
+
+
+ + +

Quy tắc mẫu

+ Phiên bản {fixture.scoring_config.version} +
+ +
    + {#each fixture.scoring_config.rules as rule (rule.id)} +
  • +
    + {rule.id} + {rule.points} điểm khi khớp +
    + + {rule.field} + {rule.operator} + {JSON.stringify(rule.value)} + + {#if rule.description} +

    {rule.description}

    + {/if} +
  • + {/each} +
+
+
+
+ +

Hội thoại mẫu

+ + {#each fixture.snapshot.conversations ?? [] as conversation (conversation.conversation_id)} +
+

{conversation.channel ?? 'Hội thoại'}

+

+ {conversation.messages?.length ?? 0} tin nhắn +

+
    + {#each conversation.messages ?? [] as message (message.message_id)} +
  1. +
    + {senderLabels[message.sender_type]} + +
    +

    + {message.content} +

    +
  2. + {/each} +
+
+ {/each} +
+
+
diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/FixturePreview.test.ts b/apps/web/src/routes/(admin)/lead-scoring/fixtures/FixturePreview.test.ts new file mode 100644 index 0000000..65db5a6 --- /dev/null +++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/FixturePreview.test.ts @@ -0,0 +1,68 @@ +import { render } from 'svelte/server'; +import { describe, expect, it } from 'vitest'; +import fixture from '../../../../../../../contracts/lead-scoring/v2/fixtures/run-request.json'; +import type { components } from '$lib/generated/api'; +import type { PageProps } from './$types'; +import Page from './+page.svelte'; + +function renderPage(form: PageProps['form'] = null, configured = true): string { + return render(Page, { + props: { + data: { + admin: { email: 'admin@example.invalid' }, + environment: 'development', + fixture: fixture as components['schemas']['RunRequest'], + fixtureRunnerConfigured: configured, + }, + form, + params: {}, + }, + }).body; +} + +describe('fixture preview', () => { + it('shows the canonical conversation, custom fields and rules', () => { + const html = renderPage(); + const messages = fixture.snapshot.conversations.flatMap( + (conversation) => conversation.messages, + ); + expect(messages).toHaveLength(12); + for (const message of messages) expect(html).toContain(message.content); + expect(html).toContain('Khách hàng'); + expect(html).toContain('Nhân viên'); + for (const [key, value] of Object.entries(fixture.snapshot.lead_profile.custom_fields)) { + expect(html).toContain(key); + expect(html).toContain(value); + } + for (const rule of fixture.scoring_config.rules) { + expect(html).toContain(rule.id); + expect(html).toContain(rule.field); + expect(html).toContain(`${rule.points} điểm khi khớp`); + } + expect(html).toContain('Chỉ quy tắc · Quy tắc 100%, AI 0%'); + expect(html).not.toMatch(/API_KEY|BACKEND_INTERNAL_URL|Bearer/); + }); + + it('preserves the submitted AI mode and links to the backend result', () => { + const runId = 'f4a5d7c8-f55f-47d2-b2ea-611a2b78e59b'; + const html = renderPage({ + success: true, + useAI: true, + run_id: runId, + status: 'pending', + message: 'Đã gửi mẫu.', + }); + expect(html).toContain('Quy tắc + AI · Quy tắc 50%, AI 50% · medium'); + expect(html).toContain('checked'); + expect(html).toContain(`/lead-scoring/runs/${runId}`); + expect(html).toContain('có thể phát sinh chi phí'); + expect(html).toContain('fallback'); + }); + + it('shows the preview but disables submission when the runner is unconfigured', () => { + const html = renderPage(null, false); + expect(html).toContain('disabled'); + expect(html).toContain('Chưa có mã truy cập'); + expect(html).toContain(fixture.snapshot.conversations[0].messages[0].content); + }); +}); diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts b/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts index b961565..7671a80 100644 --- a/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts +++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { backend, clearAdminSession } from '$lib/server/backend'; -import { fixtureRunnerConfigured, submitScoringFixture } from '$lib/server/fixture-runner'; +import { + fixtureRunnerConfigured, + scoringFixture, + submitScoringFixture, +} from '$lib/server/fixture-runner'; import { actions, load } from './+page.server'; vi.mock('$lib/server/backend', () => ({ @@ -9,6 +13,7 @@ vi.mock('$lib/server/backend', () => ({ })); vi.mock('$lib/server/fixture-runner', () => ({ fixtureRunnerConfigured: vi.fn(), + scoringFixture: { subject_ref: 'fixture-lead' }, submitScoringFixture: vi.fn(), FixtureRunnerNotConfigured: class extends Error {}, })); @@ -21,9 +26,15 @@ const runId = 'f4a5d7c8-f55f-47d2-b2ea-611a2b78e59b'; type ActionEvent = Parameters>[0]; -function eventFor(): { event: ActionEvent; setHeaders: ReturnType } { +function eventFor(useAI = false): { event: ActionEvent; setHeaders: ReturnType } { const setHeaders = vi.fn(); - return { event: { setHeaders } as unknown as ActionEvent, setHeaders }; + const data = new FormData(); + if (useAI) data.set('use_ai', 'true'); + // Other submitted values cannot replace the server-owned fixture or weights. + data.set('ai_weight', '1'); + data.set('payload', '{"subject_ref":"tampered"}'); + const request = { formData: vi.fn().mockResolvedValue(data) }; + return { event: { setHeaders, request } as unknown as ActionEvent, setHeaders }; } beforeEach(() => { @@ -33,34 +44,46 @@ beforeEach(() => { }); describe('fixture runner page', () => { - it('shows whether a server-only fixture key is configured', async () => { + it('returns the canonical preview without credentials even when the runner is unconfigured', async () => { configuredMock.mockReturnValue(false); const page = await load({} as Parameters[0]); - expect(page).toEqual({ fixtureRunnerConfigured: false }); + expect(page).toEqual({ fixtureRunnerConfigured: false, fixture: scoringFixture }); + expect(JSON.stringify(page)).not.toMatch(/API_KEY|BACKEND_INTERNAL_URL|Bearer/); }); - 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.each([false, true])( + 'submits the chosen AI mode (%s) only after checking the admin session', + async (useAI) => { + 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(useAI); + const result = await action(event); + expect(result).toMatchObject({ + success: true, + useAI, + run_id: runId, + status: 'pending', + }); + expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' }); + expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me'); + expect(submitMock).toHaveBeenCalledWith(event, useAI); + expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan( + submitMock.mock.invocationCallOrder[0], + ); + if (useAI) { + expect(result).toMatchObject({ message: expect.stringContaining('fallback') }); + } + }, + ); 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 { event } = eventFor(true); const result = await action(event); expect(result).toMatchObject({ status: 401 }); expect(clearSessionMock).toHaveBeenCalledWith(event); @@ -76,12 +99,24 @@ describe('fixture runner page', () => { expect(submitMock).not.toHaveBeenCalled(); }); - it('does not expose a rejected tenant key in the response', async () => { - submitMock.mockResolvedValueOnce(new Response('{}', { status: 401 })); + it('does not expose a rejected app key in the response', async () => { + submitMock.mockResolvedValueOnce(new Response('private app key', { 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'); + expect(JSON.stringify(result)).not.toMatch(/Bearer|private app key/); + }); + + it('reports backend rejection without claiming that the fixture is missing', async () => { + submitMock.mockResolvedValueOnce(new Response('private backend detail', { status: 503 })); + const action = actions.default; + if (!action) throw new Error('fixture action missing'); + const result = await action(eventFor(true).event); + expect(result).toMatchObject({ + status: 503, + data: { message: 'Backend từ chối dữ liệu mẫu (HTTP 503).', useAI: true }, + }); + expect(JSON.stringify(result)).not.toContain('private backend detail'); }); }); diff --git a/apps/web/src/routes/(admin)/lead-scoring/runs/+page.server.ts b/apps/web/src/routes/(admin)/lead-scoring/runs/+page.server.ts index 04a428d..0e31411 100644 --- a/apps/web/src/routes/(admin)/lead-scoring/runs/+page.server.ts +++ b/apps/web/src/routes/(admin)/lead-scoring/runs/+page.server.ts @@ -4,7 +4,8 @@ import type { PageServerLoad } from './$types'; export type RunSummary = { run_id: string; - tenant_id: string; + app_id: string; + account_id: string; status: string; final_score: number | null; degraded: boolean; diff --git a/apps/web/src/routes/(admin)/lead-scoring/runs/+page.svelte b/apps/web/src/routes/(admin)/lead-scoring/runs/+page.svelte index 603abdd..bf945d2 100644 --- a/apps/web/src/routes/(admin)/lead-scoring/runs/+page.svelte +++ b/apps/web/src/routes/(admin)/lead-scoring/runs/+page.svelte @@ -45,9 +45,9 @@ >

- {run.tenant_id} + {run.app_id} / {run.account_id}

Thời điểmTenantThời điểmApp / AccountTrạng tháiĐiểmChi tiết {run.tenant_id}{run.app_id} / {run.account_id}

Trạng thái

-
Tenant
-
{data.run.tenant_id}
+
App
+
{data.run.app_id}
+
Account
+
{data.run.account_id}
Trạng thái
{ + const { response, data } = await backendJson(event, '/v1/admin/apps'); + if (!response.ok || !data) error(502, 'Không tải được danh sách ứng dụng.'); + return { apps: data.apps }; +}; + +export const actions: Actions = { + revoke: async (event) => { + const form = await event.request.formData(); + const app_id = String(form.get('app_id') ?? ''); + if (!app_id.trim() || app_id.length > 128) + return fail(400, { message: 'App ID không hợp lệ.' }); + const path = `/v1/admin/apps/${encodeURIComponent(app_id)}` as const; + const { response, data } = await backendJson(event, path, { method: 'DELETE' }); + if (!response.ok || !data) { + const message = + response.status === 404 + ? 'Không tìm thấy ứng dụng.' + : response.status === 401 || response.status === 403 + ? 'Phiên quản trị hết hạn hoặc CSRF không hợp lệ.' + : 'Không thể thu hồi quyền truy cập. Vui lòng thử lại.'; + return fail(response.status, { message }); + } + return { success: true, message: `Đã thu hồi quyền truy cập của ${data.app_id}.` }; + }, +}; diff --git a/apps/web/src/routes/(admin)/platform/apps/+page.svelte b/apps/web/src/routes/(admin)/platform/apps/+page.svelte new file mode 100644 index 0000000..143d34a --- /dev/null +++ b/apps/web/src/routes/(admin)/platform/apps/+page.svelte @@ -0,0 +1,80 @@ + + +Ứng dụng · AI Platform +
+

Platform / Truy cập

+

Ứng dụng tích hợp

+

+ Mỗi bản triển khai Omni dùng một app key chung cho các account. Vận hành cấp hoặc xoay vòng + key qua CLI; Web chỉ hiển thị trạng thái và thu hồi quyền truy cập, không nhận raw key. +

+
+ +{#if form?.message} +

+ {form.message} +

+{/if} + + +

Ứng dụng đã được cấp quyền

+ + {#if data.apps.length === 0} +

Chưa có ứng dụng nào được cấp quyền.

+ {:else} +
    + {#each data.apps as app (app.app_id)} +
  • +
    + {app.app_id} + {app.active ? 'Đang hoạt động' : 'Đã thu hồi'} + {app.lead_scoring + ? 'Có quyền Lead Scoring' + : 'Không có quyền Lead Scoring'} +
    +

    + Tạo {new Date(app.created_at).toLocaleString('vi-VN')} · Cập nhật {new Date( + app.updated_at, + ).toLocaleString('vi-VN')} +

    + {#if app.active} +
    confirmRevoke(event, app.app_id)} + > + + +
    + {/if} +
  • + {/each} +
+ {/if} +
+
diff --git a/apps/web/src/routes/(admin)/platform/apps/apps.test.ts b/apps/web/src/routes/(admin)/platform/apps/apps.test.ts new file mode 100644 index 0000000..5e533b8 --- /dev/null +++ b/apps/web/src/routes/(admin)/platform/apps/apps.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { backendJson } from '$lib/server/backend'; +import { actions, load } from './+page.server'; + +type AppEvent = Parameters>[0]; +vi.mock('$lib/server/backend', () => ({ backendJson: vi.fn() })); +const mock = vi.mocked(backendJson); +beforeEach(() => vi.clearAllMocks()); + +function eventFor(app_id: string): AppEvent { + return { + request: new Request('http://localhost/platform/apps', { + method: 'POST', + body: new URLSearchParams({ app_id }), + }), + } as AppEvent; +} + +describe('app credential administration', () => { + it('only allows revocation from the browser', () => { + expect(Object.keys(actions)).toEqual(['revoke']); + }); + it('loads public app status without secrets', async () => { + const apps = [ + { + app_id: 'omni-local', + active: true, + lead_scoring: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ]; + mock.mockResolvedValueOnce({ response: Response.json({ apps }), data: { apps } }); + expect(await load({} as Parameters[0])).toEqual({ apps }); + }); + it('revokes through the server helper and encodes the app ID', async () => { + mock.mockResolvedValueOnce({ + response: Response.json({}), + data: { + app_id: 'omni/local', + active: false, + lead_scoring: true, + created_at: '', + updated_at: '', + }, + }); + expect(await actions.revoke!(eventFor('omni/local'))).toMatchObject({ success: true }); + expect(mock).toHaveBeenCalledWith(expect.anything(), '/v1/admin/apps/omni%2Flocal', { + method: 'DELETE', + }); + }); + it('rejects blank IDs before making a backend request', async () => { + expect(await actions.revoke!(eventFor(' '))).toMatchObject({ status: 400 }); + expect(mock).not.toHaveBeenCalled(); + }); + it('uses a safe failure message instead of backend error contents', async () => { + mock.mockResolvedValueOnce({ + response: new Response('sensitive', { status: 403 }), + data: null, + }); + const result = await actions.revoke!(eventFor('omni-local')); + expect(result).toMatchObject({ status: 403 }); + expect(JSON.stringify(result)).not.toContain('sensitive'); + }); +}); diff --git a/apps/web/src/routes/(admin)/platform/tenant-keys/+page.server.ts b/apps/web/src/routes/(admin)/platform/tenant-keys/+page.server.ts deleted file mode 100644 index 2aa29cd..0000000 --- a/apps/web/src/routes/(admin)/platform/tenant-keys/+page.server.ts +++ /dev/null @@ -1,84 +0,0 @@ -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(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( - 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(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(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}.` }; - }, -}; diff --git a/apps/web/src/routes/(admin)/platform/tenant-keys/+page.svelte b/apps/web/src/routes/(admin)/platform/tenant-keys/+page.svelte deleted file mode 100644 index 2947155..0000000 --- a/apps/web/src/routes/(admin)/platform/tenant-keys/+page.svelte +++ /dev/null @@ -1,159 +0,0 @@ - - -Mã truy cập · AI Platform -
-

Platform / Truy cập

-

Mã truy cập của tổ chức

-

- Tạo mã cho tổ chức gửi yêu cầu chấm điểm. -

-
- -{#if form?.message} -

- {form.message} -

-{/if} - -{#if form?.tenant_key} -
-

Sao chép mã vừa tạo

-

- 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. -

-
- {form.tenant_key} - -
- {#if copied}

Đã sao chép mã.

{/if} - {#if copyError}{/if} -
-{/if} - -
- - -

Tạo mã truy cập

- Dành cho tổ chức chưa có mã. -
- -
- - - -
-
-
- - - -

Tổ chức đã có mã

-
- - {#if data.tenants.length === 0} -

Chưa có tổ chức nào được cấp mã.

- {:else} -
    - {#each data.tenants as tenant (tenant.tenant_id)} -
  • -
    - {tenant.tenant_id} - - {tenant.active ? 'Đang hoạt động' : 'Đã thu hồi'} - -
    -

    - Tạo {new Date(tenant.created_at).toLocaleString('vi-VN')} · Cập nhật {new Date( - tenant.updated_at, - ).toLocaleString('vi-VN')} -

    -
    -
    - confirmAction(event, 'rotate', tenant.tenant_id)} - > - - -
    - {#if tenant.active} -
    - confirmAction(event, 'revoke', tenant.tenant_id)} - > - - -
    - {/if} -
    -
  • - {/each} -
- {/if} -
-
-
diff --git a/apps/web/src/routes/(admin)/platform/tenant-keys/tenant-keys.test.ts b/apps/web/src/routes/(admin)/platform/tenant-keys/tenant-keys.test.ts deleted file mode 100644 index 93acaaf..0000000 --- a/apps/web/src/routes/(admin)/platform/tenant-keys/tenant-keys.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { backend, backendJson } from '$lib/server/backend'; -import { actions } from './+page.server'; - -type TenantKeyRequestEvent = Parameters>[0]; - -vi.mock('$lib/server/backend', () => ({ - backend: vi.fn(), - backendJson: vi.fn(), -})); - -function eventFor(values: Record): { - event: TenantKeyRequestEvent; - setHeaders: ReturnType; -} { - 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(); - }); -});