Files
knowledge-governance/internal/httpapi/publish_now_test.go
T
姚章浩 f8faed0b21 feat(release): 发布到生产一键完成(建批+锁定+授权合成一个动作)
用户的反馈是对的:上一轮只砍掉了"冻结/锁定"两个按钮,但一条文档从"已批准"到"发出"实际仍要
~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 条全过。
2026-10-10 15:48:30 +08:00

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())
}
}