Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.
Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
today rather than a milestone that would change it.
Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
4.6 KiB
Go
129 lines
4.6 KiB
Go
package server
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
)
|
|
|
|
func okHandler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Body != nil {
|
|
if _, err := io.ReadAll(r.Body); err != nil {
|
|
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
}
|
|
|
|
func TestLimitRequestBody_RejectsOversizedBody(t *testing.T) {
|
|
h := limitRequestBody(okHandler())
|
|
|
|
big := strings.NewReader(strings.Repeat("x", maxRequestBody+1))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/playlists", big))
|
|
if w.Code != http.StatusRequestEntityTooLarge {
|
|
t.Errorf("oversized body: status = %d, want the handler's read to fail", w.Code)
|
|
}
|
|
|
|
small := strings.NewReader(`{"name":"ok"}`)
|
|
w = httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/playlists", small))
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("small body: status = %d, want 204", w.Code)
|
|
}
|
|
}
|
|
|
|
// The cases a cross-site form or no-preflight fetch could produce must be
|
|
// refused when the session cookie rides along; everything a real client
|
|
// sends must pass.
|
|
func TestRequireJSONForCookieWrites(t *testing.T) {
|
|
cookie := &http.Cookie{Name: auth.SessionCookieName, Value: "tok"}
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
contentType string
|
|
body string
|
|
withCookie bool
|
|
want int
|
|
}{
|
|
{"form post with cookie", http.MethodPost, "/api/me/password", "application/x-www-form-urlencoded", "a=b", true, http.StatusUnsupportedMediaType},
|
|
{"text/plain post with cookie", http.MethodPost, "/api/me/password", "text/plain", `{"a":1}`, true, http.StatusUnsupportedMediaType},
|
|
{"multipart with cookie", http.MethodPut, "/api/me/profile", "multipart/form-data; boundary=x", "--x--", true, http.StatusUnsupportedMediaType},
|
|
{"body without content type", http.MethodPost, "/api/me/profile", "", `{"a":1}`, true, http.StatusUnsupportedMediaType},
|
|
|
|
{"json with cookie", http.MethodPost, "/api/me/password", "application/json", `{}`, true, http.StatusNoContent},
|
|
{"json with charset", http.MethodPost, "/api/me/password", "application/json; charset=utf-8", `{}`, true, http.StatusNoContent},
|
|
{"bodiless delete with cookie", http.MethodDelete, "/api/me/sessions/1", "", "", true, http.StatusNoContent},
|
|
{"form post without cookie (bearer client)", http.MethodPost, "/api/me/password", "text/plain", "x", false, http.StatusNoContent},
|
|
{"subsonic post is not /api", http.MethodPost, "/rest/scrobble", "application/x-www-form-urlencoded", "id=1", true, http.StatusNoContent},
|
|
{"GET is never checked", http.MethodGet, "/api/me", "text/plain", "", true, http.StatusNoContent},
|
|
}
|
|
h := requireJSONForCookieWrites(okHandler())
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var body io.Reader
|
|
if tc.body != "" {
|
|
body = strings.NewReader(tc.body)
|
|
}
|
|
req := httptest.NewRequest(tc.method, tc.path, body)
|
|
if tc.contentType != "" {
|
|
req.Header.Set("Content-Type", tc.contentType)
|
|
}
|
|
if tc.withCookie {
|
|
req.AddCookie(cookie)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
if w.Code != tc.want {
|
|
t.Errorf("status = %d, want %d", w.Code, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityHeaders(t *testing.T) {
|
|
serve := func(hops int, proto string) http.Header {
|
|
h := securityHeaders(func() int { return hops })(okHandler())
|
|
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
|
if proto != "" {
|
|
req.Header.Set("X-Forwarded-Proto", proto)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
return w.Header()
|
|
}
|
|
|
|
base := serve(0, "")
|
|
for key, want := range map[string]string{
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
"X-Frame-Options": "DENY",
|
|
} {
|
|
if got := base.Get(key); got != want {
|
|
t.Errorf("%s = %q, want %q", key, got, want)
|
|
}
|
|
}
|
|
if !strings.Contains(base.Get("Permissions-Policy"), "microphone=()") {
|
|
t.Errorf("Permissions-Policy = %q", base.Get("Permissions-Policy"))
|
|
}
|
|
|
|
// HSTS with no trusted proxy, and through one saying https or http.
|
|
if got := serve(0, "https").Get("Strict-Transport-Security"); got != "" {
|
|
t.Errorf("hops 0 + forwarded https: HSTS = %q, want none", got)
|
|
}
|
|
if got := serve(1, "https").Get("Strict-Transport-Security"); got == "" {
|
|
t.Error("hops 1 + forwarded https: want HSTS")
|
|
}
|
|
if got := serve(1, "").Get("Strict-Transport-Security"); got != "" {
|
|
t.Errorf("plain request: HSTS = %q, want none", got)
|
|
}
|
|
}
|