Keep API not-found and 401 responses usable behind Ingress and in the Web UI.
Return native JSON with the missing id in the message, clear stale sessions on 401, and document ingress-nginx annotations so branded HTML 404/405 pages do not rewrite simulator bodies.
This commit is contained in:
+24
@@ -6,6 +6,9 @@ import asyncio
|
||||
from typing import cast
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
|
||||
from app.api.errors import ApiError, api_error_handler, unhandled_exception_handler
|
||||
from app.api.middleware import HeadAsGetMiddleware, RequestContextMiddleware
|
||||
@@ -30,6 +33,26 @@ from app.vsphere.soap.router import router as vsphere_soap_router
|
||||
from app.web.routes import router as web_router
|
||||
|
||||
|
||||
async def _json_http_exception_handler(
|
||||
_request: StarletteRequest, exc: StarletteHTTPException
|
||||
) -> JSONResponse:
|
||||
"""Always return JSON for HTTP errors — never HTML error pages from the app."""
|
||||
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict):
|
||||
# VsphereError and similar already carry the product envelope in detail.
|
||||
content: dict | list = {"detail": detail}
|
||||
elif isinstance(detail, list):
|
||||
content = {"detail": detail}
|
||||
else:
|
||||
content = {"detail": detail}
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
def create_app(
|
||||
settings: Settings | None = None,
|
||||
database_factory: DatabaseFactory = default_database_factory,
|
||||
@@ -110,6 +133,7 @@ def create_app(
|
||||
app.add_middleware(VsphereVersionGateMiddleware)
|
||||
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||
app.add_exception_handler(ApiError, api_error_handler)
|
||||
app.add_exception_handler(StarletteHTTPException, _json_http_exception_handler)
|
||||
# Native vSphere surface (survives contract hot-swap).
|
||||
from app.vsphere.soap.pbm import router as vsphere_pbm_router
|
||||
|
||||
|
||||
@@ -218,9 +218,7 @@ def _is_seed_host_or_named_vm(moid: str, obj: Any) -> bool:
|
||||
|
||||
|
||||
async def set_host_maintenance(database: Database, host: str, enabled: bool) -> dict[str, Any]:
|
||||
obj = await inventory.get_object(database, host)
|
||||
if obj is None or obj.type != "HostSystem":
|
||||
raise not_found(f"Host {host} not found")
|
||||
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
|
||||
props = dict(obj.props)
|
||||
props["connection_state"] = "CONNECTED"
|
||||
props["maintenance_mode"] = enabled
|
||||
|
||||
@@ -14,10 +14,7 @@ from app.vsphere.errors import invalid_argument, not_found
|
||||
|
||||
|
||||
async def require_vm(database: Database, vm: str) -> inventory.ManagedObject:
|
||||
obj = await inventory.get_object(database, vm)
|
||||
if obj is None or obj.type != "VirtualMachine":
|
||||
raise not_found(f"VM {vm} not found")
|
||||
return obj
|
||||
return await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||
|
||||
|
||||
def _default_identity(name: str, moid: str) -> dict[str, str]:
|
||||
|
||||
+24
-2
@@ -41,16 +41,38 @@ def unauthenticated(message: str = "Authentication required") -> VsphereError:
|
||||
)
|
||||
|
||||
|
||||
def not_found(message: str = "Not found") -> VsphereError:
|
||||
def not_found(message: str = "Not found", *, args: list[Any] | None = None) -> VsphereError:
|
||||
return VsphereError(
|
||||
404,
|
||||
error_type="not_found",
|
||||
messages=[
|
||||
{"default_message": message, "id": "com.vmware.vapi.std.errors.not_found", "args": []}
|
||||
{
|
||||
"default_message": message,
|
||||
"id": "com.vmware.vapi.std.errors.not_found",
|
||||
"args": list(args or []),
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def no_such(resource: str, resource_id: str) -> VsphereError:
|
||||
"""Native not-found with the missing id in the message (Ingress-friendly JSON)."""
|
||||
|
||||
message = f"No such {resource} ('{resource_id}')"
|
||||
return VsphereError(
|
||||
404,
|
||||
error_type="not_found",
|
||||
messages=[
|
||||
{
|
||||
"default_message": message,
|
||||
"id": "com.vmware.vapi.std.errors.not_found",
|
||||
"args": [resource_id],
|
||||
}
|
||||
],
|
||||
data={resource: message},
|
||||
)
|
||||
|
||||
|
||||
def already_exists(message: str = "Already exists") -> VsphereError:
|
||||
return VsphereError(
|
||||
400,
|
||||
|
||||
@@ -60,6 +60,36 @@ async def get_object(database: Database, moid: str) -> ManagedObject | None:
|
||||
return None if row is None else _row(row)
|
||||
|
||||
|
||||
_TYPE_RESOURCE: dict[str, str] = {
|
||||
"HostSystem": "host",
|
||||
"VirtualMachine": "vm",
|
||||
"Datastore": "datastore",
|
||||
"Datacenter": "datacenter",
|
||||
"Folder": "folder",
|
||||
"ClusterComputeResource": "cluster",
|
||||
"ResourcePool": "resource_pool",
|
||||
"Network": "network",
|
||||
}
|
||||
|
||||
|
||||
async def require_object(
|
||||
database: Database,
|
||||
moid: str,
|
||||
*,
|
||||
type_name: str | None = None,
|
||||
resource: str | None = None,
|
||||
) -> ManagedObject:
|
||||
"""Return the managed object or raise a native not-found with the bad id."""
|
||||
|
||||
from app.vsphere.errors import no_such
|
||||
|
||||
obj = await get_object(database, moid)
|
||||
label = resource or (type_name and _TYPE_RESOURCE.get(type_name)) or "object"
|
||||
if obj is None or (type_name is not None and obj.type != type_name):
|
||||
raise no_such(label, moid)
|
||||
return obj
|
||||
|
||||
|
||||
async def upsert_object(
|
||||
database: Database,
|
||||
*,
|
||||
|
||||
@@ -464,9 +464,7 @@ async def host_storage(
|
||||
database: Database = Depends(get_database),
|
||||
_: SessionInfo = Depends(require_read),
|
||||
) -> list[dict[str, Any]]:
|
||||
obj = await inventory.get_object(database, host)
|
||||
if obj is None or obj.type != "HostSystem":
|
||||
raise not_found(f"Host {host} not found")
|
||||
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
|
||||
devices = obj.props.get("storage_devices")
|
||||
return list(devices) if isinstance(devices, list) else []
|
||||
|
||||
@@ -477,9 +475,7 @@ async def host_networking(
|
||||
database: Database = Depends(get_database),
|
||||
_: SessionInfo = Depends(require_read),
|
||||
) -> dict[str, Any]:
|
||||
obj = await inventory.get_object(database, host)
|
||||
if obj is None or obj.type != "HostSystem":
|
||||
raise not_found(f"Host {host} not found")
|
||||
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
|
||||
networking = obj.props.get("networking")
|
||||
return networking if isinstance(networking, dict) else {}
|
||||
|
||||
@@ -490,9 +486,7 @@ async def folder_children(
|
||||
database: Database = Depends(get_database),
|
||||
_: SessionInfo = Depends(require_read),
|
||||
) -> list[dict[str, str]]:
|
||||
parent = await inventory.get_object(database, folder)
|
||||
if parent is None:
|
||||
raise not_found(f"Folder {folder} not found")
|
||||
await inventory.require_object(database, folder, resource="folder")
|
||||
children = [obj for obj in await inventory.list_objects(database) if obj.parent_moid == folder]
|
||||
return [{"moid": c.moid, "type": c.type, "name": c.name} for c in children]
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.db.pool import Database
|
||||
from app.dependencies import get_database
|
||||
from app.vsphere import inventory
|
||||
from app.vsphere.domain import vm_ops
|
||||
from app.vsphere.errors import invalid_argument, not_found, unauthenticated
|
||||
from app.vsphere.errors import invalid_argument, unauthenticated
|
||||
from app.vsphere.rest import mappers
|
||||
from app.vsphere.security.authz import require_privilege, require_read
|
||||
from app.vsphere.security.session import (
|
||||
@@ -200,9 +200,7 @@ async def get_vm(
|
||||
database: Database = Depends(get_database),
|
||||
_: SessionInfo = Depends(require_read),
|
||||
) -> dict[str, Any]:
|
||||
obj = await inventory.get_object(database, vm)
|
||||
if obj is None or obj.type != "VirtualMachine":
|
||||
raise not_found(f"VM {vm} not found")
|
||||
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||
return mappers.vm_info(obj)
|
||||
|
||||
|
||||
@@ -286,9 +284,7 @@ async def delete_vm(
|
||||
database: Database = Depends(get_database),
|
||||
_: SessionInfo = Depends(require_privilege("VirtualMachine.Inventory.Delete")),
|
||||
) -> Response:
|
||||
obj = await inventory.get_object(database, vm)
|
||||
if obj is None or obj.type != "VirtualMachine":
|
||||
raise not_found(f"VM {vm} not found")
|
||||
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||
if obj.props.get("power_state") == "POWERED_ON":
|
||||
raise invalid_argument("VM must be powered off before delete")
|
||||
await inventory.delete_object(database, vm)
|
||||
@@ -301,9 +297,7 @@ async def get_vm_power(
|
||||
database: Database = Depends(get_database),
|
||||
_: SessionInfo = Depends(require_read),
|
||||
) -> dict[str, str]:
|
||||
obj = await inventory.get_object(database, vm)
|
||||
if obj is None or obj.type != "VirtualMachine":
|
||||
raise not_found(f"VM {vm} not found")
|
||||
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||
state = str(obj.props.get("power_state") or "POWERED_OFF")
|
||||
return {"state": state}
|
||||
|
||||
@@ -334,9 +328,7 @@ async def get_host(
|
||||
database: Database = Depends(get_database),
|
||||
_: SessionInfo = Depends(require_read),
|
||||
) -> dict[str, Any]:
|
||||
obj = await inventory.get_object(database, host)
|
||||
if obj is None or obj.type != "HostSystem":
|
||||
raise not_found(f"Host {host} not found")
|
||||
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
|
||||
return mappers.host_info(obj)
|
||||
|
||||
|
||||
@@ -355,9 +347,9 @@ async def get_datastore(
|
||||
database: Database = Depends(get_database),
|
||||
_: SessionInfo = Depends(require_read),
|
||||
) -> dict[str, Any]:
|
||||
obj = await inventory.get_object(database, datastore)
|
||||
if obj is None or obj.type != "Datastore":
|
||||
raise not_found(f"Datastore {datastore} not found")
|
||||
obj = await inventory.require_object(
|
||||
database, datastore, type_name="Datastore", resource="datastore"
|
||||
)
|
||||
props = obj.props
|
||||
return {
|
||||
"name": obj.name,
|
||||
@@ -421,9 +413,7 @@ async def vm_guest_identity(
|
||||
database: Database = Depends(get_database),
|
||||
_: SessionInfo = Depends(require_read),
|
||||
) -> dict[str, Any]:
|
||||
obj = await inventory.get_object(database, vm)
|
||||
if obj is None or obj.type != "VirtualMachine":
|
||||
raise not_found(f"VM {vm} not found")
|
||||
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||
return {
|
||||
"name": obj.name,
|
||||
"family": "LINUX",
|
||||
|
||||
@@ -19,7 +19,6 @@ from app.dependencies import get_database
|
||||
from app.vsphere import inventory
|
||||
from app.vsphere.domain import api_state, tagging
|
||||
from app.vsphere.domain import content as content_domain
|
||||
from app.vsphere.errors import not_found
|
||||
from app.vsphere.rest.coverage import IMPLEMENTED
|
||||
from app.vsphere.security.authz import require_read
|
||||
from app.vsphere.security.session import SessionInfo
|
||||
@@ -61,9 +60,7 @@ async def _live_get(database: Database, template: str, concrete: str) -> Any | N
|
||||
|
||||
vm = params.get("vm")
|
||||
if vm and "/hardware/" in template:
|
||||
obj = await inventory.get_object(database, vm)
|
||||
if obj is None:
|
||||
raise not_found(f"VM {vm} not found")
|
||||
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||
props = obj.props or {}
|
||||
|
||||
def _live_list(key: str) -> list[Any] | None:
|
||||
@@ -107,9 +104,7 @@ async def _live_get(database: Database, template: str, concrete: str) -> Any | N
|
||||
return nic
|
||||
|
||||
if vm and "/guest/" in template:
|
||||
obj = await inventory.get_object(database, vm)
|
||||
if obj is None:
|
||||
raise not_found(f"VM {vm} not found")
|
||||
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||
props = obj.props or {}
|
||||
if template.endswith("/guest/local-filesystem"):
|
||||
if "guest_filesystems" in props:
|
||||
@@ -130,15 +125,11 @@ async def _live_get(database: Database, template: str, concrete: str) -> Any | N
|
||||
|
||||
host = params.get("host")
|
||||
if host and template.endswith("/networking"):
|
||||
obj = await inventory.get_object(database, host)
|
||||
if obj is None:
|
||||
raise not_found(f"Host {host} not found")
|
||||
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
|
||||
networking = (obj.props or {}).get("networking")
|
||||
return networking if networking is not None else None
|
||||
if host and "storage-device" in template:
|
||||
obj = await inventory.get_object(database, host)
|
||||
if obj is None:
|
||||
raise not_found(f"Host {host} not found")
|
||||
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
|
||||
devices = (obj.props or {}).get("storage_devices")
|
||||
return devices if devices is not None else None
|
||||
|
||||
@@ -156,9 +147,7 @@ async def _mutate_vm_hardware(
|
||||
vm = params.get("vm")
|
||||
if not vm or "/hardware/" not in template:
|
||||
return False
|
||||
obj = await inventory.get_object(database, vm)
|
||||
if obj is None:
|
||||
raise not_found(f"VM {vm} not found")
|
||||
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||
props = dict(obj.props or {})
|
||||
|
||||
if verb == "POST" and template.endswith("/hardware/cdrom"):
|
||||
|
||||
+27
-9
@@ -6228,6 +6228,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function clearSession(options = {}) {
|
||||
const { toastMessage = null, toastKind = "info", closePanel = true } = options;
|
||||
state.ticket = null;
|
||||
state.csrf = null;
|
||||
state.username = null;
|
||||
document.cookie = "vmware-api-session-id=; Max-Age=0; path=/; SameSite=Strict";
|
||||
try { localStorage.removeItem(LS_AUTH); } catch { /* ignore */ }
|
||||
try { sessionStorage.removeItem(LS_AUTH); } catch { /* ignore */ }
|
||||
setAuth(false);
|
||||
persistAuth();
|
||||
if (closePanel) closeAuthPanel();
|
||||
resetClusterStats("—");
|
||||
if (toastMessage) toast(toastMessage, toastKind);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
if (state.ticket) {
|
||||
@@ -6240,15 +6255,15 @@
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
state.ticket = null;
|
||||
state.csrf = null;
|
||||
state.username = null;
|
||||
document.cookie = "vmware-api-session-id=; Max-Age=0; path=/; SameSite=Strict";
|
||||
setAuth(false);
|
||||
persistAuth();
|
||||
closeAuthPanel();
|
||||
resetClusterStats("—");
|
||||
toast("Signed out", "info");
|
||||
clearSession({ toastMessage: "Signed out", toastKind: "info" });
|
||||
}
|
||||
|
||||
function expireSession() {
|
||||
clearSession({
|
||||
toastMessage: "Session expired — sign in again",
|
||||
toastKind: "warn",
|
||||
closePanel: false,
|
||||
});
|
||||
}
|
||||
|
||||
function updateStatusBadge(status) {
|
||||
@@ -6340,6 +6355,9 @@
|
||||
const text = await res.text();
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
||||
if (res.status === 401 && (state.ticket || state.username)) {
|
||||
expireSession();
|
||||
}
|
||||
return { status: res.status, body: parsed, durationMs: Math.round(performance.now() - started) };
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,60 @@ DB-backed стаб — 501 не должен появляться для изв
|
||||
base64 от `user:password`).
|
||||
- Отказ по правам — попробуйте сравнить `administrator@vsphere.local` и
|
||||
`readonly@vsphere.local` (см. [Авторизация](domains/authz.md)).
|
||||
- В Web UI ответ 401 очищает сохранённую сессию и переключает бейдж в шапке
|
||||
на **Guest** с toast «Session expired — sign in again».
|
||||
|
||||
## Ingress возвращает брендированный HTML 404 / nginx 405 вместо JSON
|
||||
|
||||
Симулятор отвечает на API-ошибки JSON (`detail` / `error_type` /
|
||||
`messages`). Если вы видите HTML «page not found» сайта или голую страницу
|
||||
nginx **405**, **Ingress / reverse proxy** подменил тело upstream (часто через
|
||||
`custom-http-errors` у ingress-nginx).
|
||||
|
||||
Исправьте аннотации Ingress для этого хоста (см.
|
||||
`helm/vmware-api-simulator/values-ingress-example.yaml`):
|
||||
|
||||
```yaml
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-intercept-errors: "false"
|
||||
nginx.ingress.kubernetes.io/custom-http-errors: "502,503"
|
||||
```
|
||||
|
||||
Затем перепроверьте с `Accept: application/json`. Отсутствующий host должен
|
||||
выглядеть так:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": {
|
||||
"error_type": "not_found",
|
||||
"messages": [
|
||||
{
|
||||
"default_message": "No such host ('host-999')",
|
||||
"id": "com.vmware.vapi.std.errors.not_found",
|
||||
"args": ["host-999"]
|
||||
}
|
||||
],
|
||||
"data": { "host": "No such host ('host-999')" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Seeded id хостов для `small` / `large` / `big` начинаются с `host-11`.
|
||||
Cookbook-ВМ включают `vm-101` (`web-01`).
|
||||
|
||||
### Корректная authenticated-мутация (vSphere Automation)
|
||||
|
||||
Сессия в заголовке/cookie + JSON-тело (не form-urlencoded):
|
||||
|
||||
```bash
|
||||
SID=$(curl -sk -u 'administrator@vsphere.local:VMware1!' -X POST \
|
||||
"https://HOST/api/session" | tr -d '"')
|
||||
curl -sk -X POST "https://HOST/api/vcenter/vm" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "vmware-api-session-id: $SID" \
|
||||
-d '{"name":"lab-vm","guest_os":"OTHER_GUEST_64","placement":{"folder":"group-v23","host":"host-11","datastore":"datastore-31","resource_pool":"resgroup-22"}}'
|
||||
```
|
||||
|
||||
## Задача никогда не завершается
|
||||
|
||||
|
||||
@@ -31,6 +31,59 @@ should not appear for a known path. If you see it:
|
||||
`user:password` base64).
|
||||
- Privilege denial — try `administrator@vsphere.local` vs
|
||||
`readonly@vsphere.local` to compare (see [Authorization](domains/authz.md)).
|
||||
- In the Web UI, a 401 clears the stored session and switches the header badge
|
||||
to **Guest** with a “Session expired — sign in again” toast.
|
||||
|
||||
## Ingress returns branded HTML 404 / nginx 405 instead of JSON
|
||||
|
||||
The simulator answers API errors as JSON (`detail` / `error_type` /
|
||||
`messages`). If you see a site HTML “page not found” or plain nginx **405**
|
||||
page, the **Ingress / reverse proxy** replaced the upstream body (often via
|
||||
`custom-http-errors` on the ingress-nginx controller).
|
||||
|
||||
Fix on the Ingress for this host (see
|
||||
`helm/vmware-api-simulator/values-ingress-example.yaml`):
|
||||
|
||||
```yaml
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-intercept-errors: "false"
|
||||
nginx.ingress.kubernetes.io/custom-http-errors: "502,503"
|
||||
```
|
||||
|
||||
Then re-check with `Accept: application/json`. A missing host should look like:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": {
|
||||
"error_type": "not_found",
|
||||
"messages": [
|
||||
{
|
||||
"default_message": "No such host ('host-999')",
|
||||
"id": "com.vmware.vapi.std.errors.not_found",
|
||||
"args": ["host-999"]
|
||||
}
|
||||
],
|
||||
"data": { "host": "No such host ('host-999')" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Seeded host ids for `small` / `large` / `big` start at `host-11`. Cookbook VMs
|
||||
include `vm-101` (`web-01`).
|
||||
|
||||
### Correct authenticated mutation (vSphere Automation)
|
||||
|
||||
Session cookie/header + JSON body (not form-urlencoded):
|
||||
|
||||
```bash
|
||||
SID=$(curl -sk -u 'administrator@vsphere.local:VMware1!' -X POST \
|
||||
"https://HOST/api/session" | tr -d '"')
|
||||
curl -sk -X POST "https://HOST/api/vcenter/vm" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "vmware-api-session-id: $SID" \
|
||||
-d '{"name":"lab-vm","guest_os":"OTHER_GUEST_64","placement":{"folder":"group-v23","host":"host-11","datastore":"datastore-31","resource_pool":"resgroup-22"}}'
|
||||
```
|
||||
|
||||
## Task never finishes
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ seed:
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
# Keep API JSON bodies (404/405/401). Cluster-wide custom-http-errors HTML
|
||||
# pages must not rewrite simulator error responses.
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-intercept-errors: "false"
|
||||
# If the controller still injects branded HTML for 404/405, narrow or clear
|
||||
# custom-http-errors on this Ingress (overrides controller defaults):
|
||||
nginx.ingress.kubernetes.io/custom-http-errors: "502,503"
|
||||
hosts:
|
||||
- host: vmware-sim.example.com
|
||||
paths:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""vSphere not-found helpers expose the missing id in the message."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.vsphere.errors import no_such, not_found
|
||||
|
||||
|
||||
def test_no_such_includes_resource_id() -> None:
|
||||
error = no_such("host", "host-999")
|
||||
assert error.status_code == 404
|
||||
detail = error.detail
|
||||
assert isinstance(detail, dict)
|
||||
assert detail["error_type"] == "not_found"
|
||||
message = detail["messages"][0]
|
||||
assert message["default_message"] == "No such host ('host-999')"
|
||||
assert message["args"] == ["host-999"]
|
||||
assert detail["data"]["host"] == "No such host ('host-999')"
|
||||
|
||||
|
||||
def test_not_found_accepts_args() -> None:
|
||||
error = not_found("Guest file not found: /tmp/x", args=["/tmp/x"])
|
||||
assert error.detail["messages"][0]["args"] == ["/tmp/x"]
|
||||
Reference in New Issue
Block a user