feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled

This commit is contained in:
admin committed 2026-08-28 14:02:07 +02:00
1 parent a000152423
commit f02ec9977c
41 files changed
+3277 -151

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
package updateagent
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
func TestRedactSecretRemovesCredentialFromErrors(t *testing.T) {
t.Parallel()
const secret = "sensitive-registry-token"
redacted := redactSecret(errors.New("registry rejected "+secret), secret)
if strings.Contains(redacted.Error(), secret) || !strings.Contains(redacted.Error(), "[redacted]") {
t.Fatalf("credential was not redacted: %q", redacted)
}
}
type blockingRunner struct {
started chan updateprotocol.Request
release chan struct{}
}
func (r *blockingRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) error {
progress(updateprotocol.PhasePulling, "image is being pulled")
r.started <- request
select {
case <-ctx.Done():
return ctx.Err()
case <-r.release:
return nil
}
}
func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
t.Parallel()
runner := &blockingRunner{started: make(chan updateprotocol.Request, 1), release: make(chan struct{})}
controller := NewController(runner, slog.New(slog.NewTextHandler(io.Discard, nil)))
handler := controller.Handler()
invalid := httptest.NewRecorder()
handler.ServeHTTP(invalid, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"stable","registry_username":"user","registry_token":"secret-token"}`)))
if invalid.Code != http.StatusBadRequest {
t.Fatalf("invalid version accepted: status=%d", invalid.Code)
}
trailing := httptest.NewRecorder()
handler.ServeHTTP(trailing, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v1.2.3","registry_username":"user","registry_token":"secret-token"} {}`)))
if trailing.Code != http.StatusBadRequest {
t.Fatalf("request with trailing JSON accepted: status=%d", trailing.Code)
}
const secret = "registry-token-must-not-leak"
body := []byte(`{"version":"v1.2.3","registry_username":"user","registry_token":"` + secret + `"}`)
accepted := httptest.NewRecorder()
handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
if accepted.Code != http.StatusAccepted {
t.Fatalf("valid update rejected: status=%d body=%s", accepted.Code, accepted.Body.String())
}
select {
case request := <-runner.started:
if request.Version != "v1.2.3" || request.RegistryToken != secret {
t.Fatalf("runner received an altered request: %#v", request)
}
case <-time.After(time.Second):
t.Fatal("update runner did not start")
}
busy := httptest.NewRecorder()
handler.ServeHTTP(busy, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
if busy.Code != http.StatusConflict {
t.Fatalf("parallel update was not rejected: status=%d", busy.Code)
}
status := httptest.NewRecorder()
handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/v1/status", nil))
if status.Code != http.StatusOK || strings.Contains(status.Body.String(), secret) || !strings.Contains(status.Body.String(), `"phase":"pulling"`) {
t.Fatalf("unsafe or invalid public status: status=%d body=%s", status.Code, status.Body.String())
}
close(runner.release)
deadline := time.Now().Add(time.Second)
for controller.Status().Phase != updateprotocol.PhaseComplete && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if controller.Status().Phase != updateprotocol.PhaseComplete {
t.Fatalf("update did not complete: %#v", controller.Status())
}
}