diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 604566e..ff6e799 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,16 +2,8 @@ name: Docker on: push: - branches: - - 'main' tags: - 'v*' - pull_request: - branches: - - 'main' - paths: - - 'Dockerfile' - - '.dockerignore' env: REGISTRY: ghcr.io diff --git a/internal/fs/builder_test.go b/internal/fs/builder_test.go new file mode 100644 index 0000000..0ae7408 --- /dev/null +++ b/internal/fs/builder_test.go @@ -0,0 +1,82 @@ +package fs_test + +import ( + "testing" + + "github.com/walheimlab/walheim-go/internal/config" + "github.com/walheimlab/walheim-go/internal/fs" +) + +func TestFromContext_Local(t *testing.T) { + ctx := &config.Context{ + Name: "local-test", + DataDir: "/tmp/data", + } + + filesystem, dataDir, err := fs.FromContext(ctx) + if err != nil { + t.Fatalf("FromContext failed: %v", err) + } + + if dataDir != "/tmp/data" { + t.Errorf("expected dataDir '/tmp/data', got %q", dataDir) + } + + // Verify it's a LocalFS + if _, ok := filesystem.(*fs.LocalFS); !ok { + t.Errorf("expected *fs.LocalFS, got %T", filesystem) + } +} + +func TestFromContext_S3(t *testing.T) { + ctx := &config.Context{ + Name: "s3-test", + S3: &config.S3Config{ + Endpoint: "http://localhost:9000", + Region: "us-east-1", + Bucket: "mybucket", + Prefix: "myprefix", + AccessKeyID: "minio", + SecretAccessKey: "miniosecret", + }, + } + + filesystem, dataDir, err := fs.FromContext(ctx) + if err != nil { + t.Fatalf("FromContext failed: %v", err) + } + + if dataDir != "" { + t.Errorf("expected empty dataDir, got %q", dataDir) + } + + // Verify it's an S3FS + if _, ok := filesystem.(*fs.S3FS); !ok { + t.Errorf("expected *fs.S3FS, got %T", filesystem) + } +} + +func TestFromContext_S3NoCredentials(t *testing.T) { + ctx := &config.Context{ + Name: "s3-test-no-creds", + S3: &config.S3Config{ + Endpoint: "http://localhost:9000", + Region: "us-east-1", + Bucket: "mybucket", + Prefix: "myprefix", + }, + } + + filesystem, dataDir, err := fs.FromContext(ctx) + if err != nil { + t.Fatalf("FromContext failed: %v", err) + } + + if dataDir != "" { + t.Errorf("expected empty dataDir, got %q", dataDir) + } + + if _, ok := filesystem.(*fs.S3FS); !ok { + t.Errorf("expected *fs.S3FS, got %T", filesystem) + } +} diff --git a/internal/fs/local_test.go b/internal/fs/local_test.go index b637482..86bfa32 100644 --- a/internal/fs/local_test.go +++ b/internal/fs/local_test.go @@ -207,3 +207,49 @@ func TestLocalFS_ReadDir_empty(t *testing.T) { t.Errorf("expected empty, got %v", entries) } } + +func TestLocalFS_WriteFile_MkdirAllError(t *testing.T) { + lfs, dir := newLocalFS(t) + // Create a file + filePath := filepath.Join(dir, "somefile") + if err := lfs.WriteFile(filePath, []byte("data")); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + // Try to write to a path where "somefile" is a parent directory + badPath := filepath.Join(filePath, "child", "file.txt") + + err := lfs.WriteFile(badPath, []byte("data")) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestLocalFS_Exists_Error(t *testing.T) { + lfs := fs.NewLocalFS() + + _, err := lfs.Exists("foo\x00bar") + if err == nil { + t.Fatal("expected error for path with null byte, got nil") + } +} + +func TestLocalFS_IsDir_Error(t *testing.T) { + lfs := fs.NewLocalFS() + + _, err := lfs.IsDir("foo\x00bar") + if err == nil { + t.Fatal("expected error for path with null byte, got nil") + } +} + +func TestLocalFS_ReadDir_Error(t *testing.T) { + lfs, dir := newLocalFS(t) + // Try to read a non-existent directory + badPath := filepath.Join(dir, "nonexistent") + + _, err := lfs.ReadDir(badPath) + if err == nil { + t.Fatal("expected error reading nonexistent directory, got nil") + } +} diff --git a/internal/labels/labels_test.go b/internal/labels/labels_test.go new file mode 100644 index 0000000..020b496 --- /dev/null +++ b/internal/labels/labels_test.go @@ -0,0 +1,497 @@ +package labels_test + +import ( + "errors" + "testing" + + "github.com/walheimlab/walheim-go/internal/exitcode" + "github.com/walheimlab/walheim-go/internal/labels" + "github.com/walheimlab/walheim-go/internal/registry" + "github.com/walheimlab/walheim-go/internal/resource" + "github.com/walheimlab/walheim-go/internal/testutil" +) + +type errorFS struct { + *testutil.MemFS + readErr error + writeErr error +} + +func (e *errorFS) ReadFile(path string) ([]byte, error) { + if e.readErr != nil { + return nil, e.readErr + } + + return e.MemFS.ReadFile(path) +} + +func (e *errorFS) WriteFile(path string, data []byte) error { + if e.writeErr != nil { + return e.writeErr + } + + return e.MemFS.WriteFile(path, data) +} + +func init() { + registry.Register(registry.Registration{ + Info: resource.KindInfo{ + Group: "test", + Version: "v1alpha1", + Kind: "ClusterWidget", + Plural: "clusterwidgets", + }, + Scope: registry.ClusterScoped, + }) + registry.Register(registry.Registration{ + Info: resource.KindInfo{ + Group: "test", + Version: "v1alpha1", + Kind: "NamespacedWidget", + Plural: "namespacedwidgets", + }, + Scope: registry.NamespaceScoped, + }) +} + +func TestResolvePath_ClusterScoped(t *testing.T) { + fs := testutil.NewMemFS() + + path, err := labels.ResolvePath(fs, "/data", "clusterwidgets", "foo", "") + if err != nil { + t.Fatalf("ResolvePath failed: %v", err) + } + + expected := "/data/clusterwidgets/foo/.clusterwidget.yaml" + if path != expected { + t.Errorf("expected %q, got %q", expected, path) + } + + // Cluster-scoped with namespace should fail + _, err = labels.ResolvePath(fs, "/data", "clusterwidgets", "foo", "ns") + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.UsageError { + t.Errorf("expected exitcode.UsageError, got %v", err) + } +} + +func TestResolvePath_Namespaced(t *testing.T) { + fs := testutil.NewMemFS() + + path, err := labels.ResolvePath(fs, "/data", "namespacedwidgets", "foo", "ns1") + if err != nil { + t.Fatalf("ResolvePath failed: %v", err) + } + + expected := "/data/namespaces/ns1/namespacedwidgets/foo/.namespacedwidget.yaml" + if path != expected { + t.Errorf("expected %q, got %q", expected, path) + } + + // Namespaced without namespace should fail + _, err = labels.ResolvePath(fs, "/data", "namespacedwidgets", "foo", "") + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.UsageError { + t.Errorf("expected exitcode.UsageError, got %v", err) + } +} + +func TestResolvePath_UnknownKind(t *testing.T) { + fs := testutil.NewMemFS() + + _, err := labels.ResolvePath(fs, "/data", "unknownkind", "foo", "") + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.UsageError { + t.Errorf("expected exitcode.UsageError, got %v", err) + } +} + +func TestGet_NonexistentFile(t *testing.T) { + fs := testutil.NewMemFS() + + _, err := labels.Get(fs, "/data", "clusterwidgets", "foo", "") + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.NotFound { + t.Errorf("expected exitcode.NotFound, got %v", err) + } +} + +func TestGet_InvalidYAML(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(`{invalid: yaml: `)) + + _, err := labels.Get(fs, "/data", "clusterwidgets", "foo", "") + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.Failure { + t.Errorf("expected exitcode.Failure, got %v", err) + } +} + +func TestGet_NoLabels(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(` +metadata: + name: foo +`)) + + m, err := labels.Get(fs, "/data", "clusterwidgets", "foo", "") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if len(m) != 0 { + t.Errorf("expected empty map, got %v", m) + } +} + +func TestGet_WithLabels(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(` +metadata: + name: foo + labels: + app: walheim + env: prod +`)) + + m, err := labels.Get(fs, "/data", "clusterwidgets", "foo", "") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if len(m) != 2 || m["app"] != "walheim" || m["env"] != "prod" { + t.Errorf("unexpected labels map: %v", m) + } +} + +func TestSet_SuccessAndFailure(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(` +metadata: + name: foo + labels: + app: walheim +`)) + + // Write without overwrite of existing key should fail + err := labels.Set(fs, "/data", "clusterwidgets", "foo", "", []string{"app=newval"}, false) + if err == nil { + t.Fatal("expected conflict error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.Conflict { + t.Errorf("expected exitcode.Conflict, got %v", err) + } + + // Write without overwrite of new key should succeed + changed, removed, err := labels.SetTracked(fs, "/data", "clusterwidgets", "foo", "", []string{"env=prod"}, false) + if err != nil { + t.Fatalf("SetTracked failed: %v", err) + } + + if len(changed) != 1 || changed[0] != "env" || len(removed) != 0 { + t.Errorf("unexpected changed/removed: %v, %v", changed, removed) + } + + // With overwrite should succeed on existing key + changed, removed, err = labels.SetTracked(fs, "/data", "clusterwidgets", "foo", "", []string{"app=newval"}, true) + if err != nil { + t.Fatalf("SetTracked failed: %v", err) + } + + if len(changed) != 1 || changed[0] != "app" || len(removed) != 0 { + t.Errorf("unexpected changed/removed: %v, %v", changed, removed) + } + + // Remove key + changed, removed, err = labels.SetTracked(fs, "/data", "clusterwidgets", "foo", "", []string{"app-"}, false) + if err != nil { + t.Fatalf("SetTracked failed: %v", err) + } + + if len(changed) != 0 || len(removed) != 1 || removed[0] != "app" { + t.Errorf("unexpected changed/removed: %v, %v", changed, removed) + } + + // Verification of final document content + m, err := labels.Get(fs, "/data", "clusterwidgets", "foo", "") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if len(m) != 1 || m["env"] != "prod" { + t.Errorf("unexpected final labels: %v", m) + } +} + +func TestSet_Validation(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(` +metadata: + name: foo +`)) + + // Invalid specs + invalidSpecs := []string{ + "invalidSpecNoEqualsOrMinus", + "=val", + "invalid..key=val", + "invalid@key=val", + } + + for _, spec := range invalidSpecs { + t.Run(spec, func(t *testing.T) { + err := labels.Set(fs, "/data", "clusterwidgets", "foo", "", []string{spec}, true) + if err == nil { + t.Fatalf("expected error for spec %q, got nil", spec) + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || (exitErr.Code != exitcode.UsageError) { + t.Errorf("expected UsageError for spec %q, got %v", spec, err) + } + }) + } +} + +func TestSet_InvalidRootNode(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(`"hello"`)) + + err := labels.Set(fs, "/data", "clusterwidgets", "foo", "", []string{"key=value"}, true) + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.Failure { + t.Errorf("expected exitcode.Failure, got %v", err) + } +} + +func TestGet_InvalidLabelsType(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(` +metadata: + labels: "not-a-map" +`)) + + _, err := labels.Get(fs, "/data", "clusterwidgets", "foo", "") + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.Failure { + t.Errorf("expected exitcode.Failure, got %v", err) + } +} + +func TestSet_InvalidLabelsType(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(` +metadata: + labels: "not-a-map" +`)) + + _, _, err := labels.SetTracked(fs, "/data", "clusterwidgets", "foo", "", []string{"key=value"}, true) + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.Failure { + t.Errorf("expected exitcode.Failure, got %v", err) + } +} + +func TestSet_MissingMetadata(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(` +name: foo +`)) + + changed, removed, err := labels.SetTracked(fs, "/data", "clusterwidgets", "foo", "", []string{"key=value"}, true) + if err != nil { + t.Fatalf("SetTracked failed: %v", err) + } + + if len(changed) != 1 || changed[0] != "key" || len(removed) != 0 { + t.Errorf("unexpected changed/removed: %v, %v", changed, removed) + } + + m, err := labels.Get(fs, "/data", "clusterwidgets", "foo", "") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if m["key"] != "value" { + t.Errorf("expected label key=value, got: %v", m) + } +} + +func TestSet_EmptyDoc(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(``)) + + _, _, err := labels.SetTracked(fs, "/data", "clusterwidgets", "foo", "", []string{"key=value"}, true) + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.Failure { + t.Errorf("expected exitcode.Failure, got %v", err) + } +} + +func TestGet_ScalarRoot(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(`"hello"`)) + + m, err := labels.Get(fs, "/data", "clusterwidgets", "foo", "") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if len(m) != 0 { + t.Errorf("expected empty map, got %v", m) + } +} + +func TestGet_MissingMetadata(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(` +name: foo +`)) + + m, err := labels.Get(fs, "/data", "clusterwidgets", "foo", "") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if len(m) != 0 { + t.Errorf("expected empty map, got %v", m) + } +} + +func TestGet_ResolvePathError(t *testing.T) { + fs := testutil.NewMemFS() + + _, err := labels.Get(fs, "/data", "unknownkind", "foo", "") + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.UsageError { + t.Errorf("expected exitcode.UsageError, got %v", err) + } +} + +func TestSetTracked_ResolvePathError(t *testing.T) { + fs := testutil.NewMemFS() + + _, _, err := labels.SetTracked(fs, "/data", "unknownkind", "foo", "", []string{"key=value"}, true) + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.UsageError { + t.Errorf("expected exitcode.UsageError, got %v", err) + } +} + +func TestSetTracked_ReadManifestError(t *testing.T) { + memFS := testutil.NewMemFS() + fs := &errorFS{ + MemFS: memFS, + readErr: errors.New("read error"), + } + + _, _, err := labels.SetTracked(fs, "/data", "clusterwidgets", "foo", "", []string{"key=value"}, true) + if err == nil { + t.Fatal("expected error, got nil") + } + // Note: in readManifestDoc, any read error is treated as exitcode.NotFound + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.NotFound { + t.Errorf("expected exitcode.NotFound, got %v", err) + } +} + +func TestSetTracked_WriteManifestError(t *testing.T) { + memFS := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = memFS.WriteFile(path, []byte(` +metadata: + name: foo +`)) + fs := &errorFS{ + MemFS: memFS, + writeErr: errors.New("write error"), + } + + _, _, err := labels.SetTracked(fs, "/data", "clusterwidgets", "foo", "", []string{"key=value"}, true) + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.Failure { + t.Errorf("expected exitcode.Failure, got %v", err) + } +} + +func TestSetTracked_InvalidKeyForRemoval(t *testing.T) { + fs := testutil.NewMemFS() + path := "/data/clusterwidgets/foo/.clusterwidget.yaml" + _ = fs.WriteFile(path, []byte(` +metadata: + name: foo +`)) + + _, _, err := labels.SetTracked(fs, "/data", "clusterwidgets", "foo", "", []string{"invalid..key-"}, true) + if err == nil { + t.Fatal("expected error, got nil") + } + + var exitErr *exitcode.Error + if !errors.As(err, &exitErr) || exitErr.Code != exitcode.UsageError { + t.Errorf("expected exitcode.UsageError, got %v", err) + } +} diff --git a/internal/rsync/rsync_test.go b/internal/rsync/rsync_test.go new file mode 100644 index 0000000..b9eb0cd --- /dev/null +++ b/internal/rsync/rsync_test.go @@ -0,0 +1,376 @@ +package rsync_test + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net" + "os" + "path/filepath" + "testing" + + "github.com/pkg/sftp" + "github.com/walheimlab/walheim-go/internal/rsync" + "github.com/walheimlab/walheim-go/internal/testutil" + gossh "golang.org/x/crypto/ssh" + "os/user" +) + +// generateHostKey generates a temporary RSA host key. +func generateHostKey(t *testing.T) gossh.Signer { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate private key: %v", err) + } + + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + + signer, err := gossh.ParsePrivateKey(pemBytes) + if err != nil { + t.Fatalf("Failed to parse private key: %v", err) + } + + return signer +} + +// generateKeyHelper generates an RSA key pair. +func generateKeyHelper(t *testing.T) (privatePEM []byte, public gossh.PublicKey) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate private key: %v", err) + } + + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + + signer, err := gossh.ParsePrivateKey(pemBytes) + if err != nil { + t.Fatalf("Failed to parse private key: %v", err) + } + + return pemBytes, signer.PublicKey() +} + +// startMockSFTPServer starts an SSH+SFTP server listening on a local port. +func startMockSFTPServer(t *testing.T, hostKey gossh.Signer, user string, clientPubKey gossh.PublicKey) (net.Listener, string, int) { + t.Helper() + + config := &gossh.ServerConfig{ + PublicKeyCallback: func(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) { + if conn.User() == user && string(key.Marshal()) == string(clientPubKey.Marshal()) { + return nil, nil + } + + return nil, fmt.Errorf("auth failed for user %q", conn.User()) + }, + } + config.AddHostKey(hostKey) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen failed: %v", err) + } + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + + go func(c net.Conn) { + _, chans, reqs, err := gossh.NewServerConn(c, config) + if err != nil { + return + } + + go gossh.DiscardRequests(reqs) + + for newChannel := range chans { + if newChannel.ChannelType() != "session" { + _ = newChannel.Reject(gossh.UnknownChannelType, "unknown channel type") + continue + } + + channel, requests, err := newChannel.Accept() + if err != nil { + continue + } + + go func(ch gossh.Channel, reqs <-chan *gossh.Request) { + defer func() { + _ = ch.Close() + }() + + for req := range reqs { + switch req.Type { + case "subsystem": + if len(req.Payload) >= 4 { + length := uint32(req.Payload[0])<<24 | uint32(req.Payload[1])<<16 | uint32(req.Payload[2])<<8 | uint32(req.Payload[3]) + + subsystem := string(req.Payload[4 : 4+length]) + if subsystem == "sftp" { + _ = req.Reply(true, nil) + + server, err := sftp.NewServer(ch) + if err == nil { + _ = server.Serve() + } + + return + } + } + + _ = req.Reply(false, nil) + + return + default: + _ = req.Reply(false, nil) + } + } + }(channel, requests) + } + }(conn) + } + }() + + addr := listener.Addr().(*net.TCPAddr) + + return listener, addr.IP.String(), addr.Port +} + +func TestSyncer_Sync(t *testing.T) { + hostKey := generateHostKey(t) + clientKey, clientPub := generateKeyHelper(t) + + listener, host, port := startMockSFTPServer(t, hostKey, "testuser", clientPub) + + defer func() { _ = listener.Close() }() + + // Prepare mock source filesystem + fs := testutil.NewMemFS() + _ = fs.WriteFile("/src/file1.txt", []byte("hello world")) + _ = fs.WriteFile("/src/sub/file2.txt", []byte("sub world")) + _ = fs.MkdirAll("/src/sub") + + // Target directory on OS + destDir := t.TempDir() + + syncer := rsync.NewSyncer() + syncer.Port = port + syncer.IdentityKey = clientKey + + err := syncer.Sync(fs, "/src", "testuser@"+host, destDir) + if err != nil { + t.Fatalf("Sync failed: %v", err) + } + + // Verify files were correctly synced to destDir on local filesystem + data1, err := os.ReadFile(filepath.Join(destDir, "file1.txt")) + if err != nil { + t.Fatalf("failed to read destination file1: %v", err) + } + + if string(data1) != "hello world" { + t.Errorf("file1.txt content mismatch: got %q", data1) + } + + data2, err := os.ReadFile(filepath.Join(destDir, "sub", "file2.txt")) + if err != nil { + t.Fatalf("failed to read destination file2: %v", err) + } + + if string(data2) != "sub world" { + t.Errorf("file2.txt content mismatch: got %q", data2) + } +} + +func TestSyncer_SyncErrors(t *testing.T) { + // Connection error + fs := testutil.NewMemFS() + syncer := rsync.NewSyncer() + syncer.Port = 9999 + syncer.IdentityKey = []byte("invalid-key-data") + + err := syncer.Sync(fs, "/src", "testuser@127.0.0.1", "/dest") + if err == nil { + t.Fatal("expected error, got nil") + } + + // Build auth methods error + syncer.IdentityKey = []byte("invalid") + + err = syncer.Sync(fs, "/src", "testuser@127.0.0.1", "/dest") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestSyncer_Sync_IdentityFile(t *testing.T) { + hostKey := generateHostKey(t) + clientKey, clientPub := generateKeyHelper(t) + + listener, host, port := startMockSFTPServer(t, hostKey, "testuser", clientPub) + + defer func() { _ = listener.Close() }() + + fs := testutil.NewMemFS() + _ = fs.WriteFile("/src/file1.txt", []byte("hello")) + + destDir := t.TempDir() + tempDir := t.TempDir() + keyFile := filepath.Join(tempDir, "client_key") + + err := os.WriteFile(keyFile, clientKey, 0600) + if err != nil { + t.Fatalf("failed to write key file: %v", err) + } + + syncer := rsync.NewSyncer() + syncer.Port = port + syncer.IdentityFile = keyFile + + err = syncer.Sync(fs, "/src", "testuser@"+host, destDir) + if err != nil { + t.Fatalf("Sync failed: %v", err) + } +} + +func TestSyncer_Sync_ParseRemoteNoUser(t *testing.T) { + currUser, err := user.Current() + if err != nil { + t.Skip("skipping because user.Current() is not supported on this platform/runner") + } + + hostKey := generateHostKey(t) + _, clientPub := generateKeyHelper(t) + + listener, host, port := startMockSFTPServer(t, hostKey, currUser.Username, clientPub) + + defer func() { _ = listener.Close() }() + + fs := testutil.NewMemFS() + _ = fs.WriteFile("/src/file1.txt", []byte("hello")) + + destDir := t.TempDir() + tempDir := t.TempDir() + keyFile := filepath.Join(tempDir, "client_key") + // Save the client key + clientKey, _ := generateKeyHelper(t) + + err = os.WriteFile(keyFile, clientKey, 0600) + if err != nil { + t.Fatalf("failed to write key file: %v", err) + } + + syncer := rsync.NewSyncer() + syncer.Port = port + syncer.IdentityFile = keyFile + + // Sync with just hostname (should fall back to current OS user) + // We might fail authentication if the OS username doesn't match the server expectations or keys, + // but it should at least hit the parseRemote fallback and try to dial. + _ = syncer.Sync(fs, "/src", host, destDir) +} + +func TestSyncer_Sync_IdentityFileError(t *testing.T) { + fs := testutil.NewMemFS() + syncer := rsync.NewSyncer() + syncer.IdentityFile = "/nonexistent-key-file-path-rsync-test" + + err := syncer.Sync(fs, "/src", "testuser@127.0.0.1", "/dest") + if err == nil { + t.Fatal("expected error for nonexistent identity file, got nil") + } +} + +type errorMockFS struct { + readDirErr error + isDirErr error + readFileErr error +} + +func (e *errorMockFS) ReadFile(path string) ([]byte, error) { + if e.readFileErr != nil { + return nil, e.readFileErr + } + + return []byte("data"), nil +} + +func (e *errorMockFS) WriteFile(path string, data []byte) error { return nil } +func (e *errorMockFS) MkdirAll(path string) error { return nil } +func (e *errorMockFS) RemoveAll(path string) error { return nil } +func (e *errorMockFS) Exists(path string) (bool, error) { return false, nil } + +func (e *errorMockFS) IsDir(path string) (bool, error) { + if e.isDirErr != nil { + return false, e.isDirErr + } + + return false, nil +} + +func (e *errorMockFS) ReadDir(path string) ([]string, error) { + if e.readDirErr != nil { + return nil, e.readDirErr + } + + return []string{"file1.txt"}, nil +} + +func TestSyncer_Sync_FSErrors(t *testing.T) { + hostKey := generateHostKey(t) + clientKey, clientPub := generateKeyHelper(t) + + listener, host, port := startMockSFTPServer(t, hostKey, "testuser", clientPub) + + defer func() { _ = listener.Close() }() + + destDir := t.TempDir() + + syncer := rsync.NewSyncer() + syncer.Port = port + syncer.IdentityKey = clientKey + + // 1. ReadDir error + fsErr := &errorMockFS{ + readDirErr: fmt.Errorf("read dir failure"), + } + + err := syncer.Sync(fsErr, "/src", "testuser@"+host, destDir) + if err == nil { + t.Fatal("expected error, got nil") + } + + // 2. IsDir error + fsErr = &errorMockFS{ + isDirErr: fmt.Errorf("is dir failure"), + } + + err = syncer.Sync(fsErr, "/src", "testuser@"+host, destDir) + if err == nil { + t.Fatal("expected error, got nil") + } + + // 3. ReadFile error + fsErr = &errorMockFS{ + readFileErr: fmt.Errorf("read file failure"), + } + + err = syncer.Sync(fsErr, "/src", "testuser@"+host, destDir) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/ssh/client_test.go b/internal/ssh/client_test.go index f2a9915..9b1eae9 100644 --- a/internal/ssh/client_test.go +++ b/internal/ssh/client_test.go @@ -1,11 +1,161 @@ package ssh import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net" + "os" + "os/user" "testing" + + gossh "golang.org/x/crypto/ssh" ) -// Run, RunOutput, Exec, TestConnection require a live SSH server and are -// covered by the integration tests in internal/integration. +// generateHostKey generates a temporary RSA host key. +func generateHostKey(t *testing.T) gossh.Signer { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate private key: %v", err) + } + + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + + signer, err := gossh.ParsePrivateKey(pemBytes) + if err != nil { + t.Fatalf("Failed to parse private key: %v", err) + } + + return signer +} + +// generateKeyHelper generates an RSA key pair. +func generateKeyHelper(t *testing.T) (privatePEM []byte, public gossh.PublicKey) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate private key: %v", err) + } + + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + + signer, err := gossh.ParsePrivateKey(pemBytes) + if err != nil { + t.Fatalf("Failed to parse private key: %v", err) + } + + return pemBytes, signer.PublicKey() +} + +// startMockSSHServer starts an SSH server listening on a local port. +// It returns the listener and the host/port. +func startMockSSHServer(t *testing.T, hostKey gossh.Signer, user string, clientPubKey gossh.PublicKey) (net.Listener, string, int) { + t.Helper() + + config := &gossh.ServerConfig{ + PublicKeyCallback: func(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) { + if conn.User() == user && string(key.Marshal()) == string(clientPubKey.Marshal()) { + return nil, nil + } + + return nil, fmt.Errorf("auth failed for user %q", conn.User()) + }, + } + config.AddHostKey(hostKey) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen failed: %v", err) + } + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return // listener closed + } + + go func(c net.Conn) { + _, chans, reqs, err := gossh.NewServerConn(c, config) + if err != nil { + return + } + + // Discard out-of-band requests + go gossh.DiscardRequests(reqs) + + for newChannel := range chans { + if newChannel.ChannelType() != "session" { + _ = newChannel.Reject(gossh.UnknownChannelType, "unknown channel type") + continue + } + + channel, requests, err := newChannel.Accept() + if err != nil { + continue + } + + go func(ch gossh.Channel, reqs <-chan *gossh.Request) { + defer func() { + _ = ch.Close() + }() + + for req := range reqs { + switch req.Type { + case "exec": + if len(req.Payload) >= 4 { + length := uint32(req.Payload[0])<<24 | uint32(req.Payload[1])<<16 | uint32(req.Payload[2])<<8 | uint32(req.Payload[3]) + + if len(req.Payload) >= 4+int(length) { + cmd := string(req.Payload[4 : 4+length]) + + switch cmd { + case "hello": + _, _ = ch.Write([]byte("world\n")) + _, _ = ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0}) + case "fail": + _, _ = ch.SendRequest("exit-status", false, []byte{0, 0, 0, 1}) + default: + _, _ = ch.Write([]byte("unknown command\n")) + _, _ = ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0}) + } + } + } + + _ = req.Reply(true, nil) + + return + case "pty-req": + _ = req.Reply(true, nil) + case "shell": + _ = req.Reply(true, nil) + + return + default: + _ = req.Reply(false, nil) + } + } + }(channel, requests) + } + }(conn) + } + }() + + addr := listener.Addr().(*net.TCPAddr) + + return listener, addr.IP.String(), addr.Port +} func TestNewClient_defaults(t *testing.T) { c := NewClient("myhost.local") @@ -14,7 +164,7 @@ func TestNewClient_defaults(t *testing.T) { } if c.ConnectTimeout != 5 { - t.Errorf("ConnectTimeout = %d, want 5", c.ConnectTimeout) + t.Errorf("ConnectTimeout = %d, want %d", c.ConnectTimeout, 5) } } @@ -30,9 +180,14 @@ func TestParseRemote_withUser(t *testing.T) { } func TestParseRemote_noUser_fallsBackToOSUser(t *testing.T) { + currUser, err := user.Current() + if err != nil { + t.Skip("skipping because user.Current() is not supported on this platform/runner") + } + u, h := parseRemote("example.com") - if u == "" { - t.Error("expected non-empty user from OS fallback") + if u != currUser.Username { + t.Errorf("user = %q, want %q", u, currUser.Username) } if h != "example.com" { @@ -41,7 +196,6 @@ func TestParseRemote_noUser_fallsBackToOSUser(t *testing.T) { } func TestParseRemote_atInHost(t *testing.T) { - // Only the last '@' separates user from host. u, h := parseRemote("user@host@example.com") if u != "user@host" { t.Errorf("user = %q, want %q", u, "user@host") @@ -51,3 +205,166 @@ func TestParseRemote_atInHost(t *testing.T) { t.Errorf("host = %q, want %q", h, "example.com") } } + +func TestMockSSH_Success(t *testing.T) { + hostKey := generateHostKey(t) + clientKey, clientPub := generateKeyHelper(t) + + listener, host, port := startMockSSHServer(t, hostKey, "testuser", clientPub) + + defer func() { _ = listener.Close() }() + + c := NewClient("testuser@" + host) + c.Port = port + c.IdentityKey = clientKey + + // Test connection + if !c.TestConnection() { + t.Fatal("expected connection to succeed") + } + + // Test run output + out, err := c.RunOutput("hello") + if err != nil { + t.Fatalf("RunOutput failed: %v", err) + } + + if out != "world\n" { + t.Errorf("expected 'world\n', got %q", out) + } + + // Test run output fail command + _, err = c.RunOutput("fail") + if err == nil { + t.Fatal("expected error, got nil") + } + + // Test run output unknown command + out, err = c.RunOutput("unknown") + if err != nil { + t.Fatalf("RunOutput failed: %v", err) + } + + if out != "unknown command\n" { + t.Errorf("expected 'unknown command\n', got %q", out) + } + + // Test Run + err = c.Run("hello") + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + // Test Exec + err = c.Exec("hello", false) + if err != nil { + t.Fatalf("Exec failed: %v", err) + } +} + +func TestMockSSH_ConnectionFailures(t *testing.T) { + // Wrong port / unreachable + c := NewClient("testuser@127.0.0.1") + c.Port = 9999 // likely unused port + c.ConnectTimeout = 1 + + if c.TestConnection() { + t.Fatal("expected connection to fail") + } + + _, err := c.RunOutput("hello") + if err == nil { + t.Fatal("expected error, got nil") + } + + err = c.Run("hello") + if err == nil { + t.Fatal("expected error, got nil") + } + + err = c.Exec("hello", false) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestSignerFromBytes_Errors(t *testing.T) { + _, err := signerFromBytes([]byte("invalid-key")) + if err == nil { + t.Fatal("expected error parsing invalid key, got nil") + } +} + +func TestSignerFromFile_Errors(t *testing.T) { + // Nonexistent file + _, err := signerFromFile("/nonexistent-file-path-ssh-test") + if err == nil { + t.Fatal("expected error reading nonexistent file, got nil") + } + + // Invalid format file + tempDir := t.TempDir() + tempFile := tempDir + "/invalid-key.pem" + + err = os.WriteFile(tempFile, []byte("invalid-key-data"), 0600) + if err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + _, err = signerFromFile(tempFile) + if err == nil { + t.Fatal("expected error parsing invalid format key, got nil") + } +} + +func TestMockSSH_IdentityFile(t *testing.T) { + hostKey := generateHostKey(t) + clientKey, clientPub := generateKeyHelper(t) + + listener, host, port := startMockSSHServer(t, hostKey, "testuser", clientPub) + + defer func() { _ = listener.Close() }() + + tempDir := t.TempDir() + keyFile := tempDir + "/client_key" + + err := os.WriteFile(keyFile, clientKey, 0600) + if err != nil { + t.Fatalf("failed to write key file: %v", err) + } + + c := NewClient("testuser@" + host) + c.Port = port + c.IdentityFile = keyFile + + if !c.TestConnection() { + t.Fatal("expected connection to succeed with IdentityFile") + } + + out, err := c.RunOutput("hello") + if err != nil { + t.Fatalf("RunOutput failed: %v", err) + } + + if out != "world\n" { + t.Errorf("expected 'world\n', got %q", out) + } +} + +func TestMockSSH_ExecTTY(t *testing.T) { + hostKey := generateHostKey(t) + clientKey, clientPub := generateKeyHelper(t) + + listener, host, port := startMockSSHServer(t, hostKey, "testuser", clientPub) + + defer func() { _ = listener.Close() }() + + c := NewClient("testuser@" + host) + c.Port = port + c.IdentityKey = clientKey + + err := c.Exec("hello", true) + if err != nil { + t.Fatalf("Exec with TTY failed: %v", err) + } +} diff --git a/internal/yamlutil/yamlutil_test.go b/internal/yamlutil/yamlutil_test.go index 17c9b53..4d49003 100644 --- a/internal/yamlutil/yamlutil_test.go +++ b/internal/yamlutil/yamlutil_test.go @@ -1,6 +1,7 @@ package yamlutil_test import ( + "errors" "strings" "testing" @@ -73,3 +74,20 @@ func TestMarshal_nil(t *testing.T) { t.Errorf("Marshal(nil) = %q, want null", string(data)) } } + +type errorMarshaler struct{} + +func (e errorMarshaler) MarshalYAML() (interface{}, error) { + return nil, errors.New("custom marshal error") +} + +func TestMarshal_error(t *testing.T) { + _, err := yamlutil.Marshal(errorMarshaler{}) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "custom marshal error") { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/pkg/api/walheim/v1alpha1/types_test.go b/pkg/api/walheim/v1alpha1/types_test.go index e6cf5f8..fd136cb 100644 --- a/pkg/api/walheim/v1alpha1/types_test.go +++ b/pkg/api/walheim/v1alpha1/types_test.go @@ -227,3 +227,57 @@ func TestServiceLabels_UnmarshalYAML_listForm(t *testing.T) { t.Errorf("bare = %q, want empty", labels.Values["bare"]) } } + +func TestServiceLabels_MarshalYAML_alwaysMap(t *testing.T) { + labels := ServiceLabels{Values: map[string]string{"A": "1"}} + + out, err := yaml.Marshal(labels) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + if !strings.Contains(string(out), "A: \"1\"") && !strings.Contains(string(out), "A: '1'") && !strings.Contains(string(out), "A: 1") { + t.Errorf("marshal output %q does not look like map form", string(out)) + } + + if strings.Contains(string(out), "- ") { + t.Errorf("marshal output %q looks like list form, want map form", string(out)) + } +} + +func TestServiceEnv_UnmarshalYAML_decodeMapError(t *testing.T) { + input := "KEY1: [nested, array]\n" + + var env ServiceEnv + if err := yaml.Unmarshal([]byte(input), &env); err == nil { + t.Fatal("expected unmarshal error for nested array, got nil") + } +} + +func TestServiceEnv_UnmarshalYAML_decodeSeqError(t *testing.T) { + input := "- [nested, array]\n" + + var env ServiceEnv + if err := yaml.Unmarshal([]byte(input), &env); err == nil { + t.Fatal("expected unmarshal error for nested array inside sequence, got nil") + } +} + +func TestServiceLabels_UnmarshalYAML_decodeMapError(t *testing.T) { + input := "KEY1: [nested, array]\n" + + var labels ServiceLabels + if err := yaml.Unmarshal([]byte(input), &labels); err == nil { + t.Fatal("expected unmarshal error for nested array, got nil") + } +} + +func TestServiceLabels_UnmarshalYAML_decodeSeqError(t *testing.T) { + input := "- [nested, array]\n" + + var labels ServiceLabels + if err := yaml.Unmarshal([]byte(input), &labels); err == nil { + t.Fatal("expected unmarshal error for nested array inside sequence, got nil") + } +} +