diff --git a/bindings/csharp/FusionFramework/BuiltinMiddleware.cs b/bindings/csharp/FusionFramework/BuiltinMiddleware.cs new file mode 100644 index 0000000..61644ad --- /dev/null +++ b/bindings/csharp/FusionFramework/BuiltinMiddleware.cs @@ -0,0 +1,11 @@ +namespace FusionFramework; + +/// Convenience aliases for built-in middleware factories used in scaffolded apps. +public static class BuiltinMiddleware +{ + public static FusionMiddleware FrameworkHeaders() => Middleware.FrameworkHeaders(); + public static FusionMiddleware SecurityHeaders() => Middleware.SecurityHeaders(); + public static FusionMiddleware Cors() => Middleware.Cors(); + public static FusionMiddleware CacheHeaders() => Middleware.CacheHeaders(); + public static FusionMiddleware RequestId() => Middleware.RequestId(); +} diff --git a/bindings/csharp/FusionFramework/FusionApp.cs b/bindings/csharp/FusionFramework/FusionApp.cs index 689d747..f5cb1d0 100644 --- a/bindings/csharp/FusionFramework/FusionApp.cs +++ b/bindings/csharp/FusionFramework/FusionApp.cs @@ -20,8 +20,6 @@ public FusionApp(FusionSettings? settings = null) settings ??= SettingsStore.Current; Native.fusion_app_set_settings(_app, settings.Handle); - // Default: advertise Fusion to clients / Wappalyzer-style detectors. - _middleware.Add(Middleware.FrameworkHeaders()); } public FusionApp Use(FusionMiddleware middleware) diff --git a/bindings/csharp/FusionFramework/Middleware.cs b/bindings/csharp/FusionFramework/Middleware.cs index ad1c598..30ce04b 100644 --- a/bindings/csharp/FusionFramework/Middleware.cs +++ b/bindings/csharp/FusionFramework/Middleware.cs @@ -186,7 +186,125 @@ public static FusionMiddleware RequireRoles( public static FusionMiddleware RequireRoles(params string[] roles) => RequireRoles((IEnumerable)roles); - /// Default identity middleware — advertises Fusion on every response. + /// Common security response headers. + public static FusionMiddleware SecurityHeaders( + string contentTypeOptions = "nosniff", + string frameOptions = "DENY", + string referrerPolicy = "strict-origin-when-cross-origin", + string permissionsPolicy = "camera=(), microphone=(), geolocation=(), payment=()", + string coop = "same-origin", + string corp = "same-origin", + string? csp = null, + string? hsts = null) + { + var extra = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["X-Content-Type-Options"] = contentTypeOptions, + ["X-Frame-Options"] = frameOptions, + ["Referrer-Policy"] = referrerPolicy, + ["Permissions-Policy"] = permissionsPolicy, + ["Cross-Origin-Opener-Policy"] = coop, + ["Cross-Origin-Resource-Policy"] = corp, + }; + if (!string.IsNullOrEmpty(csp)) extra["Content-Security-Policy"] = csp!; + if (!string.IsNullOrEmpty(hsts)) extra["Strict-Transport-Security"] = hsts!; + + return (request, callNext) => + { + var result = ResolveAwaitable(callNext(request)); + return MergeResponseHeaders(result, extra); + }; + } + + /// Set Cache-Control on responses. + public static FusionMiddleware CacheHeaders(string defaultValue = "no-store") => + (request, callNext) => + { + var result = ResolveAwaitable(callNext(request)); + return MergeResponseHeaders(result, new Dictionary + { + ["Cache-Control"] = defaultValue, + }); + }; + + /// Echo or generate X-Request-Id on each request. + public static FusionMiddleware RequestId(string header = "X-Request-Id", bool incoming = true) => + (request, callNext) => + { + string? rid = incoming ? GetHeader(request, header) : null; + if (string.IsNullOrEmpty(rid)) + rid = Guid.NewGuid().ToString(); + EnsureState(request)["request_id"] = rid; + var result = ResolveAwaitable(callNext(request)); + return MergeResponseHeaders(result, new Dictionary { [header] = rid }); + }; + + /// CORS middleware; answers OPTIONS preflight with 204. + public static FusionMiddleware Cors( + IEnumerable? allowOrigins = null, + IEnumerable? allowMethods = null, + IEnumerable? allowHeaders = null, + IEnumerable? exposeHeaders = null, + bool allowCredentials = false, + int maxAge = 600) + { + var origins = (allowOrigins ?? new[] { "*" }).Select(o => o.ToString()).ToList(); + var methods = (allowMethods ?? new[] + { + "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", + }).Select(m => m.ToUpperInvariant()).ToList(); + var headers = (allowHeaders ?? new[] + { + "Authorization", "Content-Type", "Accept", "Origin", "X-Request-Id", + }).ToList(); + var expose = (exposeHeaders ?? new[] { "X-Request-Id" }).ToList(); + var allowAll = origins.Contains("*"); + + Dictionary CorsHeaders(string? origin) + { + var chosen = "*"; + if (!allowAll) + { + if (!string.IsNullOrEmpty(origin) && origins.Contains(origin)) + chosen = origin; + else if (origins.Count > 0) + chosen = origins[0]; + } + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Access-Control-Allow-Origin"] = chosen, + ["Access-Control-Allow-Methods"] = string.Join(", ", methods), + ["Access-Control-Allow-Headers"] = string.Join(", ", headers), + ["Access-Control-Expose-Headers"] = string.Join(", ", expose), + ["Access-Control-Max-Age"] = maxAge.ToString(), + ["Vary"] = "Origin", + }; + if (allowCredentials && chosen != "*") + map["Access-Control-Allow-Credentials"] = "true"; + return map; + } + + return (request, callNext) => + { + var origin = GetHeader(request, "Origin"); + var extra = CorsHeaders(origin); + if (string.Equals(request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) + { + return new Dictionary + { + ["status"] = 204, + ["body"] = "", + ["headers"] = extra, + }; + } + + var result = ResolveAwaitable(callNext(request)); + return MergeResponseHeaders(result, extra); + }; + } + + /// Optional identity middleware — not enabled by default. Add via app.Use(Middleware.FrameworkHeaders()). public static FusionMiddleware FrameworkHeaders() { var extra = Header.Fingerprint(); @@ -228,6 +346,18 @@ static object Error(int status, string detail) => ["body"] = new Dictionary { ["detail"] = detail }, }; + static string? GetHeader(FusionRequest request, string name) + { + if (request.Headers.TryGetValue(name, out var direct) && !string.IsNullOrEmpty(direct)) + return direct; + foreach (var kv in request.Headers) + { + if (string.Equals(kv.Key, name, StringComparison.OrdinalIgnoreCase)) + return kv.Value; + } + return null; + } + static byte[] Base64UrlDecode(string input) { var s = input.Replace('-', '+').Replace('_', '/'); diff --git a/crates/fusion-core/src/lib.rs b/crates/fusion-core/src/lib.rs index f1ae6a6..29775a5 100644 --- a/crates/fusion-core/src/lib.rs +++ b/crates/fusion-core/src/lib.rs @@ -91,7 +91,7 @@ impl App { let fingerprint = self .settings .get_bool("fingerprint.enabled") - .unwrap_or(true); + .unwrap_or(false); if fingerprint { server::listen(self.router, addr).await } else { diff --git a/crates/fusion-core/src/server.rs b/crates/fusion-core/src/server.rs index bc0198a..ced7f0c 100644 --- a/crates/fusion-core/src/server.rs +++ b/crates/fusion-core/src/server.rs @@ -24,13 +24,13 @@ use crate::router::Router; /// Options for [`listen_with`]. #[derive(Debug, Clone)] pub struct ListenOptions { - /// Add Fusion identity headers (`X-Powered-By`, …) on every response. Default: true. + /// Add Fusion identity headers (`X-Powered-By`, …) on every response. Default: false. pub fingerprint: bool, } impl Default for ListenOptions { fn default() -> Self { - Self { fingerprint: true } + Self { fingerprint: false } } } diff --git a/crates/fusion-core/src/templates.rs b/crates/fusion-core/src/templates.rs index e454ef2..2485223 100644 --- a/crates/fusion-core/src/templates.rs +++ b/crates/fusion-core/src/templates.rs @@ -85,7 +85,7 @@ fn collect_templates( continue; } let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); - if ext != "html" && ext != "tera" { + if ext != "html" && ext != "tera" && ext != "css" { continue; } let rel = path @@ -144,4 +144,21 @@ mod tests { assert!(html.contains("Go")); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn includes_css_partial() { + clear_template_cache(); + let dir = std::env::temp_dir().join("fusion_tpl_css_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("home")).unwrap(); + std::fs::write(dir.join("home/style.css"), "body { color: red; }").unwrap(); + std::fs::write( + dir.join("home/index.html"), + r#""#, + ) + .unwrap(); + let html = render_template("home/index.html", &json!({}), &dir).unwrap(); + assert!(html.contains("color: red")); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/fusion-node/README.md b/crates/fusion-node/README.md index 00fba04..f1ca53b 100644 --- a/crates/fusion-node/README.md +++ b/crates/fusion-node/README.md @@ -37,7 +37,7 @@ export const ItemModule = route('/api/[module]/{id}')( }, ) -const MIDDLEWARE = [] // your middleware; Fusion already adds frameworkHeaders() by default +const MIDDLEWARE = [] // add middleware explicitly, e.g. frameworkHeaders() settings.ensureLoaded() const app = new FusionApp(getSettings()) diff --git a/crates/fusion-node/index.d.ts b/crates/fusion-node/index.d.ts index 86e0a30..6840944 100644 --- a/crates/fusion-node/index.d.ts +++ b/crates/fusion-node/index.d.ts @@ -163,6 +163,33 @@ export type FusionMiddleware = ( export function frameworkHeaders(): FusionMiddleware +export function securityHeaders(options?: { + contentTypeOptions?: string + frameOptions?: string + referrerPolicy?: string + permissionsPolicy?: string + coop?: string + corp?: string + csp?: string + hsts?: string +}): FusionMiddleware + +export function cors(options?: { + allowOrigins?: string | string[] + allowMethods?: string[] + allowHeaders?: string[] + exposeHeaders?: string[] + allowCredentials?: boolean + maxAge?: number +}): FusionMiddleware + +export function cacheHeaders(options?: { default?: string; value?: string }): FusionMiddleware + +export function requestId(options?: { + header?: string + incoming?: boolean +}): FusionMiddleware + export type FusionResponse = | string | { diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index 3bc0512..5d7e6d0 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -168,6 +168,105 @@ function frameworkHeaders() { } } +function getHeader(request, name) { + const headers = request.headers || {} + const target = String(name).toLowerCase() + for (const [key, value] of Object.entries(headers)) { + if (String(key).toLowerCase() === target) return String(value) + } + return null +} + +function headerMiddleware(extra) { + return async (request, callNext) => { + const result = await awaitMaybe(callNext(request)) + return mergeResponseHeaders(result, extra) + } +} + +function securityHeaders(options = {}) { + const extra = { + 'X-Content-Type-Options': options.contentTypeOptions ?? 'nosniff', + 'X-Frame-Options': options.frameOptions ?? 'DENY', + 'Referrer-Policy': options.referrerPolicy ?? 'strict-origin-when-cross-origin', + 'Permissions-Policy': + options.permissionsPolicy ?? 'camera=(), microphone=(), geolocation=(), payment=()', + 'Cross-Origin-Opener-Policy': options.coop ?? 'same-origin', + 'Cross-Origin-Resource-Policy': options.corp ?? 'same-origin', + } + if (options.csp) extra['Content-Security-Policy'] = String(options.csp) + if (options.hsts) extra['Strict-Transport-Security'] = String(options.hsts) + return headerMiddleware(extra) +} + +function cacheHeaders(options = {}) { + return headerMiddleware({ + 'Cache-Control': options.default ?? options.value ?? 'no-store', + }) +} + +function requestId(options = {}) { + const headerName = options.header ?? 'X-Request-Id' + const incoming = options.incoming !== false + return async (request, callNext) => { + const state = ensureState(request) + let rid = incoming ? getHeader(request, headerName) : null + if (!rid) { + rid = + typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}` + } + state.request_id = rid + const result = await awaitMaybe(callNext(request)) + return mergeResponseHeaders(result, { [headerName]: rid }) + } +} + +function cors(options = {}) { + const origins = Array.isArray(options.allowOrigins) + ? options.allowOrigins.map(String) + : [String(options.allowOrigins ?? '*')] + const methods = ( + options.allowMethods ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] + ).map((m) => String(m).toUpperCase()) + const allowHeaders = ( + options.allowHeaders ?? ['Authorization', 'Content-Type', 'Accept', 'Origin', 'X-Request-Id'] + ).map(String) + const exposeHeaders = (options.exposeHeaders ?? ['X-Request-Id']).map(String) + const allowCredentials = !!options.allowCredentials + const maxAge = Number(options.maxAge ?? 600) + const allowAll = origins.includes('*') + + function corsHeaders(origin) { + let chosen = '*' + if (!allowAll) { + if (origin && origins.includes(origin)) chosen = origin + else if (origins.length) chosen = origins[0] + } + const out = { + 'Access-Control-Allow-Origin': chosen, + 'Access-Control-Allow-Methods': methods.join(', '), + 'Access-Control-Allow-Headers': allowHeaders.join(', '), + 'Access-Control-Expose-Headers': exposeHeaders.join(', '), + 'Access-Control-Max-Age': String(maxAge), + Vary: 'Origin', + } + if (allowCredentials && chosen !== '*') out['Access-Control-Allow-Credentials'] = 'true' + return out + } + + return async (request, callNext) => { + const origin = getHeader(request, 'Origin') + const extra = corsHeaders(origin) + if (String(request.method || 'GET').toUpperCase() === 'OPTIONS') { + return { status: 204, body: '', headers: extra } + } + const result = await awaitMaybe(callNext(request)) + return mergeResponseHeaders(result, extra) + } +} + class FusionBaseApi { constructor(request) { this.request = request && typeof request === 'object' ? request : emptyRequest() @@ -1014,8 +1113,7 @@ class FusionApp { this.settings = getSettings() this.engine = new NativeApp() this.mounted = false - // Default: advertise Fusion to clients / Wappalyzer-style detectors. - this._middleware = [frameworkHeaders()] + this._middleware = [] } use(middleware) { @@ -1162,6 +1260,10 @@ module.exports = { bearerJwt, requireRoles, frameworkHeaders, + securityHeaders, + cors, + cacheHeaders, + requestId, runMiddlewareChain, coerceParam, parsePagination, diff --git a/crates/fusion-node/package-lock.json b/crates/fusion-node/package-lock.json new file mode 100644 index 0000000..322844d --- /dev/null +++ b/crates/fusion-node/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "fusion-framework", + "version": "1.2.6", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fusion-framework", + "version": "1.2.6", + "license": "BSD-3-Clause", + "os": [ + "linux", + "darwin", + "win32" + ], + "devDependencies": { + "@napi-rs/cli": "^2.18.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "dev": true, + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + } + } +} diff --git a/crates/fusion-py/README.md b/crates/fusion-py/README.md index dfcac83..ec8e9ea 100644 --- a/crates/fusion-py/README.md +++ b/crates/fusion-py/README.md @@ -30,7 +30,7 @@ class ItemModule(FusionBaseApi): return self.response({"id": id}, status=status.HTTP_SUCCESS) -MIDDLEWARE: list = [] # your middleware; Fusion already adds framework_headers() by default +MIDDLEWARE: list = [] # add middleware explicitly, e.g. framework_headers() def main() -> None: diff --git a/crates/fusion-py/python/fusion_framework/__init__.py b/crates/fusion-py/python/fusion_framework/__init__.py index a6d1572..6f807f2 100644 --- a/crates/fusion-py/python/fusion_framework/__init__.py +++ b/crates/fusion-py/python/fusion_framework/__init__.py @@ -2,7 +2,16 @@ from fusion_framework.config import settings from fusion_framework.http import HTTPException -from fusion_framework.middleware import bearer_jwt, framework_headers, require_roles, use +from fusion_framework.middleware import ( + bearer_jwt, + cache_headers, + cors, + framework_headers, + request_id, + require_roles, + security_headers, + use, +) from fusion_framework.pagination import PaginationParams, paginated_body, parse_pagination from fusion_framework.template import FusionBaseTemplate, render_template from . import header, status @@ -13,8 +22,12 @@ "header", "HTTPException", "bearer_jwt", + "cache_headers", + "cors", "framework_headers", + "request_id", "require_roles", + "security_headers", "use", "PaginationParams", "parse_pagination", diff --git a/crates/fusion-py/python/fusion_framework/app.py b/crates/fusion-py/python/fusion_framework/app.py index b649ed7..0196e10 100644 --- a/crates/fusion-py/python/fusion_framework/app.py +++ b/crates/fusion-py/python/fusion_framework/app.py @@ -11,7 +11,7 @@ has_unversioned_routes as _has_unversioned_routes, ) from fusion_framework.config import get_settings, load_settings_module, settings as settings_store -from fusion_framework.middleware import framework_headers, set_active_global +from fusion_framework.middleware import set_active_global def _as_dict(value: Any) -> dict: @@ -365,8 +365,7 @@ def __init__(self, app_settings=None): self.settings = app_settings or get_settings() self._engine = App() self._mounted = False - # Default: advertise Fusion to clients / Wappalyzer-style detectors. - self._middleware: list = [framework_headers()] + self._middleware: list = [] def use(self, middleware) -> None: """Register global middleware: ``(request, call_next) -> response``.""" diff --git a/crates/fusion-py/python/fusion_framework/middleware.py b/crates/fusion-py/python/fusion_framework/middleware.py index 88151bd..810d17a 100644 --- a/crates/fusion-py/python/fusion_framework/middleware.py +++ b/crates/fusion-py/python/fusion_framework/middleware.py @@ -222,11 +222,11 @@ async def _await_merge() -> Any: def framework_headers() -> Middleware: - """Default identity middleware: advertise Fusion on every response. + """Optional identity middleware: advertise Fusion on responses. Adds ``X-Powered-By``, ``X-Framework``, and ``X-Fusion-Version``. - The Rust core also injects these on the wire (covers 404s). Disable with - ``fingerprint.enabled: false`` in ``fusion..json``. + Not enabled by default — add with ``app.use(framework_headers())``. + Wire-level injection is off unless ``fingerprint.enabled: true`` in settings. """ try: from fusion_framework._fusion import fingerprint_headers as _fp @@ -256,6 +256,132 @@ def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> return middleware +def _get_header(request: RequestDict, name: str) -> str | None: + headers = request.get("headers") or {} + target = name.lower() + for key, value in headers.items(): + if str(key).lower() == target: + return str(value) + return None + + +def security_headers( + *, + content_type_options: str = "nosniff", + frame_options: str = "DENY", + referrer_policy: str = "strict-origin-when-cross-origin", + permissions_policy: str = "camera=(), microphone=(), geolocation=(), payment=()", + coop: str = "same-origin", + corp: str = "same-origin", + csp: str | None = None, + hsts: str | None = None, +) -> Middleware: + """Add common security response headers.""" + extra: dict[str, str] = { + "X-Content-Type-Options": content_type_options, + "X-Frame-Options": frame_options, + "Referrer-Policy": referrer_policy, + "Permissions-Policy": permissions_policy, + "Cross-Origin-Opener-Policy": coop, + "Cross-Origin-Resource-Policy": corp, + } + if csp: + extra["Content-Security-Policy"] = csp + if hsts: + extra["Strict-Transport-Security"] = hsts + + def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any: + return _call_next_merge_headers(call_next, request, extra) + + return middleware + + +def cache_headers(*, default: str = "no-store") -> Middleware: + """Set ``Cache-Control`` on responses that do not already define it.""" + extra = {"Cache-Control": default} + + def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any: + return _call_next_merge_headers(call_next, request, extra) + + return middleware + + +def request_id( + *, + header: str = "X-Request-Id", + incoming: bool = True, +) -> Middleware: + """Attach a request id to ``state`` and echo it on the response.""" + import uuid + + def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any: + state = _ensure_state(request) + rid = _get_header(request, header) if incoming else None + if not rid: + rid = str(uuid.uuid4()) + state["request_id"] = rid + return _call_next_merge_headers(call_next, request, {header: rid}) + + return middleware + + +def cors( + *, + allow_origins: Iterable[str] | str = "*", + allow_methods: Iterable[str] | None = None, + allow_headers: Iterable[str] | None = None, + expose_headers: Iterable[str] | None = None, + allow_credentials: bool = False, + max_age: int = 600, +) -> Middleware: + """CORS middleware; short-circuits ``OPTIONS`` preflight with 204.""" + origins = ( + [str(allow_origins)] + if isinstance(allow_origins, str) + else [str(o) for o in allow_origins] + ) + methods = ( + ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] + if allow_methods is None + else [str(m).upper() for m in allow_methods] + ) + headers = ( + ["Authorization", "Content-Type", "Accept", "Origin", "X-Request-Id"] + if allow_headers is None + else [str(h) for h in allow_headers] + ) + expose = ["X-Request-Id"] if expose_headers is None else [str(h) for h in expose_headers] + allow_all = "*" in origins + + def _cors_headers(origin: str | None) -> dict[str, str]: + chosen = "*" + if not allow_all: + if origin and origin in origins: + chosen = origin + elif origins: + chosen = origins[0] + out = { + "Access-Control-Allow-Origin": chosen, + "Access-Control-Allow-Methods": ", ".join(methods), + "Access-Control-Allow-Headers": ", ".join(headers), + "Access-Control-Expose-Headers": ", ".join(expose), + "Access-Control-Max-Age": str(max_age), + "Vary": "Origin", + } + if allow_credentials and chosen != "*": + out["Access-Control-Allow-Credentials"] = "true" + return out + + def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any: + origin = _get_header(request, "Origin") + extra = _cors_headers(origin) + if str(request.get("method", "GET")).upper() == "OPTIONS": + return {"status": 204, "body": "", "headers": extra} + return _call_next_merge_headers(call_next, request, extra) + + return middleware + + def dispatch_route( request: RequestDict, handler: Callable[[RequestDict], Any], diff --git a/crates/fusion-py/python/fusion_framework/test_middleware.py b/crates/fusion-py/python/fusion_framework/test_middleware.py index a1538ff..82cc48b 100644 --- a/crates/fusion-py/python/fusion_framework/test_middleware.py +++ b/crates/fusion-py/python/fusion_framework/test_middleware.py @@ -6,8 +6,10 @@ from fusion_framework.middleware import ( bearer_jwt, clear_active_global, + cors, dispatch_route, framework_headers, + request_id, require_roles, set_active_global, ) @@ -52,6 +54,50 @@ def test_bearer_jwt_populates_state(): clear_active_global() +def test_no_middleware_by_default(): + """FusionApp does not inject framework headers unless explicitly added.""" + clear_active_global() + set_active_global([]) + try: + request = {"path": "/", "headers": {}, "method": "GET"} + result = dispatch_route(request, _handler, []) + assert result["status"] == 200 + headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()} + assert "x-powered-by" not in headers + finally: + clear_active_global() + + +def test_request_id_header(): + clear_active_global() + set_active_global([request_id()]) + try: + request = {"path": "/", "headers": {}, "method": "GET"} + result = dispatch_route(request, _handler, []) + headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()} + assert "x-request-id" in headers + assert result["body"]["state"]["request_id"] == headers["x-request-id"] + finally: + clear_active_global() + + +def test_cors_options_preflight(): + clear_active_global() + set_active_global([cors()]) + try: + request = { + "path": "/api", + "headers": {"Origin": "https://example.com"}, + "method": "OPTIONS", + } + result = dispatch_route(request, _handler, []) + assert result["status"] == 204 + headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()} + assert headers.get("access-control-allow-origin") + finally: + clear_active_global() + + def test_framework_headers_awaits_async_handler(): """Regression: sync framework_headers must not stringify coroutine bodies.