From eac235ed0c351cbf31db0e7a400e609b1c9e76f7 Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Wed, 19 Aug 2026 18:17:19 +0200 Subject: [PATCH] feat: serve the platform favicon on the documentation pages FastAPI's built-in /docs and /redoc hardcode a favicon hosted on fastapi.tiangolo.com, and the constructor exposes no way to change it. The built-ins are switched off and re-registered in core/docs.py with the NC3 Testing Platform mark, served from the package at /favicon.ico -- which also answers the unprompted browser request that was 404ing. Registering the pages by hand means owning what the built-ins did: root_path prefixing for a proxy that mounts the API under a sub-path, and the Swagger OAuth2 redirect page the "Authorize" flow returns through. Both are pinned by tests. All four routes are include_in_schema=False, so api/openapi.json is unchanged. --- src/nc3_testing_platform/core/docs.py | 98 +++++++++++++++ src/nc3_testing_platform/main.py | 8 +- src/nc3_testing_platform/static/favicon.ico | Bin 0 -> 15086 bytes tests/test_docs_branding.py | 125 ++++++++++++++++++++ 4 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/nc3_testing_platform/core/docs.py create mode 100644 src/nc3_testing_platform/static/favicon.ico create mode 100644 tests/test_docs_branding.py diff --git a/src/nc3_testing_platform/core/docs.py b/src/nc3_testing_platform/core/docs.py new file mode 100644 index 0000000..225caab --- /dev/null +++ b/src/nc3_testing_platform/core/docs.py @@ -0,0 +1,98 @@ +"""The browser-facing pages: the platform favicon and the API documentation. + +FastAPI's built-in `/docs` and `/redoc` routes write +`https://fastapi.tiangolo.com/img/favicon.png` into their HTML and the +constructor exposes no way to change it, so `main` switches the built-ins off +and this module registers them again with the platform's own icon, served from +this package rather than fetched from a third party. + +Registering the pages by hand means owning what the built-ins did for free: +prefixing every URL with `root_path`, so both pages keep working when a proxy +mounts the API under a sub-path, and the Swagger OAuth2 redirect page the +"Authorize" flow hands control back to. + +Both pages still load the Swagger UI and ReDoc bundles from cdn.jsdelivr.net — +FastAPI's default. Vendoring those is a separate change with its own trade-off. + +None of these routes reach the OpenAPI document (`include_in_schema=False`): +they are the documentation surface, not part of the API contract. +""" + +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.openapi.docs import ( + get_redoc_html, + get_swagger_ui_html, + get_swagger_ui_oauth2_redirect_html, +) +from fastapi.responses import FileResponse, HTMLResponse + +# `parents[1]` is the package root: this module sits one level down, in `core`. +FAVICON_PATH = Path(__file__).resolve().parents[1] / "static" / "favicon.ico" +FAVICON_URL = "/favicon.ico" +FAVICON_MEDIA_TYPE = "image/vnd.microsoft.icon" + +DOCS_URL = "/docs" +REDOC_URL = "/redoc" +# FastAPI's own default path, kept so a Swagger OAuth2 client registered +# against the built-in route needs no new redirect URI. +SWAGGER_OAUTH2_REDIRECT_URL = "/docs/oauth2-redirect" + +# The icon changes only with a rebrand and a stale tab icon harms nobody, so a +# day of caching spares the app one request per documentation page view. +_FAVICON_CACHE_CONTROL = "public, max-age=86400" + + +def register_docs(app: FastAPI) -> None: + """Registers the favicon and the branded documentation pages on `app`. + + The app must be constructed with `docs_url=None` and `redoc_url=None`: + FastAPI registers its own routes from the constructor, so they would + otherwise be matched first and these would never be reached. + + Args: + app: The application to register the routes on. + + Raises: + ValueError: If the app publishes no OpenAPI document, leaving the + documentation pages nothing to render. + """ + openapi_url = app.openapi_url + if not openapi_url: + raise ValueError("The documentation pages need an openapi_url to render.") + + @app.get(FAVICON_URL, include_in_schema=False) + async def favicon() -> FileResponse: + """The platform icon, for the documentation pages and for browsers that ask unprompted.""" + return FileResponse( + FAVICON_PATH, + media_type=FAVICON_MEDIA_TYPE, + headers={"Cache-Control": _FAVICON_CACHE_CONTROL}, + ) + + @app.get(DOCS_URL, include_in_schema=False) + async def swagger_ui(request: Request) -> HTMLResponse: + """Swagger UI, branded, at FastAPI's default path.""" + prefix = request.scope.get("root_path", "").rstrip("/") + return get_swagger_ui_html( + openapi_url=f"{prefix}{openapi_url}", + title=f"{app.title} - Swagger UI", + oauth2_redirect_url=f"{prefix}{SWAGGER_OAUTH2_REDIRECT_URL}", + swagger_favicon_url=f"{prefix}{FAVICON_URL}", + ) + + @app.get(SWAGGER_OAUTH2_REDIRECT_URL, include_in_schema=False) + async def swagger_ui_oauth2_redirect() -> HTMLResponse: + """Hands an authorization-code response from the provider back to Swagger UI.""" + return get_swagger_ui_oauth2_redirect_html() + + @app.get(REDOC_URL, include_in_schema=False) + async def redoc(request: Request) -> HTMLResponse: + """ReDoc, branded, at FastAPI's default path.""" + prefix = request.scope.get("root_path", "").rstrip("/") + return get_redoc_html( + openapi_url=f"{prefix}{openapi_url}", + title=f"{app.title} - ReDoc", + redoc_favicon_url=f"{prefix}{FAVICON_URL}", + ) diff --git a/src/nc3_testing_platform/main.py b/src/nc3_testing_platform/main.py index 917e7c2..30dbf93 100644 --- a/src/nc3_testing_platform/main.py +++ b/src/nc3_testing_platform/main.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, FastAPI from nc3_testing_platform.core.csrf import OriginCheckMiddleware +from nc3_testing_platform.core.docs import register_docs from nc3_testing_platform.core.errors import ( configure_openapi, register_exception_handlers, @@ -43,11 +44,16 @@ version="4.0.1", summary="v4.0 backend MVP for the NC3 Testing Platform.", openapi_url="/api/v1/openapi.json", - docs_url="/docs", + # The documentation pages are registered by hand, branded with the platform + # favicon instead of FastAPI's remotely hosted one (core/docs.py). The + # built-ins would be matched first, so they are switched off here. + docs_url=None, + redoc_url=None, ) register_exception_handlers(app) configure_openapi(app) +register_docs(app) # CSRF origin validation (IDR-010): pure ASGI, so the SSE route streams # through untouched. Inert until AUTH_PUBLIC_ORIGIN is set. diff --git a/src/nc3_testing_platform/static/favicon.ico b/src/nc3_testing_platform/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..843e87b0dd3cb7191dec066d3db65389837ca5a3 GIT binary patch literal 15086 zcmeHu2Xs}{w)Wh6pL9w{2qm--5+D##X(5n=9w3CO2!zl(0YMZCU{Fy+QB*9~E5(Xj z5fl|jAp`=1UZwaWAc9g9lAQE&a?X6;T<0WWyTkk5yKlTP{@!*;A=e++dE*YBVH zKevXNk21B6Wop%xscRDJtvazj75e}>diEH~$!&Y^JGg(QNfiIkG>Y#wiR3$=`!!t4 z4e!Itu~(5;quQU)I5uScqh~V>pUm|1%m3bW%ui=(8sTU?N3bEZUbBwfz~ggT#qd>N z<%VHw_X}li(Baq359f`-+1@Ogxm`T(>^q7hz1uJabYuhAwE<(9k|!`N!ruNnkMDnA ziXFoGpm9v2XK^cFInp?S8JO+BTm}4A;IyK*R5v+yUbA8yHd!0^h_UV$Km%RmW!NOv z5=Jof8~xvhzw~fCl^z>Nil&jY*|JhLS@#+u)%tJUHkX&0P!fACPtE_@@fD}U9Y7r!4A z!ymWk%=;h%<-jicWCMPi{KoT@(nt2?ZBAL%MECw@kJ4iUs5rMNmFx?ok^>zybB4F^ zbsHkfoh4Lqd=!PQ=Yot{m3OlIKfInZ zH1N*uXu6Zp)N~_11vZ&z#zTeF>ps>)6WHao^-S0w@6ew7waAIALH0t{gKL`{eDv4p zUwWjsvio-HAu?xrnTmD@&xZ{%XM6pmaPL&Ot-y_@NpJb3sdMK&vZ=LYd6t&swMi}d zf~aVRVJbU4jI3wkDQV<46xf*wzJzH4e28&r90D0Bku`vi?7~yfNtK{~X))KF<<90B+?vo`FxrcD5*O~&&~(-0T&_FrJD=N$b!56SR>=eWk)?kXKXeOdUYpFje2n58 zA1clZnq&a}wJpzWS8=v`g}R@i=r8N6 z!uxpFgf%o-RRld&w2kzV{4UCW>kNNMcuDNl;8czfJ5Vjp!b5GkbJkzMFFLaD^SYpS zbGce zqfcyV(w2|bd%h^st+GgDBhzFD{coGIYh5(w_#G)d+^1Q|p#-pNDCZb0AO`YZTL@}sEaU|0I-RnmTZ6uLgU?rvZgey9?BSfWi;kQC{=lKlvdvDc;@CfNlie z1dldny3}A->&Wu7@Wr^UqsK0MaYRx-j)@AhMYRvHg#-pzUs}1s3ffuicDv0|Sz+7p z8CeH`}yxe`#L$Gc{Tnj@C@_OhU4xZ{qSQ$5_@rY+t!tx z!a{9bBEsZ1zqsN#z{ditR<_w}RrqPi%gy20Q^qYFG-v{S_)4njMs7c?^!QNhaX0uN zgQ1UyDm1g;ze4xmx1)i7j@`>Y@KX!^1a{43@o9?pwS!D`lJQ$X-&8p8YgNKGmAM}K zm!&`3ePr?=j%?S~);YX`9gU@hwF%}`&pYq~J7W)I4R)--zVUO5+&gLe~JF!{h7kWt90O-^M@I!U@bNbMUj)oXQLnCRj zcV)SL*iSzb8f(7en)X)FC49m+ZOquTmlI+-adc=qYc+NU{eyo?f3B*MEnw^A zOPBbed1)&bq|K)ECmuI_zU9+j;U7Pj>Aq=9uu~@Rz39)G;=6)>#Q&4VVZHd(U%m7P zC$gXBtXd{>mKS8e|0$<^wJO~MIjA_&J$^{ih~r%%LRt6*O?npmLjRXvSSfgg?y7hG z!Ksa3e`WO%oD2VJb7xHd-Ry%>SW92U^uill2maqhC+{eq8Zi&UhweLm9tR;#Zj06i zt=)uq+)!dpG;Ip>SLxBd1U@mLf%j{_;pBab@J>l46@I;Qa@2_AVLwHOh6vBttMSV| zfS-8jEO$4 z5qdl6uJA(^4vJhrhO6M`i7Z>C$2!--<`LFs>T|yn|Bf2`Z`I(343}l=ch)@}75UEF zYwsLBn9uw3^6Urp?c)Rc@;HA#%-7z%JbT(y?$M0|s2JKyc zxPG0MJoan+;P)E*@U6cPz2n5McptH6p5T{t)rT&^-dLrFZe3p&u^Mc)32`5lofroG z@dW>PfPWf4dk_C`=`MHPv&J*#!~uqj;)ghxY7jHtB~B zOg!eKxHf0{)L)42{M8?_J8y}K6YD`%zq^-zKx6KGANNEIAu*2N7QccKm#%|fP4$r5 zK>k9FWZva#LKA;K#hd~Ez+tznMX>w!{T``{m%Q}a-8;7hwpHX)n&_I!%1WL;XEwtY zD1B5d+rVyx-r=beQdf;kPNE?L`kS_G-SR8^qo6wy$1sJ&u_>sV%DpU9@(bPX+y{>6 z!!Jn;5{J+PBE@MHg|;@nIs$?vGfK8e4O;iFC&kafU+bYok7{luBG z7C(3K!dZb`i8a`S{uYbnxAeC~gtV#b(<7E=PELJxVE<&Ap3>X+=#6=FcTXghpPi;` zap|!nHQ$U!9QiqniE}2%?3sJ*7tZepAKgoT!4K>TzwDnJB?ltmcY1?|>WK}Q>$Cwi zesW1po!f^$Y1882g-`$O^a)XkEOKDOI;_y|R$#WmHVDt#5_@*%NvRWGPl+Y!-k7u< zOFL_YpOazE^e~kkj@3#J!cM~1tv+Cv$OCa1VlzTM<%0&o>rVdxGGRlkEV&Hj6G;#K zvyEy2w>ult9(Ld_-rGj}H_cY@Jz4MOx?&GbIsG@a2TGUb)VXr^n166$@}r|ZeE);1 zXHFjDYgaGZ7tEQ#vC(1ne(~LScv8}p-h&<;$Yox>1-lxJE;`V?`JoV^8!sZ#|L)~# z$iM1EhlBPynUDvI;qBE}WgmdQ0`UGfMIpyPG`dZlbEq^n=9;aeFJ_Y{OFcR z5_dS|SbCeR0eZ^J$7|1;GrW#}e7^;;(`G$4_r6i{9vhfEYfjSruS9=+H>1s>dtz1>uLv`>)CRdTSi@}FlcVoF7xM6d`Z=*(aF&+vbKhHXAj zvnJ3VGGc}A&{lFz5<7?w^5lyQzcYmHz6bu#bTvSG@KN){@ZSZy#E25R*l^uexYOXW z9FL#)fakk>*gcJp(@;L`Imx!)^G$P>+x5bo>K)lP@x3MfK@XjlQ~$H?W)fXY7JiM= zo>{HtSMoaqJcM|@J$Oa=&DH)7a;dqI$VG_Wbm}A(mm$}c-Y{J4mrJ`iCb45b=MP8R z=z_R?Ar*c`#+|jYh9>_%2$^xcm(lPPrmULxTigxYuW+pyObSF1Dq9Ch5W?_aq7iFk;Qs^wr z!B>u6T`StD*$a2N@!ib&Cx6O`?p5?9QQ;0xqhNom#uqhudig2pi@D%ix?H%-v~@ds z0m~8Lap0@Tp8)e`f?e{UpmDt9da(wTtCMxe^Vpv~4?oQfkdvoj6IpX<%(_=AHi>O^ zYlhsHrgQ^UZ1>eT*N6BU*NBqcbzi@mQTJS7hF7_gam2XDUorAm7UX>{mF3ocn=cxj zc}p$g-K1?<*-P&jch=;Nacc$HOftt%`0x_D%w5d>5q&w|iCyuwV1$e^5lYV_ zWiFW$`tkO?*zsHPzKk`kacK@4)rO6+b82cKCir=I!bf;ccb&&l}G5!_FhNePR6hBX{T(UC2_QMSoMAl7*jD4YqHjN zK6(-Y8wfriWS-b%B2D54rbg4S7H< z5c;=Yn;v{b_ygnC)Omzn(lB;4-`K^kMf`Ic`LZR8pJ&f+Z=minm<`>}!9P8c**JF< zx@$FiiXO+aH=VJp%4Mpt?j0AuWlotJHun%{(OYnPLskcjVO?^I;+u+%lVj-%j(C0W z1k?$qu@=^ojq5j=ez?H~p6k^vp4Wg!kAn`kz?+r;#}7J7qFCjST~=}{*Y`=K|Fa^wQ1Kf-n79eG#Akg;S;8C&MST*qWi(70z&5$ly! zrm%Q61e?^oB)1?o?%qTEZL!lgZYy7dAP0tbF#IOnaSoZ$9+=5q$j1aCHg1jlyU$zi zv+>vp$9{0lUGNTSk1N*v8vDKLka$gOxA>mo`+-hI!w#6!3wp|%$ql+z!?=PM zPBPUEVxwIGn~=-a@GL=psJ!L9W6JMI^O?X4QqPlItS0ZsyU3=fv1CjcTjr3tWKQtB zAvSaBLI)<;gWnGOr{@dO9kBz(Anc{m`M{^aZs^!29kMBPOl>*z`P(00y^lEN`O^WN zHw243EAPm=GKP#LW6Ibvhu9aHQ|6W$mtgt7<9{awASX&x?&<9iS&0{JCXQGA`!1*} z;yjTCNpIgk_}&QT34|yEkEKfnia8*iL^AEqL?p=d$^c!L>{CfPy&^p_^#jw<-zI9A zfc|SDAL)^W@|1oB*ggRLYSITedabVX8;I5=K$Ow1MX%1&aUMXFvW&=7-EY8ixNbr( z&q*)unc&yfouGPM#>Y9WN3SP3=G8|!aOm)*{qVdt0N2xr(w}kQPEXjN){)>-t5G7> zg(q;AXoK`FL_)TG{nN09Ub*U{=17+t)eDDA2A%5~dwma-ha10x+4OsJN0^hmmFJtRo^4rmx zgtHMh0^dUoQhv3f(|;C&qAzUD@Tsh|$2TyK!)D?88CS$ty<2zZ6{yL5i1_axu)}`? zO{TzxHwo_zI}TedcDzljLzn)m^Oqj(L8T}09l-Hq@bY;g-!hFn)gBCijXDS4K=~-} zQ>y((<-Z{gDuta|2_0T%&HGA+>mjgPf8{7UF%r*eb%-VHj~Y}Nj-_=D1%II8_Z4vOJ(@biMXH7KJsuTsf%|*P5*#7Q`LGThGxXgT(HB$cXzmOai(Fh>~Ven$E?mRxPoZj%#Cht zfrx8^s|@(Y5=$ks2mTM zW@%iKr7Ik`j(ivLDOv8fi}y5(EzYS&$mZ+4@3&*0;lD!;k#E>5dmwxP8f^HZ`6bVI zeFFA7%gsQ;+>NF>b5fOsn7e!zm7sS1acN%5rRP6svH0pHT4LR;?JCVS6gHJ($nq*L z&THGTcyB8z+tW}-jA9Cc%s~bx3k`%%AukvIc>Zu%uMT>H?_0H2`8edkBv)kJV;tT6 z-hjy?MofOEXMEC`*I$44yR4iOd5f2)#hL(@ehQDadK5 z!B^FzpeOOYU6k;F#43pQu}i-{N)L4>&|J|Se70WJgLSiIkMZ>@%co806+hrk_fFv) z8q~tRZR@9Eacnnl{K)UVw`CVb^OO;XjeHo^*>ND>C;5IIaj^LBAy~hPn^1SONG=ez zK}XF(?|*8a1~!)6hm-_~VLuY|$hbP1QdeV8SzO^|Kt=1-*V zDS73}!h*Z}=BukdxNK%BIk(XD%Jygtc_>=-_){{!f&Hei-h5v*{UMjvsxjW%qo!$? z@!b$;*IRg9c#ZS)Q)yFEmUWAcYK+;h5w|17q=qDXpXox0IfOfr6D%+ryUJcT@tzVNm5r*^XHp3 z4H-OoZA@esYLFeOx<-a`L`ZAiu=X9kb?YWyx_FVVU%O^IeDEMYID1y#hvv?qWlNqg z7Q)UUr(r-BiQha<^fTtJ5*~ov9Ev@tfUT$kFBBtZ+Z-_(eYIwUD{{t=!Mc+jbuFDs zcG5>{*KJ;V?(5Th{_NSR3+K+U)Cwo2jKOyS5vm5)y>mxf-}oLpYwF~_(Ho`#GI`JYfN|1v-DBuPB$jGHU*{ZtF!ppC1hyEy8~P5KM{MW!pV>B#!G-*~dv zY*yr!KT@`zlrk3k(8^K2K|QTkY)tv0hvo-9{NP*ywqNM~ORinj{Dl6%v>A9L4C*OY8gw;1p0BuKbU1)Q)o~_ueAAI`BN5>vJezutH^4&s}ee$@`9Ns3-*1b~%_wALmZ`Azr zwNe(Iq=Cbh)X1OID-k#8umQ3M?J&Qhxy0qL%PRLR`vH9%f%|yIrWfsMpqqC$5}h9j z-KX-sMaZXHc5B(Mr}r8D@#a124jnoF&DaMf{ty-3{cxXtlNR3I6Ovq>>;6>1uI9b* zEm9lku7<@q^$F)1)~(Cfm?QEY{Zk1H--|93nm{+EN=+YF#p5S@e`FwskNSr1BhK1V zg#=w+{{D7%^(DYt$re2}+zvOtc+^dTB2K7K)ib`|+bcu^a z{-FmI56Yb2w=3X*kDWe(@I$up)x6j=IJE+?KI}fedn()n`;$|Dg?v-uurbIVgD+91 zhW&vJ6#WPt;k<|Zw_7FleN-`O!&SMYC#RNc$Q`LQ!T-n--jg{Ymmh*20~9@+G6|VT z!=6xmd;=-IYjocdmuD6}<;qup?5NwI@*Mqq0cSJGKJ8~PyS!j9? zaujYLMjdWAZu)^>zCnHY1T8PgcHdKy?Q)0@)5nOr9xXiHqDSBREIw0bFunTX=bF3= zQK3U8*}#}fB=;wC$XrN$>UH|or(Mk}6IFO;*JceIrRcdObI63=s?>t|z(=Vh#F z|4ZgTZuYv&DS85Pt>A*vO;mGM7K*Y=XHZRus8x2i+8t%sT!r z!FcUkXYS7U#XojO+-CS9M^+cPx-M+2TF3PAdZy>zLi-zR46bRlSu^S(-=*@aPK$1G zjFk;OY}B5OZioZJp@&2lR_DDQei}T8wJ6yKPeKND;5UivkSptMjoCom!vy*n@KaPi z4r`M2N=-uLq##RD*VN=0c~;($cV!G2OUA4oU-(Y!;vXHT&q?l!P_v-^$h*#5H`*-D<+>>YIS&75tT^U2hk}+lM-yZlh;z{tZFWX<}%eDX?wkNpoNn}@p zNwcpuEwcsa0k#Cq+mcSJD@mHmYENW4ETp5?1Ds7=^T0h1JcB&PYInS2!n?}?Ov}=d zf=EDWWP_gIl4uI>`QX$GQxCdq@U_QRJ=qq3BrOquR+FcIru-|1{ND{|tNxz_bl34+ zt&X|``GC*rv|_3iz#8rw1ircVkZ(=p8)a$5yL_~pU-_9hpR~0f+}1>#iXd(t@~!PbeSvkF>#-*;P#qG0t`9 zuAYs+=W>+nYexki!KQqO+TuX+@J0oi{azU02qd28JRc%n~`t$kn& zr$9EZL0?zGZ|)-Ro3s6>^hgX9d`bEZ#G{2-wP(QQmBYqxRSsGB098U4Z~W<-Mm`H3 z-AW0=%LAYfDv$@rh3(ZlCt9@19QgH!H_gSIsb4S7_Ba#Yy-!Z-z)(Ja{sI@4RNY+h z{5zrR*KeVuu{T}idlBgTb@D#)?*0!a-bhJJ{bMi-Y>GRNzWx#%PbK;iot`$`N zaP1P08alW#HaeW|OHTY-aI4l7HWB(H$?lK$%klnx(Zh|y?JtGL{Xp-%x~f)5mT~LW zHtpEe>jl}?DjWCj-Je^x3cHm!V0`<8_+iu{EXP&8_rkpE1-~?*h16z+8h0}4&602K zzyGw>zQfxWf0#Zm@uNfQn|xHU+b!?rR}IH1ia{6Qop;r{z<|Cqez?6UwZ|A|u)9|l z27R+OZ$r`(`R{gLK54=i@74|Gao&OUHsIgUcvk#NE8guU-z}g|#I;*u55O17j`@Z0 zd6(AQR135>_HNH7{UdCQ|3gvyi)U}bj`qXzQbW=ELx;osvu0w;Kg0(}6msYq#Z`4A*v_5o4BWJ_!2wZD~dvc`XS;-l% H!Mpzfi_ZiG literal 0 HcmV?d00001 diff --git a/tests/test_docs_branding.py b/tests/test_docs_branding.py new file mode 100644 index 0000000..52039a1 --- /dev/null +++ b/tests/test_docs_branding.py @@ -0,0 +1,125 @@ +"""Tests the favicon route and the self-hosted, branded documentation pages. + +The point of registering `/docs` and `/redoc` by hand is the platform icon; the +cost is owning the behaviour FastAPI's built-ins provided, so these tests pin +both — the branding, and the `root_path` prefixing and OAuth2 redirect page a +handwritten registration is free to forget. +""" + +import pytest +from fastapi.testclient import TestClient + +from nc3_testing_platform.core.docs import ( + DOCS_URL, + FAVICON_MEDIA_TYPE, + FAVICON_PATH, + FAVICON_URL, + REDOC_URL, + SWAGGER_OAUTH2_REDIRECT_URL, +) +from nc3_testing_platform.main import app + +# The default FastAPI ships in the documentation HTML. Nothing the platform +# serves may reach out to it: it is a third-party request from an authenticated +# origin, and an outage there would break the page's icon. +_FASTAPI_HOST = "fastapi.tiangolo.com" + +# The `root_path` a reverse proxy that mounts the API under a sub-path sets. +_MOUNT_PREFIX = "/testing-platform" + +_ICO_MAGIC = b"\x00\x00\x01\x00" + + +@pytest.fixture(scope="module") +def client() -> TestClient: + """The platform app, served from the origin root.""" + return TestClient(app) + + +@pytest.fixture(scope="module") +def mounted_client() -> TestClient: + """The platform app as a proxy mounting it under a sub-path presents it.""" + return TestClient(app, root_path=_MOUNT_PREFIX) + + +def test_favicon_is_the_committed_icon(client: TestClient) -> None: + """The route serves the icon shipped in the package, declared as an ICO.""" + response = client.get(FAVICON_URL) + + assert response.status_code == 200 + assert response.headers["content-type"] == FAVICON_MEDIA_TYPE + assert response.content == FAVICON_PATH.read_bytes() + + +def test_committed_icon_is_a_real_ico() -> None: + """The asset is an ICO container, not a PNG renamed. + + A renamed PNG is served happily and then ignored by the browsers that read + the declared type rather than sniffing. + """ + assert FAVICON_PATH.read_bytes().startswith(_ICO_MAGIC) + + +def test_favicon_is_cacheable(client: TestClient) -> None: + """The icon carries a cache lifetime, so it is not re-fetched per page view.""" + response = client.get(FAVICON_URL) + + assert "max-age=" in response.headers["cache-control"] + + +@pytest.mark.parametrize("url", [DOCS_URL, REDOC_URL]) +def test_documentation_page_uses_the_platform_favicon( + client: TestClient, url: str +) -> None: + """Both pages point at the local icon and at no external one.""" + response = client.get(url) + + assert response.status_code == 200 + assert f'href="{FAVICON_URL}"' in response.text + assert _FASTAPI_HOST not in response.text + + +@pytest.mark.parametrize("url", [DOCS_URL, REDOC_URL]) +def test_documentation_page_survives_being_mounted_under_a_prefix( + mounted_client: TestClient, url: str +) -> None: + """Behind a proxy, both the icon and the document are addressed through `root_path`. + + Unprefixed, the page would ask the proxy's own root for them and render + without an icon and without a specification. + """ + response = mounted_client.get(url) + + assert response.status_code == 200 + assert f'href="{_MOUNT_PREFIX}{FAVICON_URL}"' in response.text + assert f"{_MOUNT_PREFIX}{app.openapi_url}" in response.text + + +def test_swagger_oauth2_redirect_page_is_registered(client: TestClient) -> None: + """The page Swagger UI's "Authorize" flow returns through still answers. + + FastAPI registers it only alongside its own `/docs`; switching that off + drops it unless it is registered by hand, and the failure surfaces only + mid-login. + """ + response = client.get(SWAGGER_OAUTH2_REDIRECT_URL) + + assert response.status_code == 200 + assert "oauth2" in response.text + + +def test_swagger_ui_declares_the_redirect_page_it_registers( + client: TestClient, +) -> None: + """The URL the page hands to Swagger UI is the one that is served.""" + response = client.get(DOCS_URL) + + assert f"'{SWAGGER_OAUTH2_REDIRECT_URL}'" in response.text + + +def test_documentation_routes_stay_out_of_the_contract() -> None: + """No documentation route reaches the OpenAPI document.""" + paths = app.openapi()["paths"] + + for url in (FAVICON_URL, DOCS_URL, REDOC_URL, SWAGGER_OAUTH2_REDIRECT_URL): + assert url not in paths