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.
223 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|