v0.33.0 cf26365

release_e2e_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close