Feat settings cascade with user_overridable flag
Three-tier settings resolution (global → team → user) with per-key user_overridable flag in package manifests. Admins can lock settings that team admins and users cannot override. - Add package_team_settings table (migration 010) - Add GetTeamSettings/SetTeamSettings/DeleteTeamSettings to PackageStore - Add ResolveSettings, ParseSettingsSchema, FilterOverridableKeys - Rewrite Starlark settings.get() to use cascade resolver - Add TeamID to RunContext for team-aware resolution - Enforce user_overridable on user settings save - Add team package settings API (GET/PUT/DELETE) - 12 unit tests for cascade resolution Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
200
server/store/settings_cascade_test.go
Normal file
200
server/store/settings_cascade_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
func TestResolveSettings_GlobalOnly(t *testing.T) {
|
||||
global := json.RawMessage(`{"font_size": 14, "theme": "dark"}`)
|
||||
schema := []SettingSchema{
|
||||
{Key: "font_size", UserOverridable: boolPtr(true)},
|
||||
{Key: "theme", UserOverridable: boolPtr(true)},
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, nil, nil, schema)
|
||||
|
||||
if merged["font_size"] != float64(14) {
|
||||
t.Errorf("font_size: got %v, want 14", merged["font_size"])
|
||||
}
|
||||
if merged["theme"] != "dark" {
|
||||
t.Errorf("theme: got %v, want dark", merged["theme"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_UserOverridesGlobal(t *testing.T) {
|
||||
global := json.RawMessage(`{"font_size": 14, "theme": "dark"}`)
|
||||
user := json.RawMessage(`{"font_size": 18}`)
|
||||
schema := []SettingSchema{
|
||||
{Key: "font_size", UserOverridable: boolPtr(true)},
|
||||
{Key: "theme", UserOverridable: boolPtr(true)},
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, nil, user, schema)
|
||||
|
||||
if merged["font_size"] != float64(18) {
|
||||
t.Errorf("font_size: got %v, want 18", merged["font_size"])
|
||||
}
|
||||
if merged["theme"] != "dark" {
|
||||
t.Errorf("theme: got %v, want dark", merged["theme"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_LockedKeyBlocksUserAndTeam(t *testing.T) {
|
||||
global := json.RawMessage(`{"api_url": "https://prod.example.com", "font_size": 14}`)
|
||||
team := json.RawMessage(`{"api_url": "https://team.example.com", "font_size": 16}`)
|
||||
user := json.RawMessage(`{"api_url": "https://user.example.com", "font_size": 20}`)
|
||||
schema := []SettingSchema{
|
||||
{Key: "api_url", UserOverridable: boolPtr(false)},
|
||||
{Key: "font_size", UserOverridable: boolPtr(true)},
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, team, user, schema)
|
||||
|
||||
if merged["api_url"] != "https://prod.example.com" {
|
||||
t.Errorf("api_url: got %v, want https://prod.example.com (locked)", merged["api_url"])
|
||||
}
|
||||
if merged["font_size"] != float64(20) {
|
||||
t.Errorf("font_size: got %v, want 20 (user wins)", merged["font_size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_TeamOverridesGlobal(t *testing.T) {
|
||||
global := json.RawMessage(`{"font_size": 14}`)
|
||||
team := json.RawMessage(`{"font_size": 16}`)
|
||||
schema := []SettingSchema{
|
||||
{Key: "font_size", UserOverridable: boolPtr(true)},
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, team, nil, schema)
|
||||
|
||||
if merged["font_size"] != float64(16) {
|
||||
t.Errorf("font_size: got %v, want 16", merged["font_size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_UserOverridesTeam(t *testing.T) {
|
||||
global := json.RawMessage(`{"font_size": 14}`)
|
||||
team := json.RawMessage(`{"font_size": 16}`)
|
||||
user := json.RawMessage(`{"font_size": 20}`)
|
||||
schema := []SettingSchema{
|
||||
{Key: "font_size", UserOverridable: boolPtr(true)},
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, team, user, schema)
|
||||
|
||||
if merged["font_size"] != float64(20) {
|
||||
t.Errorf("font_size: got %v, want 20", merged["font_size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_AbsentFlagDefaultsTrue(t *testing.T) {
|
||||
global := json.RawMessage(`{"font_size": 14}`)
|
||||
user := json.RawMessage(`{"font_size": 20}`)
|
||||
// UserOverridable is nil — should default to true
|
||||
schema := []SettingSchema{
|
||||
{Key: "font_size"},
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, nil, user, schema)
|
||||
|
||||
if merged["font_size"] != float64(20) {
|
||||
t.Errorf("font_size: got %v, want 20 (nil flag = overridable)", merged["font_size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_UnknownKeyDefaultsOverridable(t *testing.T) {
|
||||
global := json.RawMessage(`{"known": "a", "unknown": "b"}`)
|
||||
user := json.RawMessage(`{"unknown": "c"}`)
|
||||
schema := []SettingSchema{
|
||||
{Key: "known", UserOverridable: boolPtr(false)},
|
||||
// "unknown" is not in schema
|
||||
}
|
||||
|
||||
merged := ResolveSettings(global, nil, user, schema)
|
||||
|
||||
if merged["unknown"] != "c" {
|
||||
t.Errorf("unknown: got %v, want c (undeclared keys are overridable)", merged["unknown"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_NilLayers(t *testing.T) {
|
||||
merged := ResolveSettings(nil, nil, nil, nil)
|
||||
if merged == nil {
|
||||
t.Error("expected non-nil empty map")
|
||||
}
|
||||
if len(merged) != 0 {
|
||||
t.Errorf("expected empty map, got %v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettings_EmptyJSON(t *testing.T) {
|
||||
merged := ResolveSettings(json.RawMessage(`{}`), json.RawMessage(`{}`), json.RawMessage(`{}`), nil)
|
||||
if len(merged) != 0 {
|
||||
t.Errorf("expected empty map, got %v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSettingsSchema(t *testing.T) {
|
||||
manifest := map[string]any{
|
||||
"settings": []any{
|
||||
map[string]any{"key": "font_size", "type": "number", "default": 14, "user_overridable": true},
|
||||
map[string]any{"key": "api_url", "type": "string", "user_overridable": false},
|
||||
map[string]any{"key": "theme", "type": "string"},
|
||||
map[string]any{"no_key": true}, // should be skipped
|
||||
},
|
||||
}
|
||||
|
||||
schema := ParseSettingsSchema(manifest)
|
||||
|
||||
if len(schema) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(schema))
|
||||
}
|
||||
|
||||
if schema[0].Key != "font_size" || !schema[0].IsUserOverridable() {
|
||||
t.Errorf("font_size: expected overridable=true, got %v", schema[0].UserOverridable)
|
||||
}
|
||||
if schema[1].Key != "api_url" || schema[1].IsUserOverridable() {
|
||||
t.Errorf("api_url: expected overridable=false, got %v", schema[1].UserOverridable)
|
||||
}
|
||||
if schema[2].Key != "theme" || !schema[2].IsUserOverridable() {
|
||||
t.Errorf("theme: nil flag should default to overridable=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSettingsSchema_NoSettings(t *testing.T) {
|
||||
schema := ParseSettingsSchema(map[string]any{})
|
||||
if schema != nil {
|
||||
t.Errorf("expected nil, got %v", schema)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterOverridableKeys(t *testing.T) {
|
||||
settings := map[string]any{
|
||||
"font_size": 20,
|
||||
"api_url": "https://hack.example.com",
|
||||
"theme": "light",
|
||||
}
|
||||
schema := []SettingSchema{
|
||||
{Key: "font_size", UserOverridable: boolPtr(true)},
|
||||
{Key: "api_url", UserOverridable: boolPtr(false)},
|
||||
{Key: "theme"}, // nil = overridable
|
||||
}
|
||||
|
||||
filtered, rejected := FilterOverridableKeys(settings, schema)
|
||||
|
||||
if _, ok := filtered["api_url"]; ok {
|
||||
t.Error("api_url should have been stripped")
|
||||
}
|
||||
if filtered["font_size"] != 20 {
|
||||
t.Errorf("font_size: got %v, want 20", filtered["font_size"])
|
||||
}
|
||||
if filtered["theme"] != "light" {
|
||||
t.Errorf("theme: got %v, want light", filtered["theme"])
|
||||
}
|
||||
if len(rejected) != 1 || rejected[0] != "api_url" {
|
||||
t.Errorf("rejected: got %v, want [api_url]", rejected)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user