Initial commit: forge-tools-proxmox — MCP-сервер для Proxmox VE
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
package pve
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// include: unit-тесты политики/конфига домена (без сети и без MCP).
|
||||
|
||||
func TestParseConfigDefaults(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"hosts": [{"alias":"pve","url":"https://10.0.0.5:8006/api2/json","token_id":"u@pve!mcp","token_secret":"S"}]
|
||||
}`)
|
||||
cfg, err := ParseConfig(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfig: %v", err)
|
||||
}
|
||||
if !cfg.IsReadOnly() {
|
||||
t.Error("default ReadOnly should be true")
|
||||
}
|
||||
if cfg.Default != "pve" {
|
||||
t.Errorf("default host = %q, want pve", cfg.Default)
|
||||
}
|
||||
if !cfg.HostExists("pve") {
|
||||
t.Error("pve should exist")
|
||||
}
|
||||
if cfg.MultiHost() {
|
||||
t.Error("single host should not be multi")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigNoHostsFails(t *testing.T) {
|
||||
if _, err := ParseConfig([]byte(`{}`)); err == nil {
|
||||
t.Fatal("expected error for config without hosts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigMissingTokenFails(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"hosts":[{"alias":"pve","url":"https://10.0.0.5:8006/api2/json"}]
|
||||
}`)
|
||||
if _, err := ParseConfig(raw); err == nil {
|
||||
t.Fatal("expected fail-closed on missing token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigUnexpandedVarFails(t *testing.T) {
|
||||
// ${VAR} отсутствует в окружении => token_secret пустой => fail-closed.
|
||||
raw := []byte(`{
|
||||
"hosts":[{"alias":"pve","url":"https://10.0.0.5:8006/api2/json","token_id":"m","token_secret":"${DEFINITELY_MISSING_VAR}"}]
|
||||
}`)
|
||||
if _, err := ParseConfig(raw); err == nil {
|
||||
t.Fatal("expected fail-closed on unexpanded secret var")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigDuplicateAliasFails(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"hosts":[
|
||||
{"alias":"pve","url":"https://10.0.0.1:8006/api2/json","token_id":"a","token_secret":"s"},
|
||||
{"alias":"pve","url":"https://10.0.0.2:8006/api2/json","token_id":"b","token_secret":"t"}
|
||||
]}`)
|
||||
if _, err := ParseConfig(raw); err == nil {
|
||||
t.Fatal("expected fail on duplicate alias")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigDuplicateURLFails(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"hosts":[
|
||||
{"alias":"a","url":"https://10.0.0.1:8006/api2/json","token_id":"a","token_secret":"s"},
|
||||
{"alias":"b","url":"https://10.0.0.1:8006/api2/json","token_id":"b","token_secret":"t"}
|
||||
]}`)
|
||||
if _, err := ParseConfig(raw); err == nil {
|
||||
t.Fatal("expected fail on duplicate url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigMultiHostGlobalAllowlistFails(t *testing.T) {
|
||||
// Мульти-гипервизор + глобальный allowlist = коллизия VMID → fail-closed.
|
||||
raw := []byte(`{
|
||||
"hosts":[
|
||||
{"alias":"a","url":"https://10.0.0.1:8006/api2/json","token_id":"a","token_secret":"s"},
|
||||
{"alias":"b","url":"https://10.0.0.2:8006/api2/json","token_id":"b","token_secret":"t"}
|
||||
],
|
||||
"allowlist":{"vmids":[100]}}`)
|
||||
if _, err := ParseConfig(raw); err == nil {
|
||||
t.Fatal("expected fail on global allowlist in multi-host config")
|
||||
}
|
||||
}
|
||||
|
||||
// Ключевой тест коллизии VMID: один и тот же vmid на разных хостах
|
||||
// обязан давать РАЗНЫЙ вердикт по авторизации.
|
||||
func TestWriteAllowed_NoCrossHostLeak(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Hosts: []HostConfig{
|
||||
{Alias: "a", URL: "https://10.0.0.1:8006/api2/json", TokenID: "u", TokenSecret: "s", AllowVMIDs: []int{500}},
|
||||
{Alias: "b", URL: "https://10.0.0.2:8006/api2/json", TokenID: "u", TokenSecret: "s", AllowVMIDs: []int{200}},
|
||||
},
|
||||
ReadOnly: boolPtr(false),
|
||||
}
|
||||
if !cfg.WriteAllowed("a", 500) {
|
||||
t.Error("host a vmid 500 should be allowed")
|
||||
}
|
||||
if cfg.WriteAllowed("b", 500) {
|
||||
t.Error("host b vmid 500 must be DENIED (not in its allowlist) — no cross-host leak")
|
||||
}
|
||||
if !cfg.WriteAllowed("b", 200) {
|
||||
t.Error("host b vmid 200 should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAllowedFailClosedEmpty(t *testing.T) {
|
||||
cfg := &Config{Hosts: []HostConfig{{Alias: "a", URL: "https://10.0.0.1:8006/api2/json", TokenID: "u", TokenSecret: "s"}}, ReadOnly: boolPtr(false)}
|
||||
if cfg.WriteAllowed("a", 500) {
|
||||
t.Error("empty allow_vmids must fail-closed (deny writes)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAllowedReadOnlyDenies(t *testing.T) {
|
||||
cfg := &Config{Hosts: []HostConfig{{Alias: "a", URL: "https://10.0.0.1:8006/api2/json", TokenID: "u", TokenSecret: "s", AllowVMIDs: []int{500}}}, ReadOnly: boolPtr(true)}
|
||||
if cfg.WriteAllowed("a", 500) {
|
||||
t.Error("read_only must deny writes even if allowlist set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAllowedGlobalFallbackSingleHost(t *testing.T) {
|
||||
// Одиночный гипервизор: глобальный allowlist — допустимый фолбэк.
|
||||
cfg := &Config{
|
||||
Hosts: []HostConfig{{Alias: "a", URL: "https://10.0.0.1:8006/api2/json", TokenID: "u", TokenSecret: "s"}},
|
||||
Allowlist: Allowlist{VMIDs: []int{500}},
|
||||
ReadOnly: boolPtr(false),
|
||||
}
|
||||
if !cfg.WriteAllowed("a", 500) {
|
||||
t.Error("global allowlist should fall back for single host")
|
||||
}
|
||||
if cfg.WriteAllowed("a", 100) {
|
||||
t.Error("vmid 100 should be denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeWriteAllowed(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Hosts: []HostConfig{{Alias: "a", URL: "https://10.0.0.1:8006/api2/json", TokenID: "u", TokenSecret: "s", AllowNodes: []string{"pve"}}},
|
||||
ReadOnly: boolPtr(false),
|
||||
}
|
||||
if !cfg.NodeWriteAllowed("a", "pve") {
|
||||
t.Error("node pve should be allowed")
|
||||
}
|
||||
if cfg.NodeWriteAllowed("a", "other") {
|
||||
t.Error("node other should be denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenyConfigKeys(t *testing.T) {
|
||||
cfg := &Config{denyConfigKeys: DefaultDenyConfigKeys}
|
||||
for _, k := range []string{"delete", "revert", "hotplug"} {
|
||||
if !cfg.DenyConfigKey(k) {
|
||||
t.Errorf("key %q should be denied", k)
|
||||
}
|
||||
}
|
||||
if cfg.DenyConfigKey("name") {
|
||||
t.Error("name should NOT be denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidHTTPURL(t *testing.T) {
|
||||
valid := []string{"https://pve.local:8006/api2/json", "http://10.0.0.1:8006/api2/json"}
|
||||
for _, u := range valid {
|
||||
if !isValidHTTPURL(u) {
|
||||
t.Errorf("expected valid: %s", u)
|
||||
}
|
||||
}
|
||||
invalid := []string{"", "ftp://x", "https://", "javascript:alert(1)"}
|
||||
for _, u := range invalid {
|
||||
if isValidHTTPURL(u) {
|
||||
t.Errorf("expected invalid: %s", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIdentifier(t *testing.T) {
|
||||
ok := []string{"pve", "pve1", "snap-name", "104", "local-lvm", "scsi0"}
|
||||
for _, s := range ok {
|
||||
if err := ValidateIdentifier(s); err != nil {
|
||||
t.Errorf("expected valid %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
bad := []string{"", "a/b", "..", "a b", "a;rm", "a\\b", "a\x00b", "a:b", "a@b"}
|
||||
for _, s := range bad {
|
||||
if err := ValidateIdentifier(s); err == nil {
|
||||
t.Errorf("expected invalid %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUPID(t *testing.T) {
|
||||
ok := "UPID:pve:00000000:root@pam!mcp:1:2:3:qemu:100:abc"
|
||||
if err := ValidateUPID(ok); err != nil {
|
||||
t.Errorf("expected valid upid %q: %v", ok, err)
|
||||
}
|
||||
bad := []string{"", "a/b", "..", "a\\b", "a\x00b"}
|
||||
for _, s := range bad {
|
||||
if err := ValidateUPID(s); err == nil {
|
||||
t.Errorf("expected invalid upid %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
Reference in New Issue
Block a user