Add support for config inheritance
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
)
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
@@ -121,3 +125,437 @@ func TestCodeTemplate(t *testing.T) {
|
||||
t.Error("code template should have denied commands")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeRelaxedTemplate(t *testing.T) {
|
||||
cfg, err := Load("code-relaxed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load code-relaxed template: %v", err)
|
||||
}
|
||||
|
||||
// Should inherit AllowPty from code template
|
||||
if !cfg.AllowPty {
|
||||
t.Error("code-relaxed should inherit AllowPty=true from code")
|
||||
}
|
||||
|
||||
// Should have wildcard in allowed domains
|
||||
hasWildcard := false
|
||||
for _, domain := range cfg.Network.AllowedDomains {
|
||||
if domain == "*" {
|
||||
hasWildcard = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWildcard {
|
||||
t.Error("code-relaxed should have '*' in allowed domains")
|
||||
}
|
||||
|
||||
// Should inherit denied domains from code
|
||||
if len(cfg.Network.DeniedDomains) == 0 {
|
||||
t.Error("code-relaxed should inherit denied domains from code")
|
||||
}
|
||||
|
||||
// Should inherit filesystem config from code
|
||||
if len(cfg.Filesystem.AllowWrite) == 0 {
|
||||
t.Error("code-relaxed should inherit allowWrite from code")
|
||||
}
|
||||
if len(cfg.Filesystem.DenyRead) == 0 {
|
||||
t.Error("code-relaxed should inherit denyRead from code")
|
||||
}
|
||||
if len(cfg.Filesystem.DenyWrite) == 0 {
|
||||
t.Error("code-relaxed should inherit denyWrite from code")
|
||||
}
|
||||
|
||||
// Should inherit command config from code
|
||||
if len(cfg.Command.Deny) == 0 {
|
||||
t.Error("code-relaxed should inherit command deny list from code")
|
||||
}
|
||||
|
||||
// Extends should be cleared after resolution
|
||||
if cfg.Extends != "" {
|
||||
t.Error("extends should be cleared after loading")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExtends(t *testing.T) {
|
||||
t.Run("nil config", func(t *testing.T) {
|
||||
result, err := ResolveExtends(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("expected nil result for nil input")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no extends", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
AllowPty: true,
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
},
|
||||
}
|
||||
result, err := ResolveExtends(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != cfg {
|
||||
t.Error("expected same config when no extends")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends code template", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Extends: "code",
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"private-registry.company.com"},
|
||||
},
|
||||
}
|
||||
result, err := ResolveExtends(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have merged config
|
||||
if result.Extends != "" {
|
||||
t.Error("extends should be cleared after resolution")
|
||||
}
|
||||
|
||||
// Should have AllowPty from base template
|
||||
if !result.AllowPty {
|
||||
t.Error("should inherit AllowPty from code template")
|
||||
}
|
||||
|
||||
// Should have domains from both
|
||||
hasPrivateRegistry := false
|
||||
hasAnthropic := false
|
||||
for _, domain := range result.Network.AllowedDomains {
|
||||
if domain == "private-registry.company.com" {
|
||||
hasPrivateRegistry = true
|
||||
}
|
||||
if domain == "*.anthropic.com" {
|
||||
hasAnthropic = true
|
||||
}
|
||||
}
|
||||
if !hasPrivateRegistry {
|
||||
t.Error("should have private-registry.company.com from override")
|
||||
}
|
||||
if !hasAnthropic {
|
||||
t.Error("should have *.anthropic.com from base template")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends nonexistent template", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Extends: "nonexistent-template",
|
||||
}
|
||||
_, err := ResolveExtends(cfg)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent template")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtendsChainDepth(t *testing.T) {
|
||||
// This tests that the maxExtendsDepth limit is respected.
|
||||
// We can't easily create a deep chain with embedded templates,
|
||||
// but we can test that the code template (which has no extends)
|
||||
// loads correctly.
|
||||
cfg, err := Load("code")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Error("expected non-nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
// Template names (not paths)
|
||||
{"code", false},
|
||||
{"npm-install", false},
|
||||
{"my-template", false},
|
||||
|
||||
// Absolute paths
|
||||
{"/path/to/config.json", true},
|
||||
{"/etc/fence/base.json", true},
|
||||
|
||||
// Relative paths
|
||||
{"./base.json", true},
|
||||
{"../shared/base.json", true},
|
||||
{"configs/base.json", true},
|
||||
|
||||
// Windows-style paths
|
||||
{"C:\\path\\to\\config.json", true},
|
||||
{".\\base.json", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isPath(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isPath(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendsFilePath(t *testing.T) {
|
||||
// Create temp directory for test files
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
t.Run("extends absolute path", func(t *testing.T) {
|
||||
// Create base config file
|
||||
baseContent := `{
|
||||
"network": {
|
||||
"allowedDomains": ["base.example.com"]
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": ["/tmp"]
|
||||
}
|
||||
}`
|
||||
basePath := filepath.Join(tmpDir, "base.json")
|
||||
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write base config: %v", err)
|
||||
}
|
||||
|
||||
// Config that extends the base via absolute path
|
||||
cfg := &config.Config{
|
||||
Extends: basePath,
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"override.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have merged domains
|
||||
if len(result.Network.AllowedDomains) != 2 {
|
||||
t.Errorf("expected 2 domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
}
|
||||
|
||||
// Should have filesystem from base
|
||||
if len(result.Filesystem.AllowWrite) != 1 || result.Filesystem.AllowWrite[0] != "/tmp" {
|
||||
t.Errorf("expected AllowWrite [/tmp], got %v", result.Filesystem.AllowWrite)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends relative path", func(t *testing.T) {
|
||||
// Create base config in subdir
|
||||
subDir := filepath.Join(tmpDir, "configs")
|
||||
if err := os.MkdirAll(subDir, 0o750); err != nil {
|
||||
t.Fatalf("failed to create subdir: %v", err)
|
||||
}
|
||||
|
||||
baseContent := `{
|
||||
"allowPty": true,
|
||||
"network": {
|
||||
"allowedDomains": ["relative-base.example.com"]
|
||||
}
|
||||
}`
|
||||
basePath := filepath.Join(subDir, "base.json")
|
||||
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write base config: %v", err)
|
||||
}
|
||||
|
||||
// Config that extends via relative path
|
||||
cfg := &config.Config{
|
||||
Extends: "./configs/base.json",
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"child.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResolveExtendsWithBaseDir(cfg, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should inherit AllowPty
|
||||
if !result.AllowPty {
|
||||
t.Error("should inherit AllowPty from base")
|
||||
}
|
||||
|
||||
// Should have merged domains
|
||||
if len(result.Network.AllowedDomains) != 2 {
|
||||
t.Errorf("expected 2 domains, got %d", len(result.Network.AllowedDomains))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends nonexistent file", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Extends: "/nonexistent/path/config.json",
|
||||
}
|
||||
|
||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends invalid JSON file", func(t *testing.T) {
|
||||
invalidPath := filepath.Join(tmpDir, "invalid.json")
|
||||
if err := os.WriteFile(invalidPath, []byte("{invalid json}"), 0o600); err != nil {
|
||||
t.Fatalf("failed to write invalid config: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Extends: invalidPath,
|
||||
}
|
||||
|
||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends file with invalid config", func(t *testing.T) {
|
||||
// Create config with invalid domain pattern
|
||||
invalidContent := `{
|
||||
"network": {
|
||||
"allowedDomains": ["*.com"]
|
||||
}
|
||||
}`
|
||||
invalidPath := filepath.Join(tmpDir, "invalid-domain.json")
|
||||
if err := os.WriteFile(invalidPath, []byte(invalidContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Extends: invalidPath,
|
||||
}
|
||||
|
||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("circular extends via files", func(t *testing.T) {
|
||||
// Create two files that extend each other
|
||||
fileA := filepath.Join(tmpDir, "a.json")
|
||||
fileB := filepath.Join(tmpDir, "b.json")
|
||||
|
||||
contentA := `{"extends": "` + fileB + `"}`
|
||||
contentB := `{"extends": "` + fileA + `"}`
|
||||
|
||||
if err := os.WriteFile(fileA, []byte(contentA), 0o600); err != nil {
|
||||
t.Fatalf("failed to write a.json: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fileB, []byte(contentB), 0o600); err != nil {
|
||||
t.Fatalf("failed to write b.json: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Extends: fileA,
|
||||
}
|
||||
|
||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for circular extends")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested extends chain", func(t *testing.T) {
|
||||
// Create a chain: child -> middle -> base
|
||||
baseContent := `{
|
||||
"network": {
|
||||
"allowedDomains": ["base.com"]
|
||||
}
|
||||
}`
|
||||
basePath := filepath.Join(tmpDir, "chain-base.json")
|
||||
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write base: %v", err)
|
||||
}
|
||||
|
||||
middleContent := `{
|
||||
"extends": "` + basePath + `",
|
||||
"network": {
|
||||
"allowedDomains": ["middle.com"]
|
||||
}
|
||||
}`
|
||||
middlePath := filepath.Join(tmpDir, "chain-middle.json")
|
||||
if err := os.WriteFile(middlePath, []byte(middleContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write middle: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Extends: middlePath,
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"child.com"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have all three domains
|
||||
if len(result.Network.AllowedDomains) != 3 {
|
||||
t.Errorf("expected 3 domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file extends template", func(t *testing.T) {
|
||||
// Create a file that extends a built-in template
|
||||
fileContent := `{
|
||||
"extends": "code",
|
||||
"network": {
|
||||
"allowedDomains": ["extra.example.com"]
|
||||
}
|
||||
}`
|
||||
filePath := filepath.Join(tmpDir, "extends-template.json")
|
||||
if err := os.WriteFile(filePath, []byte(fileContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
// Config that extends this file
|
||||
cfg := &config.Config{
|
||||
Extends: filePath,
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"top.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have AllowPty from code template
|
||||
if !result.AllowPty {
|
||||
t.Error("should inherit AllowPty from code template")
|
||||
}
|
||||
|
||||
// Should have domains from all levels
|
||||
hasAnthropic := false
|
||||
hasExtra := false
|
||||
hasTop := false
|
||||
for _, domain := range result.Network.AllowedDomains {
|
||||
switch domain {
|
||||
case "*.anthropic.com":
|
||||
hasAnthropic = true
|
||||
case "extra.example.com":
|
||||
hasExtra = true
|
||||
case "top.example.com":
|
||||
hasTop = true
|
||||
}
|
||||
}
|
||||
if !hasAnthropic {
|
||||
t.Error("should have *.anthropic.com from code template")
|
||||
}
|
||||
if !hasExtra {
|
||||
t.Error("should have extra.example.com from middle file")
|
||||
}
|
||||
if !hasTop {
|
||||
t.Error("should have top.example.com from top config")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user