Verify concurrent revision conflicts, null/string rejection, plain-user denials, and PG lock/read paths for the new policy key. Co-authored-by: Cursor <cursoragent@cursor.com>
177 lines
5.5 KiB
Go
177 lines
5.5 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 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)
|
|
}
|
|
}
|