395 lines
18 KiB
Go
395 lines
18 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type viewerTestBrowser struct {
|
|
handler http.Handler
|
|
cookie *http.Cookie
|
|
id string
|
|
}
|
|
|
|
func viewerTestApp(t *testing.T) *UIApp {
|
|
t.Helper()
|
|
d := rankingTestDownloader(t, func(*http.Request) (*http.Response, error) {
|
|
t.Error("viewer tests must not request upstream resources")
|
|
return nil, errors.New("external requests forbidden")
|
|
})
|
|
program, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d.ffmpegInstaller = &ffmpegInstaller{state: ffmpegInstallState{Status: "ready", Path: program}}
|
|
drama := Drama{ID: historyFixtureDramaID, Title: "多人无图测试", Source: sourceHongguo, TotalEpisode: 3}
|
|
chapters := []Chapter{}
|
|
for index := 1; index <= 3; index++ {
|
|
chapters = append(chapters, Chapter{ID: fmt.Sprintf("hongguo:7000000000000000001:%d", index), CurrentEpisode: rawEpisode(index), VideoURL: "hongguo-cenc://9000000000000000001"})
|
|
}
|
|
d.hongguoClient().details["7000000000000000001"] = hongguoDetailEntry{Drama: drama, Chapters: chapters, ExpiresAt: time.Now().Add(time.Hour)}
|
|
app := &UIApp{cfg: d.cfg, downloader: d, dramas: []Drama{drama}}
|
|
t.Cleanup(app.closePlaybacks)
|
|
return app
|
|
}
|
|
|
|
func (browser *viewerTestBrowser) request(t *testing.T, method, path string, input any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var body bytes.Buffer
|
|
if input != nil {
|
|
if err := json.NewEncoder(&body).Encode(input); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
request := httptest.NewRequest(method, "http://shared.example.test"+path, &body)
|
|
request.RemoteAddr = "192.168.1.10:1234"
|
|
request.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
request.Header.Set("Content-Type", "application/json")
|
|
if browser.cookie != nil {
|
|
request.AddCookie(browser.cookie)
|
|
}
|
|
if browser.id != "" {
|
|
request.Header.Set("X-Juku-Viewer", browser.id)
|
|
}
|
|
writer := httptest.NewRecorder()
|
|
browser.handler.ServeHTTP(writer, request)
|
|
return writer
|
|
}
|
|
|
|
func newViewerTestBrowser(t *testing.T, app *UIApp) *viewerTestBrowser {
|
|
t.Helper()
|
|
browser := &viewerTestBrowser{handler: app.routes()}
|
|
first := browser.request(t, http.MethodGet, "/api/ui/viewer", nil)
|
|
cookies := first.Result().Cookies()
|
|
if first.Code != http.StatusOK || len(cookies) != 1 {
|
|
t.Fatal("could not establish a browser identity", first.Code, first.Body.String())
|
|
}
|
|
browser.cookie = cookies[0]
|
|
confirmed := browser.request(t, http.MethodGet, "/api/ui/viewer?confirm=1", nil)
|
|
var result struct {
|
|
ID string `json:"id"`
|
|
Ready bool `json:"ready"`
|
|
}
|
|
if err := json.Unmarshal(confirmed.Body.Bytes(), &result); err != nil || !result.Ready || len(result.ID) != 64 {
|
|
t.Fatal("browser identity was not confirmed", confirmed.Code, err, confirmed.Body.String())
|
|
}
|
|
browser.id = result.ID
|
|
return browser
|
|
}
|
|
|
|
func viewerResultOK(t *testing.T, result *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if result.Code != http.StatusOK {
|
|
t.Fatal(result.Code, result.Body.String())
|
|
}
|
|
}
|
|
|
|
func (browser *viewerTestBrowser) open(t *testing.T, app *UIApp) string {
|
|
t.Helper()
|
|
result := browser.request(t, http.MethodPost, "/api/ui/playback/open", map[string]any{"dramaId": historyFixtureDramaID, "resume": true})
|
|
viewerResultOK(t, result)
|
|
var opened struct {
|
|
Session string `json:"session"`
|
|
}
|
|
if err := json.Unmarshal(result.Body.Bytes(), &opened); err != nil || opened.Session == "" {
|
|
t.Fatal("missing playback session", err)
|
|
}
|
|
app.playbackMu.Lock()
|
|
session := app.playbacks[opened.Session]
|
|
session.run, session.currentIndex, session.state = 1, 1, "ended"
|
|
session.historyRuns = map[uint64]playbackHistoryRun{1: {episode: 1, duration: 90}, 2: {episode: 2, duration: 90}}
|
|
app.playbackMu.Unlock()
|
|
return opened.Session
|
|
}
|
|
|
|
func (browser *viewerTestBrowser) history(t *testing.T) []playbackHistoryEntry {
|
|
t.Helper()
|
|
result := browser.request(t, http.MethodGet, "/api/ui/playback/history", nil)
|
|
viewerResultOK(t, result)
|
|
if !strings.Contains(result.Header().Get("Cache-Control"), "no-store") || !strings.Contains(result.Header().Get("Vary"), "Cookie") {
|
|
t.Fatal("personal history could be cached across browsers", result.Header())
|
|
}
|
|
var response struct {
|
|
Data []playbackHistoryEntry `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(result.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return response.Data
|
|
}
|
|
|
|
func (browser *viewerTestBrowser) following(t *testing.T) []followingView {
|
|
t.Helper()
|
|
result := browser.request(t, http.MethodGet, "/api/ui/following", nil)
|
|
viewerResultOK(t, result)
|
|
var response struct {
|
|
Data []followingView `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(result.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return response.Data
|
|
}
|
|
|
|
func TestViewerIdentityCookiesRestartAndInstanceSeparation(t *testing.T) {
|
|
app := viewerTestApp(t)
|
|
alice := newViewerTestBrowser(t, app)
|
|
bob := newViewerTestBrowser(t, app)
|
|
if alice.id == bob.id || alice.cookie.Value == bob.cookie.Value {
|
|
t.Fatal("two browsers with the same LAN address share an identity")
|
|
}
|
|
cookie := alice.cookie
|
|
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" || cookie.Domain != "" || cookie.MaxAge <= 0 || cookie.Expires.Before(time.Now().Add(300*24*time.Hour)) {
|
|
t.Fatal("unexpected browser cookie properties", cookie.Name)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(app.cfg.dataDirectory(), "viewers")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("anonymous page visits created personal record files", err)
|
|
}
|
|
unauthenticated := &viewerTestBrowser{handler: app.routes()}
|
|
for _, path := range []string{"/api/ui/following", "/api/ui/playback/history", "/api/ui/playback/status?session=anything", "/api/ui/viewer?confirm=1"} {
|
|
if result := unauthenticated.request(t, http.MethodGet, path, nil); result.Code != http.StatusUnauthorized {
|
|
t.Fatal("missing identity was accepted", path, result.Code)
|
|
}
|
|
}
|
|
restarted := &UIApp{cfg: app.cfg}
|
|
alice.handler = restarted.routes()
|
|
viewerResultOK(t, alice.request(t, http.MethodGet, "/api/ui/playback/history", nil))
|
|
other := &UIApp{cfg: Config{dataDir: t.TempDir()}}
|
|
alice.handler = other.routes()
|
|
if result := alice.request(t, http.MethodGet, "/api/ui/playback/history", nil); result.Code != http.StatusUnauthorized || other.browserViewers().cookie == app.browserViewers().cookie {
|
|
t.Fatal("two deployments shared a cookie identity", result.Code)
|
|
}
|
|
alice.handler = app.routes()
|
|
for _, value := range []string{cookie.Value + "x", strings.Replace(cookie.Value, ".", ".invalid.", 1), app.browserViewers().token(strings.Repeat("a", 64), time.Now().Add(-time.Hour)), "../../playback-history.json"} {
|
|
forged := *cookie
|
|
forged.Value = value
|
|
alice.cookie = &forged
|
|
if result := alice.request(t, http.MethodGet, "/api/ui/playback/history", nil); result.Code != http.StatusUnauthorized {
|
|
t.Fatal("invalid or expired cookie reached personal records", result.Code)
|
|
}
|
|
}
|
|
alice.cookie, alice.id = cookie, bob.id
|
|
if result := alice.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true}); result.Code != http.StatusConflict {
|
|
t.Fatal("an old tab silently wrote into a changed browser identity", result.Code)
|
|
}
|
|
if len(bob.following(t)) != 0 {
|
|
t.Fatal("client-supplied viewer id changed another browser's records")
|
|
}
|
|
}
|
|
|
|
func TestViewerHistoryFollowingConcurrentProgressAndResume(t *testing.T) {
|
|
app := viewerTestApp(t)
|
|
alice, bob := newViewerTestBrowser(t, app), newViewerTestBrowser(t, app)
|
|
aliceSession, bobSession := alice.open(t, app), bob.open(t, app)
|
|
var done sync.WaitGroup
|
|
for index, browser := range []*viewerTestBrowser{alice, bob} {
|
|
done.Add(1)
|
|
go func(index int, browser *viewerTestBrowser) {
|
|
defer done.Done()
|
|
session := []string{aliceSession, bobSession}[index]
|
|
for sequence := uint64(1); sequence <= 8; sequence++ {
|
|
progress := playbackHistoryProgress{Run: uint64(index + 1), Episode: index + 1, Sequence: sequence, Position: float64(index*40) + float64(sequence), Duration: 90}
|
|
viewerResultOK(t, browser.request(t, http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": session, "progress": progress}))
|
|
}
|
|
}(index, browser)
|
|
}
|
|
done.Wait()
|
|
for index, browser := range []*viewerTestBrowser{alice, bob} {
|
|
entries := browser.history(t)
|
|
if len(entries) != 1 || entries[0].Index != index+1 || entries[0].Position != float64(index*40+8) {
|
|
t.Fatal("simultaneous playback mixed progress", index, entries)
|
|
}
|
|
viewerResultOK(t, browser.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true, "completed": index == 1}))
|
|
result := browser.request(t, http.MethodPost, "/api/ui/playback/open", map[string]any{"dramaId": historyFixtureDramaID, "resume": true, "fromHistory": true})
|
|
viewerResultOK(t, result)
|
|
var resumed struct {
|
|
Session string `json:"session"`
|
|
Index int `json:"initialIndex"`
|
|
Position float64 `json:"initialPosition"`
|
|
}
|
|
if err := json.Unmarshal(result.Body.Bytes(), &resumed); err != nil || resumed.Index != index+1 || resumed.Position != float64(index*40+8) {
|
|
t.Fatal("resume used another browser's progress", resumed, err)
|
|
}
|
|
app.closePlayback(resumed.Session)
|
|
}
|
|
viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": false}))
|
|
if len(alice.following(t)) != 0 || len(bob.following(t)) != 1 || !bob.following(t)[0].Completed {
|
|
t.Fatal("following changes affected another browser")
|
|
}
|
|
for _, input := range []map[string]any{{"dramaId": historyFixtureDramaID}, {"all": true}} {
|
|
viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/playback/history/remove", input))
|
|
late := alice.request(t, http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": aliceSession, "progress": historyProgress(99, 77)})
|
|
viewerResultOK(t, late)
|
|
if len(alice.history(t)) != 0 || len(bob.history(t)) != 1 || bob.history(t)[0].Position != 48 || !strings.Contains(late.Body.String(), `"saved":false`) {
|
|
t.Fatal("deletion, clearing or late autosave crossed browser boundaries")
|
|
}
|
|
}
|
|
restarted := &UIApp{cfg: app.cfg}
|
|
alice.handler, bob.handler = restarted.routes(), restarted.routes()
|
|
if len(alice.history(t)) != 0 || bob.history(t)[0].Position != 48 || !bob.following(t)[0].Completed {
|
|
t.Fatal("restart lost browser-specific records")
|
|
}
|
|
}
|
|
|
|
func TestViewerPlaybackEndpointsRejectOtherBrowsersAndBeaconSavesOwner(t *testing.T) {
|
|
app := viewerTestApp(t)
|
|
alice, bob := newViewerTestBrowser(t, app), newViewerTestBrowser(t, app)
|
|
sessionID := alice.open(t, app)
|
|
for _, tc := range []struct {
|
|
method, path string
|
|
body any
|
|
}{
|
|
{http.MethodGet, "/api/ui/playback/status?session=" + sessionID, nil},
|
|
{http.MethodGet, "/api/ui/playback/stream?episode=1&session=" + sessionID, nil},
|
|
{http.MethodGet, "/api/ui/playback/hls/index.m3u8?run=1&session=" + sessionID, nil},
|
|
{http.MethodHead, "/api/ui/playback/hls/segment.ts?run=1&segment=0&session=" + sessionID, nil},
|
|
{http.MethodGet, "/api/ui/playback/danmaku?episode=1&start=0&duration=90000&session=" + sessionID, nil},
|
|
{http.MethodPost, "/api/ui/playback/hls/open", map[string]any{"session": sessionID, "episode": 1}},
|
|
{http.MethodPost, "/api/ui/playback/prepare", map[string]any{"session": sessionID, "episode": 1}},
|
|
{http.MethodPost, "/api/ui/playback/prefetch", map[string]any{"session": sessionID, "episode": 2, "run": 1, "version": 1}},
|
|
{http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": sessionID, "progress": historyProgress(1, 55)}},
|
|
{http.MethodPost, "/api/ui/playback/control", map[string]any{"session": sessionID, "action": "heartbeat"}},
|
|
{http.MethodPost, "/api/ui/playback/control", map[string]any{"session": sessionID, "action": "close", "progress": historyProgress(1, 55)}},
|
|
} {
|
|
result := bob.request(t, tc.method, tc.path, tc.body)
|
|
if result.Code != http.StatusGone {
|
|
t.Error("another browser accessed a playback session", tc.path, result.Code, result.Body.String())
|
|
}
|
|
}
|
|
if state, ok := app.playbackStatus(sessionID, false); !ok || state.Run != 1 || state.State != "ended" || len(alice.history(t)) != 0 || len(bob.history(t)) != 0 {
|
|
t.Fatal("unauthorized playback requests changed progress or state", state)
|
|
}
|
|
alice.id = ""
|
|
viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/playback/control", map[string]any{"session": sessionID, "action": "close", "progress": historyProgress(1, 31)}))
|
|
if entries := alice.history(t); len(entries) != 1 || entries[0].Position != 31 {
|
|
t.Fatal("cookie-only close beacon lost the owner's final progress", entries)
|
|
}
|
|
if _, ok := app.playbackStatus(sessionID, false); ok || len(bob.history(t)) != 0 {
|
|
t.Fatal("close beacon affected another browser or kept the session")
|
|
}
|
|
}
|
|
|
|
func viewerLegacyFixture(t *testing.T, app *UIApp) map[string][]byte {
|
|
t.Helper()
|
|
entry := playbackHistoryEntry{DramaID: historyFixtureDramaID, Source: sourceHongguo, Title: "升级前的无图记录", ChapterID: "one", Index: 1, Total: 3, Episode: "1", Position: 26, Duration: 90, Mode: "online", WatchedAt: time.Now().Add(-time.Hour)}
|
|
history := newPlaybackHistoryStore(app.cfg.dataDirectory())
|
|
if _, err := history.record(entry, time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := history.flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
following := newFollowingStore(app.cfg.dataDirectory())
|
|
if _, err := following.update(historyFixtureDramaID, func(followingEntry, bool) (followingEntry, error) {
|
|
return followingEntry{DramaID: historyFixtureDramaID, Source: sourceHongguo, Title: entry.Title, Saved: true, KnownEpisodes: 3, AddedAt: entry.WatchedAt, UpdatedAt: entry.WatchedAt}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
originals := make(map[string][]byte)
|
|
for _, name := range []string{"playback-history.json", "following.json"} {
|
|
body, err := os.ReadFile(filepath.Join(app.cfg.dataDirectory(), name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
originals[name] = body
|
|
}
|
|
if err := app.prepareBrowserViewers(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return originals
|
|
}
|
|
|
|
func TestViewerLegacyImportPrivateRetryableAndNonDestructive(t *testing.T) {
|
|
app := viewerTestApp(t)
|
|
originals := viewerLegacyFixture(t, app)
|
|
alice, bob := newViewerTestBrowser(t, app), newViewerTestBrowser(t, app)
|
|
if len(alice.history(t)) != 0 || len(bob.history(t)) != 0 || len(bob.following(t)) != 0 {
|
|
t.Fatal("old shared records were assigned to a visitor automatically")
|
|
}
|
|
if result := bob.request(t, http.MethodPost, "/api/ui/viewer/legacy", map[string]any{"code": "invalid"}); result.Code != http.StatusForbidden {
|
|
t.Fatal("legacy records did not require the server's recovery code", result.Code)
|
|
}
|
|
code, err := os.ReadFile(filepath.Join(app.cfg.dataDirectory(), "viewer-legacy-code"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
input := map[string]any{"code": strings.TrimSpace(string(code))}
|
|
manager := app.browserViewers()
|
|
viewer := manager.acquire(alice.id)
|
|
defer viewer.release()
|
|
store := viewer.playbackHistory()
|
|
path := store.path
|
|
store.path = app.cfg.dataDirectory()
|
|
if result := alice.request(t, http.MethodPost, "/api/ui/viewer/legacy", input); result.Code != http.StatusInternalServerError {
|
|
t.Fatal("failed disk write was reported as a completed import", result.Code)
|
|
}
|
|
if result := bob.request(t, http.MethodPost, "/api/ui/viewer/legacy", input); result.Code != http.StatusConflict {
|
|
t.Fatal("partial import could be claimed by a different browser", result.Code)
|
|
}
|
|
store.path = path
|
|
viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/viewer/legacy", input))
|
|
if entries := alice.history(t); len(entries) != 1 || entries[0].Position != 26 || len(alice.following(t)) != 1 || len(bob.history(t)) != 0 || len(bob.following(t)) != 0 {
|
|
t.Fatal("legacy records were lost or shared during import", entries)
|
|
}
|
|
viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/playback/history/remove", map[string]any{"all": true}))
|
|
viewerResultOK(t, alice.request(t, http.MethodPost, "/api/ui/viewer/legacy", input))
|
|
if len(alice.history(t)) != 0 {
|
|
t.Fatal("reusing a consumed code resurrected deleted history")
|
|
}
|
|
for name, before := range originals {
|
|
after, err := os.ReadFile(filepath.Join(app.cfg.dataDirectory(), name))
|
|
if err != nil || !bytes.Equal(before, after) {
|
|
t.Fatal("import altered an original shared file", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestViewerCacheKeepsActiveSessionsAndKeyCorruptionFailsClosed(t *testing.T) {
|
|
app := viewerTestApp(t)
|
|
browser := newViewerTestBrowser(t, app)
|
|
session := browser.open(t, app)
|
|
viewerResultOK(t, browser.request(t, http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": session, "progress": historyProgress(1, 12)}))
|
|
viewerResultOK(t, browser.request(t, http.MethodPost, "/api/ui/playback/history/remove", map[string]any{"all": true}))
|
|
for index := 0; index < viewerIdleCacheLimit+20; index++ {
|
|
other := newViewerTestBrowser(t, app)
|
|
if len(other.history(t)) != 0 {
|
|
t.Fatal("new browser has someone else's records")
|
|
}
|
|
}
|
|
manager := app.browserViewers()
|
|
manager.mu.Lock()
|
|
count := len(manager.viewers)
|
|
manager.mu.Unlock()
|
|
if count > viewerIdleCacheLimit {
|
|
t.Fatal("inactive viewer memory is unbounded", count)
|
|
}
|
|
viewerResultOK(t, browser.request(t, http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": session, "progress": historyProgress(2, 42)}))
|
|
if len(browser.history(t)) != 0 {
|
|
t.Fatal("cache eviction lost an active player's deletion barrier")
|
|
}
|
|
path := filepath.Join(app.cfg.dataDirectory(), "viewer-key")
|
|
broken := []byte("broken-key")
|
|
if err := os.WriteFile(path, broken, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restarted := &UIApp{cfg: app.cfg}
|
|
browser.handler = restarted.routes()
|
|
if result := browser.request(t, http.MethodGet, "/api/ui/playback/history", nil); result.Code != http.StatusServiceUnavailable {
|
|
t.Fatal("corrupt identity key was silently replaced", result.Code)
|
|
}
|
|
actual, _ := os.ReadFile(path)
|
|
if !bytes.Equal(actual, broken) {
|
|
t.Fatal("corrupt identity key was overwritten")
|
|
}
|
|
}
|