package app import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) func TestSourcePermissionsFilterReadsAndRejectMixedActions(t *testing.T) { app, admin := administratorFixture(t) member := newAccountTestBrowser(t, app) member.register(t, "limited-user") member.watch(t, app, 1, 9) hidden := "huangdou:hidden-series" app.mu.Lock() app.dramas = append(app.dramas, Drama{ID: hidden, Source: sourceHuangdou, Title: "restricted secret title", CoverURL: "https://tideember.cc/hidden-cover.jpg"}, Drama{ID: "cloudfront-legacy", Title: "legacy secret"}, Drama{ID: "hongguo:7999999999999999999", Source: sourceHuangdou, Title: "conflicting secret"}) app.libraryAttempted = true app.libraryRevision = 7 app.librarySources = map[string]librarySourceState{sourceHongguo: {Count: 1, Status: "ready"}, sourceHuangdou: {Count: 1, Status: "failed", Error: "restricted secret error"}} app.tasks = map[string]*UITask{ "allowed-task": {ID: "allowed-task", DramaID: historyFixtureDramaID, DramaTitle: "allowed task", Status: uiStatusQueued, Source: Task{DramaID: historyFixtureDramaID, Chapter: Chapter{Source: sourceHongguo}}}, "hidden-task": {ID: "hidden-task", DramaID: hidden, DramaTitle: "restricted secret task", Status: uiStatusQueued, Source: Task{DramaID: hidden, Chapter: Chapter{Source: sourceHuangdou}}}, } app.taskOrder = []string{"allowed-task", "hidden-task"} app.merges = map[string]*UIMergeState{hidden: {DramaID: hidden, DramaTitle: "restricted secret merge"}} app.mu.Unlock() viewer := app.browserViewers().acquire(member.id) defer viewer.release() history := viewer.playbackHistory() history.mu.Lock() history.entries[hidden] = playbackHistoryEntry{DramaID: hidden, Source: sourceHuangdou, Title: "restricted secret history", Episode: "1", Index: 1, Total: 2, Position: 10, Duration: 50, WatchedAt: time.Now()} history.mu.Unlock() follows := viewer.followingStore() follows.mu.Lock() follows.entries[hidden] = followingEntry{DramaID: hidden, Source: sourceHuangdou, Title: "restricted secret following", Saved: true, AddedAt: time.Now(), UpdatedAt: time.Now()} follows.mu.Unlock() viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/sources", map[string]any{"username": "limited-user", "sources": []string{sourceHongguo}})) for _, path := range []string{"/api/ui/dramas?revision=7", "/api/ui/tasks", "/api/ui/following", "/api/ui/playback/history", "/api/ui/rankings"} { response := member.request(t, http.MethodGet, path, nil) viewerResultOK(t, response) if strings.Contains(response.Body.String(), "secret") || strings.Contains(response.Body.String(), "huangdou") || strings.Contains(response.Body.String(), "huangguo") { t.Fatalf("hidden source leaked through %s: %s", path, response.Body.String()) } if path == "/api/ui/dramas?revision=7" { var result struct { Data []Drama `json:"data"` Total int `json:"total"` } if json.Unmarshal(response.Body.Bytes(), &result) != nil || result.Total != 1 || len(result.Data) != 1 { t.Fatal("incorrect scoped catalogue", response.Body.String()) } } } for _, test := range []struct { path string body any }{ {"/api/ui/download", map[string]any{"ids": []string{historyFixtureDramaID, hidden}}}, {"/api/ui/update", map[string]any{"ids": []string{hidden}}}, {"/api/ui/download", map[string]any{"ids": []string{"cloudfront-legacy"}}}, {"/api/ui/download", map[string]any{"ids": []string{"hongguo:7999999999999999999"}}}, {"/api/ui/merge", map[string]any{"dramaIds": []string{hidden}}}, {"/api/ui/playback/open", map[string]any{"dramaId": hidden}}, {"/api/ui/playback/open", map[string]any{"taskId": "hidden-task"}}, {"/api/ui/following", map[string]any{"dramaId": hidden, "saved": true}}, {"/api/ui/playback/history/remove", map[string]any{"dramaId": hidden}}, {"/api/ui/cover/repair", map[string]any{"dramaId": hidden}}, {"/api/emby/export", map[string]any{"dramaId": hidden, "baseUrl": "http://local.test"}}, } { response := member.request(t, http.MethodPost, test.path, test.body) if response.Code != http.StatusForbidden { t.Fatalf("%s allowed forbidden source: %d %s", test.path, response.Code, response.Body.String()) } } for _, action := range []string{"retry", "cancel", "pause", "resume", "clear"} { response := member.request(t, http.MethodPost, "/api/ui/tasks/"+action, map[string]any{"ids": []string{"allowed-task", "hidden-task"}}) if response.Code != http.StatusForbidden { t.Fatal("mixed action accepted", action, response.Code, response.Body.String()) } } if app.tasks["allowed-task"].Status != uiStatusQueued || app.tasks["hidden-task"].Status != uiStatusQueued { t.Fatal("mixed forbidden batch partially mutated tasks") } for _, path := range []string{"/api/ui/dramas?update=1&source=huangdou", "/api/ui/image?url=" + url.QueryEscape("https://tideember.cc/hidden-cover.jpg")} { if response := member.request(t, http.MethodGet, path, nil); response.Code != http.StatusForbidden { t.Fatal("restricted read triggered upstream request", path, response.Code) } } viewerResultOK(t, member.request(t, http.MethodPost, "/api/ui/playback/history/remove", map[string]bool{"all": true})) if _, exists := history.get(hidden); !exists { t.Fatal("clear visible history deleted inaccessible records") } if _, exists := history.get(historyFixtureDramaID); exists { t.Fatal("visible history was not cleared") } ctx := withSourceScope(context.Background(), accountRecord{Sources: []string{"huangguo"}}) for _, source := range []string{"cloudfront", "huangguoai", "huangguo-video"} { if !sourceAllowed(ctx, source) { t.Fatal("source group excludes provider", source) } } } func TestSourceRevocationClosesStreamsAndRejectsStalePagesAndExports(t *testing.T) { app, admin := administratorFixture(t) member := newAccountTestBrowser(t, app) member.register(t, "revoked-user") session := member.watch(t, app, 1, 9) account := app.browserViewers().accountStore().state.Accounts["revoked-user"] key, err := app.embySigningKey(true) if err != nil { t.Fatal(err) } chapter := "hongguo:7000000000000000001:1" query := url.Values{"id": {historyFixtureDramaID}, "chapter": {chapter}, "account": {account.ID}, "key": {embyToken(key, historyFixtureDramaID, chapter, account.ID)}} if result := member.request(t, http.MethodHead, "/api/emby/stream.m3u8?"+query.Encode(), nil); result.Code != http.StatusOK { t.Fatal("valid account export rejected", result.Code) } viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/sources", map[string]any{"username": "revoked-user", "sources": []string{sourceHuangdou}})) app.playbackMu.Lock() _, exists := app.playbacks[session] app.playbackMu.Unlock() if exists { t.Fatal("permission revocation left playback session running") } if result := member.request(t, http.MethodPost, "/api/ui/playback/control", map[string]string{"session": session, "action": "heartbeat"}); result.Code != http.StatusGone { t.Fatal("revoked playback still usable", result.Code) } for _, endpoint := range []string{"stream.m3u8", "segment.ts"} { if result := member.request(t, http.MethodGet, "/api/emby/"+endpoint+"?"+query.Encode(), nil); result.Code != http.StatusForbidden { t.Fatal("revoked export still usable", result.Code) } } query.Del("account") if result := member.request(t, http.MethodGet, "/api/emby/stream.m3u8?"+query.Encode(), nil); result.Code != http.StatusForbidden { t.Fatal("stripping account bypasses export signature") } request := httptest.NewRequest(http.MethodGet, "http://shared.example.test/api/ui/dramas", nil) for _, cookie := range member.cookies { request.AddCookie(cookie) } request.Header.Set("X-Juku-Sources", strings.Join(allAccountSources(), ",")) response := httptest.NewRecorder() app.routes().ServeHTTP(response, request) if response.Code != http.StatusConflict || !strings.Contains(response.Body.String(), "sources_changed") { t.Fatal("stale page kept old source state", response.Code) } for _, test := range []struct { method, path string body any }{ {http.MethodGet, "/api/ui/search?q=test", nil}, {http.MethodPost, "/api/ui/recommendations", map[string]any{"genre": "short_play", "offset": 0}}, } { if result := member.request(t, test.method, test.path, test.body); result.Code != http.StatusForbidden { t.Fatal("restricted recommendations/search accepted", result.Code, result.Body.String()) } } ctx := withSourceScope(context.Background(), account) ctx = context.WithValue(ctx, viewerContextKey{}, app.browserViewers().acquire(member.id)) defer contextViewer(ctx).release() late := &playbackSession{dramaID: historyFixtureDramaID, viewer: contextViewer(ctx)} ctx = withSourceScope(ctx, accountRecord{Sources: []string{sourceHuangdou}}) if viewerOwnsPlayback(ctx, late) { t.Fatal("late session bypasses current source scope") } }