mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
214 lines
5.8 KiB
Go
214 lines
5.8 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"golang.org/x/crypto/pbkdf2"
|
|
)
|
|
|
|
func TestHandleImportConfigAcceptsLegacyVersion40Bundle(t *testing.T) {
|
|
targetDir := t.TempDir()
|
|
t.Setenv("PULSE_DATA_DIR", targetDir)
|
|
|
|
cfg := &config.Config{
|
|
DataPath: targetDir,
|
|
ConfigPath: targetDir,
|
|
}
|
|
handler := newTestConfigHandlers(t, cfg)
|
|
handler.reloadFunc = func() error { return nil }
|
|
|
|
// Build source export payload with distinct node data.
|
|
sourceDir := t.TempDir()
|
|
sourcePersistence := config.NewConfigPersistence(sourceDir)
|
|
sourceNodes := []config.PVEInstance{
|
|
{
|
|
Name: "pve-legacy-source",
|
|
Host: "https://legacy-source.example:8006",
|
|
User: "root@pam",
|
|
Password: "legacy-secret",
|
|
},
|
|
}
|
|
if err := sourcePersistence.SaveNodesConfig(sourceNodes, nil, nil); err != nil {
|
|
t.Fatalf("failed to save source nodes: %v", err)
|
|
}
|
|
if err := sourcePersistence.SaveSystemSettings(*config.DefaultSystemSettings()); err != nil {
|
|
t.Fatalf("failed to save source system settings: %v", err)
|
|
}
|
|
|
|
passphrase := "legacy-import-passphrase"
|
|
exported, err := sourcePersistence.ExportConfig(passphrase)
|
|
if err != nil {
|
|
t.Fatalf("failed to export source config: %v", err)
|
|
}
|
|
|
|
legacyPayload := mustRewriteExportVersion(t, exported, passphrase, "4.0", true)
|
|
|
|
// Seed target with baseline values and API tokens to verify 4.0 token preservation.
|
|
targetPersistence := config.NewConfigPersistence(targetDir)
|
|
handler.defaultPersistence = targetPersistence
|
|
if err := targetPersistence.SaveNodesConfig([]config.PVEInstance{
|
|
{
|
|
Name: "pve-baseline",
|
|
Host: "https://baseline.example:8006",
|
|
User: "root@pam",
|
|
},
|
|
}, nil, nil); err != nil {
|
|
t.Fatalf("failed to save baseline nodes: %v", err)
|
|
}
|
|
if err := targetPersistence.SaveSystemSettings(*config.DefaultSystemSettings()); err != nil {
|
|
t.Fatalf("failed to save baseline system settings: %v", err)
|
|
}
|
|
|
|
baselineTokens := []config.APITokenRecord{
|
|
{
|
|
ID: "token-baseline",
|
|
Name: "baseline",
|
|
Hash: "hash-baseline",
|
|
Prefix: "hashb",
|
|
Suffix: "line",
|
|
CreatedAt: time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC),
|
|
Scopes: []string{config.ScopeSettingsRead},
|
|
},
|
|
}
|
|
if err := targetPersistence.SaveAPITokens(baselineTokens); err != nil {
|
|
t.Fatalf("failed to save baseline api tokens: %v", err)
|
|
}
|
|
|
|
// Required by handleImportConfig -> config.Load().
|
|
if err := os.WriteFile(filepath.Join(targetDir, "config.json"), []byte("{}"), 0644); err != nil {
|
|
t.Fatalf("failed to write config.json: %v", err)
|
|
}
|
|
|
|
body, _ := json.Marshal(ImportConfigRequest{
|
|
Data: legacyPayload,
|
|
Passphrase: passphrase,
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.HandleImportConfig(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
nodesAfter, err := targetPersistence.LoadNodesConfig()
|
|
if err != nil {
|
|
t.Fatalf("failed to load nodes after import: %v", err)
|
|
}
|
|
if len(nodesAfter.PVEInstances) != 1 || nodesAfter.PVEInstances[0].Name != "pve-legacy-source" {
|
|
t.Fatalf("expected imported legacy nodes, got %+v", nodesAfter.PVEInstances)
|
|
}
|
|
|
|
tokensAfter, err := targetPersistence.LoadAPITokens()
|
|
if err != nil {
|
|
t.Fatalf("failed to load api tokens after import: %v", err)
|
|
}
|
|
if len(tokensAfter) != 1 || tokensAfter[0].ID != "token-baseline" {
|
|
t.Fatalf("expected baseline api tokens to remain for 4.0 import, got %+v", tokensAfter)
|
|
}
|
|
}
|
|
|
|
func mustRewriteExportVersion(t *testing.T, payload, passphrase, version string, stripAPITokens bool) string {
|
|
t.Helper()
|
|
|
|
raw, err := base64.StdEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
t.Fatalf("base64 decode failed: %v", err)
|
|
}
|
|
|
|
plaintext, err := decryptExportCompatPayload(raw, passphrase)
|
|
if err != nil {
|
|
t.Fatalf("decrypt export payload failed: %v", err)
|
|
}
|
|
|
|
var data config.ExportData
|
|
if err := json.Unmarshal(plaintext, &data); err != nil {
|
|
t.Fatalf("unmarshal export payload failed: %v", err)
|
|
}
|
|
|
|
data.Version = version
|
|
if stripAPITokens {
|
|
data.APITokens = nil
|
|
}
|
|
|
|
updatedPlaintext, err := json.Marshal(data)
|
|
if err != nil {
|
|
t.Fatalf("marshal updated export payload failed: %v", err)
|
|
}
|
|
|
|
updatedCiphertext, err := encryptExportCompatPayload(updatedPlaintext, passphrase)
|
|
if err != nil {
|
|
t.Fatalf("encrypt updated export payload failed: %v", err)
|
|
}
|
|
|
|
return base64.StdEncoding.EncodeToString(updatedCiphertext)
|
|
}
|
|
|
|
func encryptExportCompatPayload(plaintext []byte, passphrase string) ([]byte, error) {
|
|
salt := make([]byte, 32)
|
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
key := pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New)
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
|
return append(salt, ciphertext...), nil
|
|
}
|
|
|
|
func decryptExportCompatPayload(ciphertext []byte, passphrase string) ([]byte, error) {
|
|
if len(ciphertext) < 32 {
|
|
return nil, io.ErrUnexpectedEOF
|
|
}
|
|
|
|
salt := ciphertext[:32]
|
|
cipherbody := ciphertext[32:]
|
|
|
|
key := pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New)
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(cipherbody) < gcm.NonceSize() {
|
|
return nil, io.ErrUnexpectedEOF
|
|
}
|
|
|
|
nonce := cipherbody[:gcm.NonceSize()]
|
|
payload := cipherbody[gcm.NonceSize():]
|
|
return gcm.Open(nil, nonce, payload, nil)
|
|
}
|