Merge master
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

This commit is contained in:
2026-10-25 15:21:56 +11:00
32 changed files with 1232 additions and 88 deletions
+22 -2
View File
@@ -2,6 +2,7 @@ package v1
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
@@ -11,6 +12,7 @@ import (
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -121,7 +123,7 @@ func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote
if err != nil {
var proxyErr *proxy.ProxyError
if errors.As(err, &proxyErr) {
http.Error(w, proxyErr.Message, proxyErr.Status)
writeProxyError(w, proxyErr)
return
}
slog.Error("proxy fetch failed", "remote", remote.Name, "path", path, "error", err)
@@ -149,6 +151,24 @@ func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote
io.Copy(w, body)
}
// writeProxyError answers a quarantine refusal as JSON and anything else as text.
func writeProxyError(w http.ResponseWriter, pe *proxy.ProxyError) {
if pe.RetryAfter > 0 {
w.Header().Set("Retry-After", strconv.Itoa(pe.RetryAfter))
}
if pe.Published.IsZero() {
http.Error(w, pe.Message, pe.Status)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(pe.Status)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": pe.Message,
"published": pe.Published.Format(time.RFC3339),
"available": pe.Available.Format(time.RFC3339),
})
}
func rewritesMutable(prov provider.Provider, path string) bool {
_, sr := prov.(provider.ServeRewriter)
_, rr := prov.(provider.ResponseRewriter)
@@ -176,7 +196,7 @@ func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
if err != nil {
var proxyErr *proxy.ProxyError
if errors.As(err, &proxyErr) {
http.Error(w, proxyErr.Message, proxyErr.Status)
writeProxyError(w, proxyErr)
return
}
slog.Error("proxy head failed", "remote", remote.Name, "path", path, "error", err)
+16
View File
@@ -0,0 +1,16 @@
package v1
import (
"net/http/httptest"
"testing"
"git.unkin.net/unkin/artifactapi/internal/proxy"
)
func TestWriteProxyErrorRetryAfter(t *testing.T) {
w := httptest.NewRecorder()
writeProxyError(w, &proxy.ProxyError{Status: 503, Message: "quarantine check unavailable", RetryAfter: 30})
if w.Code != 503 || w.Header().Get("Retry-After") != "30" {
t.Errorf("code %d, Retry-After %q", w.Code, w.Header().Get("Retry-After"))
}
}
+10 -1
View File
@@ -72,7 +72,7 @@ func (h *RemotesHandler) get(w http.ResponseWriter, r *http.Request) {
const defaultMutableTTL = 3600
func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
remote := models.Remote{MutableTTL: defaultMutableTTL}
remote := models.Remote{MutableTTL: defaultMutableTTL, QuarantineDays: models.DefaultQuarantineDays}
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
@@ -108,6 +108,10 @@ func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := remote.ValidateQuarantine(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := h.db.CreateRemote(r.Context(), &remote); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -130,6 +134,7 @@ func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) {
var remote models.Remote
if oldKnown {
remote.MutableTTL = existing.MutableTTL
remote.QuarantineDays = existing.QuarantineDays
}
if err := json.NewDecoder(r.Body).Decode(&remote); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
@@ -152,6 +157,10 @@ func (h *RemotesHandler) update(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := remote.ValidateQuarantine(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := h.db.UpdateRemote(r.Context(), &remote); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
+64
View File
@@ -100,3 +100,67 @@ func TestSyncIntervalAPI(t *testing.T) {
}
}
}
func TestQuarantineDaysDefault(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
h := NewRemotesHandler(db, nil, nil).Routes()
days := func() int {
t.Helper()
r, err := db.GetRemote(context.Background(), "q-days")
if err != nil {
t.Fatal(err)
}
return r.QuarantineDays
}
const body = `"package_type":"npm","base_url":"https://example.com","quarantine_enabled":true`
for i, s := range []struct {
method, path, json string
want int
}{
{"POST", "/", `{"name":"q-days",` + body + `}`, 3},
{"PUT", "/q-days", `{` + body + `,"quarantine_days":14}`, 14},
{"PUT", "/q-days", `{` + body + `}`, 14},
} {
if c := do(t, h, s.method, s.path, s.json); c/100 != 2 {
t.Fatalf("step %d = %d", i, c)
}
if got := days(); got != s.want {
t.Errorf("step %d: quarantine_days = %d, want %d", i, got, s.want)
}
}
}
func TestQuarantineDaysValidation(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
h := NewRemotesHandler(db, nil, nil).Routes()
const body = `"package_type":"npm","base_url":"https://example.com"`
for i, s := range []struct {
method, path, json string
want int
}{
{"POST", "/", `{"name":"q-val",` + body + `,"quarantine_enabled":true,"quarantine_days":0}`, 400},
{"POST", "/", `{"name":"q-val",` + body + `,"quarantine_enabled":true,"quarantine_days":-1}`, 400},
{"POST", "/", `{"name":"q-val",` + body + `,"quarantine_days":0}`, 201},
{"PUT", "/q-val", `{` + body + `,"quarantine_enabled":true}`, 400},
{"PUT", "/q-val", `{` + body + `,"quarantine_enabled":true,"quarantine_days":1}`, 200},
{"PUT", "/q-val", `{` + body + `,"quarantine_enabled":true,"quarantine_days":0}`, 400},
} {
if c := do(t, h, s.method, s.path, s.json); c != s.want {
t.Errorf("step %d %s %s = %d, want %d", i, s.method, s.path, c, s.want)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
package v2
import (
"encoding/json"
"net/http/httptest"
"testing"
"git.unkin.net/unkin/artifactapi/internal/database"
)
func TestVirtualParallelFetchRoundTrip(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
h := NewVirtualsHandler(db).Routes()
parallel := func() bool {
t.Helper()
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/vpf", nil))
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || w.Code != 200 {
t.Fatalf("get = %d %s", w.Code, w.Body)
}
v, ok := got["parallel_fetch"].(bool)
if !ok {
t.Fatalf("parallel_fetch missing from %s", w.Body)
}
return v
}
const body = `"package_type":"rpm","members":["a"]`
steps := []struct {
method, path, json string
want bool
}{
{"POST", "/", `{"name":"vpf",` + body + `}`, false},
{"PUT", "/vpf", `{` + body + `,"parallel_fetch":true}`, true},
{"PUT", "/vpf", `{` + body + `,"parallel_fetch":false}`, false},
}
for i, s := range steps {
if c := do(t, h, s.method, s.path, s.json); c/100 != 2 {
t.Fatalf("step %d %s %s = %d", i, s.method, s.path, c)
}
if got := parallel(); got != s.want {
t.Errorf("step %d: parallel_fetch = %v, want %v", i, got, s.want)
}
}
if c := do(t, h, "DELETE", "/vpf", ""); c != 204 {
t.Fatalf("delete = %d", c)
}
}