Compare commits

..
Author SHA1 Message Date
Deluan fec03d8780 fix(scanner): check user ID instead of IsAdmin for playlist import guard
The condition `!u.IsAdmin && u.ID == ""` was wrong — with the synthetic
admin fallback (IsAdmin: true, ID: ""), it evaluated to false and
allowed playlist import with an empty OwnerID. The check should be
just `u.ID == ""` to skip whenever there's no real user.
2026-05-04 17:58:26 -04:00
Deluan 5b01636d33 Revert "fix(scanner): skip library filter for empty user ID on fresh installs"
This reverts commit 91647ed46f.
2026-05-04 17:54:42 -04:00
Deluan fda536ff17 fix(playlists): prevent playlist import when no admin user exists
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-04 17:53:44 -04:00
Deluan 91647ed46f fix(scanner): skip library filter for empty user ID on fresh installs
When no admin user exists (fresh DB), WithAdminUser falls back to
model.User{ID: ""}. The empty ID bypassed neither the invalidUserId
("-1") check nor the IsAdmin check in applyLibraryFilter, causing
Phase 3's GetTouchedAlbums to filter by user_id="" and find zero
albums. The album refresh never ran, leaving multi-disc albums with
incomplete folder_ids from Phase 1.

Fix: treat empty user ID the same as invalidUserId in
applyLibraryFilter, skipping the library filter entirely. This is
more targeted than making the fallback user admin, which would affect
other callers like Phase 4 playlist import.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-04 17:36:24 -04:00
Deluan Quintão f48416685f fix(artwork): fix stale cache and top-level album artwork for multi-disc albums (#5457)
* fix(artwork): include top-level album folders in parent cover art lookup

The Path != "." guard added in #5451 was too aggressive — it excluded
any folder with Path=".", which includes top-level album folders (not
just the library root). Changed to ParentID != "" which correctly
excludes only the actual library root folder.

Fixes #5456

* fix: correct comment in test — album is under library root, not artist root

* test: add ascii tree diagram to top-level album e2e test

* test: replace internal bug references with issue link in e2e comments

Signed-off-by: Deluan <deluan@navidrome.org>

* test: add e2e test matching reporter's exact library layout (#5456)

Adds a deeply nested test (Genre/Artist/Album/Disc) with 12 discs
using the reporter's actual folder names to verify artwork resolution
works for non-top-level album folders too.

* fix(scanner): use a syntectic admin user when no admin user is found

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): bump album UpdatedAt on Phase 3 refresh to invalidate artwork cache

When Phase 3 corrects an album's FolderIDs (or any other field), bump
UpdatedAt to the current time. This ensures the artwork cache key changes,
invalidating any stale artwork that was resolved and cached during Phase 1
when the album had incomplete folder data.

* fix(artwork): include ImportedAt in artwork cache key to invalidate stale cache

Reverts the Phase 3 UpdatedAt bump (which would change album.UpdatedAt
semantics) and instead includes album.ImportedAt in the artwork cache key
computation. Since ImportedAt is bumped to time.Now() on every album Put,
any Phase 3 correction naturally invalidates cached artwork that was
resolved mid-scan with incomplete folder data.

* fix(artwork): simplify lastUpdate logic using TimeNewest utility

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-04 17:26:39 -04:00
Deluan Quintão a34a4abbc1 chore(ci): update GitHub Actions to latest major versions (#5462)
* chore(ci): update GitHub Actions to latest major versions

Update actions/cache v4→v5, actions/github-script v3→v7,
actions/stale v9→v10, docker/login-action v3→v4,
docker/setup-buildx-action v3→v4, and docker/metadata-action v5→v6.
The github-script upgrade also migrates Octokit API calls from
github.* to github.rest.* namespace (required since v5).

* fix(ci): address review feedback on GitHub Actions update

Pass github_token to docker/metadata-action@v6 to avoid API rate
limiting. Fix github-script pagination to use the correct Octokit
paginate.iterator pattern (pass endpoint method, not awaited response).
2026-05-04 13:59:05 -04:00
14 changed files with 213 additions and 166 deletions

No files matched your search

+5 -4
View File
@@ -53,13 +53,13 @@ runs:
- name: Login to Docker Hub
if: inputs.hub_username != '' && inputs.hub_password != ''
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ inputs.hub_username }}
password: ${{ inputs.hub_password }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -67,12 +67,13 @@ runs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
token: ${{ inputs.github_token }}
labels: |
maintainer=deluan@navidrome.org
images: |
+6 -7
View File
@@ -8,7 +8,7 @@ jobs:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v3
- uses: actions/github-script@v7
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
@@ -19,8 +19,7 @@ jobs:
const pull_user_id = ${{github.event.sender.id}};
const issue_number = await (async () => {
const pulls = await github.pulls.list({owner, repo});
for await (const {data} of github.paginate.iterator(pulls)) {
for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) {
for (const pull of data) {
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
return pull.number;
@@ -34,7 +33,7 @@ jobs:
return core.error(`No matching pull request found`);
}
const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
@@ -43,12 +42,12 @@ jobs:
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
const {data: comments} = await github.issues.listComments({repo, owner, issue_number});
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
if (existing_comment) {
core.info(`Updating comment ${existing_comment.id}`);
await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
} else {
core.info(`Creating a comment`);
await github.issues.createComment({repo, owner, issue_number, body});
await github.rest.issues.createComment({repo, owner, issue_number, body});
}
+1 -1
View File
@@ -145,7 +145,7 @@ jobs:
- name: Cache ffmpeg
id: ffmpeg-cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: C:\ffmpeg
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
- uses: actions/stale@v9
- uses: actions/stale@v10
with:
operations-per-run: 999
days-before-issue-stale: 180
+37 -12
View File
@@ -37,15 +37,15 @@ var _ = Describe("Album artwork resolution", func() {
})
})
// Bug 2 variant: cover.* basenames tie across album-root and per-disc folders;
// compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder
// files first.
// https://github.com/navidrome/navidrome/issues/5376
// cover.* basenames tie across album-root and per-disc folders;
// compareImageFiles must prefer shallower paths.
When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg ← currently wins (bug)
// │ └── cover.jpg ← should not win
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg
@@ -68,15 +68,15 @@ var _ = Describe("Album artwork resolution", func() {
})
})
// Bug 2: folder.jpg basenames tie across album-root and per-disc folders;
// the lexicographic full-path tiebreaker in compareImageFiles ranks
// "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg".
// https://github.com/navidrome/navidrome/issues/5376
// folder.jpg basenames tie across album-root and per-disc folders;
// compareImageFiles must prefer shallower paths.
When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← currently wins (bug)
// │ └── folder.jpg ← should not win
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
@@ -97,15 +97,14 @@ var _ = Describe("Album artwork resolution", func() {
})
})
// Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder
// lookup whenever an album lives entirely under a single subfolder, so an
// album-root cover is never considered.
// https://github.com/navidrome/navidrome/issues/5376
// Single-subfolder albums must still consider the parent folder's images.
When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() {
// Artist/
// └── Album/
// ├── disc1/
// │ └── 01 - Track.mp3
// └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug)
// └── cover.jpg ← should win (parent-folder fallback)
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
@@ -119,6 +118,32 @@ var _ = Describe("Album artwork resolution", func() {
})
})
// https://github.com/navidrome/navidrome/issues/5456
When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() {
// Album/ (top-level folder, Path=".")
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── cover.jpg ← should win (album-root)
It("prefers the album-root cover.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Album/cover.jpg": imageFile("album-root"),
"Album/CD1/folder.jpg": imageFile("disc1"),
"Album/CD2/folder.jpg": imageFile("disc2"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() {
// Artist/
// └── Album/
+95
View File
@@ -1,6 +1,7 @@
package artworke2e_test
import (
"fmt"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
@@ -255,6 +256,100 @@ var _ = Describe("Disc artwork resolution", func() {
})
})
// Reproduces https://github.com/navidrome/navidrome/issues/5456
// Deeply nested layout matching the reporter's actual structure.
When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() {
// Genre/Artist/Album/ ← album root with cover.jpg
// ├── cover.jpg ← album-level cover
// ├── Disc 01 (Subtitle)/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← disc 1 art
// ├── Disc 02 (Subtitle)/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── ... (12 discs)
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
discNames := []string{
"Disc 01 (Birth of the Dead - The Studio Sides)",
"Disc 02 (Birth of the Dead - The Live Sides)",
"Disc 03 (The Grateful Dead)",
"Disc 04 (Anthem of the Sun)",
"Disc 05 (Aoxomoxoa)",
"Disc 06 (Live; Dead)",
"Disc 07 (Workingman's Dead)",
"Disc 08 (American Beauty)",
"Disc 09 (Grateful Dead)",
"Disc 10 (Europe '72)",
"Disc 11 (Europe '72)",
"Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))",
}
layout := fstest.MapFS{
"Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"),
}
for i, name := range discNames {
discNum := i + 1
prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name)
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)})
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum))
}
setLayout(layout)
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
for i := range discNames {
discNum := i + 1
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))),
"disc %d should use its own folder.jpg", discNum)
}
})
})
// https://github.com/navidrome/navidrome/issues/5456
// Top-level album variant — album folder at library root (Path=".").
When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() {
// Album/ (top-level, Path=".")
// ├── cover.jpg ← album-level cover
// ├── Disc 01/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← disc 1 art
// ├── Disc 02/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── Disc 03/
// ├── 01 - Track.mp3
// └── folder.jpg
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
layout := fstest.MapFS{
"Album/cover.jpg": imageFile("album-root-cover"),
}
for i := 1; i <= 3; i++ {
prefix := fmt.Sprintf("Album/Disc %02d/", i)
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)})
layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i))
}
setLayout(layout)
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover")))
for i := 1; i <= 3; i++ {
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))),
"disc %d should use its own folder.jpg", i)
}
})
})
When("discsubtitle is set but no image filename matches the subtitle", func() {
// Artist/
// └── Album/
+5 -5
View File
@@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/natural"
)
@@ -53,10 +54,9 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
lib: lib,
}
a.cacheKey.artID = artID
if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) {
a.cacheKey.lastUpdate = *a.updatedAt
} else {
a.cacheKey.lastUpdate = al.UpdatedAt
a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
if imagesUpdateAt != nil {
a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt)
}
return a, nil
}
@@ -131,7 +131,7 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
} else if err != nil {
return nil, nil, nil, err
}
if parentFolder != nil && parentFolder.Path != "." {
if parentFolder != nil && parentFolder.ParentID != "" {
folders = append(folders, *parentFolder)
}
}
+51 -8
View File
@@ -141,6 +141,7 @@ var _ = Describe("Album Artwork Reader", func() {
ID: "parentFolder",
Path: "Artist",
Name: "Album",
ParentID: "artistFolder",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg", "back.jpg"},
}
@@ -213,14 +214,14 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(repo.getCallCount).To(Equal(0))
})
It("does not include top-level parent for multi-folder albums", func() {
// Two album parts under the same artist folder — parent is artist-level
It("does not include library root parent for multi-folder albums", func() {
// Two album parts directly under the library root — parent is the root itself
repo.result = []model.Folder{
{
ID: "folder1",
Path: ".",
Name: "AlbumPart1",
ParentID: "artistFolder",
ParentID: "rootFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
@@ -228,16 +229,17 @@ var _ = Describe("Album Artwork Reader", func() {
ID: "folder2",
Path: ".",
Name: "AlbumPart2",
ParentID: "artistFolder",
ParentID: "rootFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
repo.parentResult = &model.Folder{
ID: "artistFolder",
Path: ".",
Name: "Artist",
ImageFiles: []string{"artist.jpg"},
ID: "rootFolder",
Path: "",
Name: ".",
ParentID: "",
ImageFiles: []string{"unrelated.jpg"},
}
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
@@ -248,6 +250,46 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(repo.getCallCount).To(Equal(1))
})
It("includes top-level album folder for multi-disc albums", func() {
// Album folder directly under library root, with disc subfolders
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Album",
Name: "Disc1",
ParentID: "albumFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{"folder.jpg"},
},
{
ID: "folder2",
Path: "Album",
Name: "Disc2",
ParentID: "albumFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{"folder.jpg"},
},
}
repo.parentResult = &model.Folder{
ID: "albumFolder",
Path: ".",
Name: "Album",
ParentID: "rootFolder",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg"},
}
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
Expect(imgFiles).To(HaveLen(3))
Expect(imgFiles[0]).To(Equal("Album/cover.jpg"))
Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg"))
Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg"))
Expect(repo.getCallCount).To(Equal(1))
})
It("does not query parent for single-folder albums that already have images", func() {
repo.result = []model.Folder{
{
@@ -283,6 +325,7 @@ var _ = Describe("Album Artwork Reader", func() {
ID: "albumFolder",
Path: "Artist",
Name: "Album",
ParentID: "artistFolder",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg"},
}
+4 -4
View File
@@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
type discArtworkReader struct {
@@ -105,10 +106,9 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
updatedAt: imagesUpdatedAt,
}
r.cacheKey.artID = artID
if r.updatedAt != nil && r.updatedAt.After(al.UpdatedAt) {
r.cacheKey.lastUpdate = *r.updatedAt
} else {
r.cacheKey.lastUpdate = al.UpdatedAt
r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt)
if imagesUpdatedAt != nil {
r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt)
}
return r, nil
}
+1 -1
View File
@@ -100,7 +100,7 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
} else {
log.Error(ctx, "No admin user found!", err)
}
u = &model.User{}
u = &model.User{IsAdmin: true, UserName: "admin"}
}
ctx = request.WithUsername(ctx, u.UserName)
+1 -1
View File
@@ -50,7 +50,7 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error {
return nil
}
u, _ := request.UserFrom(p.ctx)
if !u.IsAdmin {
if u.ID == "" {
log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+
"Please create an admin user first, and then update the playlists for them to be imported")
return nil
+6 -19
View File
@@ -26,11 +26,6 @@ function storeAuthenticationInfo(authInfo) {
localStorage.setItem('is-authenticated', 'true')
}
const isNetworkError = (error) => {
const msg = error?.message || ''
return msg === 'Failed to fetch' || msg.includes('NetworkError')
}
const authProvider = {
login: ({ username, password }) => {
let url = baseUrl('/auth/login')
@@ -58,7 +53,10 @@ const authProvider = {
return response
})
.catch((error) => {
if (isNetworkError(error)) {
if (
error.message === 'Failed to fetch' ||
error.stack === 'TypeError: Failed to fetch'
) {
throw new Error('errors.network_error')
}
@@ -80,22 +78,11 @@ const authProvider = {
? Promise.resolve()
: Promise.reject(),
checkError: (error) => {
if (error?.status === 401) {
checkError: ({ status }) => {
if (status === 401) {
removeItems()
return Promise.reject()
}
if (config.extAuthLogoutURL && isNetworkError(error)) {
const now = Date.now()
const lastReload =
Number(sessionStorage.getItem('ext-auth-reload-ts')) || 0
if (now - lastReload > 30000) {
sessionStorage.setItem('ext-auth-reload-ts', String(now))
removeItems()
window.location.reload()
return new Promise(() => {})
}
}
return Promise.resolve()
},
-100
View File
@@ -1,100 +0,0 @@
import { describe, it, beforeEach, afterEach, expect } from 'vitest'
import config from './config'
import authProvider from './authProvider'
describe('authProvider', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('is-authenticated', 'true')
localStorage.setItem('token', 'test-token')
localStorage.setItem('userId', 'test-user')
localStorage.setItem('role', 'admin')
config.extAuthLogoutURL = ''
})
afterEach(() => {
config.extAuthLogoutURL = ''
})
describe('checkError', () => {
it('rejects and clears storage on 401', async () => {
await expect(authProvider.checkError({ status: 401 })).rejects.toBe(
undefined,
)
expect(localStorage.getItem('is-authenticated')).toBeNull()
})
it('resolves on non-401 HTTP errors', async () => {
await expect(
authProvider.checkError({ status: 500 }),
).resolves.toBeUndefined()
expect(localStorage.getItem('is-authenticated')).toBe('true')
})
it('resolves on network error without extAuth configured', async () => {
config.extAuthLogoutURL = ''
await expect(
authProvider.checkError(new TypeError('Failed to fetch')),
).resolves.toBeUndefined()
expect(localStorage.getItem('is-authenticated')).toBe('true')
})
it('clears auth and sets reload guard on TypeError with extAuth', () => {
config.extAuthLogoutURL = 'https://auth.example.com/logout'
// window.location.reload throws in jsdom, so we catch it
try {
authProvider.checkError(new TypeError('Failed to fetch'))
} catch {
// jsdom "Not implemented: navigation" is expected
}
expect(localStorage.getItem('is-authenticated')).toBeNull()
expect(sessionStorage.getItem('ext-auth-reload-ts')).not.toBeNull()
})
it('clears auth on Firefox NetworkError with extAuth', () => {
config.extAuthLogoutURL = 'https://auth.example.com/logout'
const error = new Error('NetworkError when attempting to fetch resource')
try {
authProvider.checkError(error)
} catch {
// jsdom "Not implemented: navigation" is expected
}
expect(localStorage.getItem('is-authenticated')).toBeNull()
expect(sessionStorage.getItem('ext-auth-reload-ts')).not.toBeNull()
})
it('does not reload-loop within 30 seconds', async () => {
config.extAuthLogoutURL = 'https://auth.example.com/logout'
sessionStorage.setItem('ext-auth-reload-ts', String(Date.now() - 10000))
await expect(
authProvider.checkError(new TypeError('Failed to fetch')),
).resolves.toBeUndefined()
expect(localStorage.getItem('is-authenticated')).toBe('true')
})
it('allows reload again after 30 seconds', () => {
config.extAuthLogoutURL = 'https://auth.example.com/logout'
sessionStorage.setItem('ext-auth-reload-ts', String(Date.now() - 31000))
try {
authProvider.checkError(new TypeError('Failed to fetch'))
} catch {
// jsdom "Not implemented: navigation" is expected
}
expect(localStorage.getItem('is-authenticated')).toBeNull()
const ts = parseInt(sessionStorage.getItem('ext-auth-reload-ts'), 10)
expect(Date.now() - ts).toBeLessThan(5000)
})
})
describe('checkAuth', () => {
it('resolves when authenticated', async () => {
await expect(authProvider.checkAuth()).resolves.toBeUndefined()
})
it('rejects when not authenticated', async () => {
localStorage.removeItem('is-authenticated')
await expect(authProvider.checkAuth()).rejects.toBe(undefined)
})
})
})
-3
View File
@@ -14,9 +14,6 @@ const localStorageMock = (function () {
setItem: function (key, value) {
store[key] = value.toString()
},
removeItem: function (key) {
delete store[key]
},
clear: function () {
store = {}
},