package adminauth import ( "context" "net/http" "net/http/httptest" "testing" "time" "litepan/internal/domain" "litepan/internal/store" "litepan/pkg/security" ) func newTestAuth(t *testing.T) (*Service, domain.ConfigRepository, context.Context) { t.Helper() ctx := context.Background() db, err := store.Open(ctx, store.Options{Memory: true}) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := db.Migrate(ctx); err != nil { t.Fatalf("migrate: %v", err) } st := store.New(db) _ = st.Configs.Set(ctx, KeyAdminUsername, "admin") _ = st.Configs.Set(ctx, KeyAdminPassword, security.HashPassword("changed-secret")) return New(st.Configs, []byte("test-secret-key-min-16b"), nil), st.Configs, ctx } func newBareTestAuth(t *testing.T) (*Service, domain.ConfigRepository, context.Context) { t.Helper() ctx := context.Background() db, err := store.Open(ctx, store.Options{Memory: true}) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := db.Migrate(ctx); err != nil { t.Fatalf("migrate: %v", err) } st := store.New(db) return New(st.Configs, []byte("test-secret-key-min-16b"), nil), st.Configs, ctx } func TestDefaultAdminPasswordIsInitializedAsHashAndMustChange(t *testing.T) { svc, configs, ctx := newBareTestAuth(t) username, storedPassword := svc.adminCredentials(ctx) if username != defaultAdminUsername { t.Fatalf("username = %q, want %q", username, defaultAdminUsername) } if !security.IsPasswordHash(storedPassword) { t.Fatalf("default password should be stored as hash, got %q", storedPassword) } if !security.VerifyAdminPassword(storedPassword, defaultAdminPassword) { t.Fatal("hashed default password should verify admin password") } persisted, ok, err := configs.Get(ctx, KeyAdminPassword) if err != nil { t.Fatalf("get persisted password: %v", err) } if !ok || persisted != storedPassword { t.Fatalf("persisted password = %q, ok=%v, want stored hash %q", persisted, ok, storedPassword) } state := svc.credentialState(ctx) if !state.MustChangePassword { t.Fatal("default admin should still require password change") } if state.PasswordChangeReason != "default_credentials" { t.Fatalf("reason = %q, want default_credentials", state.PasswordChangeReason) } } func TestReadSessionExpiresAfterTimeout(t *testing.T) { svc, configs, ctx := newTestAuth(t) _ = configs.Set(ctx, KeySessionTimeout, "1800") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) if err := svc.WriteSession(rec, req, Session{IsAdmin: true, Username: "admin"}, false); err != nil { t.Fatalf("write session: %v", err) } cookie := rec.Result().Cookies()[0] checkReq := httptest.NewRequest(http.MethodGet, "/", nil) checkReq.AddCookie(cookie) if _, ok := svc.ReadSession(checkReq); !ok { t.Fatal("session should be valid immediately after login") } sess, ok := svc.ReadSession(checkReq) if !ok || sess == nil { t.Fatal("expected session payload") } sess.CreatedAt = time.Now().Add(-31 * time.Minute).Format(time.RFC3339) rec2 := httptest.NewRecorder() if err := svc.WriteSession(rec2, checkReq, *sess, false); err != nil { t.Fatalf("rewrite session: %v", err) } expiredReq := httptest.NewRequest(http.MethodGet, "/", nil) expiredReq.AddCookie(rec2.Result().Cookies()[0]) if _, ok := svc.ReadSession(expiredReq); ok { t.Fatal("session should expire after configured timeout") } } func TestUpdateCredentialsPreservesSessionCreatedAt(t *testing.T) { svc, configs, ctx := newTestAuth(t) _ = configs.Set(ctx, KeySessionTimeout, "1800") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) createdAt := time.Now().Add(-20 * time.Minute).Format(time.RFC3339) if err := svc.WriteSession(rec, req, Session{ IsAdmin: true, Username: "admin", CreatedAt: createdAt, }, false); err != nil { t.Fatalf("write session: %v", err) } cookie := rec.Result().Cookies()[0] updateReq := httptest.NewRequest(http.MethodPost, "/api/admin/update-credentials", nil) updateReq.AddCookie(cookie) sess, ok := svc.ReadSession(updateReq) if !ok { t.Fatal("expected active session before update") } timeout := 0.5 updateRec := httptest.NewRecorder() if err := svc.UpdateCredentials(ctx, updateReq, updateRec, UpdateCredentialsRequest{ AdminUsername: "admin", SessionTimeout: &timeout, }, sess); err != nil { t.Fatalf("update credentials: %v", err) } afterReq := httptest.NewRequest(http.MethodGet, "/", nil) afterReq.AddCookie(updateRec.Result().Cookies()[0]) got, ok := svc.ReadSession(afterReq) if !ok || got == nil { t.Fatal("session should remain valid after settings save") } if got.CreatedAt != createdAt { t.Fatalf("CreatedAt changed after settings save: got %q want %q", got.CreatedAt, createdAt) } } func TestUpdateCredentialsClearsMustChangePassword(t *testing.T) { svc, _, ctx := newTestAuth(t) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) if err := svc.WriteSession(rec, req, Session{ IsAdmin: true, Username: "admin", MustChangePassword: true, PasswordChangeReason: "default_credentials", }, false); err != nil { t.Fatalf("write session: %v", err) } cookie := rec.Result().Cookies()[0] updateReq := httptest.NewRequest(http.MethodPost, "/api/admin/update-credentials", nil) updateReq.AddCookie(cookie) sess, ok := svc.ReadSession(updateReq) if !ok { t.Fatal("expected active session before update") } updateRec := httptest.NewRecorder() if err := svc.UpdateCredentials(ctx, updateReq, updateRec, UpdateCredentialsRequest{ AdminUsername: "admin", AdminPassword: "new-secret", }, sess); err != nil { t.Fatalf("update credentials: %v", err) } afterReq := httptest.NewRequest(http.MethodGet, "/", nil) afterReq.AddCookie(updateRec.Result().Cookies()[0]) got, ok := svc.ReadSession(afterReq) if !ok || got == nil { t.Fatal("session should remain valid after password change") } if got.MustChangePassword { t.Fatal("session must_change_password should be cleared after password upgrade") } st := svc.Status(ctx, afterReq) if st.MustChangePassword { t.Fatal("status must_change_password should be false after password upgrade") } } func TestUpdateCredentialsValidationFailureDoesNotPersistAnySetting(t *testing.T) { svc, configs, ctx := newTestAuth(t) if err := configs.Set(ctx, KeyPublicIndexEnabled, "true"); err != nil { t.Fatalf("set public index: %v", err) } publicIndexEnabled := false invalidConcurrency := 6 err := svc.UpdateCredentials( ctx, httptest.NewRequest(http.MethodPost, "/api/admin/update-credentials", nil), httptest.NewRecorder(), UpdateCredentialsRequest{ AdminUsername: "changed_admin", PublicIndexEnabled: &publicIndexEnabled, UploadTaskConcurrency: &invalidConcurrency, }, nil, ) if err == nil { t.Fatal("expected invalid concurrency to reject the whole update") } username, ok, getErr := configs.Get(ctx, KeyAdminUsername) if getErr != nil { t.Fatalf("get username: %v", getErr) } if !ok || username != "admin" { t.Fatalf("username changed after rejected update: got %q, ok=%v", username, ok) } publicIndex, ok, getErr := configs.Get(ctx, KeyPublicIndexEnabled) if getErr != nil { t.Fatalf("get public index: %v", getErr) } if !ok || publicIndex != "true" { t.Fatalf("public index changed after rejected update: got %q, ok=%v", publicIndex, ok) } }