E2e integration test covering TLS + encryption + locked files in a push/pull cycle (integration/phase4_test.go). Final doc updates. Phase 4 complete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
266 lines
7.2 KiB
Go
266 lines
7.2 KiB
Go
package integration
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"math/big"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kisom/sgard/client"
|
|
"github.com/kisom/sgard/garden"
|
|
"github.com/kisom/sgard/server"
|
|
"github.com/kisom/sgard/sgardpb"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials"
|
|
)
|
|
|
|
func generateSelfSignedCert(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
|
t.Helper()
|
|
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generating key: %v", err)
|
|
}
|
|
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "sgard-e2e"},
|
|
NotBefore: time.Now().Add(-time.Minute),
|
|
NotAfter: time.Now().Add(time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
|
|
DNSNames: []string{"localhost"},
|
|
}
|
|
|
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatalf("creating certificate: %v", err)
|
|
}
|
|
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
|
keyDER, err := x509.MarshalECPrivateKey(key)
|
|
if err != nil {
|
|
t.Fatalf("marshaling key: %v", err)
|
|
}
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
|
|
|
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
|
if err != nil {
|
|
t.Fatalf("loading key pair: %v", err)
|
|
}
|
|
|
|
pool := x509.NewCertPool()
|
|
pool.AppendCertsFromPEM(certPEM)
|
|
|
|
return cert, pool
|
|
}
|
|
|
|
// TestE2E_Phase4 exercises TLS + encryption + locked files in a push/pull cycle.
|
|
func TestE2E_Phase4(t *testing.T) {
|
|
// --- Setup TLS server ---
|
|
cert, caPool := generateSelfSignedCert(t)
|
|
|
|
serverDir := t.TempDir()
|
|
serverGarden, err := garden.Init(serverDir)
|
|
if err != nil {
|
|
t.Fatalf("init server garden: %v", err)
|
|
}
|
|
|
|
serverCreds := credentials.NewTLS(&tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
MinVersion: tls.VersionTLS12,
|
|
})
|
|
srv := grpc.NewServer(grpc.Creds(serverCreds))
|
|
sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden))
|
|
t.Cleanup(func() { srv.Stop() })
|
|
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
go func() { _ = srv.Serve(lis) }()
|
|
|
|
clientCreds := credentials.NewTLS(&tls.Config{
|
|
RootCAs: caPool,
|
|
MinVersion: tls.VersionTLS12,
|
|
})
|
|
|
|
// --- Build source garden with encryption + locked files ---
|
|
srcRoot := t.TempDir()
|
|
srcRepoDir := filepath.Join(srcRoot, "repo")
|
|
srcGarden, err := garden.Init(srcRepoDir)
|
|
if err != nil {
|
|
t.Fatalf("init source garden: %v", err)
|
|
}
|
|
|
|
if err := srcGarden.EncryptInit("test-passphrase"); err != nil {
|
|
t.Fatalf("EncryptInit: %v", err)
|
|
}
|
|
|
|
plainFile := filepath.Join(srcRoot, "plain")
|
|
secretFile := filepath.Join(srcRoot, "secret")
|
|
lockedFile := filepath.Join(srcRoot, "locked")
|
|
encLockedFile := filepath.Join(srcRoot, "enc-locked")
|
|
|
|
if err := os.WriteFile(plainFile, []byte("plain data"), 0o644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if err := os.WriteFile(secretFile, []byte("secret data"), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if err := os.WriteFile(lockedFile, []byte("locked data"), 0o644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if err := os.WriteFile(encLockedFile, []byte("enc+locked data"), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
if err := srcGarden.Add([]string{plainFile}); err != nil {
|
|
t.Fatalf("Add plain: %v", err)
|
|
}
|
|
if err := srcGarden.Add([]string{secretFile}, garden.AddOptions{Encrypt: true}); err != nil {
|
|
t.Fatalf("Add encrypted: %v", err)
|
|
}
|
|
if err := srcGarden.Add([]string{lockedFile}, garden.AddOptions{Lock: true}); err != nil {
|
|
t.Fatalf("Add locked: %v", err)
|
|
}
|
|
if err := srcGarden.Add([]string{encLockedFile}, garden.AddOptions{Encrypt: true, Lock: true}); err != nil {
|
|
t.Fatalf("Add encrypted+locked: %v", err)
|
|
}
|
|
|
|
// Bump timestamp so push wins.
|
|
srcManifest := srcGarden.GetManifest()
|
|
srcManifest.Updated = time.Now().UTC().Add(time.Hour)
|
|
if err := srcGarden.ReplaceManifest(srcManifest); err != nil {
|
|
t.Fatalf("ReplaceManifest: %v", err)
|
|
}
|
|
|
|
// --- Push over TLS ---
|
|
ctx := context.Background()
|
|
|
|
pushConn, err := grpc.NewClient(lis.Addr().String(),
|
|
grpc.WithTransportCredentials(clientCreds),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("dial for push: %v", err)
|
|
}
|
|
defer func() { _ = pushConn.Close() }()
|
|
|
|
pushClient := client.New(pushConn)
|
|
pushed, err := pushClient.Push(ctx, srcGarden)
|
|
if err != nil {
|
|
t.Fatalf("Push: %v", err)
|
|
}
|
|
if pushed < 2 {
|
|
t.Errorf("expected at least 2 blobs pushed, got %d", pushed)
|
|
}
|
|
|
|
// --- Pull to a fresh garden over TLS ---
|
|
dstRoot := t.TempDir()
|
|
dstRepoDir := filepath.Join(dstRoot, "repo")
|
|
dstGarden, err := garden.Init(dstRepoDir)
|
|
if err != nil {
|
|
t.Fatalf("init dest garden: %v", err)
|
|
}
|
|
|
|
pullConn, err := grpc.NewClient(lis.Addr().String(),
|
|
grpc.WithTransportCredentials(clientCreds),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("dial for pull: %v", err)
|
|
}
|
|
defer func() { _ = pullConn.Close() }()
|
|
|
|
pullClient := client.New(pullConn)
|
|
pulled, err := pullClient.Pull(ctx, dstGarden)
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
if pulled < 2 {
|
|
t.Errorf("expected at least 2 blobs pulled, got %d", pulled)
|
|
}
|
|
|
|
// --- Verify the pulled manifest ---
|
|
dstManifest := dstGarden.GetManifest()
|
|
if len(dstManifest.Files) != 4 {
|
|
t.Fatalf("expected 4 entries, got %d", len(dstManifest.Files))
|
|
}
|
|
|
|
type entryInfo struct {
|
|
encrypted bool
|
|
locked bool
|
|
}
|
|
entryMap := make(map[string]entryInfo)
|
|
for _, e := range dstManifest.Files {
|
|
entryMap[e.Path] = entryInfo{e.Encrypted, e.Locked}
|
|
}
|
|
|
|
// Verify flags survived round trip.
|
|
for path, info := range entryMap {
|
|
switch {
|
|
case path == toTilde(secretFile):
|
|
if !info.encrypted {
|
|
t.Errorf("%s should be encrypted", path)
|
|
}
|
|
case path == toTilde(lockedFile):
|
|
if !info.locked {
|
|
t.Errorf("%s should be locked", path)
|
|
}
|
|
case path == toTilde(encLockedFile):
|
|
if !info.encrypted || !info.locked {
|
|
t.Errorf("%s should be encrypted+locked", path)
|
|
}
|
|
case path == toTilde(plainFile):
|
|
if info.encrypted || info.locked {
|
|
t.Errorf("%s should be plain", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify encryption config survived.
|
|
if dstManifest.Encryption == nil {
|
|
t.Fatal("encryption config should survive push/pull")
|
|
}
|
|
if dstManifest.Encryption.Algorithm != "xchacha20-poly1305" {
|
|
t.Errorf("algorithm = %s, want xchacha20-poly1305", dstManifest.Encryption.Algorithm)
|
|
}
|
|
if _, ok := dstManifest.Encryption.KekSlots["passphrase"]; !ok {
|
|
t.Error("passphrase slot should survive push/pull")
|
|
}
|
|
|
|
// Verify all blobs arrived.
|
|
for _, e := range dstManifest.Files {
|
|
if e.Hash != "" && !dstGarden.BlobExists(e.Hash) {
|
|
t.Errorf("blob missing for %s (hash %s)", e.Path, e.Hash)
|
|
}
|
|
}
|
|
|
|
// Unlock on dest and verify DEK works.
|
|
if err := dstGarden.UnlockDEK(func() (string, error) { return "test-passphrase", nil }); err != nil {
|
|
t.Fatalf("UnlockDEK on dest: %v", err)
|
|
}
|
|
}
|
|
|
|
func toTilde(path string) string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return path
|
|
}
|
|
rel, err := filepath.Rel(home, path)
|
|
if err != nil || len(rel) > 0 && rel[0] == '.' {
|
|
return path
|
|
}
|
|
return "~/" + rel
|
|
}
|