1// SPDX-License-Identifier: AGPL-3.0-or-later
2package app
3
4import (
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "strings"
14 "sync/atomic"
15 "testing"
16
17 "bnema.dev/ermite/internal/adapters/blobdisk"
18 "bnema.dev/ermite/internal/adapters/ciartifacts"
19 "bnema.dev/ermite/internal/config"
20 "bnema.dev/ermite/internal/domain"
21 "bnema.dev/ermite/internal/ports"
22 "bnema.dev/ermite/internal/usecase"
23 "github.com/johannesboyne/gofakes3"
24 "github.com/johannesboyne/gofakes3/backend/s3mem"
25 "github.com/stretchr/testify/assert"
26 "github.com/stretchr/testify/require"
27)
28
29func TestReleaseBlobForge(t *testing.T) {
30 for _, backend := range []string{"disk", "s3"} {
31 t.Run(backend, func(t *testing.T) {
32 env := map[string]string{}
33 var admin atomic.Int64
34 if backend == "s3" {
35 mem := s3mem.New()
36 require.NoError(t, mem.CreateBucket("assets"))
37 fake := gofakes3.New(mem).Server()
38 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39 // Allow only the startup probe/list and object/multipart subset. No bucket administration.
40 bucket := r.URL.Path == "/assets" || r.URL.Path == "/assets/"
41 allowed := true
42 if bucket {
43 allowed = r.Method == "HEAD" || (r.Method == "GET" && (r.URL.Query().Get("list-type") == "2" || r.URL.Query().Has("uploads")))
44 }
45 for _, key := range []string{"acl", "policy", "tagging", "lifecycle", "versioning", "cors", "notification", "encryption", "website", "logging"} {
46 if r.URL.Query().Has(key) {
47 allowed = false
48 }
49 }
50 if !allowed {
51 admin.Add(1)
52 http.Error(w, "unexpected bucket operation", 500)
53 return
54 }
55 fake.ServeHTTP(w, r)
56 }))
57 t.Cleanup(srv.Close)
58 env = map[string]string{config.EnvS3Bucket: "assets", config.EnvS3Endpoint: srv.URL, config.EnvS3Region: "us-east-1", config.EnvS3AccessKeyID: "test-private-id", config.EnvS3SecretAccessKey: "test-private-secret", config.EnvS3ForcePathStyle: "1", config.EnvS3Prefix: "forge/"}
59 }
60 h := newHarness(t, harnessOpts{env: env})
61 h.publicRepo("demo", "README.md")
62 rel, err := h.forge.Releases.CreateRelease(h.ctx, domain.Root(), h.name("demo"), ports.CreateReleaseParams{Tag: "v1", TargetCommitish: "main", Title: "Version one"})
63 require.NoError(t, err)
64 asset, err := h.forge.Releases.UploadAsset(h.ctx, domain.Root(), h.name("demo"), ports.UploadAssetParams{ReleaseID: rel.ID, Name: "demo.bin", Size: 7}, strings.NewReader("payload"))
65 require.NoError(t, err)
66 if backend == "disk" {
67 data, err := os.ReadFile(filepath.Join(blobdisk.Dir(h.data), "objects", filepath.FromSlash(asset.BlobKey())))
68 require.NoError(t, err)
69 assert.Equal(t, "payload", string(data))
70 }
71 req, err := http.NewRequestWithContext(h.ctx, "GET", h.url(fmt.Sprintf("/-/github/api/repos/owner/demo/releases/assets/%d", asset.ID)), nil)
72 require.NoError(t, err)
73 req.Header.Set("Accept", "application/octet-stream")
74 req.Header.Set("Range", "bytes=1-3")
75 res, err := h.srv.Client().Do(req)
76 require.NoError(t, err)
77 body, err := io.ReadAll(res.Body)
78 require.NoError(t, err)
79 res.Body.Close()
80 assert.Equal(t, 206, res.StatusCode)
81 assert.Equal(t, "ayl", string(body))
82 assert.Empty(t, res.Header.Get("Location"))
83 res, err = h.srv.Client().Get(h.url(fmt.Sprintf("/-/github/api/repos/owner/demo/releases/%d", rel.ID)))
84 require.NoError(t, err)
85 var obj map[string]any
86 require.NoError(t, json.NewDecoder(res.Body).Decode(&obj))
87 res.Body.Close()
88 assert.Equal(t, 200, res.StatusCode)
89 assert.Contains(t, obj["upload_url"], "http://example.test/-/github/api/uploads/")
90 // New artifacts use the same production backend, not a test-local blob store.
91 wt := h.workTree("main")
92 h.mustGit(wt, "fetch", "-q", h.urlAuth(adminToken, "/demo.git"), "main")
93 h.mustGit(wt, "reset", "--hard", "FETCH_HEAD")
94 c := &ciHarness{harness: h, root: h.api(adminToken), repo: "demo", wt: wt}
95 runner := c.registerRunner("alpha", "ubuntu-latest")
96 _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
97 up := c.jobArtifacts(job)
98 a := up.upload("dist", []byte("artifact bytes"))
99 _, data, err := up.download(a.GetId(), 0)
100 require.NoError(t, err)
101 assert.Equal(t, "artifact bytes", string(data))
102 record, err := h.forge.store.Artifacts().GetArtifact(h.ctx, a.GetId())
103 require.NoError(t, err)
104 assert.Equal(t, domain.CIArtifactStoreBlob, record.Store)
105 _, err = h.forge.Blobs.Stat(h.ctx, record.BlobKey())
106 require.NoError(t, err)
107 assert.Contains(t, h.logs.String(), "blob store ready")
108 assert.NotContains(t, h.logs.String(), "test-private-")
109 assert.Zero(t, admin.Load(), "no bucket-admin operations")
110 })
111 }
112}
113
114func TestBlobStartupProbe(t *testing.T) {
115 _, err := blobStore(context.Background(), config.Config{S3Bucket: "missing", S3Region: "us-east-1"})
116 require.Error(t, err)
117 assert.Contains(t, err.Error(), config.EnvS3Bucket)
118 mem := s3mem.New()
119 srv := httptest.NewServer(gofakes3.New(mem).Server())
120 defer srv.Close()
121 _, err = blobStore(context.Background(), config.Config{S3Bucket: "missing", S3Endpoint: srv.URL, S3Region: "us-east-1", S3AccessKeyID: "private-id", S3SecretAccessKey: "private-secret", S3ForcePathStyle: true})
122 require.Error(t, err)
123 assert.Contains(t, err.Error(), "ERMITE_S3_BUCKET")
124 assert.Contains(t, err.Error(), "not reachable")
125 assert.NotContains(t, err.Error(), "private-")
126}
127
128func TestArtifactLegacyProductionWiring(t *testing.T) {
129 // Seed the old tree before forge startup, exactly as existing installations do.
130 data, err := os.MkdirTemp("", "ermite-legacy")
131 require.NoError(t, err)
132 t.Cleanup(func() { _ = os.RemoveAll(data) })
133 require.NoError(t, prepareDataDir(data))
134 require.NoError(t, os.MkdirAll(ciartifacts.Dir(data), 0700))
135 h := newHarness(t, harnessOpts{env: map[string]string{config.EnvDataDir: data}})
136 c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj", wt: h.workTree("main")}
137 _, err = h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
138 require.NoError(t, err)
139 runner := c.registerRunner("alpha", "ubuntu-latest")
140 _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow})
141 up := c.jobArtifacts(job)
142 created := up.upload("old", []byte("legacy bytes"))
143 store := h.forge.store.Artifacts()
144 a, err := store.GetArtifact(h.ctx, created.GetId())
145 require.NoError(t, err)
146 path := filepath.Join(ciartifacts.Dir(data), fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID))
147 require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
148 require.NoError(t, os.WriteFile(path, []byte("legacy bytes"), 0600))
149 require.NoError(t, h.forge.Blobs.Delete(h.ctx, a.BlobKey()))
150 a.Store = domain.CIArtifactStoreLegacy
151 used, version, err := store.ArtifactUsage(h.ctx, a.Repo)
152 require.NoError(t, err)
153 require.NoError(t, store.ApplyArtifacts(h.ctx, ports.CIArtifactWrite{Repo: a.Repo, Used: used, UsageVersion: version, Put: []domain.CIArtifact{a}}))
154 _, body, err := up.download(a.ID, 0)
155 require.NoError(t, err)
156 assert.Equal(t, "legacy bytes", string(body))
157 // A fresh install does not need or create the legacy tree.
158 fresh := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci = func(cfg *usecase.CIConfig) { assert.Nil(t, cfg.ArtifactFiles) } })
159 assert.NoDirExists(t, ciartifacts.Dir(fresh.data))
160}