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>
201 lines
6.0 KiB
Go
201 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|