From feafe31716cdbac304dbcd0bce6fe7b205747f0a Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 1 May 2026 22:16:11 +0300 Subject: Task 1: Create AuthService and route handleBootstrap/handleLogin through it Introduce service.AuthService interface with Bootstrap and Login methods, a concrete authService implementation, and a MockAuthService for testing. Wire AuthService into api.Server and update cmd/mediaplayer/main.go to use it. This removes direct store access from handleBootstrap and handleLogin, fixing the DIP violation. Sentinel errors (ErrAlreadyBootstrapped, ErrInvalidCredentials) are added to the service package so the API layer can map them to the correct HTTP status codes without leaking DB details. Files created: - internal/service/auth.go Files modified: - internal/service/service.go - internal/service/media.go - internal/service/mock.go - internal/repository/repository.go - internal/repository/mock.go - internal/api/server.go - internal/api/handlers_auth.go - internal/api/handlers_test.go - internal/api/handlers_more_test.go - cmd/mediaplayer/main.go --- cmd/mediaplayer/main.go | 3 +- internal/api/handlers_auth.go | 69 ++++++----------- internal/api/handlers_more_test.go | 149 ++++++++++++++++++------------------- internal/api/handlers_test.go | 85 ++++++++++++--------- internal/api/server.go | 5 +- internal/repository/mock.go | 1 + internal/repository/repository.go | 5 ++ internal/service/auth.go | 86 +++++++++++++++++++++ internal/service/media.go | 2 + internal/service/mock.go | 20 +++++ internal/service/service.go | 12 +++ 11 files changed, 274 insertions(+), 163 deletions(-) create mode 100644 internal/service/auth.go diff --git a/cmd/mediaplayer/main.go b/cmd/mediaplayer/main.go index 812e984..68e59e1 100644 --- a/cmd/mediaplayer/main.go +++ b/cmd/mediaplayer/main.go @@ -84,6 +84,7 @@ func run(args []string) error { adminSvc := service.NewAdminService(store, clk, hasher, fsScanner, cfg.MediaRoot) progressSvc := service.NewProgressService(store, clk) + authSvc := service.NewAuthService(store, clk, hasher, sm) // Start the background GC worker that hard-deletes soft-deleted media. gcWorker := service.NewGCWorker(store, clk, cfg.MediaRoot, time.Duration(cfg.GCIntervalMinutes)*time.Minute, logger) @@ -91,7 +92,7 @@ func run(args []string) error { defer gcWorker.Stop() staticFS := http.Dir("web") - server := api.NewServer(store, hasher, sm, cfg, mediaSvc, adminSvc, progressSvc, staticFS) + server := api.NewServer(store, hasher, sm, cfg, mediaSvc, adminSvc, progressSvc, authSvc, staticFS) gs := api.NewGracefulServer(server, cfg) diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 96991e6..5bf5f39 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -1,10 +1,11 @@ package api import ( + "errors" "net/http" "time" - "codeberg.org/snonux/player/internal/model" + "codeberg.org/snonux/player/internal/service" ) type bootstrapRequest struct { @@ -22,6 +23,9 @@ type loginRequest struct { // ------------------------------------------------------------------ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { + if !requireService(w, s.authSvc) { + return + } var req bootstrapRequest if err := readJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) @@ -32,41 +36,24 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { return } - ctx := r.Context() - count, err := s.store.CountUsers(ctx) + res, err := s.authSvc.Bootstrap(r.Context(), req.Username, req.Password) if err != nil { + if errors.Is(err, service.ErrAlreadyBootstrapped) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "bootstrap already complete"}) + return + } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) return } - if count > 0 { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "bootstrap already complete"}) - return - } - hash, err := s.hasher.Hash(req.Password) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) - return - } - - user := &model.User{Username: req.Username, PasswordHash: hash, IsAdmin: true, CreatedAt: time.Now()} - id, err := s.store.CreateUser(ctx, user) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) - return - } - user.ID = id - - sessID, err := s.sm.CreateSession(ctx, id) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) - return - } - s.setSessionCookie(w, sessID) - writeJSON(w, http.StatusOK, map[string]interface{}{"id": user.ID, "username": user.Username, "is_admin": user.IsAdmin}) + s.setSessionCookie(w, res.SessionID) + writeJSON(w, http.StatusOK, map[string]interface{}{"id": res.User.ID, "username": res.User.Username, "is_admin": res.User.IsAdmin}) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if !requireService(w, s.authSvc) { + return + } var req loginRequest if err := readJSON(r, &req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) @@ -77,28 +64,18 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } - ctx := r.Context() - user, err := s.store.GetUserByUsername(ctx, req.Username) - if err != nil { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) - return - } - if user == nil { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) - return - } - if err := s.hasher.Compare(user.PasswordHash, req.Password); err != nil { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) - return - } - - sessID, err := s.sm.CreateSession(ctx, user.ID) + res, err := s.authSvc.Login(r.Context(), req.Username, req.Password) if err != nil { + if errors.Is(err, service.ErrInvalidCredentials) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + return + } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) return } - s.setSessionCookie(w, sessID) - writeJSON(w, http.StatusOK, map[string]interface{}{"id": user.ID, "username": user.Username, "is_admin": user.IsAdmin}) + + s.setSessionCookie(w, res.SessionID) + writeJSON(w, http.StatusOK, map[string]interface{}{"id": res.User.ID, "username": res.User.Username, "is_admin": res.User.IsAdmin}) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/handlers_more_test.go b/internal/api/handlers_more_test.go index 61838cb..a4fd3c4 100644 --- a/internal/api/handlers_more_test.go +++ b/internal/api/handlers_more_test.go @@ -89,7 +89,7 @@ func TestNewGracefulServer(t *testing.T) { func TestPingStore_nonPinger(t *testing.T) { store := &repository.MockStore{} - srv := newTestServer(t, store, nil, nil, &internal.Config{}, nil, nil, nil, nil) + srv := newTestServer(t, store, nil, nil, &internal.Config{}, nil, nil, nil, nil, nil) if err := srv.pingStore(context.Background()); err != nil { t.Fatal("expected nil for non-pinger") } @@ -97,7 +97,7 @@ func TestPingStore_nonPinger(t *testing.T) { func TestPingStore_pingerError(t *testing.T) { store := &mockPingStore{err: errors.New("down")} - srv := newTestServer(t, store, nil, nil, &internal.Config{}, nil, nil, nil, nil) + srv := newTestServer(t, store, nil, nil, &internal.Config{}, nil, nil, nil, nil, nil) if err := srv.pingStore(context.Background()); err == nil { t.Fatal("expected error") } @@ -159,7 +159,7 @@ func TestServer_ServeFile_success(t *testing.T) { }, } sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) - srv := newTestServer(t, store, nil, sm, &internal.Config{SessionTimeoutHours: 24}, nil, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, &internal.Config{SessionTimeoutHours: 24}, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) @@ -179,7 +179,7 @@ func TestServer_ServeFile_notFound(t *testing.T) { }, } sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) - srv := newTestServer(t, store, nil, sm, &internal.Config{SessionTimeoutHours: 24}, nil, nil, nil, fs) + srv := newTestServer(t, store, nil, sm, &internal.Config{SessionTimeoutHours: 24}, nil, nil, nil, nil, fs) req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) @@ -219,14 +219,13 @@ func TestServer_Bootstrap_negativePaths(t *testing.T) { } func TestServer_Bootstrap_hashError(t *testing.T) { - store := &repository.MockStore{ - UserRepo: repository.MockUserRepo{ - CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }, + cfg := &internal.Config{SessionTimeoutHours: 24} + authSvc := &service.MockAuthService{ + BootstrapFunc: func(ctx context.Context, username, password string) (*service.AuthResult, error) { + return nil, errors.New("hash err") }, } - cfg := &internal.Config{SessionTimeoutHours: 24} - hasher := &errHasher{} - srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil) + srv := newTestServer(t, nil, nil, nil, cfg, nil, nil, nil, authSvc, nil) body := `{"username":"u","password":"p"}` req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") @@ -238,14 +237,13 @@ func TestServer_Bootstrap_hashError(t *testing.T) { } func TestServer_Bootstrap_createUserError(t *testing.T) { - store := &repository.MockStore{ - UserRepo: repository.MockUserRepo{ - CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }, - CreateUserFunc: func(ctx context.Context, user *model.User) (int64, error) { return 0, errors.New("boom") }, + cfg := &internal.Config{SessionTimeoutHours: 24} + authSvc := &service.MockAuthService{ + BootstrapFunc: func(ctx context.Context, username, password string) (*service.AuthResult, error) { + return nil, errors.New("boom") }, } - cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, store, &staticHasher{fixed: "h"}, nil, cfg, nil, nil, nil, nil) + srv := newTestServer(t, nil, nil, nil, cfg, nil, nil, nil, authSvc, nil) body := `{"username":"u","password":"p"}` req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") @@ -257,18 +255,13 @@ func TestServer_Bootstrap_createUserError(t *testing.T) { } func TestServer_Bootstrap_createSessionError(t *testing.T) { - store := &repository.MockStore{ - UserRepo: repository.MockUserRepo{ - CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }, - CreateUserFunc: func(ctx context.Context, user *model.User) (int64, error) { return 1, nil }, + cfg := &internal.Config{SessionTimeoutHours: 24} + authSvc := &service.MockAuthService{ + BootstrapFunc: func(ctx context.Context, username, password string) (*service.AuthResult, error) { + return nil, errors.New("boom") }, } - repo := repository.MockSessionRepo{ - CreateSessionFunc: func(ctx context.Context, session *model.Session) error { return errors.New("boom") }, - } - sm := auth.NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) - cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, store, &staticHasher{fixed: "h"}, sm, cfg, nil, nil, nil, nil) + srv := newTestServer(t, nil, nil, nil, cfg, nil, nil, nil, authSvc, nil) body := `{"username":"u","password":"p"}` req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") @@ -284,12 +277,11 @@ func TestServer_Bootstrap_createSessionError(t *testing.T) { // ------------------------------------------------------------------ func TestServer_Login_negativePaths(t *testing.T) { - hasher := &staticHasher{fixed: "hashed"} cfg := &internal.Config{SessionTimeoutHours: 24} t.Run("invalid json", func(t *testing.T) { - store := buildSessionStore(1) - srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil) + authSvc := &service.MockAuthService{} + srv := newTestServer(t, nil, nil, nil, cfg, nil, nil, nil, authSvc, nil) req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(`bad`))) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -300,18 +292,19 @@ func TestServer_Login_negativePaths(t *testing.T) { }) t.Run("db error", func(t *testing.T) { - store := buildSessionStore(1) - store.UserRepo.GetUserByUsernameFunc = func(ctx context.Context, username string) (*model.User, error) { - return nil, errors.New("boom") + authSvc := &service.MockAuthService{ + LoginFunc: func(ctx context.Context, username, password string) (*service.AuthResult, error) { + return nil, errors.New("boom") + }, } - srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil) + srv := newTestServer(t, nil, nil, nil, cfg, nil, nil, nil, authSvc, nil) body := `{"username":"alice","password":"correct"}` req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) - if rr.Code != http.StatusUnauthorized { - t.Fatalf("expected %d, got %d", http.StatusUnauthorized, rr.Code) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected %d, got %d", http.StatusInternalServerError, rr.Code) } }) } @@ -350,7 +343,7 @@ func TestServer_SetCover(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/sets/"+tt.id+"/cover", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -368,7 +361,7 @@ func TestServer_ListSets_negative(t *testing.T) { cfg := &internal.Config{SessionTimeoutHours: 24} t.Run("nil service", func(t *testing.T) { - srv := newTestServer(t, store, nil, sm, cfg, nil, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/sets", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -384,7 +377,7 @@ func TestServer_ListSets_negative(t *testing.T) { return nil, errors.New("boom") }, } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/sets", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -434,7 +427,7 @@ func TestServer_Upload(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) var req *http.Request if tt.noFile { var buf bytes.Buffer @@ -471,7 +464,7 @@ func TestServer_MediaDetail_nilService(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, store, nil, sm, cfg, nil, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/media/1", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -508,7 +501,7 @@ func TestServer_Favorite_negative(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/media/"+tt.id+"/favorite", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -524,7 +517,7 @@ func TestServer_AddTag_nilService(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, store, nil, sm, cfg, nil, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/media/1/tags", strings.NewReader(`{"tag":"x"}`)) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) req.Header.Set("Content-Type", "application/json") @@ -563,7 +556,7 @@ func TestServer_RemoveTag_negative(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/media/%s/tags/%s", tt.id, tt.tag), nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -611,7 +604,7 @@ func TestServer_Stream(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/media/"+tt.id+"/stream", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -656,7 +649,7 @@ func TestServer_Download(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/media/"+tt.id+"/download", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -706,7 +699,7 @@ func TestServer_Thumbnail(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/media/"+tt.id+"/thumbnail", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -746,7 +739,7 @@ func TestServer_RegenThumbnail(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/media/"+tt.id+"/thumbnail", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -781,7 +774,7 @@ func TestServer_RegenThumbnail_errorMapping(t *testing.T) { return tt.svcErr }, } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/media/1/thumbnail", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -824,7 +817,7 @@ func TestServer_CreateShare_negative(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/media/"+tt.id+"/shares", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -863,7 +856,7 @@ func TestServer_ListShares_negative(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/media/"+tt.id+"/shares", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -902,7 +895,7 @@ func TestServer_RevokeShare(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodDelete, "/api/shares/"+tt.token, nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -924,7 +917,7 @@ func TestServer_SharePage(t *testing.T) { }) t.Run("nil service", func(t *testing.T) { - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, nil, nil, nil, fs) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, nil, nil, nil, nil, fs) req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -939,7 +932,7 @@ func TestServer_SharePage(t *testing.T) { return nil, errors.New("boom") }, } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, fs) req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -954,7 +947,7 @@ func TestServer_SharePage(t *testing.T) { return nil, nil }, } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, fs) req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -969,7 +962,7 @@ func TestServer_SharePage(t *testing.T) { return nil, service.ErrShareExpired }, } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, fs) req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -984,7 +977,7 @@ func TestServer_SharePage(t *testing.T) { return &model.Share{Token: "abc", MediaID: 1}, nil }, } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, fs) req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -1007,7 +1000,7 @@ func TestServer_SharePage(t *testing.T) { return &model.Share{Token: "abc", MediaID: 1}, nil }, } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, fs) req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) req.Header.Set("Accept", "text/html") rr := httptest.NewRecorder() @@ -1027,7 +1020,7 @@ func TestServer_SharePage(t *testing.T) { return &model.Share{Token: "abc", MediaID: 1}, nil }, } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, fs) req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) req.Header.Set("Accept", "application/json") rr := httptest.NewRecorder() @@ -1054,7 +1047,7 @@ func TestServer_ShareStream(t *testing.T) { cfg := &internal.Config{SessionTimeoutHours: 24} t.Run("nil service", func(t *testing.T) { - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, nil, nil, nil, nil) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -1069,7 +1062,7 @@ func TestServer_ShareStream(t *testing.T) { return nil, errors.New("boom") }, } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -1084,7 +1077,7 @@ func TestServer_ShareStream(t *testing.T) { return nil, service.ErrShareNotFound }, } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -1099,7 +1092,7 @@ func TestServer_ShareStream(t *testing.T) { return nil, service.ErrShareExpired }, } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -1114,7 +1107,7 @@ func TestServer_ShareStream(t *testing.T) { return nil, service.ErrMediaNotFound }, } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -1129,7 +1122,7 @@ func TestServer_ShareStream(t *testing.T) { return &service.FileResult{Path: "/nonexistent", FileName: "a.mp4"}, nil }, } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -1144,7 +1137,7 @@ func TestServer_ShareStream(t *testing.T) { return &service.FileResult{Path: path, FileName: "a.mp4"}, nil }, } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -1186,7 +1179,7 @@ func TestServer_SoftDelete_negative(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodDelete, "/api/media/"+tt.id, nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -1226,7 +1219,7 @@ func TestServer_Restore_negative(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/media/"+tt.id+"/restore", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -1272,7 +1265,7 @@ func TestServer_UpsertNote(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/media/"+tt.id+"/notes", strings.NewReader(tt.body)) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) req.Header.Set("Content-Type", "application/json") @@ -1313,7 +1306,7 @@ func TestServer_DeleteNote(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodDelete, "/api/media/"+tt.id+"/notes", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -1357,7 +1350,7 @@ func TestServer_Progress_negative(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, nil, nil, ps, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, nil, ps, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/progress", strings.NewReader(tt.body)) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) req.Header.Set("Content-Type", "application/json") @@ -1398,7 +1391,7 @@ func TestServer_AdminRescan(t *testing.T) { TriggerRescanFunc: func(ctx context.Context) error { return tt.svcErr }, } } - srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/admin/rescan", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -1434,7 +1427,7 @@ func TestServer_AdminListTrash(t *testing.T) { ListTrashFunc: func(ctx context.Context) ([]model.Media, error) { return nil, tt.svcErr }, } } - srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/admin/trash", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -1470,7 +1463,7 @@ func TestServer_AdminListUsers(t *testing.T) { ListUsersFunc: func(ctx context.Context) ([]model.User, error) { return nil, tt.svcErr }, } } - srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -1511,7 +1504,7 @@ func TestServer_AdminCreateUser(t *testing.T) { }, } } - srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/admin/users", strings.NewReader(tt.body)) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) req.Header.Set("Content-Type", "application/json") @@ -1550,7 +1543,7 @@ func TestServer_AdminDeleteUser(t *testing.T) { DeleteUserFunc: func(ctx context.Context, id int64) error { return tt.svcErr }, } } - srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil, nil) req := httptest.NewRequest(http.MethodDelete, "/api/admin/users/"+tt.id, nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -1586,7 +1579,7 @@ func TestServer_AdminListPermissions(t *testing.T) { ListPermissionsFunc: func(ctx context.Context) (*service.PermissionsMatrix, error) { return nil, tt.svcErr }, } } - srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/admin/permissions", nil) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -1624,7 +1617,7 @@ func TestServer_AdminGrantPermission(t *testing.T) { GrantPermissionFunc: func(ctx context.Context, setID, userID int64, role model.Role) error { return tt.svcErr }, } } - srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/admin/permissions", strings.NewReader(tt.body)) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) req.Header.Set("Content-Type", "application/json") @@ -1663,7 +1656,7 @@ func TestServer_AdminRevokePermission(t *testing.T) { RevokePermissionFunc: func(ctx context.Context, setID, userID int64) error { return tt.svcErr }, } } - srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, as, nil, nil, nil) req := httptest.NewRequest(http.MethodDelete, "/api/admin/permissions", strings.NewReader(tt.body)) req.AddCookie(sessionCookieForStore(t, store, sm, 1)) req.Header.Set("Content-Type", "application/json") diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 42aea48..41215c9 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -35,6 +35,7 @@ func newTestFS(files map[string]string) http.FileSystem { func newTestServer(t *testing.T, store repository.Store, hasher auth.Hasher, sm *auth.SessionManager, cfg *internal.Config, mediaSvc service.MediaService, adminSvc service.AdminService, progressSvc service.ProgressService, + authSvc service.AuthService, fs http.FileSystem, ) *Server { t.Helper() @@ -46,7 +47,7 @@ func newTestServer(t *testing.T, store repository.Store, hasher auth.Hasher, sm "share.html": "share", }) } - return NewServer(store, hasher, sm, cfg, mediaSvc, adminSvc, progressSvc, fs) + return NewServer(store, hasher, sm, cfg, mediaSvc, adminSvc, progressSvc, authSvc, fs) } func addSessionCookie(t *testing.T, store repository.Store, sm *auth.SessionManager, userID int64) *http.Cookie { @@ -246,7 +247,7 @@ func TestServer_StaticPages(t *testing.T) { } t.Run("index requires session", func(t *testing.T) { - srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil) + srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -256,7 +257,7 @@ func TestServer_StaticPages(t *testing.T) { }) t.Run("login public", func(t *testing.T) { - srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil) + srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/login.html", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -269,7 +270,7 @@ func TestServer_StaticPages(t *testing.T) { }) t.Run("bootstrap public", func(t *testing.T) { - srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil) + srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/bootstrap.html", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -280,7 +281,7 @@ func TestServer_StaticPages(t *testing.T) { t.Run("css public", func(t *testing.T) { fs := newTestFS(map[string]string{"css/theme.css": "body{}"}) - srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, fs) + srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil, fs) req := httptest.NewRequest(http.MethodGet, "/css/theme.css", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -312,6 +313,7 @@ func (h *staticHasher) Compare(hash, password string) error { func TestServer_Bootstrap(t *testing.T) { hasher := &staticHasher{fixed: "hashed"} cfg := &internal.Config{SessionTimeoutHours: 24} + clk := &clock.MockClock{T: time.Now()} t.Run("create first admin", func(t *testing.T) { store := &repository.MockStore{ @@ -323,8 +325,9 @@ func TestServer_Bootstrap(t *testing.T) { repo := repository.MockSessionRepo{ CreateSessionFunc: func(ctx context.Context, session *model.Session) error { return nil }, } - sm := auth.NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) - srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, nil) + sm := auth.NewSessionManager(&repo, clk, time.Hour) + authSvc := service.NewAuthService(store, clk, hasher, sm) + srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, authSvc, nil) body := `{"username":"admin","password":"secret"}` req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(body))) @@ -351,7 +354,8 @@ func TestServer_Bootstrap(t *testing.T) { CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }, }, } - srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil) + authSvc := service.NewAuthService(store, clk, hasher, nil) + srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, authSvc, nil) body := `{"username":"admin","password":"secret"}` req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") @@ -364,7 +368,8 @@ func TestServer_Bootstrap(t *testing.T) { t.Run("missing fields", func(t *testing.T) { store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }}} - srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil) + authSvc := service.NewAuthService(store, clk, hasher, nil) + srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, authSvc, nil) req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(`{"username":""}`))) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -374,7 +379,7 @@ func TestServer_Bootstrap(t *testing.T) { }) t.Run("wrong method", func(t *testing.T) { - srv := newTestServer(t, nil, hasher, nil, cfg, nil, nil, nil, nil) + srv := newTestServer(t, nil, hasher, nil, cfg, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/bootstrap", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -387,6 +392,7 @@ func TestServer_Bootstrap(t *testing.T) { func TestServer_Login(t *testing.T) { hasher := &staticHasher{fixed: "hashed"} cfg := &internal.Config{SessionTimeoutHours: 24} + clk := &clock.MockClock{T: time.Now()} t.Run("valid credentials", func(t *testing.T) { store := &repository.MockStore{ @@ -400,8 +406,9 @@ func TestServer_Login(t *testing.T) { repo := repository.MockSessionRepo{ CreateSessionFunc: func(ctx context.Context, session *model.Session) error { return nil }, } - sm := auth.NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) - srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, nil) + sm := auth.NewSessionManager(&repo, clk, time.Hour) + authSvc := service.NewAuthService(store, clk, hasher, sm) + srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, authSvc, nil) body := `{"username":"alice","password":"correct"}` req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") @@ -427,7 +434,8 @@ func TestServer_Login(t *testing.T) { }, }, } - srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil) + authSvc := service.NewAuthService(store, clk, hasher, nil) + srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, authSvc, nil) body := `{"username":"alice","password":"wrong"}` req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") @@ -447,7 +455,8 @@ func TestServer_Login(t *testing.T) { }, }, } - srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil) + authSvc := service.NewAuthService(store, clk, hasher, nil) + srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, authSvc, nil) body := `{"username":"nobody","password":"pass"}` req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) rr := httptest.NewRecorder() @@ -471,11 +480,13 @@ func TestServer_SessionCookieSecure(t *testing.T) { repo := repository.MockSessionRepo{ CreateSessionFunc: func(ctx context.Context, session *model.Session) error { return nil }, } - sm := auth.NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) + clk := &clock.MockClock{T: time.Now()} + sm := auth.NewSessionManager(&repo, clk, time.Hour) + authSvc := service.NewAuthService(store, clk, hasher, sm) t.Run("Secure=true by default", func(t *testing.T) { cfg := &internal.Config{SessionTimeoutHours: 24, SecureCookies: true} - srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, nil) + srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, authSvc, nil) body := `{"username":"alice","password":"correct"}` req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") @@ -493,7 +504,7 @@ func TestServer_SessionCookieSecure(t *testing.T) { t.Run("Secure=false", func(t *testing.T) { cfg := &internal.Config{SessionTimeoutHours: 24, SecureCookies: false} - srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, nil) + srv := newTestServer(t, store, hasher, sm, cfg, nil, nil, nil, authSvc, nil) body := `{"username":"alice","password":"correct"}` req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") @@ -530,7 +541,7 @@ func TestServer_SessionCookieSecure(t *testing.T) { } logoutSM := auth.NewSessionManager(&sessStore.SessionRepo, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24, SecureCookies: false} - srv := newTestServer(t, sessStore, nil, logoutSM, cfg, nil, nil, nil, nil) + srv := newTestServer(t, sessStore, nil, logoutSM, cfg, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/logout", nil) req.AddCookie(&http.Cookie{Name: "session", Value: "abc"}) rr := httptest.NewRecorder() @@ -568,7 +579,7 @@ func TestServer_Logout(t *testing.T) { } sm := auth.NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }}} - srv := newTestServer(t, store, nil, sm, cfg, nil, nil, nil, nil) + srv := newTestServer(t, store, nil, sm, cfg, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/logout", nil) req.AddCookie(&http.Cookie{Name: "session", Value: "abc"}) @@ -589,7 +600,7 @@ func TestServer_Logout(t *testing.T) { t.Run("no cookie logout", func(t *testing.T) { store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }}} - srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil) + srv := newTestServer(t, store, nil, nil, cfg, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/logout", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -600,7 +611,7 @@ func TestServer_Logout(t *testing.T) { } func TestServer_Healthz(t *testing.T) { - srv := newTestServer(t, &repository.MockStore{}, nil, nil, &internal.Config{}, nil, nil, nil, nil) + srv := newTestServer(t, &repository.MockStore{}, nil, nil, &internal.Config{}, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -615,7 +626,7 @@ func TestServer_Readyz(t *testing.T) { UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }}, } store2 := &mockPingStore{store: store, err: nil} - srv := newTestServer(t, store2, nil, nil, &internal.Config{}, nil, nil, nil, nil) + srv := newTestServer(t, store2, nil, nil, &internal.Config{}, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/readyz", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -629,7 +640,7 @@ func TestServer_Readyz(t *testing.T) { UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 1, nil }}, } store2 := &mockPingStore{store: store, err: errors.New("down")} - srv := newTestServer(t, store2, nil, nil, &internal.Config{}, nil, nil, nil, nil) + srv := newTestServer(t, store2, nil, nil, &internal.Config{}, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/readyz", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) @@ -686,7 +697,7 @@ func TestServer_MediaList(t *testing.T) { } store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) - srv := newTestServer(t, buildCountStore(1), hasher, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildCountStore(1), hasher, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/media"+tt.query, nil) req.AddCookie(addSessionCookie(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -739,7 +750,7 @@ func TestServer_MediaDetail(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/media/%s", tt.id), nil) req.AddCookie(addSessionCookie(t, store, sm, 1)) rr := httptest.NewRecorder() @@ -780,7 +791,7 @@ func TestServer_Favorite(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/media/5/favorite", strings.NewReader(`{}`)) req.AddCookie(addSessionCookie(t, store, sm, 1)) @@ -809,7 +820,7 @@ func TestServer_AddTag(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil, nil) t.Run("add tag", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/media/1/tags", strings.NewReader(`{"tag":"rock"}`)) @@ -854,7 +865,7 @@ func TestServer_RemoveTag(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodDelete, "/api/media/1/tags/rock", nil) req.AddCookie(addSessionCookie(t, store, sm, 1)) @@ -874,7 +885,7 @@ func TestServer_SoftDelete(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodDelete, "/api/media/99", nil) req.AddCookie(addSessionCookie(t, store, sm, 1)) @@ -894,7 +905,7 @@ func TestServer_Restore(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/media/99/restore", nil) req.AddCookie(addSessionCookie(t, store, sm, 1)) @@ -914,7 +925,7 @@ func TestServer_SoftDelete_Forbidden(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodDelete, "/api/media/99", nil) req.AddCookie(addSessionCookie(t, store, sm, 1)) @@ -934,7 +945,7 @@ func TestServer_Restore_Forbidden(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/api/media/99/restore", nil) req.AddCookie(addSessionCookie(t, store, sm, 1)) @@ -967,7 +978,7 @@ func TestServer_Notes(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil, nil) t.Run("get note", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/media/1/notes", nil) @@ -1026,7 +1037,7 @@ func TestServer_Progress(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, nil, nil, ps, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, nil, nil, ps, nil, nil) t.Run("ok", func(t *testing.T) { called = false @@ -1080,7 +1091,7 @@ func TestServer_Shares(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24, ShareDefaultExpiryDays: 14} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil, nil) t.Run("create share", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/media/1/shares", nil) @@ -1159,7 +1170,7 @@ func TestServer_AdminRoutes(t *testing.T) { } sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, nil, as, nil, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, nil, as, nil, nil, nil) cookie := addSessionCookie(t, store, sm, 1) @@ -1216,7 +1227,7 @@ func TestServer_ListSets(t *testing.T) { store := buildSessionStore(1) sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) cfg := &internal.Config{SessionTimeoutHours: 24} - srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil) + srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/sets", nil) req.AddCookie(addSessionCookie(t, store, sm, 1)) diff --git a/internal/api/server.go b/internal/api/server.go index cccc4e6..0ea96af 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -22,12 +22,13 @@ type Server struct { mediaSvc service.MediaService adminSvc service.AdminService progressSvc service.ProgressService + authSvc service.AuthService staticFS http.FileSystem mw *Middleware } // NewServer creates a Server with routes. -// If mediaSvc, adminSvc, or progressSvc are nil, their respective routes return 501. +// If mediaSvc, adminSvc, progressSvc, or authSvc are nil, their respective routes return 501. func NewServer( store repository.Store, hasher auth.Hasher, @@ -36,6 +37,7 @@ func NewServer( mediaSvc service.MediaService, adminSvc service.AdminService, progressSvc service.ProgressService, + authSvc service.AuthService, staticFS http.FileSystem, ) *Server { if staticFS == nil { @@ -50,6 +52,7 @@ func NewServer( mediaSvc: mediaSvc, adminSvc: adminSvc, progressSvc: progressSvc, + authSvc: authSvc, staticFS: staticFS, mw: NewMiddleware(store, sm), } diff --git a/internal/repository/mock.go b/internal/repository/mock.go index 6409dbc..a4122cd 100644 --- a/internal/repository/mock.go +++ b/internal/repository/mock.go @@ -13,6 +13,7 @@ var ( _ Store = (*MockStore)(nil) _ MediaServiceStore = (*MockStore)(nil) _ AdminServiceStore = (*MockStore)(nil) + _ AuthServiceStore = (*MockStore)(nil) _ ProgressServiceStore = (*MockStore)(nil) _ GCStore = (*MockStore)(nil) _ ScannerStore = (*MockStore)(nil) diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 53635b4..8669011 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -56,6 +56,11 @@ type GCStore interface { MediaRepo } +// AuthServiceStore is the subset of Store required by service.AuthService. +type AuthServiceStore interface { + UserRepo +} + // ScannerStore is the subset of Store required by scanner.FSScanner. type ScannerStore interface { SetRepo diff --git a/internal/service/auth.go b/internal/service/auth.go new file mode 100644 index 0000000..c52d2f0 --- /dev/null +++ b/internal/service/auth.go @@ -0,0 +1,86 @@ +package service + +import ( + "context" + "fmt" + + "codeberg.org/snonux/player/internal/auth" + "codeberg.org/snonux/player/internal/clock" + "codeberg.org/snonux/player/internal/model" + "codeberg.org/snonux/player/internal/repository" +) + +// authService is the concrete implementation of AuthService. +type authService struct { + store repository.AuthServiceStore + clock clock.Clock + hasher auth.Hasher + sm *auth.SessionManager +} + +// NewAuthService creates a concrete AuthService. +func NewAuthService(store repository.AuthServiceStore, clk clock.Clock, hasher auth.Hasher, sm *auth.SessionManager) AuthService { + return &authService{ + store: store, + clock: clk, + hasher: hasher, + sm: sm, + } +} + +// Bootstrap creates the first admin user when no users exist. +func (s *authService) Bootstrap(ctx context.Context, username, password string) (*AuthResult, error) { + count, err := s.store.CountUsers(ctx) + if err != nil { + return nil, fmt.Errorf("count users: %w", err) + } + if count > 0 { + return nil, ErrAlreadyBootstrapped + } + + hash, err := s.hasher.Hash(password) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + user := &model.User{ + Username: username, + PasswordHash: hash, + IsAdmin: true, + CreatedAt: s.clock.Now(), + } + + id, err := s.store.CreateUser(ctx, user) + if err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + user.ID = id + + sessID, err := s.sm.CreateSession(ctx, id) + if err != nil { + return nil, fmt.Errorf("create session: %w", err) + } + + return &AuthResult{User: user, SessionID: sessID}, nil +} + +// Login authenticates a user and creates a session. +func (s *authService) Login(ctx context.Context, username, password string) (*AuthResult, error) { + user, err := s.store.GetUserByUsername(ctx, username) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + if user == nil { + return nil, ErrInvalidCredentials + } + if err := s.hasher.Compare(user.PasswordHash, password); err != nil { + return nil, ErrInvalidCredentials + } + + sessID, err := s.sm.CreateSession(ctx, user.ID) + if err != nil { + return nil, fmt.Errorf("create session: %w", err) + } + + return &AuthResult{User: user, SessionID: sessID}, nil +} diff --git a/internal/service/media.go b/internal/service/media.go index 58bb524..1856592 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -53,6 +53,8 @@ var ( ErrShareExpired = errors.New("share expired") ErrMediaNotFound = errors.New("media not found") ErrUnsupportedExtension = errors.New("unsupported file extension") + ErrAlreadyBootstrapped = errors.New("already bootstrapped") + ErrInvalidCredentials = errors.New("invalid credentials") ) // supportedExtensions lists all file extensions accepted by UploadMedia. diff --git a/internal/service/mock.go b/internal/service/mock.go index f174a62..c6c2301 100644 --- a/internal/service/mock.go +++ b/internal/service/mock.go @@ -18,6 +18,7 @@ var ( _ MediaFavoriteService = (*MockMediaService)(nil) _ MediaNoteService = (*MockMediaService)(nil) _ MediaService = (*MockMediaService)(nil) + _ AuthService = (*MockAuthService)(nil) ) // MockMediaService is a fake MediaService for testing. @@ -240,6 +241,25 @@ func (m *MockAdminService) RevokePermission(ctx context.Context, setID, userID i return nil } +// MockAuthService is a fake AuthService for testing. +type MockAuthService struct { + BootstrapFunc func(ctx context.Context, username, password string) (*AuthResult, error) + LoginFunc func(ctx context.Context, username, password string) (*AuthResult, error) +} + +func (m *MockAuthService) Bootstrap(ctx context.Context, username, password string) (*AuthResult, error) { + if m.BootstrapFunc != nil { + return m.BootstrapFunc(ctx, username, password) + } + return nil, nil +} +func (m *MockAuthService) Login(ctx context.Context, username, password string) (*AuthResult, error) { + if m.LoginFunc != nil { + return m.LoginFunc(ctx, username, password) + } + return nil, nil +} + // MockProgressService is a fake ProgressService for testing. type MockProgressService struct { UpdateProgressFunc func(ctx context.Context, sessionID string, userID, mediaID int64, position float64) error diff --git a/internal/service/service.go b/internal/service/service.go index 3a14626..1ed1443 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -78,6 +78,18 @@ type AdminService interface { RevokePermission(ctx context.Context, setID, userID int64) error } +// AuthService handles bootstrap and login operations. +type AuthService interface { + Bootstrap(ctx context.Context, username, password string) (*AuthResult, error) + Login(ctx context.Context, username, password string) (*AuthResult, error) +} + +// AuthResult contains the authenticated user and session ID. +type AuthResult struct { + User *model.User + SessionID string +} + // ProgressService handles playback progress updates. type ProgressService interface { UpdateProgress(ctx context.Context, sessionID string, userID, mediaID int64, position float64) error -- cgit v1.2.3