diff --git a/src/django_bpp/storage.py b/src/django_bpp/storage.py index 52b5b7ae5..49bc1bc6a 100644 --- a/src/django_bpp/storage.py +++ b/src/django_bpp/storage.py @@ -27,10 +27,13 @@ class TolerantManifestStaticFilesStorage(ManifestStaticFilesStorage): - # Runtime: brakujący wpis w manifeście → zwróć nazwę nie-hashowaną zamiast - # rzucać "Missing staticfiles manifest entry". Chroni dev/testy bez - # collectstatic (choć tam i tak działa short-circuit DEBUG w `.url()`) - # oraz 132 vendored-refy, które zostały niezahashowane na buildzie. + # Runtime: brakujący wpis w manifeście → nie rzucaj "Missing staticfiles + # manifest entry". Potrzebne dla 132 vendored-refów niezahashowanych na + # buildzie. UWAGA: samo `manifest_strict=False` NIE wystarcza dla dev/testów + # bez collectstatic — vanilla fallback liczy wtedy hash w locie (martwy URL, + # bo hashowanej kopii nie ma na dysku). Pod pytest `DEBUG=False`, więc + # short-circuit DEBUG w `.url()` też nie ratuje. Realnie chroni nas dopiero + # override `stored_name` poniżej. manifest_strict = False def hashed_name(self, name, content=None, filename=None): @@ -41,3 +44,27 @@ def hashed_name(self, name, content=None, filename=None): # / sprite, którego targetu nie ma w zebranych statykach). Zostaw # nazwę oryginalną zamiast przerywać collectstatic — issue #269. return name + + def stored_name(self, name): + # Runtime lookup używany przez `url()`. Gdy manifestu NIE MA w ogóle + # (`staticfiles.json` nie istnieje → `hashed_files` puste — dev/testy + # bez collectstatic, albo stary `staticroot` sprzed Manifestu), + # vanilla `ManifestFilesMixin.stored_name` z `manifest_strict=False` + # liczy content-hash w locie przez `hashed_name()`. Dla pliku, który + # PRZYPADKIEM leży w STATIC_ROOT, daje to `name..ext` — nazwę, + # której fizycznej kopii nikt nigdy nie wygenerował (stary + # collectstatic zrobił tylko nie-hashowaną). URL jest martwy: + # django-compressor rzuca `UncompressableFileError`, a bezpośredni GET + # (webtest) dostaje 404. Manifest jest jedynym źródłem prawdy dla + # hashy — bez niego serwujemy nazwę oryginalną; realny plik źródłowy + # zaserwuje finder / runserver / whitenoise. + # + # Gating na PUSTY manifest (nie per-wpis) jest celowo wąski: gdy + # collectstatic wygenerował manifest (produkcja, `.baked`), + # `hashed_files` jest niepuste → delegujemy do vanilla bez zmian, więc + # cache-busting długiego ogona działa 1:1 jak dotąd. Build-time + # `post_process` używa osobnej ścieżki (`_stored_name`, force=True), + # więc ten override go nie dotyka. + if not self.hashed_files: + return name + return super().stored_name(name) diff --git a/src/django_bpp/tests/test_storage.py b/src/django_bpp/tests/test_storage.py index 346e14917..13ea821e8 100644 --- a/src/django_bpp/tests/test_storage.py +++ b/src/django_bpp/tests/test_storage.py @@ -57,6 +57,48 @@ def test_missing_manifest_entry_falls_back_instead_of_raising(): assert TolerantManifestStaticFilesStorage().url(probe) == "/static/" + probe +@override_settings(DEBUG=False) +def test_existing_file_without_manifest_is_not_hashed_on_the_fly(tmp_path): + # Regression (bug: testy padały lokalnie, przechodziły na CI). Gdy + # STATIC_ROOT zawiera plik fizyczny, ale NIE ma manifestu + # (`staticfiles.json`) — typowy stan lokalny ze starym `staticroot` + # sprzed wprowadzenia Manifestu — vanilla `ManifestFilesMixin.stored_name` + # z `manifest_strict=False` liczy content-hash w locie i zwraca + # `foo..css`. Plik z tą nazwą NIE istnieje na dysku (stary + # collectstatic wyprodukował tylko nie-hashowaną kopię), więc + # django-compressor i webtest dostają `could not be found` / 404. + # + # Bez manifestu (jedyne źródło prawdy dla hashy jest puste) musimy + # serwować nazwę ORYGINALNĄ — realny plik zaserwuje finder / runserver / + # whitenoise. Kontrast z `ManifestStaticFilesStorage` (poniżej) dowodzi, + # że to nasza subklasa, nie domyślne zachowanie Django, daje ten wynik. + (tmp_path / "foo.css").write_text("body{color:red}") + storage = TolerantManifestStaticFilesStorage(location=str(tmp_path)) + assert storage.hashed_files == {} # brak manifestu + + # Vanilla z manifest_strict=False (nasza konfiguracja) fabrykuje martwą + # nazwę hashowaną (foo..css) — to jest dokładnie bug: + vanilla = ManifestStaticFilesStorage(location=str(tmp_path)) + vanilla.manifest_strict = False + assert vanilla.stored_name("foo.css") != "foo.css" + + # Subklasa zwraca nazwę oryginalną — i runtime'owy url() też: + assert storage.stored_name("foo.css") == "foo.css" + assert storage.url("foo.css") == "/static/foo.css" + + +@override_settings(DEBUG=False) +def test_present_manifest_still_hashes(tmp_path): + # Strona przeciwna do regresji: gdy manifest ISTNIEJE (produkcja, `.baked` + # po collectstatic), cache-busting długiego ogona musi działać 1:1 jak w + # vanilla — `stored_name` deleguje do super i zwraca nazwę z manifestu. + # Override z `not self.hashed_files` jest wąski i NIE dotyka tej ścieżki. + storage = TolerantManifestStaticFilesStorage(location=str(tmp_path)) + storage.hashed_files = {"foo.css": "foo.deadbeef0123.css"} + assert storage.stored_name("foo.css") == "foo.deadbeef0123.css" + assert storage.url("foo.css") == "/static/foo.deadbeef0123.css" + + @override_settings(DEBUG=True) def test_url_in_debug_returns_unhashed_without_manifest(): # Keystone "jedna klasa storage wszędzie": w DEBUG `HashedFilesMixin.url()`