Files
knowledge-governance/internal/libraryrelease/release_test.go
T
姚章浩 7b8cc0384b
build / build (push) Successful in 1m19s
feat(library): 生产发布授权与同步/恢复编排,worker 接线
为什么:同步入口此前只会拒绝;授权、预备份、四类写入、验证与失败恢复都没有代码。
本轮把编排与授权实现出来,真实生产执行仍按授权闸口关闭。

- internal/libraryrelease:预备份 → 数据库/资源/索引/Wiki → 验证 → 失败恢复;
  授权过期/目标不符/快照不符/参数变化在动生产前拒绝;恢复失败单独上报。
- 迁移 000067 library_release_authorizations(目标+快照+参数摘要+有效期,可撤销)。
- POST/GET /api/v1/library/authorizations 与 /{id}/revoke,仅管理员签发,5–1440 分钟。
- worker library.sync 接编排:先闸口再授权,阶段落库,只有编排成功才标成功。
- 生产目标适配器未配置时显式失败(不返回零值状态),预备份存储同。
- overview 返回授权状态,页面"生产保护与恢复"显示签发人与有效期。
- 验收矩阵区分"已实现·隔离测试通过"与"真实执行未授权"。
2026-10-28 15:11:27 +08:00

211 lines
6.6 KiB
Go

package libraryrelease
import (
"context"
"errors"
"strings"
"testing"
"time"
)
type fakeTarget struct {
currentErr error
applyErr map[Stage]error
verifyErr error
restoreErr error
applied []Stage
restored int
currentN int
}
func (f *fakeTarget) Current(context.Context) (CurrentState, error) {
f.currentN++
if f.currentErr != nil {
return CurrentState{}, f.currentErr
}
return CurrentState{Revision: "prod-r1", Counts: map[string]int64{"documents": 4},
Digests: map[string]string{"knowledges": "abc"}, CapturedAt: time.Now().UTC()}, nil
}
func (f *fakeTarget) Apply(_ context.Context, stage Stage, _ string) error {
if err, ok := f.applyErr[stage]; ok && err != nil {
return err
}
f.applied = append(f.applied, stage)
return nil
}
func (f *fakeTarget) Verify(context.Context, Expectation) error { return f.verifyErr }
func (f *fakeTarget) Restore(context.Context, BackupRef) error {
f.restored++
return f.restoreErr
}
type fakeBackups struct {
err error
ref BackupRef
seen int
}
func (f *fakeBackups) Prepare(context.Context, string, CurrentState) (BackupRef, error) {
f.seen++
if f.err != nil {
return BackupRef{}, f.err
}
ref := f.ref
if ref.ID == "" {
ref = BackupRef{ID: "backup-1", Bytes: 1024, CreatedAt: time.Now().UTC()}
}
return ref, nil
}
type stageLog struct{ entries []string }
func (s *stageLog) Stage(_ context.Context, stage, state, _ string) {
s.entries = append(s.entries, stage+":"+state)
}
func (s *stageLog) has(entry string) bool {
for _, item := range s.entries {
if item == entry {
return true
}
}
return false
}
func request(now time.Time) Request {
return Request{Target: "internal-prod", SnapshotID: "snap-1", ParamsDigest: "digest-1", Now: now,
Grant: Grant{Target: "internal-prod", SnapshotID: "snap-1", ParamsDigest: "digest-1",
ExpiresAt: now.Add(30 * time.Minute), AuthorizedBy: "admin"}}
}
func TestRunHappyPathWritesAllStagesThenVerifies(t *testing.T) {
target := &fakeTarget{}
backups := &fakeBackups{}
recorder := &stageLog{}
now := time.Now()
outcome := Run(context.Background(), request(now), target, backups, recorder)
if outcome.State != "succeeded" {
t.Fatalf("state = %s (%s)", outcome.State, outcome.Error)
}
if len(target.applied) != 4 || target.restored != 0 {
t.Fatalf("applied=%v restored=%d", target.applied, target.restored)
}
if !recorder.has("verify:succeeded") || recorder.has("rollback:running") {
t.Fatalf("stage log = %v", recorder.entries)
}
}
func TestRunRefusesExpiredOrMismatchedGrantBeforeTouchingTarget(t *testing.T) {
cases := []struct {
name string
mutate func(*Request)
want error
}{
{"expired", func(r *Request) { r.Now = r.Grant.ExpiresAt.Add(time.Minute) }, ErrGrantExpired},
{"other target", func(r *Request) { r.Target = "external-prod" }, ErrGrantTarget},
{"other snapshot", func(r *Request) { r.SnapshotID = "snap-2" }, ErrGrantSnapshot},
{"params changed", func(r *Request) { r.ParamsDigest = "digest-2" }, ErrGrantParams},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
req := request(time.Now())
testCase.mutate(&req)
target := &fakeTarget{}
backups := &fakeBackups{}
outcome := Run(context.Background(), req, target, backups, nil)
if outcome.State != "failed" {
t.Fatalf("state = %s, want failed", outcome.State)
}
if !errors.Is(errors.New(outcome.Error), testCase.want) && !strings.Contains(outcome.Error, testCase.want.Error()) {
t.Fatalf("error = %q, want %v", outcome.Error, testCase.want)
}
if target.currentN != 0 || len(target.applied) != 0 || backups.seen != 0 {
t.Fatalf("refused request must not touch production: current=%d applied=%d backups=%d",
target.currentN, len(target.applied), backups.seen)
}
})
}
}
func TestRunStopsWhenProductionStateUnreadable(t *testing.T) {
target := &fakeTarget{currentErr: errors.New("生产不可达")}
backups := &fakeBackups{}
outcome := Run(context.Background(), request(time.Now()), target, backups, nil)
if outcome.State != "failed" || !strings.Contains(outcome.Error, "读取生产当前状态失败") {
t.Fatalf("outcome = %+v", outcome)
}
if backups.seen != 0 || len(target.applied) != 0 {
t.Fatal("must not write when production state is unknown")
}
}
func TestRunStopsBeforeWritingWhenBackupFails(t *testing.T) {
target := &fakeTarget{}
backups := &fakeBackups{err: errors.New("磁盘不足")}
recorder := &stageLog{}
outcome := Run(context.Background(), request(time.Now()), target, backups, recorder)
if outcome.State != "failed" {
t.Fatalf("state = %s", outcome.State)
}
if len(target.applied) != 0 {
t.Fatalf("must not write production without a verified backup: %v", target.applied)
}
if !strings.Contains(outcome.Error, ErrBackupUnavailable.Error()) {
t.Fatalf("error must name the backup failure: %q", outcome.Error)
}
if !recorder.has("prebackup:failed") {
t.Fatalf("stage log = %v", recorder.entries)
}
}
func TestRunRollsBackWhenStageFails(t *testing.T) {
target := &fakeTarget{applyErr: map[Stage]error{StageIndex: errors.New("索引写入失败")}}
recorder := &stageLog{}
outcome := Run(context.Background(), request(time.Now()), target, &fakeBackups{}, recorder)
if outcome.State != "rolled_back" {
t.Fatalf("state = %s (%s)", outcome.State, outcome.Error)
}
if outcome.FailedAt != StageIndex {
t.Fatalf("failed_at = %s", outcome.FailedAt)
}
if target.restored != 1 {
t.Fatalf("restore calls = %d, want 1", target.restored)
}
// wiki 在 index 之后,不应执行
for _, stage := range target.applied {
if stage == StageWiki {
t.Fatal("later stages must not run after a failure")
}
}
if !recorder.has("rollback:succeeded") {
t.Fatalf("stage log = %v", recorder.entries)
}
}
func TestRunRollsBackWhenVerificationFails(t *testing.T) {
target := &fakeTarget{verifyErr: errors.New("生产分块数不一致")}
outcome := Run(context.Background(), request(time.Now()), target, &fakeBackups{}, nil)
if outcome.State != "rolled_back" || target.restored != 1 {
t.Fatalf("outcome = %+v restored=%d", outcome, target.restored)
}
}
func TestRunReportsRollbackFailureDistinctly(t *testing.T) {
target := &fakeTarget{applyErr: map[Stage]error{StageDatabase: errors.New("写库失败")},
restoreErr: errors.New("恢复也失败")}
recorder := &stageLog{}
outcome := Run(context.Background(), request(time.Now()), target, &fakeBackups{}, recorder)
if outcome.State != "rollback_failed" {
t.Fatalf("state = %s, want rollback_failed", outcome.State)
}
if !strings.Contains(outcome.Rollback, "恢复失败") {
t.Fatalf("rollback message = %q", outcome.Rollback)
}
if !recorder.has("rollback:failed") {
t.Fatalf("stage log = %v", recorder.entries)
}
}