Step 16: Polish — docs, flake, goreleaser, e2e test.
Phase 2 complete. ARCHITECTURE.md: full rewrite covering gRPC protocol, SSH auth, updated package structure, all Garden methods, design decisions. README.md: add remote sync section, mirror/prune commands, sgardd usage. CLAUDE.md: add gRPC/proto/x-crypto deps, server/client/sgardpb packages. flake.nix: build both sgard + sgardd, updated vendorHash. goreleaser: add sgardd build target. E2e test: full push/pull cycle with SSH auth between two clients. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
155
client/e2e_test.go
Normal file
155
client/e2e_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kisom/sgard/garden"
|
||||
"github.com/kisom/sgard/server"
|
||||
"github.com/kisom/sgard/sgardpb"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
)
|
||||
|
||||
// TestE2EPushPullCycle tests the full lifecycle:
|
||||
// init two repos → add files to client → checkpoint → push → pull into fresh repo → verify
|
||||
func TestE2EPushPullCycle(t *testing.T) {
|
||||
// Generate auth key.
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generating key: %v", err)
|
||||
}
|
||||
signer, err := ssh.NewSignerFromKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("creating signer: %v", err)
|
||||
}
|
||||
|
||||
// Set up server.
|
||||
serverDir := t.TempDir()
|
||||
serverGarden, err := garden.Init(serverDir)
|
||||
if err != nil {
|
||||
t.Fatalf("init server: %v", err)
|
||||
}
|
||||
|
||||
auth := server.NewAuthInterceptorFromKeys([]ssh.PublicKey{signer.PublicKey()})
|
||||
lis := bufconn.Listen(bufSize)
|
||||
srv := grpc.NewServer(
|
||||
grpc.UnaryInterceptor(auth.UnaryInterceptor()),
|
||||
grpc.StreamInterceptor(auth.StreamInterceptor()),
|
||||
)
|
||||
sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden))
|
||||
t.Cleanup(func() { srv.Stop() })
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
|
||||
dial := func(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
creds := NewSSHCredentials(signer)
|
||||
conn, err := grpc.NewClient("passthrough:///bufconn",
|
||||
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||
return lis.Dial()
|
||||
}),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithPerRPCCredentials(creds),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return New(conn)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// --- Client A: add files and push ---
|
||||
clientADir := t.TempDir()
|
||||
clientA, err := garden.Init(clientADir)
|
||||
if err != nil {
|
||||
t.Fatalf("init client A: %v", err)
|
||||
}
|
||||
|
||||
// Create test dotfiles.
|
||||
root := t.TempDir()
|
||||
bashrc := filepath.Join(root, "bashrc")
|
||||
sshConfig := filepath.Join(root, "ssh_config")
|
||||
if err := os.WriteFile(bashrc, []byte("export PS1='$ '\n"), 0o644); err != nil {
|
||||
t.Fatalf("writing bashrc: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(sshConfig, []byte("Host *\n AddKeysToAgent yes\n"), 0o600); err != nil {
|
||||
t.Fatalf("writing ssh_config: %v", err)
|
||||
}
|
||||
|
||||
if err := clientA.Add([]string{bashrc, sshConfig}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if err := clientA.Checkpoint("from machine A"); err != nil {
|
||||
t.Fatalf("Checkpoint: %v", err)
|
||||
}
|
||||
|
||||
c := dial(t)
|
||||
pushed, err := c.Push(ctx, clientA)
|
||||
if err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
if pushed != 2 {
|
||||
t.Errorf("pushed %d blobs, want 2", pushed)
|
||||
}
|
||||
|
||||
// --- Client B: pull from server ---
|
||||
clientBDir := t.TempDir()
|
||||
clientB, err := garden.Init(clientBDir)
|
||||
if err != nil {
|
||||
t.Fatalf("init client B: %v", err)
|
||||
}
|
||||
// Backdate so server is newer.
|
||||
bm := clientB.GetManifest()
|
||||
bm.Updated = bm.Updated.Add(-2 * time.Hour)
|
||||
if err := clientB.ReplaceManifest(bm); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
|
||||
c2 := dial(t)
|
||||
pulled, err := c2.Pull(ctx, clientB)
|
||||
if err != nil {
|
||||
t.Fatalf("Pull: %v", err)
|
||||
}
|
||||
if pulled != 2 {
|
||||
t.Errorf("pulled %d blobs, want 2", pulled)
|
||||
}
|
||||
|
||||
// Verify client B has the same manifest and blobs as client A.
|
||||
manifestA := clientA.GetManifest()
|
||||
manifestB := clientB.GetManifest()
|
||||
|
||||
if len(manifestB.Files) != len(manifestA.Files) {
|
||||
t.Fatalf("client B has %d entries, want %d", len(manifestB.Files), len(manifestA.Files))
|
||||
}
|
||||
|
||||
for _, e := range manifestB.Files {
|
||||
if e.Type == "file" {
|
||||
dataA, err := clientA.ReadBlob(e.Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("read blob from A: %v", err)
|
||||
}
|
||||
dataB, err := clientB.ReadBlob(e.Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("read blob from B: %v", err)
|
||||
}
|
||||
if string(dataA) != string(dataB) {
|
||||
t.Errorf("blob %s content mismatch between A and B", e.Hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify manifest message survived.
|
||||
if manifestB.Message != "from machine A" {
|
||||
t.Errorf("message = %q, want 'from machine A'", manifestB.Message)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user