1package archesrv
2
3import (
4 "io"
5 "log/slog"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "os"
10 "path/filepath"
11 "testing"
12 "time"
13
14 "arche/internal/repo"
15 "arche/internal/store"
16 "arche/internal/syncpkg"
17 "arche/internal/wc"
18
19 _ "github.com/mattn/go-sqlite3"
20)
21
22func newTestServer(t *testing.T) (*forgeServer, *httptest.Server) {
23 t.Helper()
24 dir := t.TempDir()
25 db, err := openDB(filepath.Join(dir, "server.db"))
26 if err != nil {
27 t.Fatalf("openDB: %v", err)
28 }
29 t.Cleanup(func() { db.Close() })
30
31 cfg := DefaultConfig()
32 cfg.Storage.DataDir = dir
33
34 s := &forgeServer{
35 db: db,
36 cfg: cfg,
37 log: slog.New(slog.NewTextHandler(io.Discard, nil)),
38 }
39 ts := httptest.NewServer(s.routes())
40 t.Cleanup(ts.Close)
41 return s, ts
42}
43
44func makeLocalRepoWithCommit(t *testing.T) *repo.Repo {
45 t.Helper()
46 dir := t.TempDir()
47 r, err := repo.Init(dir)
48 if err != nil {
49 t.Fatalf("repo.Init local: %v", err)
50 }
51 t.Cleanup(func() { r.Close() })
52
53 if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello arche\n"), 0o644); err != nil {
54 t.Fatalf("WriteFile: %v", err)
55 }
56
57 w := wc.New(r)
58 _, commitID, err := w.Snap("initial commit")
59 if err != nil {
60 t.Fatalf("Snap: %v", err)
61 }
62
63 tx, err := r.Store.Begin()
64 if err != nil {
65 t.Fatalf("Store.Begin: %v", err)
66 }
67 if err := r.Store.SetBookmark(tx, store.Bookmark{Name: "main", CommitID: commitID}); err != nil {
68 r.Store.Rollback(tx)
69 t.Fatalf("SetBookmark: %v", err)
70 }
71 if err := r.Store.Commit(tx); err != nil {
72 t.Fatalf("Store.Commit: %v", err)
73 }
74 return r
75}
76
77func TestForgeServer_SetupPage(t *testing.T) {
78 _, ts := newTestServer(t)
79 resp, err := http.Get(ts.URL + "/setup")
80 if err != nil {
81 t.Fatalf("GET /setup: %v", err)
82 }
83 defer resp.Body.Close()
84 if resp.StatusCode != http.StatusOK {
85 t.Errorf("GET /setup: want 200, got %d", resp.StatusCode)
86 }
87}
88
89func TestForgeServer_SyncPushPull(t *testing.T) {
90 s, ts := newTestServer(t)
91
92 admin, err := s.db.CreateUser("admin", "adminpass", true)
93 if err != nil {
94 t.Fatalf("CreateUser: %v", err)
95 }
96 token, err := s.db.CreateAPIToken(admin.ID, "test")
97 if err != nil {
98 t.Fatalf("CreateAPIToken: %v", err)
99 }
100
101 if _, err := s.db.CreateRepo("testrepo", "test repo", "private"); err != nil {
102 t.Fatalf("CreateRepo: %v", err)
103 }
104 if _, err := repo.Init(filepath.Join(s.dataDir(), "testrepo")); err != nil {
105 t.Fatalf("repo.Init server side: %v", err)
106 }
107
108 localRepo := makeLocalRepoWithCommit(t)
109
110 remoteURL := ts.URL + "/testrepo"
111
112 pushClient := syncpkg.NewClient(localRepo, remoteURL, token)
113 if err := pushClient.Push(); err != nil {
114 t.Fatalf("Push: %v", err)
115 }
116
117 pullDir := t.TempDir()
118 pullRepo, err := repo.Init(pullDir)
119 if err != nil {
120 t.Fatalf("repo.Init pull: %v", err)
121 }
122 defer pullRepo.Close()
123
124 pullClient := syncpkg.NewClient(pullRepo, remoteURL, token)
125 if err := pullClient.Pull(); err != nil {
126 t.Fatalf("Pull: %v", err)
127 }
128
129 localBMs, err := localRepo.Store.ListBookmarks()
130 if err != nil {
131 t.Fatalf("ListBookmarks local: %v", err)
132 }
133 pullBMs, err := pullRepo.Store.ListBookmarks()
134 if err != nil {
135 t.Fatalf("ListBookmarks pulled: %v", err)
136 }
137 if len(pullBMs) == 0 {
138 t.Fatal("pulled repo has no bookmarks after Pull()")
139 }
140 for _, lb := range localBMs {
141 found := false
142 for _, pb := range pullBMs {
143 if lb.Name == pb.Name && lb.CommitID == pb.CommitID {
144 found = true
145 break
146 }
147 }
148 if !found {
149 t.Errorf("bookmark %q (commitID %x) missing from pulled repo", lb.Name, lb.CommitID[:8])
150 }
151 }
152
153 time.Sleep(50 * time.Millisecond)
154}
155
156func TestForgeServer_SyncUnauthorized(t *testing.T) {
157 s, ts := newTestServer(t)
158
159 admin, _ := s.db.CreateUser("admin", "adminpass", true)
160 _, _ = s.db.CreateAPIToken(admin.ID, "test")
161 _, _ = s.db.CreateRepo("privaterepo", "", "private")
162 _, _ = repo.Init(filepath.Join(s.dataDir(), "privaterepo"))
163
164 resp, err := http.Get(ts.URL + "/privaterepo/arche/v1/info")
165 if err != nil {
166 t.Fatalf("GET info: %v", err)
167 }
168 defer resp.Body.Close()
169 if resp.StatusCode != http.StatusUnauthorized {
170 t.Errorf("expected 401 for unauthenticated read, got %d", resp.StatusCode)
171 }
172}
173
174func TestForgeServer_SyncWrongToken(t *testing.T) {
175 s, ts := newTestServer(t)
176
177 admin, _ := s.db.CreateUser("admin", "adminpass", true)
178 _, _ = s.db.CreateAPIToken(admin.ID, "test")
179 _, _ = s.db.CreateRepo("privaterepo", "", "private")
180 _, _ = repo.Init(filepath.Join(s.dataDir(), "privaterepo"))
181
182 req, _ := http.NewRequest(http.MethodGet, ts.URL+"/privaterepo/arche/v1/info", nil)
183 req.Header.Set("Authorization", "Bearer wrongtoken")
184 resp, err := http.DefaultClient.Do(req)
185 if err != nil {
186 t.Fatalf("GET info: %v", err)
187 }
188 defer resp.Body.Close()
189 if resp.StatusCode != http.StatusUnauthorized {
190 t.Errorf("expected 401 for wrong token, got %d", resp.StatusCode)
191 }
192}
193
194func TestForgeServer_AdminCRUD(t *testing.T) {
195 s, ts := newTestServer(t)
196
197 _, err := s.db.CreateUser("admin", "adminpass", true)
198 if err != nil {
199 t.Fatalf("CreateUser: %v", err)
200 }
201
202 jar := newCookieJar()
203 client := &http.Client{
204 Jar: jar,
205 CheckRedirect: func(req *http.Request, via []*http.Request) error {
206 return http.ErrUseLastResponse
207 },
208 }
209
210 loginResp, err := client.PostForm(ts.URL+"/login", map[string][]string{
211 "username": {"admin"},
212 "password": {"adminpass"},
213 })
214 if err != nil {
215 t.Fatalf("POST /login: %v", err)
216 }
217 loginResp.Body.Close()
218 if loginResp.StatusCode != http.StatusFound && loginResp.StatusCode != http.StatusSeeOther {
219 t.Fatalf("POST /login: want redirect (302/303), got %d", loginResp.StatusCode)
220 }
221
222 repoResp, err := client.PostForm(ts.URL+"/admin/repos", map[string][]string{
223 "name": {"myrepo"},
224 "description": {"a test repo"},
225 "visibility": {"private"},
226 })
227 if err != nil {
228 t.Fatalf("POST /admin/repos: %v", err)
229 }
230 repoResp.Body.Close()
231 if repoResp.StatusCode >= 400 {
232 t.Errorf("POST /admin/repos: got %d", repoResp.StatusCode)
233 }
234
235 rec, err := s.db.GetRepo("myrepo")
236 if err != nil || rec == nil {
237 t.Fatalf("GetRepo: repo not found after create: %v", err)
238 }
239 if rec.Description != "a test repo" {
240 t.Errorf("Description: want %q, got %q", "a test repo", rec.Description)
241 }
242
243 userResp, err := client.PostForm(ts.URL+"/admin/users", map[string][]string{
244 "username": {"bob"},
245 "password": {"bobpass"},
246 })
247 if err != nil {
248 t.Fatalf("POST /admin/users: %v", err)
249 }
250 userResp.Body.Close()
251 if userResp.StatusCode >= 400 {
252 t.Errorf("POST /admin/users: got %d", userResp.StatusCode)
253 }
254
255 users, err := s.db.ListUsers()
256 if err != nil {
257 t.Fatalf("ListUsers: %v", err)
258 }
259 found := false
260 for _, u := range users {
261 if u.Username == "bob" {
262 found = true
263 }
264 }
265 if !found {
266 t.Error("user 'bob' not found after admin create")
267 }
268}
269
270type simpleCookieJar struct {
271 cookies []*http.Cookie
272}
273
274func newCookieJar() *simpleCookieJar { return &simpleCookieJar{} }
275
276func (j *simpleCookieJar) SetCookies(_ *url.URL, cookies []*http.Cookie) {
277 j.cookies = append(j.cookies, cookies...)
278}
279
280func (j *simpleCookieJar) Cookies(_ *url.URL) []*http.Cookie {
281 return j.cookies
282}