From 49074d0087c27cdfd50d6a42826fea0b8ccaaa28 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:49:25 +0100 Subject: [PATCH] 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 --- .../api/config_import_tenant_runtime_test.go | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 internal/api/config_import_tenant_runtime_test.go diff --git a/internal/api/config_import_tenant_runtime_test.go b/internal/api/config_import_tenant_runtime_test.go new file mode 100644 index 0000000000..087c99e484 --- /dev/null +++ b/internal/api/config_import_tenant_runtime_test.go @@ -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 +}