Files
silo-server/internal/audiobooks/abs/revocation_test.go
T
CoffeeKnyte 7adedf6429 fix(playback): close the serve-path monitoring and kill-switch gaps
Six defects on byte-serving paths, all of which made the PR's monitoring and
kill-switch claims narrower than documented.

#2/GAP-10 -- the ABS in-flight kill switch was a production no-op. accessLog
wraps every ABS route, and its statusRecorder implemented Write, WriteHeader,
Hijack and Flush but not Unwrap, so http.NewResponseController dead-ended and
SetWriteDeadline returned ErrNotSupported. A multi-GB audiobook pour survived a
RevokeUser. The existing test passed throughout because it called handlers
directly and never saw the middleware; the new test drives the mounted router
over a real socket, and both new assertions fail if Unwrap is removed again.

GAP-11 -- ebook, comic and PDF serving was invisible and un-killable: no meter,
no transfer record, no Refuse, no watcher, on a route that serves cbz/cbr/pdf
files routinely 100 MB-1 GB+. It now follows the ABS file-handler idiom. Note
guardRevocationCut is deliberately *not* reused here: it keys on a session_id
URL param and an ?st= token this route does not carry, so it would have
compiled and silently guarded nothing. Cap-exempt per decision A4 -- admission
is untouched and neither route consumes a stream slot.

#10 -- the no-proxy remote transcode hop forwarded segments through a bare
RollingDeadlineWriter, so bytes on the API hop went unaccounted for a supported
topology. Metering is scoped to media bodies; manifests are excluded so a
rewritten playlist is not counted as media, and a mid-copy failure is no longer
silently discarded.

#16 -- native, proxy and compat subtitle pours were entry-gated only. They now
carry a transport span, a meter and an in-flight watcher. Proxy subtitle bytes
are attributed only when a tracker record already exists: taking Track/Remove
lifecycle ownership per subtitle request would walk straight into the
overlapping-request defect (#1) that Batch 2 addresses. Compat subtitle
extraction is buffered and rejects bitmap formats, so a cut stops delivery but
not extraction already in progress.

M2 -- the proxy deferred tracker Remove with the request context, which is
already canceled on client disconnect, so the Redis DEL never happened and the
key lingered until TTL -- a false over-cap window that could get a legitimate
stream killed. Cleanup now uses a short bounded context.

GAP-13 -- mergeStreams took the freshest record wholesale and never merged
BytesServed, so a stream that poured 8 GiB at an edge could report 0. Merged as
a max, not a sum: the records are two observers of one pour. Fixed in
DedupeSessionInfos too, which had the same hole and feeds the admin view.

#11 needed no behaviour change -- that route was already metered, registered and
watched, and commit c24d8396 plus this Unwrap fix are what make its cut work.
Its comment claimed download-class exemption while the comment above it said the
?token= form is for iOS streaming; both facts and decision A4 are now stated.

Part of #305.
2026-07-30 06:11:43 +00:00

279 lines
8.4 KiB
Go

package abs
import (
"context"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
)
var _ interface{ Unwrap() http.ResponseWriter } = (*statusRecorder)(nil)
func TestStatusRecorderLetsResponseControllerReachSocketDeadline(t *testing.T) {
base := &deadlineResponseRecorder{ResponseRecorder: httptest.NewRecorder()}
wrapped := &statusRecorder{ResponseWriter: base}
if err := http.NewResponseController(wrapped).SetWriteDeadline(time.Now()); err != nil {
t.Fatalf("SetWriteDeadline through statusRecorder: %v", err)
}
if base.deadlines != 1 {
t.Fatalf("underlying deadline calls = %d, want 1", base.deadlines)
}
}
func TestMountedAccessLogMiddlewareLetsRevocationCutRealSocket(t *testing.T) {
store := streamrevoke.New(streamrevoke.Options{WatchInterval: 50 * time.Millisecond})
startedAt := time.Now()
started := make(chan struct{})
var startedOnce sync.Once
h := &Handler{}
router := chi.NewRouter()
router.Use(h.accessLog)
router.Get("/pour", func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
stop := store.WatchAndCutContext(r.Context(), w, "abs-mounted", 1, startedAt)
defer stop()
sw := httpstream.NewRollingDeadlineWriterCtx(r.Context(), w)
sw.WriteHeader(http.StatusOK)
chunk := make([]byte, 32<<10)
for {
if _, err := sw.Write(chunk); err != nil {
return
}
sw.Flush()
startedOnce.Do(func() { close(started) })
time.Sleep(10 * time.Millisecond)
}
})
server := httptest.NewServer(router)
defer server.Close()
response, err := http.Get(server.URL + "/pour")
if err != nil {
if response != nil {
_ = response.Body.Close()
}
t.Fatal(err)
}
defer func() { _ = response.Body.Close() }()
select {
case <-started:
case <-time.After(3 * time.Second):
t.Fatal("mounted ABS pour did not start")
}
if err := store.RevokeSession(context.Background(), "abs-mounted", "test"); err != nil {
t.Fatal(err)
}
readDone := make(chan error, 1)
go func() {
_, readErr := io.Copy(io.Discard, response.Body)
readDone <- readErr
}()
select {
case readErr := <-readDone:
if readErr == nil {
t.Fatal("client reached EOF; revocation did not hang up the socket")
}
case <-time.After(8 * time.Second):
t.Fatal("revocation did not cut the mounted ABS pour")
}
}
func TestBearerAuthCarriesJWTTimestampForUserCutoff(t *testing.T) {
const accessTokenType = "access"
secret := []byte("test-secret-32-bytes-aaaaaaaaaaaaa")
tokens := newMemTokenStore()
revocations := streamrevoke.New(streamrevoke.Options{})
if err := revocations.RevokeUser(context.Background(), 7, "cutoff"); err != nil {
t.Fatal(err)
}
cutoff := revocations.List()[0].RevokedAt
h := New(Dependencies{
MediaStore: noopMediaStore{},
TokenStore: tokens,
Config: &staticConfig{secret: secret},
Revocation: revocations,
})
issue := func(jti string, issuedAt time.Time) string {
t.Helper()
raw, err := issueJWT(secret, Claims{
Type: accessTokenType,
UserID: "7",
JTI: jti,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(issuedAt),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
})
if err != nil {
t.Fatal(err)
}
if err := tokens.InsertToken(context.Background(), ABSToken{
UserID: "7", Type: accessTokenType, JTI: jti, ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
return raw
}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth, ok := absAuthFrom(r)
if !ok {
t.Fatal("missing ctxAuth")
}
if revocations.Refuse(w, "", 7, auth.IssuedAt) {
return
}
w.WriteHeader(http.StatusNoContent)
})
protected := h.bearerAuth(next)
for _, tc := range []struct {
name string
token string
status int
}{
{name: "before cutoff refused", token: issue("old", cutoff.Add(-time.Hour)), status: http.StatusForbidden},
{name: "after cutoff admitted", token: issue("new", cutoff.Add(time.Hour)), status: http.StatusNoContent},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+tc.token)
rec := httptest.NewRecorder()
protected.ServeHTTP(rec, req)
if rec.Code != tc.status {
t.Fatalf("status=%d body=%s, want %d", rec.Code, rec.Body.String(), tc.status)
}
})
}
}
func TestPublicTrackUsesSessionStartedAtForCutoff(t *testing.T) {
h, _ := newPublicTrackHandler(t, "sid-cutoff", "book-1", false)
store := streamrevoke.New(streamrevoke.Options{})
h.deps.Revocation = store
if err := store.RevokeUser(context.Background(), 1, "cutoff"); err != nil {
t.Fatal(err)
}
rec := dispatchTrack(h, http.MethodGet, "sid-cutoff", "1")
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
sessions, ok := h.deps.PlaybackSessionStore.(*fakePlaybackSessionStore)
if !ok {
t.Fatal("unexpected playback session store type")
}
session := sessions.sessions["sid-cutoff"]
session.StartedAt = time.Now().Add(time.Hour)
sessions.sessions["sid-cutoff"] = session
rec = dispatchTrack(h, http.MethodGet, "sid-cutoff", "1")
if rec.Code != http.StatusOK {
t.Fatalf("post-cutoff session status=%d body=%s, want 200", rec.Code, rec.Body.String())
}
}
type revocationFeedFileMediaStore struct {
noopMediaStore
file *models.MediaFile
onGet func()
}
func (s *revocationFeedFileMediaStore) GetMediaFileByID(_ context.Context, id int) (*models.MediaFile, error) {
if s.onGet != nil {
s.onGet()
}
if s.file != nil && s.file.ID == id {
return s.file, nil
}
return nil, ErrNotFound
}
type deadlineResponseRecorder struct {
*httptest.ResponseRecorder
deadlines int
}
func (w *deadlineResponseRecorder) SetWriteDeadline(time.Time) error {
w.deadlines++
return nil
}
func TestPublicFeedFileUsesFeedCreatedAtForCutoff(t *testing.T) {
const (
feedSlug = "cutoff-feed"
ino = 9
)
ctx := context.Background()
feeds := newMemRSSFeedStore()
feed := RSSFeed{
ID: "feed-1",
UserID: "1",
LibraryItemID: "book-1",
Slug: feedSlug,
CreatedAt: time.Now().Add(-time.Hour),
}
feeds.rows[feed.ID] = feed
revocations := streamrevoke.New(streamrevoke.Options{})
if err := revocations.RevokeUser(ctx, 1, "cutoff"); err != nil {
t.Fatal(err)
}
cutoff := revocations.List()[0].RevokedAt
mediaStore := &revocationFeedFileMediaStore{file: &models.MediaFile{
ID: ino, ContentID: feed.LibraryItemID, FilePath: makeTempAudio(t),
}}
h := New(Dependencies{
MediaStore: mediaStore,
RSSFeedStore: feeds,
Revocation: revocations,
})
rec := dispatchABSWithParams(http.MethodGet, "/feed/"+feedSlug+"/file/9",
map[string]string{"slug": feedSlug, "ino": "9"}, nil, "", "", h.handlePublicFeedFile)
if rec.Code != http.StatusForbidden {
t.Fatalf("pre-cutoff feed status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
feed.CreatedAt = cutoff.Add(time.Nanosecond)
feeds.rows[feed.ID] = feed
for !time.Now().After(feed.CreatedAt) {
time.Sleep(time.Nanosecond)
}
rec = dispatchABSWithParams(http.MethodGet, "/feed/"+feedSlug+"/file/9",
map[string]string{"slug": feedSlug, "ino": "9"}, nil, "", "", h.handlePublicFeedFile)
if rec.Code != http.StatusOK {
t.Fatalf("post-cutoff feed status=%d body=%s, want 200", rec.Code, rec.Body.String())
}
// Revoke again between the handler's initial Refuse check and WatchAndCut.
// The deadline recorder makes the watcher observable without a real socket.
mediaStore.onGet = func() {
mediaStore.onGet = nil
if err := revocations.RevokeUser(ctx, 1, "cut during pour"); err != nil {
t.Fatalf("RevokeUser during pour: %v", err)
}
}
req := httptest.NewRequest(http.MethodGet, "/feed/"+feedSlug+"/file/9", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("slug", feedSlug)
rctx.URLParams.Add("ino", "9")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
deadlineRec := &deadlineResponseRecorder{ResponseRecorder: httptest.NewRecorder()}
h.handlePublicFeedFile(deadlineRec, req)
if deadlineRec.Code != http.StatusOK {
t.Fatalf("watched feed status=%d body=%s, want 200", deadlineRec.Code, deadlineRec.Body.String())
}
if deadlineRec.deadlines != 1 {
t.Fatalf("write deadlines=%d, want 1 from WatchAndCut", deadlineRec.deadlines)
}
}