Reproduce tenant configuration import runtime contamination

Exercise a complete synthetic archive through the authorised router with distinct default and tenant runtimes, persisted credentials and consuming settings readback. Disabled nodes prevent monitored-workload traffic. The new control is expected to fail before the scoped runtime repair.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-25 01:49:25 +01:00
parent 1076c78ddd
commit 49074d0087
@@ -0,0 +1,176 @@
package api
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
)
// Exercise a complete valid archive through the consuming router, not only the
// authorization denial or persistence transaction. Every credential is synthetic
// and every node is disabled: this test must not contact a monitored workload.
func TestConfigImportAppliesOnlySelectedTenant(t *testing.T) {
SetMultiTenantEnabled(true)
t.Cleanup(func() { SetMultiTenantEnabled(false) })
t.Setenv("PULSE_DEV", "true")
t.Setenv("PULSE_HOSTED_MODE", "false")
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
mtp := config.NewMultiTenantPersistence(dataDir)
persistences := make(map[string]*config.ConfigPersistence)
for _, orgID := range []string{"default", "tenant-a", "tenant-b"} {
if err := mtp.SaveOrganization(&models.Organization{ID: orgID, DisplayName: orgID, OwnerUserID: "import-owner"}); err != nil {
t.Fatal(err)
}
p, err := mtp.GetPersistence(orgID)
if err != nil {
t.Fatal(err)
}
persistences[orgID] = p
seedConfigImportRuntime(t, p, orgID, orgID, 71)
}
cfg, err := config.LoadWithoutLoggingInit()
if err != nil {
t.Fatal(err)
}
// Local instance identity is outside the archive and must remain intact.
cfg.AuthUser, cfg.AuthPass = "instance-admin", "synthetic-instance-password-hash"
cfg.ProxyAuthSecret = "synthetic-instance-proxy-secret"
mtm := monitoring.NewMultiTenantMonitor(cfg, mtp, nil)
t.Cleanup(mtm.Stop)
monitors := make(map[string]*monitoring.Monitor)
for _, orgID := range []string{"default", "tenant-a", "tenant-b"} {
monitors[orgID], err = mtm.GetMonitor(orgID)
if err != nil {
t.Fatal(err)
}
}
reloads := 0
router := NewRouter(cfg, monitors["default"], mtm, nil, func() error {
reloads++
return nil
}, "test")
cleanupTestRouter(t, router)
beforeDefault := cfg.DeepCopy()
beforeOther := monitors["tenant-b"].GetConfig().DeepCopy()
filesBefore := configImportFileDigests(t, persistences["default"], persistences["tenant-b"])
source := config.NewConfigPersistence(t.TempDir())
seedConfigImportRuntime(t, source, "restored", "tenant-a", 137)
archive, err := source.ExportConfig("synthetic-import-passphrase")
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(ImportConfigRequest{Data: archive, Passphrase: "synthetic-import-passphrase"})
if err != nil {
t.Fatal(err)
}
const session = "synthetic-tenant-import-owner-session"
GetSessionStore().CreateSession(session, time.Hour, "browser", "127.0.0.1", "import-owner")
req := httptest.NewRequest(http.MethodPost, "/api/config/import", bytes.NewReader(body))
req.RemoteAddr = "127.0.0.1:1234"
req.Header.Set("X-Pulse-Org-ID", "tenant-a")
req.Header.Set("X-CSRF-Token", generateCSRFToken(session))
req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: session})
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("valid tenant import status = %d: %s", rec.Code, rec.Body.String())
}
if reloads != 0 {
t.Errorf("tenant import invoked instance-wide reload %d times", reloads)
}
after, err := mtm.GetMonitor("tenant-a")
if err != nil {
t.Fatal(err)
}
afterCfg := after.GetConfig()
if len(afterCfg.PVEInstances) != 1 || afterCfg.PVEInstances[0].Name != "restored" || afterCfg.PVEInstances[0].TokenValue != "synthetic-restored-node-secret" {
t.Error("selected tenant runtime did not apply only its imported nodes/credentials")
}
if afterCfg.PublicURL != "https://restored.invalid" || afterCfg.PBSPollingInterval != 137*time.Second {
t.Errorf("selected tenant runtime settings = URL %q, PBS %s; want imported values", afterCfg.PublicURL, afterCfg.PBSPollingInterval)
}
if afterCfg.DataPath != persistences["tenant-a"].GetConfigDir() || afterCfg.ConfigPath != persistences["tenant-a"].GetConfigDir() {
t.Error("selected tenant runtime adopted a non-target storage path")
}
if len(afterCfg.APITokens) != 1 || afterCfg.APITokens[0].ID != "restored-token" || afterCfg.APITokens[0].OrgID != "tenant-a" {
t.Error("selected tenant runtime did not load only its imported token inventory")
}
for _, orgID := range []string{"default", "tenant-b"} {
current, getErr := mtm.GetMonitor(orgID)
if getErr != nil || current != monitors[orgID] {
t.Errorf("non-target %s monitor replaced or unavailable", orgID)
}
}
if !reflect.DeepEqual(cfg, beforeDefault) || !reflect.DeepEqual(monitors["tenant-b"].GetConfig(), beforeOther) {
t.Error("non-target runtime configuration or credentials changed")
}
if !reflect.DeepEqual(configImportFileDigests(t, persistences["default"], persistences["tenant-b"]), filesBefore) {
t.Error("non-target persisted settings/credentials changed")
}
persistedNodes, err := persistences["tenant-a"].LoadNodesConfig()
if err != nil || len(persistedNodes.PVEInstances) != 1 || persistedNodes.PVEInstances[0].Name != "restored" {
t.Error("selected tenant archive was not persisted")
}
// These are the same producers the existing settings page consumes.
ctx := context.WithValue(context.Background(), apicontext.OrgIDContextKey, "tenant-a")
settingsRec := httptest.NewRecorder()
router.configHandlers.HandleGetSystemSettings(settingsRec, httptest.NewRequest(http.MethodGet, "/api/system/settings", nil).WithContext(ctx))
var settings config.SystemSettings
if err := json.Unmarshal(settingsRec.Body.Bytes(), &settings); err != nil {
t.Fatal(err)
}
if settings.PublicURL != "https://restored.invalid" || settings.PBSPollingInterval != 137 {
t.Error("consuming settings handler did not read back the imported tenant values")
}
}
func seedConfigImportRuntime(t *testing.T, p *config.ConfigPersistence, name, orgID string, interval int) {
t.Helper()
if err := p.SaveNodesConfig([]config.PVEInstance{{Name: name, Host: "https://" + name + ".invalid:8006", TokenName: "synthetic@pve!monitor", TokenValue: "synthetic-" + name + "-node-secret", VerifySSL: true, Disabled: true}}, nil, nil); err != nil {
t.Fatal(err)
}
settings := config.DefaultSystemSettings()
settings.PublicURL, settings.PBSPollingInterval = "https://"+name+".invalid", interval
if err := p.SaveSystemSettings(*settings); err != nil {
t.Fatal(err)
}
token := newTokenRecord(t, "synthetic-"+name+"-api-token.12345678", []string{config.ScopeSettingsRead, config.ScopeSettingsWrite}, nil)
token.ID, token.OrgID = name+"-token", orgID
if err := p.SaveAPITokens([]config.APITokenRecord{token}); err != nil {
t.Fatal(err)
}
}
func configImportFileDigests(t *testing.T, persistences ...*config.ConfigPersistence) map[string][32]byte {
t.Helper()
digests := make(map[string][32]byte)
for _, p := range persistences {
for _, name := range []string{"nodes.enc", "system.json", "api_tokens.json", "org.json", ".encryption.key"} {
path := filepath.Join(p.GetConfigDir(), name)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
continue
}
if err != nil {
t.Fatal(err)
}
digests[path] = sha256.Sum256(data)
}
}
return digests
}