mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-07 03:13:19 +00:00
Merge branch 'main' into dev
# Conflicts: # docs/setup.md # src-ui/src/main.ts # src/documents/tests/test_api_bulk_edit.py # src/documents/tests/test_api_custom_fields.py # src/documents/tests/test_api_search.py # src/documents/tests/test_api_status.py # src/documents/tests/test_workflows.py # src/paperless_mail/tests/test_api.py
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpHandlerFn,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http'
|
||||
import { throwError } from 'rxjs'
|
||||
import * as navUtils from '../utils/navigation'
|
||||
import { createAuthExpiryInterceptor } from './auth-expiry.interceptor'
|
||||
|
||||
describe('withAuthExpiryInterceptor', () => {
|
||||
let interceptor: ReturnType<typeof createAuthExpiryInterceptor>
|
||||
let dateNowSpy: jest.SpiedFunction<typeof Date.now>
|
||||
|
||||
beforeEach(() => {
|
||||
interceptor = createAuthExpiryInterceptor()
|
||||
dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reloads when an API request returns 401', () => {
|
||||
const reloadSpy = jest
|
||||
.spyOn(navUtils, 'locationReload')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
interceptor(
|
||||
new HttpRequest('GET', '/api/documents/'),
|
||||
failingHandler('/api/documents/', 401)
|
||||
).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not reload for non-401 errors', () => {
|
||||
const reloadSpy = jest
|
||||
.spyOn(navUtils, 'locationReload')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
interceptor(
|
||||
new HttpRequest('GET', '/api/documents/'),
|
||||
failingHandler('/api/documents/', 500)
|
||||
).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reload for non-api 401 responses', () => {
|
||||
const reloadSpy = jest
|
||||
.spyOn(navUtils, 'locationReload')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
interceptor(
|
||||
new HttpRequest('GET', '/accounts/profile/'),
|
||||
failingHandler('/accounts/profile/', 401)
|
||||
).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reloads only once even with multiple API 401 responses', () => {
|
||||
const reloadSpy = jest
|
||||
.spyOn(navUtils, 'locationReload')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const request = new HttpRequest('GET', '/api/documents/')
|
||||
const handler = failingHandler('/api/documents/', 401)
|
||||
|
||||
interceptor(request, handler).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
interceptor(request, handler).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries reload after cooldown for repeated API 401 responses', () => {
|
||||
const reloadSpy = jest
|
||||
.spyOn(navUtils, 'locationReload')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
dateNowSpy
|
||||
.mockReturnValueOnce(1000)
|
||||
.mockReturnValueOnce(2500)
|
||||
.mockReturnValueOnce(3501)
|
||||
|
||||
const request = new HttpRequest('GET', '/api/documents/')
|
||||
const handler = failingHandler('/api/documents/', 401)
|
||||
|
||||
interceptor(request, handler).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
interceptor(request, handler).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
interceptor(request, handler).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
function failingHandler(url: string, status: number): HttpHandlerFn {
|
||||
return (_request) =>
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status,
|
||||
url,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandlerFn,
|
||||
HttpInterceptorFn,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http'
|
||||
import { catchError, Observable, throwError } from 'rxjs'
|
||||
import { locationReload } from '../utils/navigation'
|
||||
|
||||
export const createAuthExpiryInterceptor = (): HttpInterceptorFn => {
|
||||
let lastReloadAttempt = Number.NEGATIVE_INFINITY
|
||||
|
||||
return (
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandlerFn
|
||||
): Observable<HttpEvent<unknown>> =>
|
||||
next(request).pipe(
|
||||
catchError((error: unknown) => {
|
||||
if (
|
||||
error instanceof HttpErrorResponse &&
|
||||
error.status === 401 &&
|
||||
request.url.includes('/api/')
|
||||
) {
|
||||
const now = Date.now()
|
||||
if (now - lastReloadAttempt >= 2000) {
|
||||
lastReloadAttempt = now
|
||||
locationReload()
|
||||
}
|
||||
}
|
||||
|
||||
return throwError(() => error)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export const withAuthExpiryInterceptor = createAuthExpiryInterceptor()
|
||||
@@ -6,7 +6,7 @@ export const environment = {
|
||||
apiVersion: '10', // match src/paperless/settings.py
|
||||
appTitle: 'Paperless-ngx',
|
||||
tag: 'prod',
|
||||
version: '2.20.11',
|
||||
version: '2.20.13',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
||||
+6
-1
@@ -154,6 +154,7 @@ import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
|
||||
import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard'
|
||||
import { PermissionsGuard } from './app/guards/permissions.guard'
|
||||
import { withApiVersionInterceptor } from './app/interceptors/api-version.interceptor'
|
||||
import { withAuthExpiryInterceptor } from './app/interceptors/auth-expiry.interceptor'
|
||||
import { withCsrfInterceptor } from './app/interceptors/csrf.interceptor'
|
||||
import { DocumentTitlePipe } from './app/pipes/document-title.pipe'
|
||||
import { FilterPipe } from './app/pipes/filter.pipe'
|
||||
@@ -399,7 +400,11 @@ bootstrapApplication(AppComponent, {
|
||||
StoragePathNamePipe,
|
||||
provideHttpClient(
|
||||
withInterceptorsFromDi(),
|
||||
withInterceptors([withCsrfInterceptor, withApiVersionInterceptor]),
|
||||
withInterceptors([
|
||||
withCsrfInterceptor,
|
||||
withApiVersionInterceptor,
|
||||
withAuthExpiryInterceptor,
|
||||
]),
|
||||
withFetch()
|
||||
),
|
||||
provideUiTour({
|
||||
|
||||
@@ -154,6 +154,11 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
||||
--bs-list-group-action-active-color: var(--bs-body-color);
|
||||
--bs-list-group-action-active-bg: var(--pngx-bg-darker);
|
||||
}
|
||||
|
||||
.form-control:hover::file-selector-button {
|
||||
background-color:var(--pngx-bg-dark) !important
|
||||
}
|
||||
|
||||
.search-container {
|
||||
input, input:focus, i-bs[name="search"] , ::placeholder {
|
||||
color: var(--pngx-primary-text-contrast) !important;
|
||||
|
||||
Reference in New Issue
Block a user