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}