223 lines
7.3 KiB
Go
223 lines
7.3 KiB
Go
package proxy
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"os"
|
||
|
|
"sync"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/Silo-Server/silo-server/internal/config"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/nodeconfig"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/nodesessions"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/streamtoken"
|
||
|
|
)
|
||
|
|
|
||
|
|
const proxyTrackerTestSecret = "proxy-tracker-test-secret"
|
||
|
|
|
||
|
|
func newMountedProxyTestServer(t *testing.T, mediaPath string) (*Server, *deleteCaptureHook, string) {
|
||
|
|
t.Helper()
|
||
|
|
watcher := nodeconfig.NewWatcher(nil, nil, nil, nodeconfig.BootstrapOverrides{})
|
||
|
|
cfg := &config.Config{}
|
||
|
|
cfg.Auth.JWTSecret = proxyTrackerTestSecret
|
||
|
|
watcher.SetConfigForTest(cfg)
|
||
|
|
tracker, redisState := newProxyTestTracker(t)
|
||
|
|
server := NewServer(watcher, tracker)
|
||
|
|
token, err := streamtoken.Sign(streamtoken.Claims{
|
||
|
|
SessionID: "session-1",
|
||
|
|
MediaPath: mediaPath,
|
||
|
|
TranscodeNode: "http://transcode",
|
||
|
|
UserID: 7,
|
||
|
|
ProfileID: "profile-1",
|
||
|
|
MediaFileID: 42,
|
||
|
|
}, proxyTrackerTestSecret, time.Minute)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("sign stream token: %v", err)
|
||
|
|
}
|
||
|
|
return server, redisState, token
|
||
|
|
}
|
||
|
|
|
||
|
|
type blockingResponseWriter struct {
|
||
|
|
header http.Header
|
||
|
|
entered chan struct{}
|
||
|
|
release chan struct{}
|
||
|
|
once sync.Once
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *blockingResponseWriter) Header() http.Header { return w.header }
|
||
|
|
func (w *blockingResponseWriter) WriteHeader(int) {}
|
||
|
|
func (w *blockingResponseWriter) Write(p []byte) (int, error) {
|
||
|
|
w.once.Do(func() { close(w.entered) })
|
||
|
|
<-w.release
|
||
|
|
return len(p), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMountedDirectOverlappingRangesKeepSessionUntilFinalRequest(t *testing.T) {
|
||
|
|
path := t.TempDir() + "/movie.bin"
|
||
|
|
if err := os.WriteFile(path, bytes.Repeat([]byte("x"), 64<<10), 0o600); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
server, redisState, token := newMountedProxyTestServer(t, path)
|
||
|
|
firstWriter := &blockingResponseWriter{
|
||
|
|
header: make(http.Header),
|
||
|
|
entered: make(chan struct{}),
|
||
|
|
release: make(chan struct{}),
|
||
|
|
}
|
||
|
|
firstDone := make(chan struct{})
|
||
|
|
go func() {
|
||
|
|
defer close(firstDone)
|
||
|
|
req := httptest.NewRequest(http.MethodGet, "/stream/direct/"+token, nil)
|
||
|
|
req.Header.Set("Range", "bytes=0-32767")
|
||
|
|
server.Handler().ServeHTTP(firstWriter, req)
|
||
|
|
}()
|
||
|
|
select {
|
||
|
|
case <-firstWriter.entered:
|
||
|
|
case <-time.After(2 * time.Second):
|
||
|
|
t.Fatal("first mounted range request did not begin its pour")
|
||
|
|
}
|
||
|
|
|
||
|
|
second := httptest.NewRequest(http.MethodGet, "/stream/direct/"+token, nil)
|
||
|
|
second.Header.Set("Range", "bytes=32768-65535")
|
||
|
|
secondRec := httptest.NewRecorder()
|
||
|
|
server.Handler().ServeHTTP(secondRec, second)
|
||
|
|
if secondRec.Code != http.StatusPartialContent {
|
||
|
|
t.Fatalf("second range status = %d, want 206", secondRec.Code)
|
||
|
|
}
|
||
|
|
snapshot := server.tracker.Snapshot()
|
||
|
|
if len(snapshot) != 1 || snapshot[0].BytesServed == 0 {
|
||
|
|
t.Fatalf("first request lost tracking after second completed: %+v", snapshot)
|
||
|
|
}
|
||
|
|
key := nodesessions.KeyPrefix + server.tracker.NodeHash() + ":session-1"
|
||
|
|
if !redisState.has(key) {
|
||
|
|
t.Fatal("Redis key was deleted while the first range was still pouring")
|
||
|
|
}
|
||
|
|
|
||
|
|
close(firstWriter.release)
|
||
|
|
select {
|
||
|
|
case <-firstDone:
|
||
|
|
case <-time.After(2 * time.Second):
|
||
|
|
t.Fatal("first range request did not complete")
|
||
|
|
}
|
||
|
|
if got := server.tracker.Snapshot(); len(got) != 0 {
|
||
|
|
t.Fatalf("final request release left session behind: %+v", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMountedDirectReleasesLeaseOnEveryCompletionPath(t *testing.T) {
|
||
|
|
path := t.TempDir() + "/movie.bin"
|
||
|
|
if err := os.WriteFile(path, []byte("payload"), 0o600); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
server, _, token := newMountedProxyTestServer(t, path)
|
||
|
|
|
||
|
|
t.Run("head", func(t *testing.T) {
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
server.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodHead, "/stream/direct/"+token, nil))
|
||
|
|
if rec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("status = %d", rec.Code)
|
||
|
|
}
|
||
|
|
if got := server.tracker.Snapshot(); len(got) != 0 {
|
||
|
|
t.Fatalf("HEAD leaked lease: %+v", got)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("missing file", func(t *testing.T) {
|
||
|
|
missingServer, _, missingToken := newMountedProxyTestServer(t, path+".missing")
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
missingServer.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/stream/direct/"+missingToken, nil))
|
||
|
|
if rec.Code != http.StatusNotFound {
|
||
|
|
t.Fatalf("status = %d", rec.Code)
|
||
|
|
}
|
||
|
|
if got := missingServer.tracker.Snapshot(); len(got) != 0 {
|
||
|
|
t.Fatalf("missing-file completion leaked lease: %+v", got)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("client cancellation", func(t *testing.T) {
|
||
|
|
ctx, cancel := context.WithCancel(context.Background())
|
||
|
|
req := httptest.NewRequest(http.MethodGet, "/stream/direct/"+token, nil).WithContext(ctx)
|
||
|
|
cancel()
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
server.Handler().ServeHTTP(rec, req)
|
||
|
|
if got := server.tracker.Snapshot(); len(got) != 0 {
|
||
|
|
t.Fatalf("canceled request leaked lease: %+v", got)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||
|
|
|
||
|
|
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||
|
|
|
||
|
|
type limitedFailureWriter struct {
|
||
|
|
header http.Header
|
||
|
|
remaining int
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *limitedFailureWriter) Header() http.Header { return w.header }
|
||
|
|
func (w *limitedFailureWriter) WriteHeader(int) {}
|
||
|
|
func (w *limitedFailureWriter) Write(p []byte) (int, error) {
|
||
|
|
if w.remaining <= 0 {
|
||
|
|
return 0, errors.New("client disconnected")
|
||
|
|
}
|
||
|
|
n := min(len(p), w.remaining)
|
||
|
|
w.remaining -= n
|
||
|
|
return n, errors.New("client disconnected")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMountedTranscodeLivenessRequiresSuccessfulServedBytes(t *testing.T) {
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
method string
|
||
|
|
status int
|
||
|
|
body string
|
||
|
|
transportErr error
|
||
|
|
writer http.ResponseWriter
|
||
|
|
wantBytes int64
|
||
|
|
wantServed bool
|
||
|
|
}{
|
||
|
|
{name: "200 bytes", method: http.MethodGet, status: http.StatusOK, body: "manifest", wantBytes: 8, wantServed: true},
|
||
|
|
{name: "206 bytes", method: http.MethodGet, status: http.StatusPartialContent, body: "segment", wantBytes: 7, wantServed: true},
|
||
|
|
{name: "head zero bytes", method: http.MethodHead, status: http.StatusOK},
|
||
|
|
{name: "404 error body", method: http.MethodGet, status: http.StatusNotFound, body: "not found"},
|
||
|
|
{name: "upstream failure", method: http.MethodGet, transportErr: errors.New("dial failed")},
|
||
|
|
{name: "client write failure", method: http.MethodGet, status: http.StatusOK, body: "abcdefgh", writer: &limitedFailureWriter{header: make(http.Header), remaining: 3}, wantBytes: 3, wantServed: true},
|
||
|
|
}
|
||
|
|
for _, tc := range tests {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
server, _, token := newMountedProxyTestServer(t, "")
|
||
|
|
server.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||
|
|
if tc.transportErr != nil {
|
||
|
|
return nil, tc.transportErr
|
||
|
|
}
|
||
|
|
return &http.Response{
|
||
|
|
StatusCode: tc.status,
|
||
|
|
Header: make(http.Header),
|
||
|
|
Body: io.NopCloser(bytes.NewBufferString(tc.body)),
|
||
|
|
}, nil
|
||
|
|
})}
|
||
|
|
writer := tc.writer
|
||
|
|
if writer == nil {
|
||
|
|
writer = httptest.NewRecorder()
|
||
|
|
}
|
||
|
|
server.Handler().ServeHTTP(writer, httptest.NewRequest(tc.method, "/stream/transcode/"+token+"/master.m3u8", nil))
|
||
|
|
|
||
|
|
got := server.tracker.Snapshot()
|
||
|
|
if len(got) != 1 {
|
||
|
|
t.Fatalf("snapshot = %+v, want visible ephemeral record", got)
|
||
|
|
}
|
||
|
|
if got[0].BytesServed != tc.wantBytes {
|
||
|
|
t.Fatalf("BytesServed = %d, want %d", got[0].BytesServed, tc.wantBytes)
|
||
|
|
}
|
||
|
|
if (got[0].LastServedAt != "") != tc.wantServed {
|
||
|
|
t.Fatalf("LastServedAt = %q, want served=%v", got[0].LastServedAt, tc.wantServed)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|