package core_test import ( "bytes" "compress/gzip" "context" "encoding/json" "encoding/xml" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" aptclone "github.com/syonad/clonepack/internal/clone/apt" rpmclone "github.com/syonad/clonepack/internal/clone/rpm" "github.com/syonad/clonepack/internal/store" ) // --- helpers --- // buildRPMRepo creates an RPM repo (repomd.xml + upstream primary.xml.gz + // .primary-source marker) in localDir and writes fake package files on disk. func buildRPMRepo(t *testing.T, localDir string, pkgs []struct{ name, version, href string }) { t.Helper() if err := rpmclone.InitEmptyRepo(localDir); err != nil { t.Fatalf("InitEmptyRepo: %v", err) } // Build upstream primary.xml.gz. var xmlBuf bytes.Buffer xmlBuf.WriteString(``) fmt.Fprintf(&xmlBuf, ``, len(pkgs)) for _, p := range pkgs { fmt.Fprintf(&xmlBuf, `%s`+ ``+ `x86_64`+ `abc%s`+ ``+ ``+ ``, p.name, p.version, p.name, p.href) } xmlBuf.WriteString(``) var gz bytes.Buffer gzw := gzip.NewWriter(&gz) gzw.Write(xmlBuf.Bytes()) gzw.Close() upstreamPath := filepath.Join(localDir, "repodata", "upstream-primary.xml.gz") if err := os.WriteFile(upstreamPath, gz.Bytes(), 0o644); err != nil { t.Fatalf("write upstream primary: %v", err) } // .primary-source marker so RegenerateMetadata always filters from upstream. marker := filepath.Join(localDir, "repodata", rpmclone.PrimarySourceFile) if err := os.WriteFile(marker, []byte("repodata/upstream-primary.xml.gz"), 0o644); err != nil { t.Fatalf("write .primary-source: %v", err) } // Minimal repomd.xml pointing at the upstream (regen will redirect to local). type loc struct { Href string `xml:"href,attr"` } type chk struct { Type string `xml:"type,attr"` Value string `xml:",chardata"` } type entry struct { Type string `xml:"type,attr"` Location loc `xml:"location"` Checksum chk `xml:"checksum"` } type repomdDoc struct { XMLName xml.Name `xml:"repomd"` Data []entry `xml:"data"` } rm := repomdDoc{Data: []entry{{ Type: "primary", Location: loc{Href: "repodata/upstream-primary.xml.gz"}, Checksum: chk{Type: "sha256", Value: "placeholder"}, }}} repomdXML, _ := xml.MarshalIndent(rm, "", " ") if err := os.WriteFile( filepath.Join(localDir, "repodata", "repomd.xml"), append([]byte(xml.Header), repomdXML...), 0o644, ); err != nil { t.Fatalf("write repomd.xml: %v", err) } // Create fake package files. for _, p := range pkgs { dest := filepath.Join(localDir, filepath.FromSlash(p.href)) os.MkdirAll(filepath.Dir(dest), 0o755) os.WriteFile(dest, []byte("fake-rpm"), 0o644) } // Regenerate so repomd.xml reflects actual checksums. if err := rpmclone.RegenerateMetadata(localDir); err != nil { t.Fatalf("RegenerateMetadata: %v", err) } } // buildAPTRepo creates an APT repo with Packages files and fake deb files on disk. func buildAPTRepo(t *testing.T, localDir string, cfg aptclone.Config, debs []struct{ pkg, version, filename string }) { t.Helper() if err := aptclone.InitEmptyRepo(localDir, cfg); err != nil { t.Fatalf("InitEmptyRepo apt: %v", err) } var stanzas []string for _, d := range debs { stanzas = append(stanzas, strings.Join([]string{ "Package: " + d.pkg, "Version: " + d.version, "Architecture: amd64", "Filename: " + d.filename, "SHA256: abc" + d.pkg, "Size: 1024", }, "\n")) } plain := []byte(strings.Join(stanzas, "\n\n") + "\n") var gz bytes.Buffer gzw := gzip.NewWriter(&gz) gzw.Write(plain) gzw.Close() gzData := gz.Bytes() for _, component := range cfg.Components { for _, arch := range cfg.Architectures { binDir := filepath.Join(localDir, "dists", cfg.Suite, component, "binary-"+arch) os.MkdirAll(binDir, 0o755) os.WriteFile(filepath.Join(binDir, ".upstream-Packages.gz"), gzData, 0o644) } } for _, d := range debs { dest := filepath.Join(localDir, filepath.FromSlash(d.filename)) os.MkdirAll(filepath.Dir(dest), 0o755) os.WriteFile(dest, []byte("fake-deb"), 0o644) } if err := aptclone.RegenerateMetadata(localDir, cfg); err != nil { t.Fatalf("RegenerateMetadata apt: %v", err) } } // --- TakeSnapshot RPM --- func TestTakeSnapshot_RPM_capturesPackages(t *testing.T) { env := newTestEnv(t) ctx := context.Background() repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ Name: "rpm-repo", Type: "rpm", SourceURL: "https://example.com", SyncMode: "manual", }) localDir := filepath.Join(env.dataDir, "repos", "1", "rpm") buildRPMRepo(t, localDir, []struct{ name, version, href string }{ {"curl", "7.88", "Packages/curl-7.88-1.x86_64.rpm"}, {"wget", "1.21", "Packages/wget-1.21-1.x86_64.rpm"}, }) snapID, err := env.snapshotSvc.TakeSnapshot(ctx, repoID, "v1") if err != nil { t.Fatalf("TakeSnapshot: %v", err) } pkgs, _ := env.snapshotStore.GetSnapshotPackages(ctx, snapID) if len(pkgs) != 2 { t.Fatalf("expected 2 packages, got %d", len(pkgs)) } names := map[string]bool{} for _, p := range pkgs { names[p.Name] = true } if !names["curl"] || !names["wget"] { t.Errorf("unexpected package names: %v", names) } } func TestTakeSnapshot_RPM_emptyRepo(t *testing.T) { env := newTestEnv(t) ctx := context.Background() repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ Name: "rpm-empty", Type: "rpm", SourceURL: "https://example.com", SyncMode: "manual", }) localDir := filepath.Join(env.dataDir, "repos", "1", "rpm") buildRPMRepo(t, localDir, nil) snapID, err := env.snapshotSvc.TakeSnapshot(ctx, repoID, "empty") if err != nil { t.Fatalf("TakeSnapshot: %v", err) } pkgs, _ := env.snapshotStore.GetSnapshotPackages(ctx, snapID) if len(pkgs) != 0 { t.Errorf("expected 0 packages, got %d", len(pkgs)) } } func TestTakeSnapshot_repoNotFound(t *testing.T) { env := newTestEnv(t) _, err := env.snapshotSvc.TakeSnapshot(context.Background(), 9999, "v1") if err == nil { t.Error("expected error for unknown repo") } } // --- TakeSnapshot APT --- func TestTakeSnapshot_APT_capturesPackages(t *testing.T) { env := newTestEnv(t) ctx := context.Background() cfg := aptclone.Config{Suite: "bookworm", Components: []string{"main"}, Architectures: []string{"amd64"}} cfgJSON, _ := json.Marshal(cfg) repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ Name: "apt-repo", Type: "apt", SourceURL: "https://deb.debian.org/debian", SyncMode: "manual", Config: string(cfgJSON), }) localDir := filepath.Join(env.dataDir, "repos", "1", "apt") buildAPTRepo(t, localDir, cfg, []struct{ pkg, version, filename string }{ {"curl", "7.88.1-10", "pool/main/c/curl/curl_7.88.1-10_amd64.deb"}, {"vim", "2:9.0.1378-2", "pool/main/v/vim/vim_9.0.1378-2_amd64.deb"}, }) snapID, err := env.snapshotSvc.TakeSnapshot(ctx, repoID, "apt-v1") if err != nil { t.Fatalf("TakeSnapshot APT: %v", err) } pkgs, _ := env.snapshotStore.GetSnapshotPackages(ctx, snapID) if len(pkgs) != 2 { t.Fatalf("expected 2 packages, got %d", len(pkgs)) } } func TestTakeSnapshot_APT_deduplicatesAcrossComponents(t *testing.T) { env := newTestEnv(t) ctx := context.Background() cfg := aptclone.Config{ Suite: "bookworm", Components: []string{"main", "contrib"}, Architectures: []string{"amd64"}, } cfgJSON, _ := json.Marshal(cfg) repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ Name: "apt-multi", Type: "apt", SourceURL: "https://deb.debian.org/debian", SyncMode: "manual", Config: string(cfgJSON), }) localDir := filepath.Join(env.dataDir, "repos", "1", "apt") aptclone.InitEmptyRepo(localDir, cfg) // Same .deb appears in both components. stanza := strings.Join([]string{ "Package: shared-pkg", "Version: 1.0", "Architecture: amd64", "Filename: pool/main/s/shared-pkg/shared-pkg_1.0_amd64.deb", "SHA256: abcdef", "Size: 1024", }, "\n") + "\n" var gz bytes.Buffer gzw := gzip.NewWriter(&gz) gzw.Write([]byte(stanza)) gzw.Close() gzData := gz.Bytes() for _, component := range cfg.Components { binDir := filepath.Join(localDir, "dists", cfg.Suite, component, "binary-amd64") os.MkdirAll(binDir, 0o755) os.WriteFile(filepath.Join(binDir, ".upstream-Packages.gz"), gzData, 0o644) } dest := filepath.Join(localDir, "pool/main/s/shared-pkg/shared-pkg_1.0_amd64.deb") os.MkdirAll(filepath.Dir(dest), 0o755) os.WriteFile(dest, []byte("fake-deb"), 0o644) aptclone.RegenerateMetadata(localDir, cfg) snapID, err := env.snapshotSvc.TakeSnapshot(ctx, repoID, "dedup-test") if err != nil { t.Fatalf("TakeSnapshot: %v", err) } pkgs, _ := env.snapshotStore.GetSnapshotPackages(ctx, snapID) if len(pkgs) != 1 { t.Errorf("expected 1 deduplicated package, got %d", len(pkgs)) } } // --- Diff --- func TestDiff_addsAndRemoves(t *testing.T) { env := newTestEnv(t) ctx := context.Background() repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ Name: "rpm-repo", Type: "rpm", SourceURL: "https://example.com", SyncMode: "manual", }) snap1ID, _ := env.snapshotStore.CreateSnapshot(ctx, repoID, "v1") env.snapshotStore.AddSnapshotPackages(ctx, snap1ID, []store.SnapshotPackage{ {Name: "curl", Version: "7.88-1", Arch: "x86_64", Location: "Packages/curl.rpm", ChecksumType: "sha256"}, {Name: "wget", Version: "1.21-1", Arch: "x86_64", Location: "Packages/wget.rpm", ChecksumType: "sha256"}, }) snap2ID, _ := env.snapshotStore.CreateSnapshot(ctx, repoID, "v2") env.snapshotStore.AddSnapshotPackages(ctx, snap2ID, []store.SnapshotPackage{ {Name: "curl", Version: "7.88-1", Arch: "x86_64", Location: "Packages/curl.rpm", ChecksumType: "sha256"}, {Name: "vim", Version: "9.0-1", Arch: "x86_64", Location: "Packages/vim.rpm", ChecksumType: "sha256"}, }) diff, err := env.snapshotSvc.Diff(ctx, repoID, snap1ID, snap2ID) if err != nil { t.Fatalf("Diff: %v", err) } if diff.Unchanged != 1 { t.Errorf("Unchanged: got %d, want 1", diff.Unchanged) } if len(diff.Added) != 1 || diff.Added[0].Name != "vim" { t.Errorf("Added: got %v, want [vim]", diff.Added) } if len(diff.Removed) != 1 || diff.Removed[0].Name != "wget" { t.Errorf("Removed: got %v, want [wget]", diff.Removed) } } func TestDiff_wrongRepo(t *testing.T) { env := newTestEnv(t) ctx := context.Background() id1, _ := env.repoStore.CreateRepo(ctx, &store.Repo{Name: "r1", Type: "rpm", SourceURL: "https://a.com", SyncMode: "manual"}) id2, _ := env.repoStore.CreateRepo(ctx, &store.Repo{Name: "r2", Type: "rpm", SourceURL: "https://b.com", SyncMode: "manual"}) snap1ID, _ := env.snapshotStore.CreateSnapshot(ctx, id1, "s1") snap2ID, _ := env.snapshotStore.CreateSnapshot(ctx, id2, "s2") _, err := env.snapshotSvc.Diff(ctx, id1, snap1ID, snap2ID) if err == nil { t.Error("expected error when snapshots belong to different repos") } } // --- Rollback --- func TestRollback_removesExtraFiles(t *testing.T) { env := newTestEnv(t) ctx := context.Background() repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ Name: "rpm-repo", Type: "rpm", SourceURL: "https://example.com", SyncMode: "manual", }) localDir := filepath.Join(env.dataDir, "repos", "1", "rpm") buildRPMRepo(t, localDir, []struct{ name, version, href string }{ {"curl", "7.88", "Packages/curl-7.88-1.x86_64.rpm"}, {"wget", "1.21", "Packages/wget-1.21-1.x86_64.rpm"}, }) // Snapshot records only curl. snapID, _ := env.snapshotStore.CreateSnapshot(ctx, repoID, "curl-only") env.snapshotStore.AddSnapshotPackages(ctx, snapID, []store.SnapshotPackage{ {Name: "curl", Version: "7.88-1", Arch: "x86_64", Location: "Packages/curl-7.88-1.x86_64.rpm", ChecksumType: "sha256"}, }) curlPath := filepath.Join(localDir, "Packages", "curl-7.88-1.x86_64.rpm") wgetPath := filepath.Join(localDir, "Packages", "wget-1.21-1.x86_64.rpm") if err := env.snapshotSvc.Rollback(ctx, repoID, snapID); err != nil { t.Fatalf("Rollback: %v", err) } if _, err := os.Stat(wgetPath); err == nil { t.Error("wget should have been removed by rollback") } if _, err := os.Stat(curlPath); err != nil { t.Errorf("curl should still be present: %v", err) } } func TestRollback_redownloadsMissingFiles(t *testing.T) { const fileContent = "fake-rpm-bytes" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(fileContent)) })) defer srv.Close() env := newTestEnv(t) ctx := context.Background() repoID, _ := env.repoStore.CreateRepo(ctx, &store.Repo{ Name: "rpm-repo", Type: "rpm", SourceURL: srv.URL, SyncMode: "manual", }) localDir := filepath.Join(env.dataDir, "repos", "1", "rpm") buildRPMRepo(t, localDir, []struct{ name, version, href string }{ {"curl", "7.88", "Packages/curl-7.88-1.x86_64.rpm"}, }) snapID, _ := env.snapshotStore.CreateSnapshot(ctx, repoID, "v1") env.snapshotStore.AddSnapshotPackages(ctx, snapID, []store.SnapshotPackage{ {Name: "curl", Version: "7.88-1", Arch: "x86_64", Location: "Packages/curl-7.88-1.x86_64.rpm", ChecksumType: "sha256"}, }) curlPath := filepath.Join(localDir, "Packages", "curl-7.88-1.x86_64.rpm") os.Remove(curlPath) if err := env.snapshotSvc.Rollback(ctx, repoID, snapID); err != nil { t.Fatalf("Rollback: %v", err) } if _, err := os.Stat(curlPath); err != nil { t.Errorf("curl should have been re-downloaded: %v", err) } }