mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-04-07 11:03:37 +00:00
Compare commits
6 Commits
3ad2c494af
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fce49f22c9 | |||
| af8e2c9e9b | |||
| 1c312bb528 | |||
| e39194e9f6 | |||
| 21cc029c18 | |||
| 40671b99d5 |
@@ -1,7 +1,7 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
build:
|
build:
|
||||||
os: ubuntu-20.04
|
os: ubuntu-lts-latest
|
||||||
tools:
|
tools:
|
||||||
python: "3.12"
|
python: "3.12"
|
||||||
apt_packages:
|
apt_packages:
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ ahriman.web.middlewares.auth\_handler module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
ahriman.web.middlewares.etag\_handler module
|
||||||
|
--------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: ahriman.web.middlewares.etag_handler
|
||||||
|
:members:
|
||||||
|
:no-undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
ahriman.web.middlewares.exception\_handler module
|
ahriman.web.middlewares.exception\_handler module
|
||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,10 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
|
|||||||
|
|
||||||
* ``address`` - optional address in form ``proto://host:port`` (``port`` can be omitted in case of default ``proto`` ports), will be used instead of ``http://{host}:{port}`` in case if set, string, optional. This option is required in case if ``OAuth`` provider is used.
|
* ``address`` - optional address in form ``proto://host:port`` (``port`` can be omitted in case of default ``proto`` ports), will be used instead of ``http://{host}:{port}`` in case if set, string, optional. This option is required in case if ``OAuth`` provider is used.
|
||||||
* ``autorefresh_intervals`` - enable page auto refresh options, space separated list of integers, optional. The first defined interval will be used as default. If no intervals set, the auto refresh buttons will be disabled. If first element of the list equals ``0``, auto refresh will be disabled by default.
|
* ``autorefresh_intervals`` - enable page auto refresh options, space separated list of integers, optional. The first defined interval will be used as default. If no intervals set, the auto refresh buttons will be disabled. If first element of the list equals ``0``, auto refresh will be disabled by default.
|
||||||
|
* ``cors_allow_headers`` - allowed CORS headers, space separated list of strings, optional.
|
||||||
|
* ``cors_allow_methods`` - allowed CORS methods, space separated list of strings, optional.
|
||||||
|
* ``cors_allow_origins`` - allowed CORS origins, space separated list of strings, optional, default ``*``.
|
||||||
|
* ``cors_expose_headers`` - exposed CORS headers, space separated list of strings, optional.
|
||||||
* ``enable_archive_upload`` - allow to upload packages via HTTP (i.e. call of ``/api/v1/service/upload`` uri), boolean, optional, default ``no``.
|
* ``enable_archive_upload`` - allow to upload packages via HTTP (i.e. call of ``/api/v1/service/upload`` uri), boolean, optional, default ``no``.
|
||||||
* ``host`` - host to bind, string, optional.
|
* ``host`` - host to bind, string, optional.
|
||||||
* ``index_url`` - full URL of the repository index page, string, optional.
|
* ``index_url`` - full URL of the repository index page, string, optional.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"react": ">=19.2.0 <19.3.0",
|
"react": ">=19.2.0 <19.3.0",
|
||||||
"react-chartjs-2": ">=5.3.0 <5.4.0",
|
"react-chartjs-2": ">=5.3.0 <5.4.0",
|
||||||
"react-dom": ">=19.2.0 <19.3.0",
|
"react-dom": ">=19.2.0 <19.3.0",
|
||||||
|
"react-error-boundary": ">=6.1.0 <6.2.0",
|
||||||
"react-syntax-highlighter": ">=16.1.0 <16.2.0"
|
"react-syntax-highlighter": ">=16.1.0 <16.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
55
frontend/src/components/common/ErrorBoundary.tsx
Normal file
55
frontend/src/components/common/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2021-2026 ahriman team.
|
||||||
|
*
|
||||||
|
* This file is part of ahriman
|
||||||
|
* (see https://github.com/arcan1s/ahriman).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import { Box, Button, Typography } from "@mui/material";
|
||||||
|
import type React from "react";
|
||||||
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
|
|
||||||
|
interface ErrorDetails {
|
||||||
|
message: string;
|
||||||
|
stack: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorFallback({ error }: FallbackProps): React.JSX.Element {
|
||||||
|
|
||||||
|
const details: ErrorDetails = error instanceof Error
|
||||||
|
? { message: error.message, stack: error.stack }
|
||||||
|
: { message: String(error), stack: undefined };
|
||||||
|
|
||||||
|
return <Box role="alert" sx={{ color: "text.primary", minHeight: "100vh", p: 6 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700 }} variant="h4">
|
||||||
|
Something went wrong
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography color="error" sx={{ fontFamily: "monospace", mt: 2 }}>
|
||||||
|
{details.message}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{details.stack && <Typography
|
||||||
|
component="pre"
|
||||||
|
sx={{ color: "text.secondary", fontFamily: "monospace", fontSize: "0.75rem", mt: 3, whiteSpace: "pre-wrap", wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{details.stack}
|
||||||
|
</Typography>}
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 2, mt: 4 }}>
|
||||||
|
<Button onClick={() => window.location.reload()} variant="outlined">Reload page</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>;
|
||||||
|
}
|
||||||
@@ -21,11 +21,18 @@ import "chartSetup";
|
|||||||
import "utils";
|
import "utils";
|
||||||
|
|
||||||
import App from "App";
|
import App from "App";
|
||||||
|
import ErrorFallback from "components/common/ErrorBoundary";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<ErrorBoundary
|
||||||
|
FallbackComponent={ErrorFallback}
|
||||||
|
onError={(error, info) => console.error("Uncaught error:", error, info.componentStack)}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ allow_read_only = yes
|
|||||||
; If no intervals set, auto refresh will be disabled. 0 can only be the first element and will disable auto refresh
|
; If no intervals set, auto refresh will be disabled. 0 can only be the first element and will disable auto refresh
|
||||||
; by default.
|
; by default.
|
||||||
autorefresh_intervals = 5 1 10 30 60
|
autorefresh_intervals = 5 1 10 30 60
|
||||||
|
; Allowed CORS headers. By default everything is allowed.
|
||||||
|
;cors_allow_headers =
|
||||||
|
; Allowed CORS methods. By default everything is allowed.
|
||||||
|
;cors_allow_methods =
|
||||||
|
; Allowed CORS origins.
|
||||||
|
;cors_allow_origins = *
|
||||||
|
; Exposed CORS headers. By default everything is exposed.
|
||||||
|
;cors_expose_headers =
|
||||||
; Enable file upload endpoint used by some triggers.
|
; Enable file upload endpoint used by some triggers.
|
||||||
;enable_archive_upload = no
|
;enable_archive_upload = no
|
||||||
; Address to bind the server.
|
; Address to bind the server.
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class Configuration(configparser.RawConfigParser):
|
|||||||
"""
|
"""
|
||||||
configparser.RawConfigParser.__init__(
|
configparser.RawConfigParser.__init__(
|
||||||
self,
|
self,
|
||||||
dict_type=ConfigurationMultiDict if allow_multi_key else dict, # type: ignore[arg-type]
|
dict_type=ConfigurationMultiDict if allow_multi_key else dict,
|
||||||
allow_no_value=allow_no_value,
|
allow_no_value=allow_no_value,
|
||||||
strict=False,
|
strict=False,
|
||||||
empty_lines_in_values=not allow_multi_key,
|
empty_lines_in_values=not allow_multi_key,
|
||||||
|
|||||||
@@ -358,6 +358,38 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
|
|||||||
"min": 0,
|
"min": 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"cors_allow_headers": {
|
||||||
|
"type": "list",
|
||||||
|
"coerce": "list",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"empty": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"cors_allow_methods": {
|
||||||
|
"type": "list",
|
||||||
|
"coerce": "list",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"empty": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"cors_allow_origins": {
|
||||||
|
"type": "list",
|
||||||
|
"coerce": "list",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"empty": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"cors_expose_headers": {
|
||||||
|
"type": "list",
|
||||||
|
"coerce": "list",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"empty": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
"enable_archive_upload": {
|
"enable_archive_upload": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"coerce": "boolean",
|
"coerce": "boolean",
|
||||||
|
|||||||
@@ -150,6 +150,6 @@ class ShellTemplate(Template):
|
|||||||
break
|
break
|
||||||
|
|
||||||
kwargs.update(mapping)
|
kwargs.update(mapping)
|
||||||
substituted = dict(generator(kwargs))
|
kwargs.update(dict(generator(kwargs)))
|
||||||
|
|
||||||
return self.safe_substitute(kwargs | substituted)
|
return self.safe_substitute(kwargs)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class HttpUpload(SyncHttpClient):
|
|||||||
str: calculated checksum of the file
|
str: calculated checksum of the file
|
||||||
"""
|
"""
|
||||||
with path.open("rb") as local_file:
|
with path.open("rb") as local_file:
|
||||||
md5 = hashlib.md5(local_file.read()) # nosec
|
md5 = hashlib.md5(local_file.read(), usedforsecurity=False)
|
||||||
return md5.hexdigest()
|
return md5.hexdigest()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -62,9 +62,7 @@ class S3(Upload):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def calculate_etag(path: Path, chunk_size: int) -> str:
|
def calculate_etag(path: Path, chunk_size: int) -> str:
|
||||||
"""
|
"""
|
||||||
calculate amazon s3 etag
|
calculate amazon s3 etag. Credits to https://teppen.io/2018/10/23/aws_s3_verify_etags/
|
||||||
credits to https://teppen.io/2018/10/23/aws_s3_verify_etags/
|
|
||||||
For this method we have to define nosec because it is out of any security context and provided by AWS
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path(Path): path to local file
|
path(Path): path to local file
|
||||||
@@ -76,14 +74,17 @@ class S3(Upload):
|
|||||||
md5s = []
|
md5s = []
|
||||||
with path.open("rb") as local_file:
|
with path.open("rb") as local_file:
|
||||||
for chunk in iter(lambda: local_file.read(chunk_size), b""):
|
for chunk in iter(lambda: local_file.read(chunk_size), b""):
|
||||||
md5s.append(hashlib.md5(chunk)) # nosec
|
md5s.append(hashlib.md5(chunk, usedforsecurity=False))
|
||||||
|
|
||||||
# in case if there is only one chunk it must be just this checksum
|
# in case if there is only one chunk it must be just this checksum
|
||||||
# and checksum of joined digest otherwise (including empty list)
|
if len(md5s) == 1:
|
||||||
checksum = md5s[0] if len(md5s) == 1 else hashlib.md5(b"".join(md5.digest() for md5 in md5s)) # nosec
|
return md5s[0].hexdigest()
|
||||||
# in case if there are more than one chunk it should be appended with amount of chunks
|
|
||||||
|
# otherwise it is checksum of joined digest (including empty list)
|
||||||
|
md5 = hashlib.md5(b"".join(md5.digest() for md5 in md5s), usedforsecurity=False)
|
||||||
|
# in case if there are more (exactly) than one chunk it should be appended with amount of chunks
|
||||||
suffix = f"-{len(md5s)}" if len(md5s) > 1 else ""
|
suffix = f"-{len(md5s)}" if len(md5s) > 1 else ""
|
||||||
return f"{checksum.hexdigest()}{suffix}"
|
return f"{md5.hexdigest()}{suffix}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def files_remove(local_files: dict[Path, str], remote_objects: dict[Path, Any]) -> None:
|
def files_remove(local_files: dict[Path, str], remote_objects: dict[Path, Any]) -> None:
|
||||||
|
|||||||
@@ -21,26 +21,34 @@ import aiohttp_cors
|
|||||||
|
|
||||||
from aiohttp.web import Application
|
from aiohttp.web import Application
|
||||||
|
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["setup_cors"]
|
__all__ = ["setup_cors"]
|
||||||
|
|
||||||
|
|
||||||
def setup_cors(application: Application) -> aiohttp_cors.CorsConfig:
|
def setup_cors(application: Application, configuration: Configuration) -> aiohttp_cors.CorsConfig:
|
||||||
"""
|
"""
|
||||||
setup CORS for the web application
|
setup CORS for the web application
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
application(Application): web application instance
|
application(Application): web application instance
|
||||||
|
configuration(Configuration): configuration instance
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
aiohttp_cors.CorsConfig: generated CORS configuration
|
aiohttp_cors.CorsConfig: generated CORS configuration
|
||||||
"""
|
"""
|
||||||
|
allow_headers = configuration.getlist("web", "cors_allow_headers", fallback=[]) or "*"
|
||||||
|
allow_methods = configuration.getlist("web", "cors_allow_methods", fallback=[]) or "*"
|
||||||
|
expose_headers = configuration.getlist("web", "cors_expose_headers", fallback=[]) or "*"
|
||||||
|
|
||||||
cors = aiohttp_cors.setup(application, defaults={
|
cors = aiohttp_cors.setup(application, defaults={
|
||||||
"*": aiohttp_cors.ResourceOptions( # type: ignore[no-untyped-call]
|
origin: aiohttp_cors.ResourceOptions( # type: ignore[no-untyped-call]
|
||||||
expose_headers="*",
|
expose_headers=expose_headers,
|
||||||
allow_headers="*",
|
allow_headers=allow_headers,
|
||||||
allow_methods="*",
|
allow_methods=allow_methods,
|
||||||
)
|
)
|
||||||
|
for origin in configuration.getlist("web", "cors_allow_origins", fallback=["*"])
|
||||||
})
|
})
|
||||||
for route in application.router.routes():
|
for route in application.router.routes():
|
||||||
cors.add(route)
|
cors.add(route)
|
||||||
|
|||||||
61
src/ahriman/web/middlewares/etag_handler.py
Normal file
61
src/ahriman/web/middlewares/etag_handler.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2021-2026 ahriman team.
|
||||||
|
#
|
||||||
|
# This file is part of ahriman
|
||||||
|
# (see https://github.com/arcan1s/ahriman).
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from aiohttp import ETag
|
||||||
|
from aiohttp.typedefs import Middleware
|
||||||
|
from aiohttp.web import HTTPNotModified, Request, Response, StreamResponse, middleware
|
||||||
|
|
||||||
|
from ahriman.web.middlewares import HandlerType
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["etag_handler"]
|
||||||
|
|
||||||
|
|
||||||
|
def etag_handler() -> Middleware:
|
||||||
|
"""
|
||||||
|
middleware to handle ETag header for conditional requests. It computes ETag from the response body
|
||||||
|
and returns 304 Not Modified if the client sends a matching ``If-None-Match`` header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Middleware: built middleware
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPNotModified: if content matches ``If-None-Match`` header sent
|
||||||
|
"""
|
||||||
|
@middleware
|
||||||
|
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
|
||||||
|
response = await handler(request)
|
||||||
|
|
||||||
|
if not isinstance(response, Response) or not isinstance(response.body, bytes):
|
||||||
|
return response
|
||||||
|
|
||||||
|
if request.method not in ("GET", "HEAD"):
|
||||||
|
return response
|
||||||
|
|
||||||
|
etag = ETag(value=hashlib.md5(response.body, usedforsecurity=False).hexdigest())
|
||||||
|
response.etag = etag
|
||||||
|
|
||||||
|
if request.if_none_match is not None and etag in request.if_none_match:
|
||||||
|
raise HTTPNotModified(headers={"ETag": response.headers["ETag"]})
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
return handle
|
||||||
@@ -209,8 +209,8 @@ class BaseView(View, CorsViewMixin):
|
|||||||
HTTPBadRequest: if supplied parameters are invalid
|
HTTPBadRequest: if supplied parameters are invalid
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
limit = int(self.request.query.get("limit", default=-1))
|
limit = int(self.request.query.get("limit", -1))
|
||||||
offset = int(self.request.query.get("offset", default=0))
|
offset = int(self.request.query.get("offset", 0))
|
||||||
except ValueError as ex:
|
except ValueError as ex:
|
||||||
raise HTTPBadRequest(reason=str(ex))
|
raise HTTPBadRequest(reason=str(ex))
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from ahriman.models.repository_id import RepositoryId
|
|||||||
from ahriman.web.apispec.info import setup_apispec
|
from ahriman.web.apispec.info import setup_apispec
|
||||||
from ahriman.web.cors import setup_cors
|
from ahriman.web.cors import setup_cors
|
||||||
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
|
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
|
||||||
|
from ahriman.web.middlewares.etag_handler import etag_handler
|
||||||
from ahriman.web.middlewares.exception_handler import exception_handler
|
from ahriman.web.middlewares.exception_handler import exception_handler
|
||||||
from ahriman.web.middlewares.metrics_handler import metrics_handler
|
from ahriman.web.middlewares.metrics_handler import metrics_handler
|
||||||
from ahriman.web.middlewares.request_id_handler import request_id_handler
|
from ahriman.web.middlewares.request_id_handler import request_id_handler
|
||||||
@@ -181,13 +182,14 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
|
|||||||
application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True))
|
application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True))
|
||||||
application.middlewares.append(request_id_handler())
|
application.middlewares.append(request_id_handler())
|
||||||
application.middlewares.append(exception_handler(application.logger))
|
application.middlewares.append(exception_handler(application.logger))
|
||||||
|
application.middlewares.append(etag_handler())
|
||||||
application.middlewares.append(metrics_handler())
|
application.middlewares.append(metrics_handler())
|
||||||
|
|
||||||
application.logger.info("setup routes")
|
application.logger.info("setup routes")
|
||||||
setup_routes(application, configuration)
|
setup_routes(application, configuration)
|
||||||
|
|
||||||
application.logger.info("setup CORS")
|
application.logger.info("setup CORS")
|
||||||
setup_cors(application)
|
setup_cors(application, configuration)
|
||||||
|
|
||||||
application.logger.info("setup templates")
|
application.logger.info("setup templates")
|
||||||
loader = jinja2.FileSystemLoader(searchpath=configuration.getpathlist("web", "templates"))
|
loader = jinja2.FileSystemLoader(searchpath=configuration.getpathlist("web", "templates"))
|
||||||
|
|||||||
85
tests/ahriman/web/middlewares/test_etag_handler.py
Normal file
85
tests/ahriman/web/middlewares/test_etag_handler.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import hashlib
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aiohttp import ETag
|
||||||
|
from aiohttp.web import HTTPNotModified, Response, StreamResponse
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from ahriman.web.middlewares.etag_handler import etag_handler
|
||||||
|
|
||||||
|
|
||||||
|
async def test_etag_handler() -> None:
|
||||||
|
"""
|
||||||
|
must set ETag header on GET responses
|
||||||
|
"""
|
||||||
|
request = pytest.helpers.request("", "", "GET")
|
||||||
|
request.if_none_match = None
|
||||||
|
request_handler = AsyncMock(return_value=Response(body=b"hello"))
|
||||||
|
|
||||||
|
handler = etag_handler()
|
||||||
|
result = await handler(request, request_handler)
|
||||||
|
assert result.etag is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_etag_handler_not_modified() -> None:
|
||||||
|
"""
|
||||||
|
must raise NotModified when ETag matches If-None-Match
|
||||||
|
"""
|
||||||
|
body = b"hello"
|
||||||
|
request = pytest.helpers.request("", "", "GET")
|
||||||
|
request.if_none_match = (ETag(value=hashlib.md5(body, usedforsecurity=False).hexdigest()),)
|
||||||
|
request_handler = AsyncMock(return_value=Response(body=body))
|
||||||
|
|
||||||
|
handler = etag_handler()
|
||||||
|
with pytest.raises(HTTPNotModified):
|
||||||
|
await handler(request, request_handler)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_etag_handler_no_match() -> None:
|
||||||
|
"""
|
||||||
|
must return full response when ETag does not match If-None-Match
|
||||||
|
"""
|
||||||
|
request = pytest.helpers.request("", "", "GET")
|
||||||
|
request.if_none_match = (ETag(value="outdated"),)
|
||||||
|
request_handler = AsyncMock(return_value=Response(body=b"hello"))
|
||||||
|
|
||||||
|
handler = etag_handler()
|
||||||
|
result = await handler(request, request_handler)
|
||||||
|
assert result.status == 200
|
||||||
|
assert result.etag is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_etag_handler_skip_post() -> None:
|
||||||
|
"""
|
||||||
|
must skip ETag for non-GET/HEAD methods
|
||||||
|
"""
|
||||||
|
request = pytest.helpers.request("", "", "POST")
|
||||||
|
request_handler = AsyncMock(return_value=Response(body=b"hello"))
|
||||||
|
|
||||||
|
handler = etag_handler()
|
||||||
|
result = await handler(request, request_handler)
|
||||||
|
assert result.etag is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_etag_handler_skip_no_body() -> None:
|
||||||
|
"""
|
||||||
|
must skip ETag for responses without body
|
||||||
|
"""
|
||||||
|
request = pytest.helpers.request("", "", "GET")
|
||||||
|
request_handler = AsyncMock(return_value=Response())
|
||||||
|
|
||||||
|
handler = etag_handler()
|
||||||
|
result = await handler(request, request_handler)
|
||||||
|
assert result.etag is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_etag_handler_skip_stream() -> None:
|
||||||
|
"""
|
||||||
|
must skip ETag for streaming responses
|
||||||
|
"""
|
||||||
|
request = pytest.helpers.request("", "", "GET")
|
||||||
|
request_handler = AsyncMock(return_value=StreamResponse())
|
||||||
|
|
||||||
|
handler = etag_handler()
|
||||||
|
result = await handler(request, request_handler)
|
||||||
|
assert "ETag" not in result.headers
|
||||||
@@ -2,13 +2,17 @@ import aiohttp_cors
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from aiohttp.web import Application
|
from aiohttp.web import Application
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
from ahriman.web.cors import setup_cors
|
||||||
|
from ahriman.web.keys import ConfigurationKey
|
||||||
|
|
||||||
|
|
||||||
def test_setup_cors(application: Application) -> None:
|
def test_setup_cors(application: Application) -> None:
|
||||||
"""
|
"""
|
||||||
must setup CORS
|
must setup CORS
|
||||||
"""
|
"""
|
||||||
cors: aiohttp_cors.CorsConfig = application[aiohttp_cors.APP_CONFIG_KEY]
|
cors = application[aiohttp_cors.APP_CONFIG_KEY]
|
||||||
# let's test here that it is enabled for all requests
|
# let's test here that it is enabled for all requests
|
||||||
for route in application.router.routes():
|
for route in application.router.routes():
|
||||||
# we don't want to deal with match info here though
|
# we don't want to deal with match info here though
|
||||||
@@ -18,3 +22,34 @@ def test_setup_cors(application: Application) -> None:
|
|||||||
continue
|
continue
|
||||||
request = pytest.helpers.request(application, url, route.method, resource=route.resource)
|
request = pytest.helpers.request(application, url, route.method, resource=route.resource)
|
||||||
assert cors._cors_impl._router_adapter.is_cors_enabled_on_request(request)
|
assert cors._cors_impl._router_adapter.is_cors_enabled_on_request(request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_cors_custom_origins(application: Application, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must setup CORS with custom origins
|
||||||
|
"""
|
||||||
|
configuration = application[ConfigurationKey]
|
||||||
|
configuration.set_option("web", "cors_allow_origins", "https://example.com https://httpbin.com")
|
||||||
|
|
||||||
|
setup_mock = mocker.patch("ahriman.web.cors.aiohttp_cors.setup", return_value=mocker.MagicMock())
|
||||||
|
setup_cors(application, configuration)
|
||||||
|
|
||||||
|
defaults = setup_mock.call_args.kwargs["defaults"]
|
||||||
|
assert "https://example.com" in defaults
|
||||||
|
assert "https://httpbin.com" in defaults
|
||||||
|
assert "*" not in defaults
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_cors_custom_methods(application: Application, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must setup CORS with custom methods
|
||||||
|
"""
|
||||||
|
configuration = application[ConfigurationKey]
|
||||||
|
configuration.set_option("web", "cors_allow_methods", "GET POST")
|
||||||
|
|
||||||
|
setup_mock = mocker.patch("ahriman.web.cors.aiohttp_cors.setup", return_value=mocker.MagicMock())
|
||||||
|
setup_cors(application, configuration)
|
||||||
|
|
||||||
|
defaults = setup_mock.call_args.kwargs["defaults"]
|
||||||
|
resource_options = next(iter(defaults.values()))
|
||||||
|
assert resource_options.allow_methods == {"GET", "POST"}
|
||||||
|
|||||||
Reference in New Issue
Block a user