feat(web): 发布页接上签发同步授权与建立基线入口
build / build (push) Successful in 1m31s

发布页原来只有一个永远 disabled 的"启动同步",title 还写着"生产同步执行链
尚未接线"——执行链其实已经存在。现在:

- 管理员看到"签发同步授权(30 分钟)"(action=sync,绑定范围+目标快照),
  签发与执行仍是两步,不会自动签发;
- 闸口全满足时"启动同步"真的按 POST /library/syncs 投递,被拒时逐条显示
  接口返回的缺项;
- 目标非空又无生效版本时,管理员看到"建立目标权威基线(维护隔离)";
- 测试封存候选改用接口给的 test_sealed(来自版本登记),不再拿归档里最新
  一份顶替;
- 维护卡片区分"未运行维护 / 维护能力未接线 / 能力未知",逐项列出缺的变量。

验证:vitest 99 个文件 469 个用例全绿,tsc --noEmit 无错;反向自检(把按钮
改回恒 disabled、把维护文案改回旧句)两处按预期变红后复原。
This commit is contained in:
姚章浩
2026-10-29 18:16:25 +08:00
parent 517d5f862d
commit 3284658eb1
5 changed files with 460 additions and 13 deletions
+42
View File
@@ -1,5 +1,47 @@
# Changelog
## 开发快照(首次发布入口:同步授权、目标权威基线与维护能力)
### Added
- **管理员在发布页签发同步授权(`POST /api/v1/library/authorizations`,`action=sync`)**:
授权把「范围 + 目标快照 + 参数摘要 + 有效期」钉死,仍与执行分成两步 ——
页面只提供入口,不会替你签发,也不会自动重试执行。工程师看得到缺项原因,
但拿不到签发按钮(接口本来就只认管理员)。
- **目标权威基线(迁移 075,作业类型 `baseline`)**:目标非空、又没有生效版本时,
首次发布的真实解法是「维护隔离 → 整库捕获 → 逐类校验 → 登记基线」。
`POST /api/v1/library/baselines` 只建作业;worker 用与同步同一套维护窗口
(关入口、停写入者、证明排空)把目标整库捕获成归档快照,再用同一套 `Verify`
证明目标与这份快照一致,最后登记 `production_effective`(证据写明
`origin=target_baseline`、快照 ID、逐表行数、目标索引映射)。
它**不写目标内容、不清空目标、不手写版本**:没有捕获与校验证据就没有基线。
基线作业与 sync/restore 共用同一把写互斥(同一 scope 只允许一个在飞写作业),
worker 被杀时同一作业 id 会接管那个窗口继续,而不是把目标留在停机状态。
- **维护能力(`GET /api/v1/library/maintenance` 的 `capability`)**:页面区分
「此刻没有维护窗口」与「维护能力没接线」,逐项列出缺少的
`LIBRARY_TARGET_*`;接口没给能力信息时显示"未知",不再用一句
"也没有维护能力配置"糊过去。
### Changed
- **`productionScopeEmpty` 改成整库口径**:原来只数 `knowledges`,"文档删了但
分块/Wiki/资源还在"的目标会被判成空库,从而绕过基线初始化。现在用适配层
同一份表清单与范围过滤(`v071.ScopeContentEmpty`)逐表计数,并把逐表行数
交回接口,"空"只有一份定义。
- **拟发布的测试封存只认版本登记(`kind=test_sealed`)**:归档里同时存在生产
预备份与目标基线快照,按"这个 scope 最新一份"挑会把目标自己的内容显示成
测试候选。现在候选来自登记、再用归档确认它还在本机;不在就说明原因。
- **发布页不再说"生产同步执行链尚未接线"**:同步按钮按真实闸口启用,
点击走 `POST /api/v1/library/syncs`;被拒时逐条显示接口返回的缺项。
- 缺项文案从"生产当前权威版本未知,不能启动写入"改为指向真实入口:
「目标非空且没有生效版本记录:先建立目标权威基线…或确认目标确实是空库」。
### Fixed
- 维护卡片把"没有窗口记录"错误地写成"没有维护能力配置",与实际接线不符。
- 首次发布时 `POST /library/baselines` 在目标读不到时**不建作业、不登记版本**
(拒绝用建基线掩盖连接/配置故障)。
## 开发快照(发布执行器可用性、整库还原入口与诊断)
### Added
+89 -3
View File
@@ -92,7 +92,7 @@ const overview: LibraryOverview = {
reasons: [
'生产写入闸口关闭(PRODUCTION_RELEASE_REQUESTS_ENABLED=false)',
'没有已核验的生产恢复演练证据(PRODUCTION_BACKUP_RESTORE_VERIFIED=false)',
'生产当前权威版本未知,不能启动写入',
'目标非空且没有生效版本记录:先建立目标权威基线(维护隔离下捕获整库并逐类校验),或确认目标确实是空库',
'本次目标快照还没有生产预备份',
],
},
@@ -150,6 +150,8 @@ const base: ViewProps = {
canCapture: true,
canWrite: true,
canRestore: false,
sync: { canSign: false, pendingGrant: false, onGrant: vi.fn(), canStart: false, pending: false, onStart: vi.fn() },
baseline: { canStart: false, pending: false, onStart: vi.fn() },
restore: null,
restoreNotice: null,
pendingRestore: false,
@@ -173,7 +175,7 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
expect(html).toContain('对外知识库')
expect(html).toContain('role="tab"')
// 只显示一个主作业卡:选中库标题出现一次,另一个库不渲染作业卡
expect(html.match(/· 已封存,生产同步未接入/g)?.length).toBe(1)
expect(html.match(/· 已封存,同步按条件放行/g)?.length).toBe(1)
expect(html).not.toContain('对外知识库 · 尚无')
})
@@ -355,8 +357,92 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
})
it('无封存时给出“尚无封存”而不是空表', () => {
const html = render({ page: { ...page, snapshots: [] } })
// 归档没有快照时接口也不会给 test_sealed:两处必须一致,否则页面会把
// 一个不存在的封存显示成"已封存"。
const html = render({
page: { ...page, snapshots: [] },
overview: { ...overview, test_sealed: undefined, test_sealed_reason: '还没有该目标的测试封存' },
})
expect(html).toContain('尚无测试封存')
expect(html).toContain('还没有测试库封存')
})
it('管理员在缺同步授权时看到签发入口,工程师只看到“需管理员签发”', () => {
const admin = render({
sync: { canSign: true, pendingGrant: false, onGrant: vi.fn(), canStart: false, pending: false, onStart: vi.fn() },
})
expect(admin).toContain('签发同步授权(30 分钟)')
// 授权入口不能顺手把执行也放行:闸口条件仍然逐条可见
expect(admin).toContain('生产写入闸口关闭')
const engineer = render()
expect(engineer).not.toContain('签发同步授权')
expect(engineer).toContain('同步授权只能由管理员按目标+快照签发')
})
it('目标非空又没有权威版本时,管理员看到建立基线入口;有生效版本时不显示', () => {
const admin = render({
baseline: { canStart: true, pending: false, onStart: vi.fn() },
})
expect(admin).toContain('建立目标权威基线(维护隔离)')
expect(admin).toContain('目标非空且没有生效版本记录')
const known = render({
baseline: { canStart: true, pending: false, onStart: vi.fn() },
overview: {
...overview,
production: {
state: 'known',
version: {
id: 'v1',
kind: 'production_effective',
snapshot_id: 'a'.repeat(64),
version_label: '基线 abc',
source_job_id: 'libbase-1',
evidence: {},
created_by: 'admin',
created_at: '2026-10-29T09:00:00Z',
},
},
},
})
// 有生效版本时不再提供基线入口(缺项说明文字里仍会提到它存在,所以只断言按钮)
expect(known).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
})
it('闸口全满足时“启动同步”真的可点,并给出执行说明', () => {
const html = render({
sync: { canSign: false, pendingGrant: false, onGrant: vi.fn(), canStart: true, pending: false, onStart: vi.fn() },
overview: { ...overview, gate: { sync_allowed: true, reasons: [] } },
})
expect(html).toMatch(/<button[^>]*class="rel-primary"[^>]*>▶ 启动同步/)
expect(html).not.toMatch(/class="rel-primary"[^>]*disabled/)
expect(html).toContain('先固定测试版本,再备份目标并写入')
})
it('维护卡片区分“未运行维护”与“没有维护能力”', () => {
const wired = render({
maintenance: {
held: false,
capability: {
configured: true,
missing: [],
instance: 'kgseal-app',
project: 'kgseal-weknora',
writers: ['app'],
ingress_service: 'ingress',
},
},
})
expect(wired).toContain('未运行维护')
expect(wired).toContain('维护能力已接线:实例 kgseal-app · 写入者 app')
expect(wired).not.toContain('也没有维护能力配置')
const broken = render({
maintenance: { held: false, capability: { configured: false, missing: ['LIBRARY_TARGET_WRITERS'] } },
})
expect(broken).toContain('能力未接线')
expect(broken).toContain('维护能力未接线:缺少 LIBRARY_TARGET_WRITERS')
// 接口没给能力信息时不能猜成"没有能力"
const unknown = render({ maintenance: { held: false } })
expect(unknown).toContain('维护能力状态未知')
expect(unknown).not.toContain('也没有维护能力配置')
})
})
+166 -10
View File
@@ -1,9 +1,12 @@
import { useCallback, useEffect, useState } from 'react'
import {
createLibraryRestoreAuthorization,
createLibrarySyncAuthorization,
enqueueLibraryBaseline,
enqueueLibraryCapture,
enqueueLibraryRecovery,
enqueueLibraryRestore,
enqueueLibrarySync,
fetchLibraryCaptures,
fetchLibraryMaintenance,
fetchLibraryOverview,
@@ -62,6 +65,24 @@ const jobStateLabel: Record<string, string> = {
type Scope = 'internal' | 'external'
// 签发授权与执行是两步:页面只提供入口,不会替人签发,也不会自动重试执行。
export interface ReleaseSyncActions {
// 管理员才能签发;工程师看得到条件但没有按钮。
canSign: boolean
pendingGrant: boolean
onGrant: () => void
// 闸口全部满足才能启动(接口用同一份判定,按钮只是提前说明)。
canStart: boolean
pending: boolean
onStart: () => void
}
export interface ReleaseBaselineActions {
canStart: boolean
pending: boolean
onStart: () => void
}
interface ScopeView {
scope: Scope
snapshot?: LibrarySnapshot
@@ -89,6 +110,10 @@ interface ViewProps {
pendingCapture: boolean
canCapture: boolean
canWrite: boolean
// 发布动作:签发授权(管理员)与启动同步是两个独立步骤,页面绝不自动签发。
sync: ReleaseSyncActions
// 目标非空又没有生效版本时,唯一入口是"建立目标权威基线"(管理员)。
baseline: ReleaseBaselineActions
// 按版本恢复是管理员动作:工程师看得到条件,但拿不到恢复入口。
canRestore: boolean
restore: LibraryRestoreAvailability | null
@@ -118,7 +143,9 @@ function scopeView(
overview?.job ?? persisted.find((item) => item.state === 'pending' || item.state === 'running') ?? persisted[0]
return {
scope,
snapshot: history[0],
// 候选只认接口给的 test_sealed(来自版本登记):归档里还有生产预备份与
// 目标基线快照,按"最新一份"挑会把目标自己的内容显示成测试封存。
snapshot: overview?.test_sealed,
history,
activeJob: scoped.find((job) => ACTIVE_STATES.includes(job.state)),
lastJob: scoped[0],
@@ -271,6 +298,14 @@ export function MaintenanceCard({
}) {
const summary = maintenanceSummary(maintenance, job)
const record = maintenance?.instance_maintenance ?? maintenance?.maintenance
// "没有窗口记录"不等于"没有维护能力":能力是配置事实,接口单独返回。
// 接口没给能力信息时也不能猜,明确写"未知"。
const capability = maintenance?.capability
const capabilityText = !capability
? '维护能力状态未知:接口没有返回能力信息,无法区分"未运行维护"与"能力未接线"'
: capability.configured
? `维护能力已接线:实例 ${capability.instance || '—'} · 写入者 ${(capability.writers ?? []).join('、') || '—'}`
: `维护能力未接线:缺少 ${(capability.missing ?? []).join('、') || '必要配置'}`
return (
<div className="rel-panel rel-maintenance">
<header className="rel-panel-head">
@@ -278,13 +313,26 @@ export function MaintenanceCard({
<h2>目标维护</h2>
<p>同步/恢复期间目标写入者被停住;级别与入口状态来自维护记录本身</p>
</div>
<span className={`rel-pill ${summary.held ? 'blue' : summary.level === '未接入' ? 'gray' : 'green'}`}>
{summary.held ? `${summary.level} · 占用中` : summary.level === '未接入' ? '未接入' : '未占用'}
<span
className={`rel-pill ${summary.held ? 'blue' : record ? 'green' : capability?.configured ? 'gray' : 'red'}`}
>
{summary.held
? `${summary.level} · 占用中`
: record
? '未占用'
: capability?.configured
? '未运行维护'
: capability
? '能力未接线'
: '能力未知'}
</span>
</header>
{summary.level === '未接入' ? (
{!record ? (
<p className="rel-scope-note">
暂无维护窗口记录,也没有维护能力配置;维护类操作保持 fail closed,不会在此状态下写目标。
暂无维护窗口记录。{capabilityText}
{capability?.configured
? ';窗口只在同步或建立基线时开启,页面不会单独开窗口。'
: ';维护类操作保持 fail closed,不会在此状态下写目标。'}
</p>
) : (
<>
@@ -534,15 +582,23 @@ function ProtectionCard({
prebackup,
gate,
authorization,
targetSealed,
sync,
baseline,
onOpen,
}: {
production: LibraryOverview['production']
prebackup: LibraryOverview['prebackup']
gate: LibraryOverview['gate']
authorization: LibraryOverview['authorization']
targetSealed: boolean
sync: ReleaseSyncActions
baseline: ReleaseBaselineActions
onOpen: () => void
}) {
const backupKnown = prebackup.state === 'known' && prebackup.version
// 基线入口只在"目标非空、却没有生效版本"时才是答案;空库走首次同步(预备份+登记)。
const baselineNeeded = production.state !== 'known' && targetSealed
return (
<aside className="rel-panel rel-protection">
<header className="rel-panel-head">
@@ -580,6 +636,21 @@ function ProtectionCard({
))}
</ul>
) : null}
<div className="rel-drawer-actions">
{sync.canSign && authorization.state !== 'active' && targetSealed ? (
<button type="button" className="rel-secondary" onClick={sync.onGrant} disabled={sync.pendingGrant}>
{sync.pendingGrant ? '签发中…' : '签发同步授权(30 分钟)'}
</button>
) : null}
{baseline.canStart && baselineNeeded ? (
<button type="button" className="rel-secondary" onClick={baseline.onStart} disabled={baseline.pending}>
{baseline.pending ? '投递中…' : '建立目标权威基线(维护隔离)'}
</button>
) : null}
</div>
{authorization.state !== 'active' && !sync.canSign ? (
<p className="rel-hint">同步授权只能由管理员按目标+快照签发;工程师看不到签发入口。</p>
) : null}
<p className="rel-hint">同步启动后,这里显示本次生产恢复点及备份进度,可直接查看对应快照。</p>
<button type="button" className="rel-secondary" onClick={onOpen}>
查看已有版本与快照 ›
@@ -601,6 +672,8 @@ interface ReadyProps {
pendingCapture: boolean
onCapture: () => void
onOpen: () => void
sync: ReleaseSyncActions
baseline: ReleaseBaselineActions
}
function ReadyBody({
@@ -610,6 +683,8 @@ function ReadyBody({
prebackup,
gate,
authorization,
sync,
baseline,
canCapture,
canWrite,
pendingCapture,
@@ -626,6 +701,9 @@ function ReadyBody({
prebackup={prebackup}
gate={gate}
authorization={authorization}
targetSealed={Boolean(snapshot)}
sync={sync}
baseline={baseline}
onOpen={onOpen}
/>
</div>
@@ -1127,6 +1205,8 @@ export function ReleaseTargetsView(props: ViewProps) {
pendingCapture,
canCapture,
canWrite,
sync,
baseline,
canRestore,
restore,
restoreNotice,
@@ -1212,7 +1292,7 @@ export function ReleaseTargetsView(props: ViewProps) {
<div className="rel-panel rel-jobhead">
<div className="rel-between">
<h2>
{scopeName(scope)} · {active ? '封存进行中' : view.snapshot ? '已封存,生产同步未接入' : '尚无测试封存'}
{scopeName(scope)} · {active ? '封存进行中' : view.snapshot ? '已封存,同步按条件放行' : '尚无测试封存'}
</h2>
<div className="rel-head-actions">
<button type="button" className="rel-secondary" onClick={() => onDrawer(true)}>
@@ -1221,14 +1301,15 @@ export function ReleaseTargetsView(props: ViewProps) {
<button
type="button"
className="rel-primary"
disabled={!gate.sync_allowed}
onClick={sync.onStart}
disabled={!sync.canStart || sync.pending}
title={
gate.sync_allowed
? '生产同步执行链尚未接线,按钮不会启用'
sync.canStart
? '按已签发的同步授权启动:先固定测试版本,再备份目标并写入'
: `暂不能启动同步:${gate.reasons.join(';')}`
}
>
▶ 启动同步
{sync.pending ? '投递中…' : '▶ 启动同步'}
</button>
</div>
</div>
@@ -1311,6 +1392,8 @@ export function ReleaseTargetsView(props: ViewProps) {
prebackup={prebackup}
gate={gate}
authorization={authorization}
sync={sync}
baseline={baseline}
canCapture={canCapture}
canWrite={canWrite}
pendingCapture={pendingCapture}
@@ -1409,7 +1492,67 @@ export function ReleaseTargets() {
const canWrite = role === 'engineer' || role === 'admin'
// 恢复写的是整套生产知识状态,接口只认管理员;页面必须与它一致。
const canRestore = role === 'admin'
// 签发同步授权同样是管理员动作:授权=钉死目标+快照+参数,不等于执行。
const canSignSync = role === 'admin'
const canCapture = canWrite && page?.capture_available === true
const [pendingSyncGrant, setPendingSyncGrant] = useState(false)
const [pendingSync, setPendingSync] = useState(false)
const [pendingBaseline, setPendingBaseline] = useState(false)
const current = overviews[scope]
const targetSnapshot = current?.test_sealed?.snapshot_id ?? ''
const grantSync = async () => {
if (!targetSnapshot) {
setNotice('还没有可发布的测试封存:先封存测试版本,再签发同步授权')
return
}
setPendingSyncGrant(true)
setNotice(null)
try {
const grant = await createLibrarySyncAuthorization(scope, targetSnapshot)
setNotice(
`已签发同步授权${grant.authorized_by ? `(${grant.authorized_by})` : ''},有效至 ${stamp(grant.expires_at)};` +
'执行仍需在下一次操作里点"启动同步"'
)
await load()
} catch (grantError) {
setNotice(grantError instanceof Error ? grantError.message : '签发同步授权失败')
} finally {
setPendingSyncGrant(false)
}
}
const startSync = async () => {
if (!targetSnapshot) {
setNotice('还没有可发布的测试封存')
return
}
setPendingSync(true)
setNotice(null)
try {
const accepted = await enqueueLibrarySync(scope, targetSnapshot)
setNotice(`已投递同步:${accepted.job_id}(先固定测试版本并生成预备份,再写目标)`)
await load()
} catch (syncError) {
setNotice(syncError instanceof Error ? syncError.message : '启动同步失败')
} finally {
setPendingSync(false)
}
}
const startBaseline = async () => {
setPendingBaseline(true)
setNotice(null)
try {
const accepted = await enqueueLibraryBaseline(scope)
setNotice(`已投递建立基线:${accepted.job_id}(维护隔离下捕获目标整库,逐类校验后登记为权威基线)`)
await load()
} catch (baselineError) {
setNotice(baselineError instanceof Error ? baselineError.message : '建立目标权威基线失败')
} finally {
setPendingBaseline(false)
}
}
// 恢复条件随"展开的那份快照"变化:闸口、该快照的授权、实例是否在维护都由接口判定。
const [restore, setRestore] = useState<LibraryRestoreAvailability | null>(null)
@@ -1513,6 +1656,19 @@ export function ReleaseTargets() {
pendingCapture={pendingCapture}
canCapture={canCapture}
canWrite={canWrite}
sync={{
canSign: canSignSync,
pendingGrant: pendingSyncGrant,
onGrant: () => void grantSync(),
canStart: current?.gate.sync_allowed === true,
pending: pendingSync,
onStart: () => void startSync(),
}}
baseline={{
canStart: canSignSync,
pending: pendingBaseline,
onStart: () => void startBaseline(),
}}
canRestore={canRestore}
restore={restore}
restoreNotice={restoreNotice}
+81
View File
@@ -255,6 +255,75 @@ export interface LibraryRestoreGrant {
expires_at: string
}
// 同步授权与执行是两步:授权把"范围 + 目标快照 + 参数摘要"钉死并给很短的有效期。
// 它不能顶替恢复授权(摘要不同),也不自动签发 —— 必须由管理员显式点击。
export async function createLibrarySyncAuthorization(
scope: string,
snapshotID: string,
minutes = 30
): Promise<LibraryRestoreGrant> {
const response = await fetch('/api/v1/library/authorizations', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ scope, snapshot_id: snapshotID, action: 'sync', expires_in_minutes: minutes }),
})
const value = (await response.json().catch(() => null)) as unknown
if (!response.ok) {
if (response.status === 403) throw new Error('生产发布授权仅管理员可签发。')
if (response.status === 400 && record(value)) throw new Error(text(value.error) || '授权参数不合法')
throw new Error(`签发同步授权失败(HTTP ${response.status})`)
}
if (!record(value) || !record(value.authorization)) throw new Error('授权响应无效')
return {
id: text(value.authorization.id),
authorized_by: text(value.authorization.authorized_by),
expires_at: text(value.authorization.expires_at),
}
}
export interface LibrarySyncAccepted {
job_id: string
scope: string
reused: boolean
}
// 启动同步:闸口未齐时接口返回 503 与逐条缺项,页面照实显示,不假装已启动。
export async function enqueueLibrarySync(scope: string, targetSnapshotID: string): Promise<LibrarySyncAccepted> {
const response = await fetch('/api/v1/library/syncs', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ scope, target_snapshot_id: targetSnapshotID }),
})
const value = (await response.json().catch(() => null)) as unknown
if (!response.ok) {
if (record(value) && Array.isArray(value.reasons)) {
const reasons = value.reasons.map(text).filter(Boolean)
if (reasons.length > 0) throw new Error(`启动同步被拒绝:${reasons.join(';')}`)
}
if (record(value) && text(value.error)) throw new Error(text(value.error))
throw new Error(`启动同步失败(HTTP ${response.status})`)
}
if (!record(value) || !record(value.job)) throw new Error('同步作业响应无效')
return { job_id: text(value.job.id), scope: text(value.job.scope), reused: bool(value.reused) }
}
// 建立目标权威基线:目标非空又没有生效版本时的唯一入口(维护隔离 + 真实捕获 + 校验)。
export async function enqueueLibraryBaseline(scope: string): Promise<LibrarySyncAccepted> {
const response = await fetch('/api/v1/library/baselines', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ scope }),
})
const value = (await response.json().catch(() => null)) as unknown
if (!response.ok) {
if (response.status === 403) throw new Error('建立目标权威基线仅管理员可发起。')
if (record(value) && text(value.error)) throw new Error(text(value.error))
throw new Error(`建立基线失败(HTTP ${response.status})`)
}
if (!record(value) || !record(value.job)) throw new Error('基线作业响应无效')
return { job_id: text(value.job.id), scope: text(value.job.scope), reused: bool(value.reused) }
}
// 恢复授权与执行是两件事:授权把"目标 + 快照 + 参数"钉死并给很短的有效期,
// 同步授权不能顶替恢复授权(摘要不同)。
export async function createLibraryRestoreAuthorization(
@@ -406,6 +475,18 @@ export interface LibraryMaintenanceView {
maintenance?: LibraryMaintenanceRecord | null
instance?: string
instance_maintenance?: LibraryInstanceMaintenanceRecord | null
// 能力是配置事实,和"此刻有没有窗口"分开:未运行维护 ≠ 没有维护能力。
capability?: LibraryMaintenanceCapability | null
}
export interface LibraryMaintenanceCapability {
configured: boolean
missing: string[]
instance?: string
project?: string
writers?: string[]
ingress_service?: string
note?: string
}
export async function fetchLibraryMaintenance(
+82
View File
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createLibrarySyncAuthorization, enqueueLibraryBaseline, enqueueLibrarySync } from './librarySnapshots'
// 这些用例钉住"两步走"的接线:授权与执行是两个请求,参数必须与接口契约一致。
// 任何一步把 action 写错、或把 snapshot 换掉,都会在真实调用里悄悄失效(授权永远匹配不上)。
const jsonResponse = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
afterEach(() => vi.restoreAllMocks())
describe('发布页的同步授权与执行接线', () => {
it('签同步授权必须声明 action=sync 与有限有效期', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse(201, {
authorization: { id: 'libauth-sync-1', authorized_by: 'admin', expires_at: '2026-10-29T10:00:00Z' },
})
)
vi.stubGlobal('fetch', fetchMock)
const grant = await createLibrarySyncAuthorization('internal', 'snap-1', 30)
expect(grant.id).toBe('libauth-sync-1')
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/v1/library/authorizations')
expect(JSON.parse(init.body)).toEqual({
scope: 'internal',
snapshot_id: 'snap-1',
action: 'sync',
expires_in_minutes: 30,
})
})
it('启动同步把准确的 range 与目标快照传给 /library/syncs', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse(202, { job: { id: 'libsync-internal-1', scope: 'internal' }, reused: false }))
vi.stubGlobal('fetch', fetchMock)
const accepted = await enqueueLibrarySync('internal', 'snap-1')
expect(accepted.job_id).toBe('libsync-internal-1')
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/v1/library/syncs')
expect(JSON.parse(init.body)).toEqual({ scope: 'internal', target_snapshot_id: 'snap-1' })
})
it('闸口未齐时把接口给的缺项原样抛出,而不是假装已启动', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
jsonResponse(503, {
code: 'library_sync_blocked',
reasons: ['目标非空且没有生效版本记录', '没有与目标快照匹配的有效发布授权'],
})
)
)
await expect(enqueueLibrarySync('internal', 'snap-1')).rejects.toThrow(
/目标非空且没有生效版本记录;没有与目标快照匹配的有效发布授权/
)
})
it('建立基线只传 range,并返回作业号', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse(202, { job: { id: 'libbase-internal-1', scope: 'internal' }, reused: false }))
vi.stubGlobal('fetch', fetchMock)
const accepted = await enqueueLibraryBaseline('internal')
expect(accepted.job_id).toBe('libbase-internal-1')
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/v1/library/baselines')
expect(JSON.parse(init.body)).toEqual({ scope: 'internal' })
})
it('建立基线被拒时照实说明(例如目标为空或已有生效版本)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
jsonResponse(409, {
code: 'library_baseline_exists',
error: '该目标已有生效版本,不需要再建基线;换版本请走同步或恢复',
})
)
)
await expect(enqueueLibraryBaseline('internal')).rejects.toThrow(/该目标已有生效版本/)
})
})