Files
Agent-System/internal/sysadmin/policy_test.go
T
WeiCHEN b627134c62 feat(sysadmin): add system:* permissions and settings audit log
Seed fine-grained console permissions (goose 00065), gate admin
handlers by system:* keys, and persist settings change audits for
all upserts. KB ops and whitelist consumers stay deferred.
2026-09-23 10:09:10 +08:00

223 lines
7.3 KiB
Go

package sysadmin
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
)
func seedAuthorOnlyPolicy(m *memStore, allowed bool, revision int64) {
raw := json.RawMessage(`false`)
if allowed {
raw = json.RawMessage(`true`)
}
uid := int64(0)
m.mu.Lock()
defer m.mu.Unlock()
m.items[SettingKeyAllowAuthorOnlyApproval] = memEntry{
value: append(json.RawMessage(nil), raw...),
updatedBy: &uid,
updatedAt: m.items[SettingKeyAllowAuthorOnlyApproval].updatedAt,
revision: revision,
}
}
func TestAuthorOnlyPolicy_DefaultClosedWhenMissing(t *testing.T) {
svc := NewService(nil, Options{Store: newMemStore()})
pol, err := svc.GetAuthorOnlyApprovalPolicy(context.Background())
if err != nil {
t.Fatalf("GetAuthorOnlyApprovalPolicy: %v", err)
}
if pol.Allowed || pol.Revision != 0 || pol.Key != SettingKeyAllowAuthorOnlyApproval {
t.Fatalf("expected closed default, got %+v", pol)
}
}
func TestAuthorOnlyPolicy_StoreUnavailable(t *testing.T) {
svc := NewService(nil, Options{})
_, err := svc.GetAuthorOnlyApprovalPolicy(context.Background())
if !errors.Is(err, ErrStoreUnavailable) {
t.Fatalf("expected ErrStoreUnavailable, got %v", err)
}
}
func TestUpdateAuthorOnly_RequiresExpectedRevision(t *testing.T) {
store := newMemStore()
seedAuthorOnlyPolicy(store, false, 0)
svc := NewService(nil, Options{Store: store})
_, err := svc.UpdateSetting(context.Background(), SettingKeyAllowAuthorOnlyApproval, json.RawMessage(`true`), 1)
if !errors.Is(err, ErrExpectedRevisionRequired) {
t.Fatalf("expected ErrExpectedRevisionRequired, got %v", err)
}
}
func TestUpdateAuthorOnly_RejectsNullAndStringBool(t *testing.T) {
store := newMemStore()
seedAuthorOnlyPolicy(store, false, 0)
svc := NewService(nil, Options{Store: store})
zero := int64(0)
for _, raw := range []string{`null`, `"true"`, `1`, `yes`} {
_, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{
Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(raw),
UpdatedBy: 1, ExpectedRevision: &zero,
})
if !errors.Is(err, ErrInvalidSettingValue) {
t.Fatalf("%s: expected ErrInvalidSettingValue, got %v", raw, err)
}
}
}
func TestUpdateAuthorOnly_CASSuccessAndConflict(t *testing.T) {
store := newMemStore()
seedAuthorOnlyPolicy(store, false, 0)
svc := NewService(nil, Options{Store: store})
zero := int64(0)
setting, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{
Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`true`),
UpdatedBy: 9, ExpectedRevision: &zero, RequestID: "req-1",
})
if err != nil {
t.Fatalf("first CAS: %v", err)
}
if setting.Value != true || setting.Revision != 1 {
t.Fatalf("expected true@rev1, got value=%v rev=%d", setting.Value, setting.Revision)
}
if len(store.audits) != 1 || store.audits[0].afterRev != 1 || store.audits[0].requestID != "req-1" {
t.Fatalf("expected one audit with request id, got %+v", store.audits)
}
pol, err := svc.GetAuthorOnlyApprovalPolicy(context.Background())
if err != nil || !pol.Allowed || pol.Revision != 1 {
t.Fatalf("policy after enable: %+v err=%v", pol, err)
}
_, err = svc.UpdateSettingEx(context.Background(), SettingUpdate{
Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`false`),
UpdatedBy: 9, ExpectedRevision: &zero,
})
if !errors.Is(err, ErrSettingRevisionConflict) {
t.Fatalf("expected conflict on stale revision, got %v", err)
}
one := int64(1)
setting, err = svc.UpdateSettingEx(context.Background(), SettingUpdate{
Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`false`),
UpdatedBy: 10, ExpectedRevision: &one,
})
if err != nil {
t.Fatalf("second CAS: %v", err)
}
if setting.Value != false || setting.Revision != 2 {
t.Fatalf("expected false@rev2, got value=%v rev=%d", setting.Value, setting.Revision)
}
}
func TestListPolicyAudits_MemoryStore(t *testing.T) {
store := newMemStore()
seedAuthorOnlyPolicy(store, false, 0)
svc := NewService(nil, Options{Store: store})
zero := int64(0)
if _, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{
Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`true`),
UpdatedBy: 9, ExpectedRevision: &zero, RequestID: "audit-list-1",
}); err != nil {
t.Fatalf("CAS: %v", err)
}
one := int64(1)
if _, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{
Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`false`),
UpdatedBy: 10, ExpectedRevision: &one, RequestID: "audit-list-2",
}); err != nil {
t.Fatalf("second CAS: %v", err)
}
audits, err := svc.ListPolicyAudits(context.Background(), SettingKeyAllowAuthorOnlyApproval, 10)
if err != nil {
t.Fatalf("ListPolicyAudits: %v", err)
}
if len(audits) != 2 {
t.Fatalf("expected 2 audits, got %d", len(audits))
}
if audits[0].RequestID != "audit-list-2" || audits[0].AfterRevision != 2 {
t.Fatalf("newest audit first: %+v", audits[0])
}
if audits[1].RequestID != "audit-list-1" || audits[1].AfterRevision != 1 {
t.Fatalf("older audit second: %+v", audits[1])
}
limited, err := svc.ListPolicyAudits(context.Background(), SettingKeyAllowAuthorOnlyApproval, 1)
if err != nil || len(limited) != 1 || limited[0].RequestID != "audit-list-2" {
t.Fatalf("limit=1: got %+v err=%v", limited, err)
}
if _, err := svc.ListPolicyAudits(context.Background(), "no.such.key", 10); !errors.Is(err, ErrUnknownSetting) {
t.Fatalf("unknown key: %v", err)
}
svcNil := NewService(nil, Options{})
if _, err := svcNil.ListPolicyAudits(context.Background(), SettingKeyAllowAuthorOnlyApproval, 10); !errors.Is(err, ErrStoreUnavailable) {
t.Fatalf("nil store: %v", err)
}
}
func TestUpdateAuthorOnly_AuditFailureRollsBack(t *testing.T) {
store := newMemStore()
seedAuthorOnlyPolicy(store, false, 0)
store.failAudit = true
svc := NewService(nil, Options{Store: store})
zero := int64(0)
_, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{
Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`true`),
UpdatedBy: 1, ExpectedRevision: &zero,
})
if err == nil {
t.Fatal("expected audit failure")
}
pol, err := svc.GetAuthorOnlyApprovalPolicy(context.Background())
if err != nil || pol.Allowed || pol.Revision != 0 {
t.Fatalf("setting must remain closed after audit failure: %+v err=%v", pol, err)
}
}
func TestUpdateAuthorOnly_ConcurrentCASOnlyOneWins(t *testing.T) {
store := newMemStore()
seedAuthorOnlyPolicy(store, false, 0)
svc := NewService(nil, Options{Store: store})
zero := int64(0)
var wg sync.WaitGroup
results := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{
Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`true`),
UpdatedBy: 1, ExpectedRevision: &zero,
})
results <- err
}()
}
wg.Wait()
close(results)
var okCount, conflictCount int
for err := range results {
switch {
case err == nil:
okCount++
case errors.Is(err, ErrSettingRevisionConflict):
conflictCount++
default:
t.Fatalf("unexpected error: %v", err)
}
}
if okCount != 1 || conflictCount != 1 {
t.Fatalf("expected 1 success + 1 conflict, got ok=%d conflict=%d", okCount, conflictCount)
}
pol, err := svc.GetAuthorOnlyApprovalPolicy(context.Background())
if err != nil || !pol.Allowed || pol.Revision != 1 {
t.Fatalf("expected single winner true@1, got %+v err=%v", pol, err)
}
}