jellyfin-tools/jellyfin-year-review/internal/auth/offline_test.go
jellyfin-year-review/internal/auth/offline_test.goRaw
1package auth
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "reelreview/internal/source"
14)
15
16func credentials(t *testing.T) (string, CredentialFile) {
17 t.Helper()
18 b, e := os.ReadFile("../../testdata/offline-auth.json")
19 if e != nil {
20 t.Fatal(e)
21 }
22 var f CredentialFile
23 if e = json.Unmarshal(b, &f); e != nil {
24 t.Fatal(e)
25 }
26 path := filepath.Join(t.TempDir(), "users.json")
27 if e = os.WriteFile(path, b, 0600); e != nil {
28 t.Fatal(e)
29 }
30 return path, f
31}
32func replaceCredentials(t *testing.T, path string, f CredentialFile) {
33 t.Helper()
34 b, e := json.Marshal(f)
35 if e != nil {
36 t.Fatal(e)
37 }
38 temp := path + ".new"
39 if e = os.WriteFile(temp, b, 0600); e != nil {
40 t.Fatal(e)
41 }
42 if e = os.Rename(temp, path); e != nil {
43 t.Fatal(e)
44 }
45}
46func TestOfflineJellyfinPasswordVectors(t *testing.T) {
47 path, _ := credentials(t)
48 p, e := NewOffline(path)
49 if e != nil {
50 t.Fatal(e)
51 }
52 for _, tt := range []struct{ name, password, id string }{{"ALICE", "fixture-only", strings.Repeat("a", 32)}, {"Legacy", "pässwörd 🎬", strings.Repeat("c", 32)}} {
53 a, revision, e := p.Login(context.Background(), tt.name, tt.password, "unused")
54 if e != nil || a.ID != tt.id || revision == "" {
55 t.Fatal("Python-derived Jellyfin password vector did not authenticate", e)
56 }
57 if _, e = p.Validate(context.Background(), Session{Account: a, Token: revision}); e != nil {
58 t.Fatal(e)
59 }
60 }
61 for _, tt := range []struct{ name, password string }{{"Alice", "wrong"}, {"Unknown", "fixture-only"}, {"Disabled", ""}, {"Alice", ""}} {
62 _, _, e = p.Login(context.Background(), tt.name, tt.password, "")
63 if !errors.Is(e, source.ErrDenied) {
64 t.Fatal("unknown/disabled/incorrect login did not fail generically")
65 }
66 }
67}
68func TestOfflineReplacementsRevokeOnlyAffectedAccounts(t *testing.T) {
69 for _, change := range []string{"password", "rename", "remove", "disable"} {
70 t.Run(change, func(t *testing.T) {
71 path, file := credentials(t)
72 p, e := NewOffline(path)
73 if e != nil {
74 t.Fatal(e)
75 }
76 a, token, e := p.Login(context.Background(), "Alice", "fixture-only", "")
77 if e != nil {
78 t.Fatal(e)
79 }
80 s := Session{Account: a, Token: token}
81 // Metadata refresh and other users changing must leave Alice's revision usable.
82 file.ExportedAt = file.ExportedAt.Add(time.Hour)
83 file.Users[1].Username = "Other name"
84 replaceCredentials(t, path, file)
85 if _, e = p.Validate(context.Background(), s); e != nil {
86 t.Fatal("unrelated refresh revoked Alice")
87 }
88 switch change {
89 case "password":
90 file.Users[0].PasswordHash = strings.Replace(file.Users[0].PasswordHash, "001122", "112233", 1)
91 case "rename":
92 file.Users[0].Username = "Renamed"
93 case "remove":
94 file.Users = file.Users[1:]
95 case "disable":
96 file.Users[0].Disabled = true
97 file.Users[0].PasswordHash = ""
98 }
99 replaceCredentials(t, path, file)
100 if _, e = p.Validate(context.Background(), s); !errors.Is(e, source.ErrDenied) {
101 t.Fatal("changed account retained session")
102 }
103 })
104 }
105}
106func TestOfflineFailsClosedWithoutLeakingVerifier(t *testing.T) {
107 path, file := credentials(t)
108 p, e := NewOffline(path)
109 if e != nil {
110 t.Fatal(e)
111 }
112 for _, mutate := range []func(*CredentialFile){
113 func(f *CredentialFile) { f.Version = 99 }, func(f *CredentialFile) { f.SourceVersion = "future" },
114 func(f *CredentialFile) { f.Users = append(f.Users, f.Users[0]) }, func(f *CredentialFile) { f.Users[0].PasswordHash = "plaintext-password" },
115 func(f *CredentialFile) {
116 f.Users[0].PasswordHash = strings.Replace(f.Users[0].PasswordHash, "210000", "1000001", 1)
117 },
118 func(f *CredentialFile) { f.Users[0].ID = "../../escape" },
119 } {
120 copy := file
121 copy.Users = append([]Credential{}, file.Users...)
122 mutate(©)
123 replaceCredentials(t, path, copy)
124 if _, _, e = p.Login(context.Background(), "Alice", "fixture-only", ""); !errors.Is(e, source.ErrUnavailable) || strings.Contains(e.Error(), "plaintext") || strings.Contains(e.Error(), "PBKDF2") {
125 t.Fatal("malformed credentials did not fail closed/generically")
126 }
127 }
128 replaceCredentials(t, path, file)
129 if e = os.Chmod(path, 0644); e != nil {
130 t.Fatal(e)
131 }
132 if _, e = NewOffline(path); e == nil {
133 t.Fatal("publicly readable credentials accepted")
134 }
135 if e = os.Chmod(path, 0600); e != nil {
136 t.Fatal(e)
137 }
138 link := filepath.Join(t.TempDir(), "link.json")
139 if e = os.Symlink(path, link); e != nil {
140 t.Fatal(e)
141 }
142 if _, e = NewOffline(link); e == nil {
143 t.Fatal("credential symlink accepted")
144 }
145 if e = os.Remove(path); e != nil {
146 t.Fatal(e)
147 }
148 if _, e = p.Validate(context.Background(), Session{}); !errors.Is(e, source.ErrUnavailable) {
149 t.Fatal("missing file fell back to credentials")
150 }
151}
152func TestOfflineBoundedVerificationAndUnicodeNames(t *testing.T) {
153 path, f := credentials(t)
154 f.Users[0].Username = "Vïewer"
155 replaceCredentials(t, path, f)
156 p, e := NewOffline(path)
157 if e != nil {
158 t.Fatal(e)
159 }
160 if _, _, e = p.Login(context.Background(), "Vïewer", "fixture-only", ""); e != nil {
161 t.Fatal("exact Unicode username failed")
162 }
163 if loginKey("Vïewer") == loginKey("vïewer") {
164 t.Fatal("ambiguous Unicode casing was normalized")
165 }
166 p.work <- struct{}{}
167 p.work <- struct{}{}
168 ctx, cancel := context.WithCancel(context.Background())
169 cancel()
170 if _, _, e = p.Login(ctx, "Vïewer", "fixture-only", ""); !errors.Is(e, source.ErrUnavailable) {
171 t.Fatal("queued canceled verification did not stop")
172 }
173}
174
175func TestOfflineRequiresExplicitAccountStatus(t *testing.T) {
176 path, _ := credentials(t)
177 p, e := NewOffline(path)
178 if e != nil {
179 t.Fatal(e)
180 }
181 b, e := os.ReadFile(path)
182 if e != nil {
183 t.Fatal(e)
184 }
185 var document map[string]any
186 if e = json.Unmarshal(b, &document); e != nil {
187 t.Fatal(e)
188 }
189 user := document["users"].([]any)[0].(map[string]any)
190 delete(user, "disabled")
191 b, e = json.Marshal(document)
192 if e != nil {
193 t.Fatal(e)
194 }
195 if e = os.WriteFile(path, b, 0600); e != nil {
196 t.Fatal(e)
197 }
198 if _, e = p.load(); !errors.Is(e, source.ErrUnavailable) {
199 t.Fatal("missing account status became enabled")
200 }
201}