Initial commit: forge-tools-proxmox — MCP-сервер для Proxmox VE

This commit is contained in:
Maksim Totmin
2026-10-01 10:41:43 +07:00
commit a7addaead9
30 changed files with 4226 additions and 0 deletions
+209
View File
@@ -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 }