Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 734a13e646 |
@@ -94,9 +94,11 @@ describe("sortParentsBeforeChildren", () => {
|
||||
{ id: "orphan", parentId: "ghost" },
|
||||
{ id: "root", parentId: undefined },
|
||||
];
|
||||
// Missing parent is skipped; orphan placed after root
|
||||
// Missing parent is skipped; orphan keeps its input order (orphans
|
||||
// and missing-parent nodes preserve relative ordering — DFS visits
|
||||
// them at their input position rather than moving them to the end).
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result.map((n) => n.id)).toEqual(["root", "orphan"]);
|
||||
expect(result.map((n) => n.id)).toEqual(["orphan", "root"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// expandWithEnv tests — ${VAR} and $VAR expansion from a provided map.
|
||||
|
||||
func TestExpandWithEnv_BracedVar(t *testing.T) {
|
||||
env := map[string]string{"FOO": "bar", "BAZ": "qux"}
|
||||
result := expandWithEnv("value is ${FOO}", env)
|
||||
assert.Equal(t, "value is bar", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_DollarVar(t *testing.T) {
|
||||
env := map[string]string{"X": "1", "Y": "2"}
|
||||
result := expandWithEnv("$X + $Y = 3", env)
|
||||
assert.Equal(t, "1 + 2 = 3", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_Mixed(t *testing.T) {
|
||||
env := map[string]string{"A": "alpha", "B": "beta"}
|
||||
result := expandWithEnv("${A}_${B}", env)
|
||||
assert.Equal(t, "alpha_beta", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_MissingVar(t *testing.T) {
|
||||
// Missing vars stay as-is (os.Getenv fallback returns "" for unset vars).
|
||||
env := map[string]string{}
|
||||
result := expandWithEnv("${UNSET}", env)
|
||||
assert.Equal(t, "", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_EmptyMap(t *testing.T) {
|
||||
result := expandWithEnv("no vars here", map[string]string{})
|
||||
assert.Equal(t, "no vars here", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_LiteralDollar(t *testing.T) {
|
||||
// A bare $ not followed by a valid identifier char stays as-is.
|
||||
result := expandWithEnv("cost $100", map[string]string{})
|
||||
assert.Equal(t, "cost $100", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_PartiallyPresent(t *testing.T) {
|
||||
env := map[string]string{"SET": "yes"}
|
||||
result := expandWithEnv("${SET} and ${NOT_SET}", env)
|
||||
// ${SET} resolved; ${NOT_SET} -> "" via empty fallback.
|
||||
assert.Equal(t, "yes and ", result)
|
||||
}
|
||||
|
||||
// mergeCategoryRouting tests — unions defaults with per-workspace routing.
|
||||
|
||||
func TestMergeCategoryRouting_EmptyInputs(t *testing.T) {
|
||||
result := mergeCategoryRouting(nil, nil)
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_DefaultsOnly(t *testing.T) {
|
||||
defaults := map[string][]string{
|
||||
"security": {"Backend Engineer", "DevOps"},
|
||||
"infra": {"SRE"},
|
||||
}
|
||||
result := mergeCategoryRouting(defaults, nil)
|
||||
assert.Equal(t, defaults, result)
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_WorkspaceOverrides(t *testing.T) {
|
||||
defaults := map[string][]string{
|
||||
"security": {"Backend Engineer", "DevOps"},
|
||||
"infra": {"SRE"},
|
||||
}
|
||||
wsRouting := map[string][]string{
|
||||
"security": {"Security Team"}, // narrows the list
|
||||
}
|
||||
result := mergeCategoryRouting(defaults, wsRouting)
|
||||
assert.Equal(t, []string{"Security Team"}, result["security"])
|
||||
assert.Equal(t, []string{"SRE"}, result["infra"]) // untouched
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_WorkspaceAddsCategory(t *testing.T) {
|
||||
defaults := map[string][]string{
|
||||
"security": {"Backend Engineer"},
|
||||
}
|
||||
wsRouting := map[string][]string{
|
||||
"ui": {"Frontend Engineer"},
|
||||
}
|
||||
result := mergeCategoryRouting(defaults, wsRouting)
|
||||
assert.Equal(t, []string{"Backend Engineer"}, result["security"])
|
||||
assert.Equal(t, []string{"Frontend Engineer"}, result["ui"])
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_EmptyListDropsCategory(t *testing.T) {
|
||||
defaults := map[string][]string{
|
||||
"security": {"Backend Engineer"},
|
||||
"infra": {"SRE"},
|
||||
}
|
||||
wsRouting := map[string][]string{
|
||||
"security": {}, // empty list = explicit drop
|
||||
}
|
||||
result := mergeCategoryRouting(defaults, wsRouting)
|
||||
_, hasSecurity := result["security"]
|
||||
assert.False(t, hasSecurity)
|
||||
assert.Equal(t, []string{"SRE"}, result["infra"])
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_EmptyDefaultKeySkipped(t *testing.T) {
|
||||
defaults := map[string][]string{
|
||||
"": {"Backend Engineer"}, // empty key should be skipped
|
||||
}
|
||||
result := mergeCategoryRouting(defaults, nil)
|
||||
_, has := result[""]
|
||||
assert.False(t, has)
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_EmptyWorkspaceKeySkipped(t *testing.T) {
|
||||
defaults := map[string][]string{
|
||||
"security": {"Backend Engineer"},
|
||||
}
|
||||
wsRouting := map[string][]string{
|
||||
"": {"Some Role"},
|
||||
}
|
||||
result := mergeCategoryRouting(defaults, wsRouting)
|
||||
_, has := result[""]
|
||||
assert.False(t, has)
|
||||
assert.Equal(t, []string{"Backend Engineer"}, result["security"])
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_DoesNotMutateInputs(t *testing.T) {
|
||||
defaults := map[string][]string{
|
||||
"security": {"Backend Engineer"},
|
||||
}
|
||||
wsRouting := map[string][]string{
|
||||
"security": {"DevOps"},
|
||||
}
|
||||
orig := defaults["security"][0]
|
||||
_ = mergeCategoryRouting(defaults, wsRouting)
|
||||
assert.Equal(t, orig, defaults["security"][0])
|
||||
}
|
||||
|
||||
// renderCategoryRoutingYAML tests — deterministic YAML emission.
|
||||
|
||||
func TestRenderCategoryRoutingYAML_Empty(t *testing.T) {
|
||||
result, err := renderCategoryRoutingYAML(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", result)
|
||||
}
|
||||
|
||||
func TestRenderCategoryRoutingYAML_SingleCategory(t *testing.T) {
|
||||
routing := map[string][]string{
|
||||
"security": {"Backend Engineer", "DevOps"},
|
||||
}
|
||||
result, err := renderCategoryRoutingYAML(routing)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, result, "security:")
|
||||
assert.Contains(t, result, "Backend Engineer")
|
||||
assert.Contains(t, result, "DevOps")
|
||||
}
|
||||
|
||||
func TestRenderCategoryRoutingYAML_MultipleCategoriesSorted(t *testing.T) {
|
||||
routing := map[string][]string{
|
||||
"zebra": {"RoleZ"},
|
||||
"alpha": {"RoleA"},
|
||||
"middleware": {"RoleM"},
|
||||
}
|
||||
result, err := renderCategoryRoutingYAML(routing)
|
||||
assert.NoError(t, err)
|
||||
// Keys are sorted alphabetically.
|
||||
idxAlpha := assertFind(t, result, "alpha:")
|
||||
idxZebra := assertFind(t, result, "zebra:")
|
||||
idxMid := assertFind(t, result, "middleware:")
|
||||
if idxAlpha > -1 && idxZebra > -1 {
|
||||
assert.True(t, idxAlpha < idxZebra, "alpha should appear before zebra")
|
||||
}
|
||||
if idxMid > -1 && idxZebra > -1 {
|
||||
assert.True(t, idxMid < idxZebra, "middleware should appear before zebra")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCategoryRoutingYAML_EmptyListCategory(t *testing.T) {
|
||||
// Empty-list category should still render (mergeCategoryRouting drops
|
||||
// them before they reach this function, but we test the render in isolation).
|
||||
routing := map[string][]string{
|
||||
"security": {},
|
||||
}
|
||||
result, err := renderCategoryRoutingYAML(routing)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, result, "security:")
|
||||
}
|
||||
|
||||
func TestRenderCategoryRoutingYAML_SpecialCharactersEscaped(t *testing.T) {
|
||||
routing := map[string][]string{
|
||||
"notes": {`has: colon`, `and "quotes"`, "emoji: 🚀"},
|
||||
}
|
||||
result, err := renderCategoryRoutingYAML(routing)
|
||||
assert.NoError(t, err)
|
||||
// Should not panic and should produce valid YAML.
|
||||
assert.Contains(t, result, "notes:")
|
||||
}
|
||||
|
||||
// appendYAMLBlock tests — safe concatenation with newline boundary.
|
||||
|
||||
func TestAppendYAMLBlock_BothEmpty(t *testing.T) {
|
||||
result := appendYAMLBlock(nil, "")
|
||||
assert.Equal(t, "", result)
|
||||
}
|
||||
|
||||
func TestAppendYAMLBlock_ExistingHasNewline(t *testing.T) {
|
||||
existing := []byte("existing:\n")
|
||||
block := "key: value\n"
|
||||
result := appendYAMLBlock(existing, block)
|
||||
assert.Equal(t, "existing:\nkey: value\n", string(result))
|
||||
}
|
||||
|
||||
func TestAppendYAMLBlock_ExistingNoNewline(t *testing.T) {
|
||||
existing := []byte("existing:")
|
||||
block := "key: value\n"
|
||||
result := appendYAMLBlock(existing, block)
|
||||
assert.Equal(t, "existing:\nkey: value\n", string(result))
|
||||
}
|
||||
|
||||
func TestAppendYAMLBlock_ExistingEmpty(t *testing.T) {
|
||||
existing := []byte("")
|
||||
block := "key: value\n"
|
||||
result := appendYAMLBlock(existing, block)
|
||||
assert.Equal(t, "key: value\n", string(result))
|
||||
}
|
||||
|
||||
func TestAppendYAMLBlock_NilExisting(t *testing.T) {
|
||||
block := "key: value\n"
|
||||
result := appendYAMLBlock(nil, block)
|
||||
assert.Equal(t, "key: value\n", string(result))
|
||||
}
|
||||
|
||||
// mergePlugins tests — union with exclusion prefix (!/-).
|
||||
|
||||
func TestMergePlugins_DefaultsOnly(t *testing.T) {
|
||||
defaults := []string{"plugin-a", "plugin-b"}
|
||||
result := mergePlugins(defaults, nil)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-b"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_WorkspaceAdds(t *testing.T) {
|
||||
defaults := []string{"plugin-a"}
|
||||
wsPlugins := []string{"plugin-b", "plugin-a"} // duplicate of default
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-b"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_ExclusionWithBang(t *testing.T) {
|
||||
defaults := []string{"plugin-a", "plugin-b", "plugin-c"}
|
||||
wsPlugins := []string{"!plugin-b"}
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-c"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_ExclusionWithDash(t *testing.T) {
|
||||
defaults := []string{"plugin-a", "plugin-b", "plugin-c"}
|
||||
wsPlugins := []string{"-plugin-b"}
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-c"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_ExclusionEmptyTarget(t *testing.T) {
|
||||
defaults := []string{"plugin-a", "plugin-b"}
|
||||
wsPlugins := []string{"!", "-"} // no-op exclusions
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-b"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_ExclusionNotInDefaults(t *testing.T) {
|
||||
// Excluding something not in defaults is a no-op.
|
||||
defaults := []string{"plugin-a"}
|
||||
wsPlugins := []string{"!plugin-b"}
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_WorkspaceAddsNew(t *testing.T) {
|
||||
defaults := []string{"plugin-a"}
|
||||
wsPlugins := []string{"plugin-b", "plugin-c"}
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-b", "plugin-c"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_EmptyInputs(t *testing.T) {
|
||||
result := mergePlugins(nil, nil)
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_DeduplicationOrder(t *testing.T) {
|
||||
// Defaults first; workspace entries deduplicated.
|
||||
defaults := []string{"plugin-a", "plugin-a", "plugin-b"}
|
||||
wsPlugins := []string{"plugin-b", "plugin-c", "plugin-c"}
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-b", "plugin-c"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_ExclusionThenAddSameName(t *testing.T) {
|
||||
// Remove then re-add: order matters.
|
||||
defaults := []string{"plugin-a", "plugin-b"}
|
||||
wsPlugins := []string{"!plugin-a", "plugin-a"}
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-b", "plugin-a"}, result)
|
||||
}
|
||||
|
||||
// isSafeRoleName tests — alphanumeric + hyphen/underscore, no path separators.
|
||||
|
||||
func TestIsSafeRoleName_Valid(t *testing.T) {
|
||||
valid := []string{
|
||||
"backend-engineer",
|
||||
"Frontend_Dev",
|
||||
"sre-123",
|
||||
"a",
|
||||
"Z",
|
||||
"role-name_v2",
|
||||
}
|
||||
for _, r := range valid {
|
||||
if !isSafeRoleName(r) {
|
||||
t.Errorf("isSafeRoleName(%q) expected true, got false", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSafeRoleName_Invalid(t *testing.T) {
|
||||
invalid := []string{
|
||||
"", // empty
|
||||
".", // current dir
|
||||
"..", // parent dir
|
||||
"role/name", // slash
|
||||
"role\\name", // backslash
|
||||
"role name", // space
|
||||
"role/name", // path separator
|
||||
"role\tname", // tab
|
||||
"role\nname", // newline
|
||||
}
|
||||
for _, r := range invalid {
|
||||
if isSafeRoleName(r) {
|
||||
t.Errorf("isSafeRoleName(%q) expected false, got true", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSafeRoleName_SpecialCharsRejected(t *testing.T) {
|
||||
bad := []string{
|
||||
"role@name",
|
||||
"role#name",
|
||||
"role$name",
|
||||
"role%name",
|
||||
"role&name",
|
||||
"role*name",
|
||||
"role?name",
|
||||
"role=name",
|
||||
}
|
||||
for _, r := range bad {
|
||||
if isSafeRoleName(r) {
|
||||
t.Errorf("isSafeRoleName(%q) expected false, got true", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertFind is a helper: returns index of first occurrence of substr in s, or -1.
|
||||
func assertFind(t *testing.T, s, substr string) int {
|
||||
t.Helper()
|
||||
idx := -1
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// supportsRuntime tests — plugin runtime compatibility checking.
|
||||
|
||||
func TestSupportsRuntime_EmptyRuntimes(t *testing.T) {
|
||||
// Empty runtimes = unspecified, try it → always compatible.
|
||||
info := pluginInfo{Name: "test", Runtimes: nil}
|
||||
assert.True(t, info.supportsRuntime("claude_code"))
|
||||
assert.True(t, info.supportsRuntime("any_runtime"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_ExactMatch(t *testing.T) {
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude_code", "anthropic"}}
|
||||
assert.True(t, info.supportsRuntime("claude_code"))
|
||||
assert.True(t, info.supportsRuntime("anthropic"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_NoMatch(t *testing.T) {
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude_code"}}
|
||||
assert.False(t, info.supportsRuntime("openai"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_HyphenUnderscoreNormalized(t *testing.T) {
|
||||
// "claude-code" and "claude_code" are considered equal.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude-code"}}
|
||||
assert.True(t, info.supportsRuntime("claude_code"))
|
||||
assert.True(t, info.supportsRuntime("anthropic_claude"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_HyphenVsUnderscoreReverse(t *testing.T) {
|
||||
// Plugin declares underscore form; runtime uses hyphen.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude_code"}}
|
||||
assert.True(t, info.supportsRuntime("claude-code"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_EmptyStringRuntime(t *testing.T) {
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude_code"}}
|
||||
// Empty runtime string: should not match any plugin.
|
||||
assert.False(t, info.supportsRuntime(""))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_SingleRuntimeMatch(t *testing.T) {
|
||||
// Multiple declared runtimes: only matching one is sufficient.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"python", "nodejs", "claude_code"}}
|
||||
assert.True(t, info.supportsRuntime("claude_code"))
|
||||
assert.False(t, info.supportsRuntime("ruby"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_AllHyphenForms(t *testing.T) {
|
||||
// Both plugin and runtime use hyphen form.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude-code"}}
|
||||
assert.True(t, info.supportsRuntime("claude-code"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_MultipleHyphenNormalization(t *testing.T) {
|
||||
// Mixed hyphen/underscore forms normalize to the same.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"some-runtime-name"}}
|
||||
assert.True(t, info.supportsRuntime("some_runtime_name"))
|
||||
assert.True(t, info.supportsRuntime("some-runtime-name"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_EmptyPluginRuntimesWithAnyInput(t *testing.T) {
|
||||
// Empty Runtimes on plugin = try it regardless of runtime.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{}}
|
||||
assert.True(t, info.supportsRuntime(""))
|
||||
assert.True(t, info.supportsRuntime("any"))
|
||||
assert.True(t, info.supportsRuntime("unknown"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_ZeroLengthRuntimes(t *testing.T) {
|
||||
// Empty slice vs nil: both should be treated as "unspecified".
|
||||
info := pluginInfo{Name: "test"}
|
||||
assert.True(t, info.supportsRuntime("anything"))
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// validateWorkspaceID tests — #687: UUID validation before DB hit.
|
||||
|
||||
func TestValidateWorkspaceID_Valid(t *testing.T) {
|
||||
for _, id := range []string{
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
"f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
"A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11", // uppercase also valid
|
||||
} {
|
||||
err := validateWorkspaceID(id)
|
||||
if err != nil {
|
||||
t.Errorf("validateWorkspaceID(%q) returned error: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceID_Invalid(t *testing.T) {
|
||||
cases := []struct {
|
||||
id string
|
||||
check func(string) bool // return true if string should be rejected
|
||||
}{
|
||||
{"", func(s string) bool { return true }}, // empty
|
||||
{"not-a-uuid", func(s string) bool { return true }}, // plain string
|
||||
{"../../etc/passwd", func(s string) bool { return true }}, // path traversal attempt
|
||||
{"550e8400-e29b-41d4-a716", func(s string) bool { return true }}, // too short
|
||||
{"550e8400e29b41d4a716446655440000", func(s string) bool { return true }}, // no dashes
|
||||
{"550e8400-e29b-41d4-a716-4466554400001", func(s string) bool { return true }}, // too long
|
||||
{"550e8400-e29b-41d4-a716-44665544000g", func(s string) bool { return true }}, // invalid char g
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := validateWorkspaceID(tc.id)
|
||||
if err == nil {
|
||||
t.Errorf("validateWorkspaceID(%q) expected error, got nil", tc.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateWorkspaceDir tests — blocks absolute paths, traversal, system dirs.
|
||||
|
||||
func TestValidateWorkspaceDir_Valid(t *testing.T) {
|
||||
valid := []string{
|
||||
"/home/ubuntu/workspace-data",
|
||||
"/opt/molecule/workspaces",
|
||||
"/var/data/molecule",
|
||||
"/Users/me/.molecule/workspaces",
|
||||
}
|
||||
for _, dir := range valid {
|
||||
err := validateWorkspaceDir(dir)
|
||||
if err != nil {
|
||||
t.Errorf("validateWorkspaceDir(%q) returned error: %v", dir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_NotAbsolute(t *testing.T) {
|
||||
rel := []string{
|
||||
"relative/path",
|
||||
"./local/workspace",
|
||||
"../escaped",
|
||||
"~/workspaces/my-ws",
|
||||
}
|
||||
for _, dir := range rel {
|
||||
err := validateWorkspaceDir(dir)
|
||||
if err == nil {
|
||||
t.Errorf("validateWorkspaceDir(%q) expected error for relative path, got nil", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_Traversal(t *testing.T) {
|
||||
// These are all absolute paths but contain ".."
|
||||
evil := []string{
|
||||
"/home/ubuntu/../../../etc/passwd",
|
||||
"/opt/molecule/../../bin/sh",
|
||||
"/data/../data/../data/../etc/shadow",
|
||||
}
|
||||
for _, dir := range evil {
|
||||
err := validateWorkspaceDir(dir)
|
||||
if err == nil {
|
||||
t.Errorf("validateWorkspaceDir(%q) expected error for traversal, got nil", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_SystemPaths(t *testing.T) {
|
||||
systemPaths := []string{
|
||||
"/etc",
|
||||
"/var",
|
||||
"/proc",
|
||||
"/sys",
|
||||
"/dev",
|
||||
"/boot",
|
||||
"/sbin",
|
||||
"/bin",
|
||||
"/lib",
|
||||
"/usr",
|
||||
"/etc/some-file",
|
||||
"/var/log",
|
||||
"/usr/local/bin",
|
||||
}
|
||||
for _, dir := range systemPaths {
|
||||
err := validateWorkspaceDir(dir)
|
||||
if err == nil {
|
||||
t.Errorf("validateWorkspaceDir(%q) expected error for system path, got nil", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateWorkspaceFields tests — length limits + YAML-injection prevention.
|
||||
|
||||
func TestValidateWorkspaceFields_Valid(t *testing.T) {
|
||||
err := validateWorkspaceFields(
|
||||
"My Workspace",
|
||||
"Backend Engineer",
|
||||
"claude-3-5-sonnet",
|
||||
"claude_code",
|
||||
)
|
||||
if err != nil {
|
||||
t.Errorf("validateWorkspaceFields with valid inputs returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_NameTooLong(t *testing.T) {
|
||||
long := make([]byte, 256)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
err := validateWorkspaceFields(string(long), "role", "model", "runtime")
|
||||
if err == nil {
|
||||
t.Error("validateWorkspaceFields expected error for name > 255 chars, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_RoleTooLong(t *testing.T) {
|
||||
long := make([]byte, 1001)
|
||||
for i := range long {
|
||||
long[i] = 'x'
|
||||
}
|
||||
err := validateWorkspaceFields("name", string(long), "model", "runtime")
|
||||
if err == nil {
|
||||
t.Error("validateWorkspaceFields expected error for role > 1000 chars, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_ModelTooLong(t *testing.T) {
|
||||
long := make([]byte, 101)
|
||||
for i := range long {
|
||||
long[i] = 'm'
|
||||
}
|
||||
err := validateWorkspaceFields("name", "role", string(long), "runtime")
|
||||
if err == nil {
|
||||
t.Error("validateWorkspaceFields expected error for model > 100 chars, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_RuntimeTooLong(t *testing.T) {
|
||||
long := make([]byte, 101)
|
||||
for i := range long {
|
||||
long[i] = 'r'
|
||||
}
|
||||
err := validateWorkspaceFields("name", "role", "model", string(long))
|
||||
if err == nil {
|
||||
t.Error("validateWorkspaceFields expected error for runtime > 100 chars, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_Newline(t *testing.T) {
|
||||
cases := []struct {
|
||||
label string
|
||||
field string
|
||||
}{
|
||||
{"name with \\n", "name\nwith\nnewline"},
|
||||
{"name with \\r", "name\rwith\rcarriage"},
|
||||
{"role with \\n", "role\nhas\nnewline"},
|
||||
{"role with \\r", "role\rhas\rcarriage"},
|
||||
{"model with \\n", "model\nhas\nnewline"},
|
||||
{"runtime with \\n", "runtime\nhas\nnewline"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := validateWorkspaceFields(tc.field, "role", "model", "runtime")
|
||||
if err == nil {
|
||||
t.Errorf("validateWorkspaceFields(%s=%q) expected error for newline, got nil", tc.label, tc.field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_YAMLSpecialChars(t *testing.T) {
|
||||
// yamlSpecialChars = "{}[]|>*&!"
|
||||
bad := []string{
|
||||
"name{with}brace",
|
||||
"name[with]bracket",
|
||||
"name|with|pipe",
|
||||
"name*with*asterisk",
|
||||
"name>with>greater",
|
||||
"name&with&ersand",
|
||||
"name!with!bang",
|
||||
"role:role:colon",
|
||||
// Combinations
|
||||
"bad{[name]}here",
|
||||
"nested|*&>!|",
|
||||
}
|
||||
for _, name := range bad {
|
||||
err := validateWorkspaceFields(name, "role", "model", "runtime")
|
||||
if err == nil {
|
||||
t.Errorf("validateWorkspaceFields(name=%q) expected error for YAML special chars, got nil", name)
|
||||
}
|
||||
}
|
||||
for _, role := range bad {
|
||||
err := validateWorkspaceFields("name", role, "model", "runtime")
|
||||
if err == nil {
|
||||
t.Errorf("validateWorkspaceFields(role=%q) expected error for YAML special chars, got nil", role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_SafePunctuation(t *testing.T) {
|
||||
// These characters should NOT be rejected (hyphen, underscore, dot, space, comma, paren, apostrophe)
|
||||
safe := []string{
|
||||
"My Workspace-v2",
|
||||
"Backend_Engineer",
|
||||
"DevOps (Senior)",
|
||||
"Product, Manager",
|
||||
"Role With Spaces",
|
||||
"O'Brien",
|
||||
}
|
||||
for _, name := range safe {
|
||||
err := validateWorkspaceFields(name, "role", "model", "runtime")
|
||||
if err != nil {
|
||||
t.Errorf("validateWorkspaceFields(name=%q) unexpected error: %v", name, err)
|
||||
}
|
||||
}
|
||||
for _, role := range safe {
|
||||
err := validateWorkspaceFields("name", role, "model", "runtime")
|
||||
if err != nil {
|
||||
t.Errorf("validateWorkspaceFields(role=%q) unexpected error: %v", role, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_EmptyFields(t *testing.T) {
|
||||
// Empty strings should not error (fields are optional in some call paths)
|
||||
err := validateWorkspaceFields("", "", "", "")
|
||||
if err != nil {
|
||||
t.Errorf("validateWorkspaceFields with all empty strings returned error: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user