Step 27: Phase 4 polish.
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>
This commit is contained in:
@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
|
||||
|
||||
## Current Status
|
||||
|
||||
**Phase:** Phase 4 in progress. Steps 21–26 complete, ready for Step 27.
|
||||
**Phase:** Phase 4 complete. All 7 steps done (21–27).
|
||||
|
||||
**Last updated:** 2026-03-24
|
||||
|
||||
@@ -42,7 +42,7 @@ ARCHITECTURE.md for design details.
|
||||
|
||||
## Up Next
|
||||
|
||||
Step 27: Phase 4 Polish + Release.
|
||||
Phase 5: Multi-Repo + Per-Machine Inclusion (to be planned).
|
||||
|
||||
## Known Issues / Decisions Deferred
|
||||
|
||||
@@ -90,3 +90,4 @@ Step 27: Phase 4 Polish + Release.
|
||||
| 2026-03-24 | 24 | DEK rotation: RotateDEK re-encrypts all blobs, re-wraps all slots, CLI command, 4 tests. |
|
||||
| 2026-03-24 | 25 | Real FIDO2: go-libfido2 bindings, build tag gating, CLI wiring, nix sgard-fido2 package. |
|
||||
| 2026-03-24 | 26 | Test cleanup: tightened lint, 3 combo tests (encrypted+locked, dir-only+locked, toggle), stale doc fixes. |
|
||||
| 2026-03-24 | 27 | Phase 4 polish: e2e test (TLS+encryption+locked+push/pull), final doc review. Phase 4 complete. |
|
||||
|
||||
@@ -270,11 +270,11 @@ Depends on Steps 17, 18.
|
||||
|
||||
### Step 27: Phase 4 Polish + Release
|
||||
|
||||
- [ ] Update all docs (ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md)
|
||||
- [ ] Update flake.nix vendorHash if deps changed
|
||||
- [ ] Update .goreleaser.yaml if needed
|
||||
- [ ] E2e test covering TLS + encryption + locked files
|
||||
- [ ] Verify: all tests pass, lint clean, both binaries compile
|
||||
- [x] Update all docs (ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md)
|
||||
- [x] Update flake.nix vendorHash (done in Step 25)
|
||||
- [x] .goreleaser.yaml — no changes needed (CGO_ENABLED=0 is correct for release binaries)
|
||||
- [x] E2e test: integration/phase4_test.go covering TLS + encryption + locked files + push/pull
|
||||
- [x] Verify: all tests pass, lint clean, both binaries compile
|
||||
|
||||
## Phase 5: Multi-Repo + Per-Machine Inclusion
|
||||
|
||||
|
||||
265
integration/phase4_test.go
Normal file
265
integration/phase4_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user