arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/internal/server/server_test.go
jellyfin-year-review/internal/server/server_test.goRaw
  1package server
  2
  3import (
  4	"encoding/json"
  5	"io"
  6	"net/http"
  7	"net/http/httptest"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11	"testing"
 12	"time"
 13
 14	"reelreview/internal/auth"
 15	"reelreview/internal/generate"
 16	"reelreview/internal/model"
 17	"reelreview/internal/source"
 18)
 19
 20const aID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
 21const bID = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
 22
 23type harness struct {
 24	s                    *Server
 25	revoked, unavailable bool
 26	logout               int
 27	authCalls            int
 28}
 29
 30func setup(t *testing.T) *harness {
 31	t.Helper()
 32	h := &harness{}
 33	jf := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 34		h.authCalls++
 35		if h.unavailable {
 36			w.WriteHeader(503)
 37			return
 38		}
 39		switch r.URL.Path {
 40		case "/Users/AuthenticateByName":
 41			var body map[string]string
 42			json.NewDecoder(r.Body).Decode(&body)
 43			if body["Pw"] != "valid" {
 44				w.WriteHeader(401)
 45				return
 46			}
 47			if !strings.Contains(r.Header.Get("Authorization"), `Client="Reel Review"`) {
 48				t.Error("missing separate app identity")
 49			}
 50			json.NewEncoder(w).Encode(map[string]any{"AccessToken": "private-token", "User": map[string]any{"Id": bID}})
 51		case "/Users/Me":
 52			if h.revoked || !strings.Contains(r.Header.Get("Authorization"), `Token="private-token"`) {
 53				w.WriteHeader(401)
 54				return
 55			}
 56			json.NewEncoder(w).Encode(map[string]any{"Id": aID, "Name": "Alice", "Policy": map[string]bool{"IsAdministrator": true}})
 57		case "/Sessions/Logout":
 58			h.logout++
 59			w.WriteHeader(204)
 60		default:
 61			w.WriteHeader(404)
 62		}
 63	}))
 64	t.Cleanup(jf.Close)
 65	j, e := source.NewJellyfin(jf.URL)
 66	if e != nil {
 67		t.Fatal(e)
 68	}
 69	data := t.TempDir()
 70	for _, year := range []int{2024, 2025} {
 71		rs := []model.Review{}
 72		for _, id := range []string{aID, bID} {
 73			rs = append(rs, model.Review{Version: 1, User: model.User{ID: id, Name: id}, Year: year, Timezone: "UTC", Availability: map[string]model.Availability{}, Seconds: 3600})
 74		}
 75		if e := generate.Publish(data, rs, nil); e != nil {
 76			t.Fatal(e)
 77		}
 78	}
 79	web := t.TempDir()
 80	os.WriteFile(filepath.Join(web, "index.html"), []byte(`<html><script>console.log('shell')</script></html>`), 0600)
 81	h.s, e = New(data, web, "https://review.test", j)
 82	if e != nil {
 83		t.Fatal(e)
 84	}
 85	t.Cleanup(func() { h.s.Close() })
 86	return h
 87}
 88func req(h *harness, method, path, body string, cookie *http.Cookie, origin string) *httptest.ResponseRecorder {
 89	r := httptest.NewRequest(method, path, strings.NewReader(body))
 90	r.RemoteAddr = "127.0.0.1:1234"
 91	if method == "POST" {
 92		r.Header.Set("Content-Type", "application/json")
 93		r.Header.Set("Origin", origin)
 94	}
 95	if cookie != nil {
 96		r.AddCookie(cookie)
 97	}
 98	w := httptest.NewRecorder()
 99	h.s.ServeHTTP(w, r)
100	return w
101}
102func signin(t *testing.T, h *harness) *http.Cookie {
103	t.Helper()
104	w := req(h, "POST", "/api/login", `{"username":"Alice","password":"valid"}`, nil, h.s.Origin)
105	if w.Code != 200 {
106		t.Fatal(w.Code, w.Body.String())
107	}
108	cookies := w.Result().Cookies()
109	if len(cookies) != 1 || !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].SameSite != http.SameSiteStrictMode {
110		t.Fatal("insecure cookie")
111	}
112	if strings.Contains(w.Body.String(), "token") {
113		t.Fatal("token leaked")
114	}
115	return cookies[0]
116}
117func TestOwnerAuthorizationAndPrivatePaths(t *testing.T) {
118	h := setup(t)
119	w := req(h, "GET", "/api/review?year=2025", "", nil, "")
120	if w.Code != 401 {
121		t.Fatal("unauthenticated review")
122	}
123	cookie := signin(t, h)
124	w = req(h, "GET", "/api/review?year=2025", "", cookie, "")
125	if w.Code != 200 || !strings.Contains(w.Body.String(), aID) || strings.Contains(w.Body.String(), bID) {
126		t.Fatal("actual token identity not used", w.Body.String())
127	}
128	for _, path := range []string{"/manifest.json", "/data/CURRENT", "/" + bID + "/2025.json", "/api/users", "/art/../../CURRENT", "/art/%2e%2e%2fCURRENT", "/_app/../../CURRENT"} {
129		w = req(h, "GET", path, "", cookie, "")
130		if w.Code != 404 {
131			t.Fatalf("private path %s = %d", path, w.Code)
132		}
133	}
134	for _, path := range []string{"/api/review?year=2025&userId=" + bID, "/api/review?year=../../2025", "/api/review?year=2025&year=2024"} {
135		if w = req(h, "GET", path, "", cookie, ""); w.Code != 400 {
136			t.Fatal("unsafe selector", path, w.Code)
137		}
138	}
139	w = req(h, "GET", "/api/session", "", cookie, "")
140	if w.Code != 200 || strings.Contains(w.Body.String(), bID) || !strings.Contains(w.Body.String(), "2024") {
141		t.Fatal("session manifest leak or missing year")
142	}
143	w = req(h, "GET", "/review/2025", "", nil, "")
144	if w.Code != 200 || !strings.Contains(w.Header().Get("Content-Security-Policy"), "sha256-") {
145		t.Fatal("deep link or CSP bootstrap broken")
146	}
147}
148func TestExpiryTamperingRevocationAndOutage(t *testing.T) {
149	h := setup(t)
150	cookie := signin(t, h)
151	tampered := *cookie
152	tampered.Value = "f" + tampered.Value[1:] + "a"
153	if req(h, "GET", "/api/session", "", &tampered, "").Code != 401 {
154		t.Fatal("tampered session")
155	}
156	h.unavailable = true
157	if req(h, "GET", "/api/review?year=2025", "", cookie, "").Code != 503 {
158		t.Fatal("stale data on auth outage")
159	}
160	h.unavailable = false
161	h.revoked = true
162	if req(h, "GET", "/api/session", "", cookie, "").Code != 401 {
163		t.Fatal("revoked token")
164	}
165	h.revoked = false
166	cookie = signin(t, h)
167	h.s.Sessions.Now = func() time.Time { return time.Now().Add(13 * time.Hour) }
168	if req(h, "GET", "/api/session", "", cookie, "").Code != 401 {
169		t.Fatal("expired app session")
170	}
171}
172func TestLoginCSRFRateLimitsAndLogout(t *testing.T) {
173	h := setup(t)
174	if req(h, "POST", "/api/login", `{"username":"A","password":"valid"}`, nil, "https://evil.test").Code != 403 {
175		t.Fatal("CSRF")
176	}
177	for i := 0; i < 10; i++ {
178		w := req(h, "POST", "/api/login", `{"username":"A","password":"bad"}`, nil, h.s.Origin)
179		if w.Code != 401 || strings.Contains(w.Body.String(), "Alice") {
180			t.Fatal("non-generic failure")
181		}
182	}
183	if req(h, "POST", "/api/login", `{"username":"A","password":"valid"}`, nil, h.s.Origin).Code != 429 {
184		t.Fatal("no login limit")
185	}
186	h = setup(t)
187	cookie := signin(t, h)
188	if req(h, "POST", "/api/logout", "{}", cookie, "https://evil.test").Code != 403 {
189		t.Fatal("logout CSRF")
190	}
191	if req(h, "POST", "/api/logout", "{}", cookie, h.s.Origin).Code != 200 || h.logout != 1 {
192		t.Fatal("logout did not revoke only app token")
193	}
194	if req(h, "GET", "/api/session", "", cookie, "").Code != 401 {
195		t.Fatal("local session survived logout")
196	}
197	cookie = signin(t, h)
198	h.unavailable = true
199	w := req(h, "POST", "/api/logout", "{}", cookie, h.s.Origin)
200	if w.Code != 503 || !strings.Contains(w.Body.String(), "Signed out locally") {
201		t.Fatal("outage logout behavior")
202	}
203	h.unavailable = false
204	if req(h, "GET", "/api/session", "", cookie, "").Code != 401 {
205		t.Fatal("outage retained session")
206	}
207}
208func TestRequestBoundsAndUnsupportedDocuments(t *testing.T) {
209	h := setup(t)
210	if req(h, "POST", "/api/login", `{"username":"`+strings.Repeat("a", 5000)+`","password":"valid"}`, nil, h.s.Origin).Code != 400 {
211		t.Fatal("body limit")
212	}
213	cookie := signin(t, h)
214	_, dir, e := generate.Manifest(h.s.Data)
215	if e != nil {
216		t.Fatal(e)
217	}
218	path := filepath.Join(dir, aID, "2025.json")
219	f, e := os.Create(path)
220	if e != nil {
221		t.Fatal(e)
222	}
223	io.WriteString(f, `{"schemaVersion":99}`)
224	f.Close()
225	if req(h, "GET", "/api/review?year=2025", "", cookie, "").Code != 503 {
226		t.Fatal("unsupported schema accepted")
227	}
228}
229
230func offlineHarness(t *testing.T) (*harness, string, auth.CredentialFile) {
231	t.Helper()
232	h := setup(t)
233	b, e := os.ReadFile("../../testdata/offline-auth.json")
234	if e != nil {
235		t.Fatal(e)
236	}
237	var file auth.CredentialFile
238	if e = json.Unmarshal(b, &file); e != nil {
239		t.Fatal(e)
240	}
241	path := filepath.Join(t.TempDir(), "users.json")
242	if e = os.WriteFile(path, b, 0600); e != nil {
243		t.Fatal(e)
244	}
245	p, e := auth.NewOffline(path)
246	if e != nil {
247		t.Fatal(e)
248	}
249	// Constructor validation must work without constructing any Jellyfin client.
250	s, e := NewWithAuth(h.s.Data, h.s.Web, h.s.Origin, p)
251	if e != nil {
252		t.Fatal(e)
253	}
254	s.Close()
255	h.s.Auth = p
256	h.unavailable = true
257	return h, path, file
258}
259func offlineSignin(t *testing.T, h *harness, name, password string) *http.Cookie {
260	t.Helper()
261	b, _ := json.Marshal(map[string]string{"username": name, "password": password})
262	w := req(h, "POST", "/api/login", string(b), nil, h.s.Origin)
263	if w.Code != 200 {
264		t.Fatal(w.Code, w.Body.String())
265	}
266	return w.Result().Cookies()[0]
267}
268func replaceAuth(t *testing.T, path string, file auth.CredentialFile) {
269	t.Helper()
270	b, _ := json.Marshal(file)
271	if e := os.WriteFile(path+".new", b, 0600); e != nil {
272		t.Fatal(e)
273	}
274	if e := os.Rename(path+".new", path); e != nil {
275		t.Fatal(e)
276	}
277}
278func TestOfflineLoginReviewAndLogoutWithNoJellyfin(t *testing.T) {
279	h, path, _ := offlineHarness(t)
280	w := req(h, "GET", "/api/config", "", nil, "")
281	if w.Code != 200 || w.Body.String() != "{\"authMode\":\"offline\"}\n" {
282		t.Fatal("config leaks more than auth mode")
283	}
284	cookie := offlineSignin(t, h, "ALICE", "fixture-only")
285	w = req(h, "GET", "/api/review?year=2025", "", cookie, "")
286	if w.Code != 200 || !strings.Contains(w.Body.String(), aID) || strings.Contains(w.Body.String(), bID) || strings.Contains(w.Body.String(), "PBKDF2") {
287		t.Fatal("offline owner boundary or verifier exposure")
288	}
289	if req(h, "GET", "/api/review?year=2025&userId="+bID, "", cookie, "").Code != 400 {
290		t.Fatal("offline impersonation")
291	}
292	for _, route := range []string{"/auth/users.json", "/users.json", "/auth/users.json.previous", "/api/auth-export"} {
293		if req(h, "GET", route, "", cookie, "").Code != 404 {
294			t.Fatal("private credentials exposed")
295		}
296	}
297	if req(h, "POST", "/api/logout", "{}", cookie, h.s.Origin).Code != 200 {
298		t.Fatal("offline logout needs network")
299	}
300	if req(h, "GET", "/api/session", "", cookie, "").Code != 401 {
301		t.Fatal("offline session survived logout")
302	}
303	cookie = offlineSignin(t, h, "Alice", "fixture-only")
304	h.s.Sessions.Now = func() time.Time { return time.Now().Add(13 * time.Hour) }
305	if req(h, "GET", "/api/session", "", cookie, "").Code != 401 {
306		t.Fatal("offline session expiry")
307	}
308	if h.authCalls != 0 {
309		t.Fatal("offline serving contacted Jellyfin")
310	}
311	// Never accept credential files in a directory that serving can publish.
312	for _, dir := range []string{h.s.Web, h.s.Data} {
313		b, e := os.ReadFile(path)
314		if e != nil {
315			t.Fatal(e)
316		}
317		private := filepath.Join(dir, "users.json")
318		if e = os.WriteFile(private, b, 0600); e != nil {
319			t.Fatal(e)
320		}
321		p, e := auth.NewOffline(private)
322		if e != nil {
323			t.Fatal(e)
324		}
325		if s, e := NewWithAuth(h.s.Data, h.s.Web, h.s.Origin, p); e == nil {
326			s.Close()
327			t.Fatal("credentials accepted in web/data directory")
328		}
329	}
330}
331func TestOfflineCredentialRefreshAndCorruption(t *testing.T) {
332	h, path, file := offlineHarness(t)
333	cookie := offlineSignin(t, h, "Alice", "fixture-only")
334	if e := os.WriteFile(path, []byte(`{"schemaVersion":99}`), 0600); e != nil {
335		t.Fatal(e)
336	}
337	w := req(h, "GET", "/api/review?year=2025", "", cookie, "")
338	if w.Code != 503 || strings.Contains(w.Body.String(), aID) {
339		t.Fatal("invalid export fell back to old credentials")
340	}
341	replaceAuth(t, path, file)
342	if req(h, "GET", "/api/session", "", cookie, "").Code != 200 {
343		t.Fatal("valid restored export unavailable")
344	}
345	file.Users[0].PasswordHash = file.Users[1].PasswordHash
346	replaceAuth(t, path, file)
347	if req(h, "GET", "/api/session", "", cookie, "").Code != 401 {
348		t.Fatal("password update retained offline session")
349	}
350	w = req(h, "POST", "/api/login", `{"username":"Alice","password":"fixture-only"}`, nil, h.s.Origin)
351	if w.Code != 401 {
352		t.Fatal("old offline password survived refresh")
353	}
354	cookie = offlineSignin(t, h, "Alice", "pässwörd 🎬")
355	file.Users[0].Disabled = true
356	file.Users[0].PasswordHash = ""
357	replaceAuth(t, path, file)
358	if req(h, "GET", "/api/review?year=2025", "", cookie, "").Code != 401 {
359		t.Fatal("disabled account retained access")
360	}
361	if h.authCalls != 0 {
362		t.Fatal("offline refresh contacted Jellyfin")
363	}
364}
365func TestOfflineLoginCSRFAndRateLimit(t *testing.T) {
366	h, _, _ := offlineHarness(t)
367	if req(h, "POST", "/api/login", `{"username":"Alice","password":"fixture-only"}`, nil, "https://evil.test").Code != 403 {
368		t.Fatal("offline CSRF")
369	}
370	for i := 0; i < 10; i++ {
371		w := req(h, "POST", "/api/login", `{"username":"Alice","password":"wrong"}`, nil, h.s.Origin)
372		if w.Code != 401 || strings.Contains(w.Body.String(), "PBKDF2") {
373			t.Fatal("offline generic failure")
374		}
375	}
376	if req(h, "POST", "/api/login", `{"username":"Alice","password":"fixture-only"}`, nil, h.s.Origin).Code != 429 {
377		t.Fatal("offline rate limit")
378	}
379}