arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
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(&copy)
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}