diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/handlers.go | 21 | ||||
| -rw-r--r-- | internal/api/handlers_more_test.go | 293 | ||||
| -rw-r--r-- | internal/api/handlers_test.go | 1 | ||||
| -rw-r--r-- | internal/service/media.go | 18 | ||||
| -rw-r--r-- | internal/service/media_test.go | 9 | ||||
| -rw-r--r-- | internal/service/no_rows_test.go | 8 |
6 files changed, 269 insertions, 81 deletions
diff --git a/internal/api/handlers.go b/internal/api/handlers.go index af6a0a0..672891d 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -665,9 +665,22 @@ func (s *Server) handleSharePage(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") share, err := s.mediaSvc.ValidateShareToken(r.Context(), token) if err != nil || share == nil { + if err != nil && errors.Is(err, service.ErrShareExpired) { + http.Error(w, "gone", http.StatusGone) + return + } http.Error(w, "not found", http.StatusNotFound) return } + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Vary", "Accept") + + accept := r.Header.Get("Accept") + if strings.Contains(accept, "text/html") || accept == "" { + s.serveFile(w, r, "share.html") + return + } writeJSON(w, http.StatusOK, share) } @@ -678,6 +691,14 @@ func (s *Server) handleShareStream(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") res, err := s.mediaSvc.StreamSharedMedia(r.Context(), token) if err != nil { + if errors.Is(err, service.ErrShareExpired) { + http.Error(w, "gone", http.StatusGone) + return + } + if errors.Is(err, service.ErrShareNotFound) || errors.Is(err, service.ErrMediaNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/internal/api/handlers_more_test.go b/internal/api/handlers_more_test.go index e7eb99a..d2ef424 100644 --- a/internal/api/handlers_more_test.go +++ b/internal/api/handlers_more_test.go @@ -3,6 +3,7 @@ package api import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -865,80 +866,242 @@ func TestServer_RevokeShare(t *testing.T) { func TestServer_SharePage(t *testing.T) { cfg := &internal.Config{SessionTimeoutHours: 24} + fs := newTestFS(map[string]string{ + "index.html": "index", + "login.html": "login", + "share.html": "<html>share</html>", + "bootstrap.html": "bootstrap", + }) - tests := []struct { - name string - token string - svcNil bool - svcErr error - share *model.Share - wantCode int - }{ - {"nil service", "abc", true, nil, nil, http.StatusNotImplemented}, - {"error", "abc", false, errors.New("boom"), nil, http.StatusNotFound}, - {"not found", "abc", false, nil, nil, http.StatusNotFound}, - {"ok", "abc", false, nil, &model.Share{Token: "abc", MediaID: 1}, http.StatusOK}, - } + t.Run("nil service", func(t *testing.T) { + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, nil, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotImplemented { + t.Fatalf("expected %d, got %d", http.StatusNotImplemented, rr.Code) + } + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var ms service.MediaService - if !tt.svcNil { - ms = &service.MockMediaService{ - ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { - return tt.share, tt.svcErr - }, - } - } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) - req := httptest.NewRequest(http.MethodGet, "/s/"+tt.token, nil) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - if rr.Code != tt.wantCode { - t.Fatalf("expected %d, got %d", tt.wantCode, rr.Code) - } - }) - } + t.Run("service error", func(t *testing.T) { + ms := &service.MockMediaService{ + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return nil, errors.New("boom") + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("not found", func(t *testing.T) { + ms := &service.MockMediaService{ + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return nil, nil + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("expired", func(t *testing.T) { + ms := &service.MockMediaService{ + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return nil, service.ErrShareExpired + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusGone { + t.Fatalf("expected %d, got %d", http.StatusGone, rr.Code) + } + }) + + t.Run("html default accept", func(t *testing.T) { + ms := &service.MockMediaService{ + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return &model.Share{Token: "abc", MediaID: 1}, nil + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + ct := rr.Header().Get("Content-Type") + if !strings.Contains(ct, "text/html") { + t.Fatalf("expected text/html content type, got %q", ct) + } + body := rr.Body.String() + if !strings.Contains(body, "<html>") { + t.Fatalf("expected share.html body, got %q", body) + } + }) + + t.Run("html explicit accept", func(t *testing.T) { + ms := &service.MockMediaService{ + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return &model.Share{Token: "abc", MediaID: 1}, nil + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + req.Header.Set("Accept", "text/html") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + ct := rr.Header().Get("Content-Type") + if !strings.Contains(ct, "text/html") { + t.Fatalf("expected text/html content type, got %q", ct) + } + }) + + t.Run("json accept", func(t *testing.T) { + ms := &service.MockMediaService{ + ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + return &model.Share{Token: "abc", MediaID: 1}, nil + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, fs) + req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) + req.Header.Set("Accept", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + ct := rr.Header().Get("Content-Type") + if !strings.Contains(ct, "application/json") { + t.Fatalf("expected application/json content type, got %q", ct) + } + var body model.Share + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + if body.Token != "abc" { + t.Fatalf("unexpected token %q", body.Token) + } + }) } func TestServer_ShareStream(t *testing.T) { path := makeTempFile(t, "shared") cfg := &internal.Config{SessionTimeoutHours: 24} - tests := []struct { - name string - token string - svcNil bool - svcErr error - res *service.FileResult - wantCode int - }{ - {"nil service", "abc", true, nil, nil, http.StatusNotImplemented}, - {"service error", "abc", false, errors.New("boom"), nil, http.StatusInternalServerError}, - {"not found", "abc", false, nil, nil, http.StatusNotFound}, - {"file missing", "abc", false, nil, &service.FileResult{Path: "/nonexistent", FileName: "a.mp4"}, http.StatusNotFound}, - {"ok", "abc", false, nil, &service.FileResult{Path: path, FileName: "a.mp4"}, http.StatusOK}, - } + t.Run("nil service", func(t *testing.T) { + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, nil, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotImplemented { + t.Fatalf("expected %d, got %d", http.StatusNotImplemented, rr.Code) + } + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var ms service.MediaService - if !tt.svcNil { - ms = &service.MockMediaService{ - StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { - return tt.res, tt.svcErr - }, - } - } - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) - req := httptest.NewRequest(http.MethodGet, "/s/"+tt.token+"/stream", nil) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - if rr.Code != tt.wantCode { - t.Fatalf("expected %d, got %d", tt.wantCode, rr.Code) - } - }) - } + t.Run("service error", func(t *testing.T) { + ms := &service.MockMediaService{ + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return nil, errors.New("boom") + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected %d, got %d", http.StatusInternalServerError, rr.Code) + } + }) + + t.Run("not found", func(t *testing.T) { + ms := &service.MockMediaService{ + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return nil, service.ErrShareNotFound + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("expired", func(t *testing.T) { + ms := &service.MockMediaService{ + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return nil, service.ErrShareExpired + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusGone { + t.Fatalf("expected %d, got %d", http.StatusGone, rr.Code) + } + }) + + t.Run("media not found", func(t *testing.T) { + ms := &service.MockMediaService{ + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return nil, service.ErrMediaNotFound + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("file missing", func(t *testing.T) { + ms := &service.MockMediaService{ + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return &service.FileResult{Path: "/nonexistent", FileName: "a.mp4"}, nil + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("ok", func(t *testing.T) { + ms := &service.MockMediaService{ + StreamSharedMediaFunc: func(ctx context.Context, token string) (*service.FileResult, error) { + return &service.FileResult{Path: path, FileName: "a.mp4"}, nil + }, + } + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/s/abc/stream", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) } // ------------------------------------------------------------------ diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 798ac81..89aa177 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -43,6 +43,7 @@ func newTestServer(t *testing.T, store repository.Store, hasher auth.Hasher, sm "index.html": "index", "login.html": "login", "bootstrap.html": "bootstrap", + "share.html": "share", }) } return NewServer(store, hasher, sm, cfg, mediaSvc, adminSvc, progressSvc, fs) diff --git a/internal/service/media.go b/internal/service/media.go index f950db3..6f4b2db 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -35,8 +35,11 @@ func NewMediaService(store repository.MediaServiceStore, clk clock.Clock, mediaR // Sentinel errors returned by the media service layer. var ( - ErrNotFound = errors.New("not found") - ErrForbidden = errors.New("access denied") + ErrNotFound = errors.New("not found") + ErrForbidden = errors.New("access denied") + ErrShareNotFound = errors.New("share not found") + ErrShareExpired = errors.New("share expired") + ErrMediaNotFound = errors.New("media not found") ) func (s *mediaService) ListSets(ctx context.Context, userID int64) ([]model.Set, error) { @@ -510,16 +513,16 @@ func (s *mediaService) ValidateShareToken(ctx context.Context, token string) (*m return nil, fmt.Errorf("get share: %w", err) } if share == nil { - return nil, nil + return nil, ErrShareNotFound } now := s.clock.Now() if now.After(share.ExpiresAt) { - return nil, nil + return nil, ErrShareExpired } if share.MaxUses != nil && share.UsedCount >= *share.MaxUses { - return nil, nil + return nil, ErrShareExpired } return share, nil @@ -530,16 +533,13 @@ func (s *mediaService) StreamSharedMedia(ctx context.Context, token string) (*Fi if err != nil { return nil, err } - if share == nil { - return nil, errors.New("invalid or expired share") - } media, err := s.store.GetMediaByID(ctx, share.MediaID) if err != nil { return nil, fmt.Errorf("get media: %w", err) } if media == nil { - return nil, errors.New("media not found") + return nil, ErrMediaNotFound } _ = s.store.UseShare(ctx, token) diff --git a/internal/service/media_test.go b/internal/service/media_test.go index 1ab7dfb..33912b4 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -905,14 +905,17 @@ func TestMediaService_ValidateShareToken(t *testing.T) { } svc := NewMediaService(store, newMockClock(), "/tmp/media") res, err := svc.ValidateShareToken(ctx, "abc") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } if tt.wantValid { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } if res == nil { t.Fatal("expected valid share") } } else { + if err == nil { + t.Fatal("expected error for invalid share") + } if res != nil { t.Fatal("expected nil share") } diff --git a/internal/service/no_rows_test.go b/internal/service/no_rows_test.go index 3606b63..9200668 100644 --- a/internal/service/no_rows_test.go +++ b/internal/service/no_rows_test.go @@ -135,8 +135,8 @@ func TestService_NoRows_ReturnsNil(t *testing.T) { } svc := NewMediaService(store, newMockClock(), "/tmp/media") sh, err := svc.ValidateShareToken(ctx, "nope") - if err != nil { - t.Fatalf("expected no error, got %v", err) + if !errors.Is(err, ErrShareNotFound) { + t.Fatalf("expected ErrShareNotFound, got %v", err) } if sh != nil { t.Fatalf("expected nil share, got %+v", sh) @@ -153,8 +153,8 @@ func TestService_NoRows_ReturnsNil(t *testing.T) { } svc := NewMediaService(store, newMockClock(), "/tmp/media") _, err := svc.StreamSharedMedia(ctx, "nope") - if err == nil { - t.Fatal("expected error for missing share") + if !errors.Is(err, ErrShareNotFound) { + t.Fatalf("expected ErrShareNotFound, got %v", err) } }) |
