f8faed0b21
用户的反馈是对的:上一轮只砍掉了"冻结/锁定"两个按钮,但一条文档从"已批准"到"发出"实际仍要
~9 次交互(找条目→勾选→填名→建批→滚到清单→找批次→授权→输语句→提交),因为"批次仪式"本身
还留在路径上。
- 新端点 POST /api/v1/release-batches/publish-now:在同一操作里先建批(SealImmediately,冻结
即锁定)再授权生产发布,返回 {batch, execution}。
- 不放松任何约束:请求门禁(403)、备份恢复验证门禁(412)、管理员身份(401)、固定确认语句(400)、
发布前快照硬门禁(409)、建批门禁(422) 全部沿用既有实现,无旁路;名称可省(服务端固定回退名,
保证重放指纹一致)。
- 界面:审核行上直接「发布到生产」(管理员 + 门禁开启时显示),工具栏支持选中多条一次发布;
复用既有确认弹窗(固定语句)。批次名不再是必经步骤。
- 实测(面板):点行级按钮 → 输 `publish-to-production` → 提交 → 批次 rb-bee80550…(sealed)
+ 执行 rx-3d831129…(completed, 项级 verified) → 页面回滚 rx-43835fac…(rolled_back, absent)。
测试:publish_now_test.go(四道关卡 + 组合调用 + 名称回退)与 reviewRelease.test.ts(请求体/回传/错误原因);
Go 26 包、前端 183 条全过。
102 lines
4.4 KiB
Go
102 lines
4.4 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
)
|
|
|
|
// publish-now 把「建批(冻结+锁定)」与「授权生产发布」合成一个动作,
|
|
// 但四个关卡一个都不放松:请求门禁、备份恢复验证、管理员身份、固定确认语句。
|
|
func TestPublishNowComposesSealAndAuthorization(t *testing.T) {
|
|
request := func(body string, identity *sessionIdentity) *http.Request {
|
|
r := httptest.NewRequest(http.MethodPost, "/api/v1/release-batches/publish-now", bytes.NewBufferString(body))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
r.Header.Set("Idempotency-Key", "op-publish-now")
|
|
if identity != nil {
|
|
r = r.WithContext(context.WithValue(r.Context(), sessionKey{}, *identity))
|
|
}
|
|
return r
|
|
}
|
|
admin := &sessionIdentity{Username: "yaozhanghao", Role: roleAdmin}
|
|
engineer := &sessionIdentity{Username: "engineer-1", Role: roleEngineer}
|
|
valid := `{"publication_ids":["tp-a"],"confirmation":"publish-to-production"}`
|
|
|
|
// 请求门禁关闭。
|
|
closedHandler, err := NewHandler(Config{Store: &fakeJobStore{}, Now: func() time.Time { return time.Now().UTC() },
|
|
ProductionReleaseRequestsEnabled: false, ProductionBackupRestoreVerified: true,
|
|
ProductionReleaseAuthorizerRef: "release-owner", ProductionReleaseAuthorizationToken: "safe-token"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
closedHandler.ServeHTTP(recorder, request(valid, admin))
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("gate off status=%d", recorder.Code)
|
|
}
|
|
|
|
// 备份恢复验证未过。
|
|
unverified, err := NewHandler(Config{Store: &fakeJobStore{}, Now: func() time.Time { return time.Now().UTC() },
|
|
ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: false,
|
|
ProductionReleaseAuthorizerRef: "release-owner", ProductionReleaseAuthorizationToken: "safe-token"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
recorder = httptest.NewRecorder()
|
|
unverified.ServeHTTP(recorder, request(valid, admin))
|
|
if recorder.Code != http.StatusPreconditionFailed {
|
|
t.Fatalf("backup unverified status=%d", recorder.Code)
|
|
}
|
|
|
|
// 非管理员 / 错语句 / 空选择。
|
|
handler, store := productionActionHandler(t)
|
|
store.createBatchResult = governance.ReleaseBatch{ID: "rb-now", Name: "立即发布", State: governance.ReleaseBatchSealed, ItemCount: 1, ReleaseChangeSetID: "rcs-now"}
|
|
for _, testCase := range []struct {
|
|
name string
|
|
body string
|
|
identity *sessionIdentity
|
|
status int
|
|
}{
|
|
{"engineer session", valid, engineer, http.StatusUnauthorized},
|
|
{"no session", valid, nil, http.StatusUnauthorized},
|
|
{"wrong phrase", `{"publication_ids":["tp-a"],"confirmation":"publish"}`, admin, http.StatusBadRequest},
|
|
{"empty selection", `{"publication_ids":[],"confirmation":"publish-to-production"}`, admin, http.StatusBadRequest},
|
|
} {
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, request(testCase.body, testCase.identity))
|
|
if recorder.Code != testCase.status {
|
|
t.Fatalf("%s status=%d want=%d", testCase.name, recorder.Code, testCase.status)
|
|
}
|
|
}
|
|
|
|
// 正常路径:建批就是"立即锁定",随后完成授权,两步在同一个操作里。
|
|
store.createBatchRequest = governance.CreateReleaseBatchRequest{}
|
|
store.authorizeReleaseCalled = false
|
|
recorder = httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, request(`{"publication_ids":["tp-a","tp-b"],"name":"立即发布 · 测试","confirmation":"publish-to-production"}`, admin))
|
|
if recorder.Code != http.StatusAccepted {
|
|
t.Fatalf("publish-now status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if !store.createBatchRequest.SealImmediately {
|
|
t.Fatal("publish-now must create the batch already sealed")
|
|
}
|
|
if store.createBatchRequest.Name != "立即发布 · 测试" || len(store.createBatchRequest.PublicationIDs) != 2 {
|
|
t.Fatalf("batch request = %#v", store.createBatchRequest)
|
|
}
|
|
if !store.authorizeReleaseCalled || store.authorizeReleaseRequest.BatchID != "rb-now" {
|
|
t.Fatalf("authorize was not composed: called=%v request=%#v", store.authorizeReleaseCalled, store.authorizeReleaseRequest)
|
|
}
|
|
if store.authorizeReleaseRequest.AuthorizationCode != "production_release_authorized" {
|
|
t.Fatalf("authorization code = %q", store.authorizeReleaseRequest.AuthorizationCode)
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "rx-safe") {
|
|
t.Fatalf("response must carry the execution: %s", recorder.Body.String())
|
|
}
|
|
}
|