check / check (push) Canceled after 0s
seal.DeriveKeys derives the session hash and block keys, the encrypted URL key and the CSRF key from the signing key, with their salts declared beside it, and returns them as one seal.Keys. internal/providers calls it once at startup and provides the value to the fx graph. The session manager, the encrypted URL generator and the CSRF protection each take their own key. session.NewManager, encurl.NewGenerator, newCSRFProtect and handlers.New return no error, as nothing in them can fail now. Each key is derived as before, with seal.DeriveKey and the same salt. The signing key itself stays the HMAC key of signed URLs and the login password. README.md says under signing_key what it protects. Model: opus-5-5
176 lines
5.0 KiB
Go
176 lines
5.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"sneak.berlin/go/pixa/internal/encurl"
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
"sneak.berlin/go/pixa/internal/seal"
|
|
)
|
|
|
|
// newEncTestServer builds a router serving the encrypted-URL route with a
|
|
// generator seeded by the shared test signing key. The image service is left
|
|
// nil: these tests exercise validation that rejects a token before any image
|
|
// is fetched, so the handler must never reach the service.
|
|
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
|
t.Helper()
|
|
|
|
keys, err := seal.DeriveKeys(testSigningKey)
|
|
if err != nil {
|
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
|
}
|
|
|
|
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
|
|
|
h := &Handlers{
|
|
log: slog.New(slog.DiscardHandler),
|
|
encGen: encGen,
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
|
|
|
return encGen, r
|
|
}
|
|
|
|
// getEncToken issues a GET for the given token and returns the recorder.
|
|
func getEncToken(srv http.Handler, token string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/v1/e/"+token+"/img.jpg", nil)
|
|
rec := httptest.NewRecorder()
|
|
srv.ServeHTTP(rec, req)
|
|
|
|
return rec
|
|
}
|
|
|
|
// TestHandleImageEnc_OverLimitDimension_Returns400 verifies that a decrypted
|
|
// token requesting a dimension beyond MaxDimension is rejected with 400
|
|
// instead of reaching the image processor and libvips.
|
|
func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
encGen, srv := newEncTestServer(t)
|
|
|
|
token, err := encGen.Generate(&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: "cdn.example.com",
|
|
Path: "/photo.jpg",
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 100000, Height: 100000},
|
|
},
|
|
}, time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("Generate() error = %v", err)
|
|
}
|
|
|
|
rec := getEncToken(srv, token)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// TestHandleImageEnc_InvalidFitMode_Returns400 verifies that a decrypted token
|
|
// carrying an unrecognized fit mode is rejected with 400 rather than surfacing
|
|
// as a 500 from the image processor's default branch.
|
|
func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
encGen, srv := newEncTestServer(t)
|
|
|
|
token, err := encGen.Generate(&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: "cdn.example.com",
|
|
Path: "/photo.jpg",
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 800, Height: 600},
|
|
FitMode: imagerequest.Fit("bogus"),
|
|
},
|
|
}, time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("Generate() error = %v", err)
|
|
}
|
|
|
|
rec := getEncToken(srv, token)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// TestHandleImageEnc_IfNoneMatch_Returns304 verifies that an image served
|
|
// through an encrypted URL carries an ETag, and that a request whose
|
|
// If-None-Match is that ETag is answered 304 Not Modified with no body.
|
|
func TestHandleImageEnc_IfNoneMatch_Returns304(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
|
target := encPhotoURL(t, h)
|
|
|
|
rec := httptest.NewRecorder()
|
|
srv.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, target, nil))
|
|
|
|
etag := rec.Header().Get("ETag")
|
|
t.Logf("GET: %d, ETag %q", rec.Code, etag)
|
|
|
|
if rec.Code != http.StatusOK || etag == "" {
|
|
t.Fatalf("GET: status = %d, ETag = %q, want %d and an ETag",
|
|
rec.Code, etag, http.StatusOK)
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
|
req.Header.Set("If-None-Match", etag)
|
|
|
|
rec = httptest.NewRecorder()
|
|
srv.ServeHTTP(rec, req)
|
|
t.Logf("GET with If-None-Match: %d, %d body bytes", rec.Code, rec.Body.Len())
|
|
|
|
if rec.Code != http.StatusNotModified || rec.Body.Len() != 0 {
|
|
t.Errorf("status = %d with %d body bytes, want %d with none",
|
|
rec.Code, rec.Body.Len(), http.StatusNotModified)
|
|
}
|
|
}
|
|
|
|
// TestHandleImageEnc_HEAD_ReturnsHeadersOnly verifies that HEAD on an
|
|
// encrypted URL is answered 200 with the headers GET sends and no body.
|
|
func TestHandleImageEnc_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h, _ := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
|
|
|
r := chi.NewRouter()
|
|
r.Head("/v1/e/{token}/*", h.HandleImageEnc())
|
|
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodHead, encPhotoURL(t, h), nil))
|
|
t.Logf("HEAD: %d, headers %v, %d body bytes",
|
|
rec.Code, rec.Header(), rec.Body.Len())
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
for _, name := range []string{
|
|
"Content-Type", "Content-Length", "Cache-Control", "ETag",
|
|
} {
|
|
if rec.Header().Get(name) == "" {
|
|
t.Errorf("HEAD response has no %s", name)
|
|
}
|
|
}
|
|
|
|
if rec.Body.Len() != 0 {
|
|
t.Errorf("HEAD response body has %d bytes, want none", rec.Body.Len())
|
|
}
|
|
}
|