feat(content): record completed renders as Assets in the content lane
A finished render becomes an `Asset` document — the DocType hanzoai/cloud clients/content already declares (framework module "marketing") and already writes itself when IT drives a render. Studio-initiated renders were the only ones with no path into that record; this closes exactly that gap and adds NO second schema, NO second lifecycle, and NO second storage plane. cloud is untouched. POST /v1/framework/Asset (the already-live generic framework surface) Same field shape clients/content writes (studio_render.go draftAsset): title, kind, design, prompt, file, generator, workflow, source_prompt_id, render_params, project, tags. `file` is the org-scoped output key (orgs/<org>/output/...) — byte-identical to the key persistOrLink produces and the key Studio's own output mirror lands, so the bytes are ALREADY persisted: this module uploads nothing and holds no storage credential. Written AS THE REQUESTING USER: the verified IAM access token rides to the prompt worker on the queue's existing `sensitive` channel (never persisted into history) and authenticates every write, so the Asset lands in that user's org with that user's rights. No service account, no impersonation, no cross-org path. Assets land in draft (the DocType default); the content lane's ONE state machine (draft->in_review->approved->queued->published) owns every transition, and a human does the promoting. Publishing is what a storefront shows, so an unattended render can never push an image to karma.style. Fire-and-forget on the same prompt-worker seam as billing: a render is never failed because the lane was unreachable. Env-gated (STUDIO_CONTENT_PUBLISH); local dev unaffected.
This commit is contained in:
+5
-1
@@ -149,7 +149,11 @@ class CacheSet:
|
||||
}
|
||||
return result
|
||||
|
||||
SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_hanzo", "api_key_hanzo")
|
||||
# Secrets that ride with a queued prompt but must never be persisted into history.
|
||||
# `iam_token` is the requesting user's own verified IAM access token: the prompt
|
||||
# worker records the finished render in the content lane as that user
|
||||
# (middleware/content_publish.py).
|
||||
SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_hanzo", "api_key_hanzo", "iam_token")
|
||||
|
||||
def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt=None, extra_data={}):
|
||||
is_v3 = issubclass(class_def, _StudioNodeInternal)
|
||||
|
||||
@@ -245,6 +245,7 @@ def prompt_worker(q, server_instance):
|
||||
# Import metrics + billing for prompt tracking
|
||||
from middleware import metrics_middleware
|
||||
from middleware import billing_middleware
|
||||
from middleware import content_publish
|
||||
enable_billing = getattr(args, "enable_billing", False)
|
||||
enable_metrics = getattr(args, "enable_metrics", False)
|
||||
|
||||
@@ -307,6 +308,26 @@ def prompt_worker(q, server_instance):
|
||||
server_instance.loop,
|
||||
)
|
||||
|
||||
# Content: record each output artifact as a draft Asset in the content
|
||||
# lane (clients/content, module "marketing") — the SAME DocType that
|
||||
# lane writes when it drives a render, so a studio-initiated render lands
|
||||
# in the same place. Written as the requesting user via their IAM token
|
||||
# from `sensitive`, so it lands in their org. Assets land draft; a human
|
||||
# moves them through the lifecycle, and published is what a storefront
|
||||
# shows. Fire-and-forget: a render is never failed if the lane is down.
|
||||
if content_publish.enabled() and e.success:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
content_publish.publish_render(
|
||||
org_id=extra_data.get("org_id") or "default",
|
||||
prompt_id=prompt_id,
|
||||
workflow=item[2],
|
||||
history_result=e.history_result,
|
||||
iam_token=extra_data.get("iam_token"),
|
||||
extra_data=extra_data,
|
||||
),
|
||||
server_instance.loop,
|
||||
)
|
||||
|
||||
# Log Time in a more readable way after 10 minutes
|
||||
if execution_time > 600:
|
||||
execution_time = time.strftime("%H:%M:%S", time.gmtime(execution_time))
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Publish a completed Studio render into the Hanzo content lane.
|
||||
|
||||
THE one writer, and it writes NOTHING new. A finished render becomes an `Asset`
|
||||
document — the DocType hanzoai/cloud `clients/content` already declares (framework
|
||||
module "marketing") and already writes itself when IT drives a render
|
||||
(clients/content/studio_render.go). Studio-initiated renders were the only ones
|
||||
with no path into that record; this closes exactly that gap and adds no second
|
||||
schema, no second lifecycle, and no second storage plane.
|
||||
|
||||
POST /v1/framework/Asset (the already-live generic framework surface)
|
||||
|
||||
Same shape `clients/content` writes (studio_render.go `draftAsset`):
|
||||
|
||||
title, kind, design, prompt, file, generator, workflow,
|
||||
source_prompt_id, render_params, project, tags
|
||||
|
||||
`file` is the object key under the org's studio output prefix —
|
||||
``orgs/<org>/output/<subfolder>/<filename>`` — byte-identical to the key
|
||||
`persistOrLink` produces, and the same key Studio's own output mirror already
|
||||
lands. So the bytes are ALREADY persisted by Studio: this module uploads nothing
|
||||
and holds no storage credential. The record points at what exists.
|
||||
|
||||
Curation is the content lane's ONE state machine (clients/content/lifecycle.go):
|
||||
|
||||
draft → in_review → approved → queued → published
|
||||
|
||||
An asset lands in `draft` (the DocType's default) and a human moves it. Publishing
|
||||
is what puts an image in front of customers, so an unattended render must never do
|
||||
it. The storefront reads `published`.
|
||||
|
||||
Auth is the requesting user's OWN verified IAM access token, carried to the prompt
|
||||
worker on the queue's existing `sensitive` channel (execution.SENSITIVE_EXTRA_DATA_KEYS)
|
||||
so it is never persisted into history. The Asset is written AS that user, into that
|
||||
user's org — the framework derives the tenant from the token's `owner` claim. No
|
||||
service account, no impersonation, no cross-org write path.
|
||||
|
||||
Fire-and-forget: a publish failure only logs. A render is NEVER failed for it.
|
||||
Env-gated; absent locally = no-op.
|
||||
|
||||
STUDIO_CLOUD_API_URL Hanzo Cloud base URL (default https://api.hanzo.ai)
|
||||
STUDIO_CONTENT_PUBLISH "1"/"true" to enable
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
import aiohttp
|
||||
|
||||
CLOUD_URL = os.environ.get("STUDIO_CLOUD_API_URL", "https://api.hanzo.ai")
|
||||
|
||||
# One Asset row per artifact; small JSON writes.
|
||||
_TIMEOUT = aiohttp.ClientTimeout(total=float(os.environ.get("STUDIO_CONTENT_TIMEOUT", "15")))
|
||||
|
||||
# clients/content declares Asset.kind as a Select; anything else would 422.
|
||||
KINDS = ("ecom", "product", "lifestyle", "hover", "hero", "thumbnail")
|
||||
DEFAULT_KIND = "product"
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
"""True when render publishing is switched on. Absent locally = no-op."""
|
||||
return os.environ.get("STUDIO_CONTENT_PUBLISH", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def artifacts(history_result: dict | None) -> list[dict]:
|
||||
"""
|
||||
The media artifacts a completed render produced, as Studio's own output refs
|
||||
({filename, subfolder, type}). Mirrors billing_middleware.count_outputs — the
|
||||
same shape Studio already returns to the UI, so there is no second notion of
|
||||
"what a render produced". Previews (type != "output") are not artifacts.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for node_output in (history_result or {}).get("outputs", {}).values():
|
||||
if not isinstance(node_output, dict):
|
||||
continue
|
||||
for values in node_output.values():
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
for v in values:
|
||||
if isinstance(v, dict) and v.get("filename") and v.get("type") == "output":
|
||||
out.append(v)
|
||||
return out
|
||||
|
||||
|
||||
def object_key(org_id: str, ref: dict) -> str:
|
||||
"""
|
||||
The asset's key under the org's studio output prefix. Byte-identical to the key
|
||||
clients/content builds in persistOrLink (orgs/<org>/output/...), which is also
|
||||
where Studio's own output mirror lands — ONE key convention, so the record and
|
||||
the bytes can never disagree.
|
||||
"""
|
||||
return posixpath.join(
|
||||
"orgs", org_id, "output", (ref.get("subfolder") or "").strip("/"), ref["filename"]
|
||||
).replace("//", "/")
|
||||
|
||||
|
||||
def kind_of(extra_data: dict) -> str:
|
||||
"""The Asset.kind Select value. Anything off-list would be refused by the engine."""
|
||||
k = str(extra_data.get("kind") or "").strip().lower()
|
||||
return k if k in KINDS else DEFAULT_KIND
|
||||
|
||||
|
||||
def _model(workflow: dict | None) -> str:
|
||||
"""Best-effort model name off the graph's first checkpoint/UNet loader."""
|
||||
for node in (workflow or {}).values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if "Loader" not in str(node.get("class_type", "")):
|
||||
continue
|
||||
for v in (node.get("inputs") or {}).values():
|
||||
if isinstance(v, str) and v.endswith((".safetensors", ".ckpt", ".gguf", ".sft")):
|
||||
return v
|
||||
return ""
|
||||
|
||||
|
||||
def asset_doc(*, org_id: str, prompt_id: str, workflow: dict, ref: dict, extra_data: dict) -> dict:
|
||||
"""
|
||||
One artifact as an Asset document — the same field set clients/content writes.
|
||||
`status` is omitted on purpose: the DocType defaults it to draft, and the
|
||||
lifecycle hook owns every transition from there.
|
||||
"""
|
||||
model = _model(workflow)
|
||||
return {
|
||||
"title": ref["filename"],
|
||||
"kind": kind_of(extra_data),
|
||||
"design": str(extra_data.get("design") or "").strip(),
|
||||
"role": str(extra_data.get("role") or "").strip(),
|
||||
"prompt": str(extra_data.get("prompt") or "").strip(),
|
||||
"file": object_key(org_id, ref),
|
||||
"generator": model or "studio",
|
||||
"workflow": model or "studio",
|
||||
"source_prompt_id": prompt_id,
|
||||
# The graph that reproduces this artifact — the reproducibility record.
|
||||
"render_params": workflow or {},
|
||||
"project": str(extra_data.get("project") or "").strip(),
|
||||
"tags": str(extra_data.get("tags") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
async def publish_render(
|
||||
*,
|
||||
org_id: str,
|
||||
prompt_id: str,
|
||||
workflow: dict,
|
||||
history_result: dict | None,
|
||||
iam_token: str | None,
|
||||
extra_data: dict | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Publish a completed render's artifacts as draft Assets. Returns how many landed.
|
||||
|
||||
Never raises: the caller is the prompt worker, and a render must not fail
|
||||
because the content lane was unreachable.
|
||||
"""
|
||||
if not enabled():
|
||||
return 0
|
||||
if not iam_token:
|
||||
# No verified user principal on this execution. We refuse to invent one:
|
||||
# writing via a service account would be a cross-org write path.
|
||||
logging.warning("content_publish: no IAM token on prompt %s — not published", prompt_id)
|
||||
return 0
|
||||
|
||||
refs = artifacts(history_result)
|
||||
if not refs:
|
||||
return 0
|
||||
extra = extra_data or {}
|
||||
url = f"{CLOUD_URL.rstrip('/')}/v1/framework/Asset"
|
||||
headers = {"Authorization": f"Bearer {iam_token}"}
|
||||
landed = 0
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_TIMEOUT) as session:
|
||||
for ref in refs:
|
||||
doc = asset_doc(
|
||||
org_id=org_id, prompt_id=prompt_id, workflow=workflow,
|
||||
ref=ref, extra_data=extra,
|
||||
)
|
||||
async with session.post(url, json=doc, headers=headers) as resp:
|
||||
if resp.status in (200, 201):
|
||||
landed += 1
|
||||
else:
|
||||
logging.warning(
|
||||
"content_publish: POST /v1/framework/Asset -> %s: %s",
|
||||
resp.status, (await resp.text())[:200],
|
||||
)
|
||||
except Exception as e: # unreachable cloud / timeout — a render is never failed for this
|
||||
logging.warning("content_publish: prompt %s not published: %s", prompt_id, e)
|
||||
|
||||
return landed
|
||||
@@ -446,6 +446,12 @@ def create_iam_auth_middleware(iam_url: str, localhost_bypass: bool = True):
|
||||
return web.json_response({"error": "Invalid or expired token"}, status=401)
|
||||
|
||||
request["iam_user"] = _with_active_org(user, request.cookies.get(_ACTIVE_ORG_COOKIE))
|
||||
# The verified raw access token. Studio records a completed render in the
|
||||
# content lane AS THE REQUESTING USER (middleware/content_publish.py), so it
|
||||
# lands in that user's org with that user's rights — no service account, no
|
||||
# impersonation. Carried to the worker over the queue's `sensitive` channel,
|
||||
# so it is never persisted into history.
|
||||
request["iam_token"] = token
|
||||
return await handler(request)
|
||||
|
||||
# Expose the login + callback handlers so server.py can register the routes.
|
||||
|
||||
@@ -1066,6 +1066,13 @@ class PromptServer():
|
||||
extra_data["create_time"] = int(time.time() * 1000) # timestamp in milliseconds
|
||||
org_id = self._get_org_id(request) # IAM org — scopes outputs + billing
|
||||
extra_data["org_id"] = org_id
|
||||
# Carry the requester's verified IAM token to the worker so the
|
||||
# finished render is recorded in the content lane as this user,
|
||||
# into this user's org. `sensitive` is stripped from history (see
|
||||
# SENSITIVE_EXTRA_DATA_KEYS) — the token is never persisted.
|
||||
iam_token = request.get("iam_token")
|
||||
if iam_token:
|
||||
sensitive["iam_token"] = iam_token
|
||||
# BYO-GPU dispatch: cloud pods only. A worker-mode node IS the
|
||||
# render backend — it must queue locally, never re-dispatch.
|
||||
dispatched = False
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Tests for recording a completed render as an Asset in the Hanzo content lane.
|
||||
|
||||
These are CONTRACT tests. They stand up a real aiohttp server that speaks the
|
||||
exact contract hanzoai/cloud serves — the generic framework surface
|
||||
(POST /v1/framework/Asset) — and drive the real publisher against it over real
|
||||
HTTP. The server side of that contract (the Asset DocType, its draft default, the
|
||||
lifecycle hook that owns every status edge, cross-org isolation) is proven
|
||||
independently against the real engine and a real SQLite by the content lane's own
|
||||
Go tests in hanzoai/cloud (clients/content/content_test.go).
|
||||
|
||||
What is asserted here is Studio's half: that a finished render produces exactly one
|
||||
Asset per output artifact, in the SAME field shape clients/content itself writes
|
||||
(so a studio-initiated render is indistinguishable from a lane-initiated one); that
|
||||
`file` is the org-scoped output key (the bytes Studio already persisted — this
|
||||
module uploads nothing); that every call is authenticated AS THE REQUESTING USER;
|
||||
that assets land as drafts (never auto-published to a storefront); and that a
|
||||
render is never failed when the lane is unreachable.
|
||||
"""
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from middleware import content_publish
|
||||
|
||||
pytestmark = pytest.mark.asyncio # module is all-async
|
||||
|
||||
TOKEN = "the-users-iam-access-token"
|
||||
PROMPT_ID = "b6f1c0e2-0000-4000-8000-000000000001"
|
||||
WORKFLOW = {"3": {"class_type": "KSampler", "inputs": {"steps": 30}},
|
||||
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": "qwen-image-edit-2511.safetensors"}}}
|
||||
HISTORY = {"outputs": {"9": {"images": [
|
||||
{"filename": "life1_00001_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"},
|
||||
{"filename": "life1_00002_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"},
|
||||
]}, "10": {"images": [
|
||||
{"filename": "preview.png", "subfolder": "", "type": "temp"}, # a preview is never an artifact
|
||||
]}}}
|
||||
EXTRA = {"design": "valentina", "kind": "lifestyle", "role": "life1",
|
||||
"prompt": "poolside, golden hour", "project": "fashion", "tags": "valentina,life1"}
|
||||
|
||||
|
||||
class Content:
|
||||
"""A stand-in for hanzoai/cloud's framework surface, module 'marketing'."""
|
||||
|
||||
def __init__(self, fail: bool = False):
|
||||
self.assets: list[dict] = []
|
||||
self.fail = fail
|
||||
|
||||
def app(self) -> web.Application:
|
||||
app = web.Application()
|
||||
app.router.add_post("/v1/framework/Asset", self._asset)
|
||||
return app
|
||||
|
||||
async def _asset(self, request):
|
||||
if request.headers.get("Authorization") != f"Bearer {TOKEN}":
|
||||
return web.json_response({"error": "valid principal required"}, status=403)
|
||||
body = await request.json()
|
||||
if self.fail:
|
||||
return web.json_response({"error": "boom"}, status=500)
|
||||
# The lane defaults status to draft; the client does not (and must not) set it.
|
||||
doc = {**body, "name": "hash-id", "doctype": "Asset", "status": "draft"}
|
||||
self.assets.append(doc)
|
||||
return web.json_response(doc, status=201)
|
||||
|
||||
|
||||
async def _publish(content, aiohttp_client, monkeypatch, **kw):
|
||||
monkeypatch.setenv("STUDIO_CONTENT_PUBLISH", "1")
|
||||
client: TestClient = await aiohttp_client(TestServer(content.app()))
|
||||
monkeypatch.setattr(content_publish, "CLOUD_URL", str(client.make_url("")).rstrip("/"))
|
||||
args = {"org_id": "karma", "prompt_id": PROMPT_ID, "workflow": WORKFLOW,
|
||||
"history_result": HISTORY, "iam_token": TOKEN, "extra_data": EXTRA}
|
||||
args.update(kw)
|
||||
return await content_publish.publish_render(**args)
|
||||
|
||||
|
||||
# --- pure helpers --------------------------------------------------------
|
||||
|
||||
async def test_artifacts_are_outputs_only_never_previews():
|
||||
refs = content_publish.artifacts(HISTORY)
|
||||
assert [r["filename"] for r in refs] == ["life1_00001_.png", "life1_00002_.png"]
|
||||
|
||||
|
||||
async def test_object_key_is_the_org_scoped_output_key():
|
||||
ref = {"filename": "life1_00001_.png", "subfolder": "swimwear/lifestyle2/valentina", "type": "output"}
|
||||
assert content_publish.object_key("karma", ref) == \
|
||||
"orgs/karma/output/swimwear/lifestyle2/valentina/life1_00001_.png"
|
||||
# no subfolder collapses cleanly (no doubled slash)
|
||||
assert content_publish.object_key("karma", {"filename": "x.png", "subfolder": "", "type": "output"}) == \
|
||||
"orgs/karma/output/x.png"
|
||||
|
||||
|
||||
async def test_kind_must_be_a_valid_select_value():
|
||||
assert content_publish.kind_of({"kind": "lifestyle"}) == "lifestyle"
|
||||
assert content_publish.kind_of({"kind": "nonsense"}) == "product" # off-list -> the default
|
||||
assert content_publish.kind_of({}) == "product"
|
||||
|
||||
|
||||
# --- the happy path ------------------------------------------------------
|
||||
|
||||
async def test_records_one_asset_per_artifact(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
assert await _publish(content, aiohttp_client, monkeypatch) == 2
|
||||
assert len(content.assets) == 2
|
||||
assert [a["title"] for a in content.assets] == ["life1_00001_.png", "life1_00002_.png"]
|
||||
|
||||
|
||||
async def test_asset_matches_the_content_lane_field_shape(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
await _publish(content, aiohttp_client, monkeypatch)
|
||||
a = content.assets[0]
|
||||
# The exact fields clients/content writes (studio_render.go draftAsset).
|
||||
assert a["file"] == "orgs/karma/output/swimwear/lifestyle2/valentina/life1_00001_.png"
|
||||
assert a["kind"] == "lifestyle"
|
||||
assert a["design"] == "valentina"
|
||||
assert a["role"] == "life1"
|
||||
assert a["source_prompt_id"] == PROMPT_ID
|
||||
assert a["render_params"] == WORKFLOW # the graph reproduces it
|
||||
assert a["generator"] == "qwen-image-edit-2511.safetensors"
|
||||
assert a["project"] == "fashion" and a["tags"] == "valentina,life1"
|
||||
|
||||
|
||||
async def test_assets_land_as_draft_never_auto_published(aiohttp_client, monkeypatch):
|
||||
"""A render must never push an image to a storefront. The client never sends a
|
||||
status; the lane defaults it to draft; a human moves it through the lifecycle."""
|
||||
content = Content()
|
||||
await _publish(content, aiohttp_client, monkeypatch)
|
||||
assert all("status" not in c for c in [content_publish.asset_doc(
|
||||
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW, ref=r, extra_data=EXTRA
|
||||
) for r in content_publish.artifacts(HISTORY)])
|
||||
assert all(a["status"] == "draft" for a in content.assets)
|
||||
|
||||
|
||||
# --- auth ----------------------------------------------------------------
|
||||
|
||||
async def test_written_as_the_requesting_user(aiohttp_client, monkeypatch):
|
||||
# The stub 403s any call without the user's exact bearer, so a full publish is
|
||||
# itself proof every write carried it.
|
||||
content = Content()
|
||||
assert await _publish(content, aiohttp_client, monkeypatch) == 2
|
||||
|
||||
|
||||
async def test_without_a_user_token_nothing_is_written(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
assert await _publish(content, aiohttp_client, monkeypatch, iam_token=None) == 0
|
||||
assert content.assets == []
|
||||
|
||||
|
||||
# --- a render is never failed --------------------------------------------
|
||||
|
||||
async def test_disabled_is_a_no_op(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
monkeypatch.delenv("STUDIO_CONTENT_PUBLISH", raising=False)
|
||||
monkeypatch.setattr(content_publish, "CLOUD_URL", "http://127.0.0.1:1")
|
||||
# enabled() reads the env directly, so call the real entrypoint with it unset.
|
||||
assert await content_publish.publish_render(
|
||||
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW,
|
||||
history_result=HISTORY, iam_token=TOKEN, extra_data=EXTRA,
|
||||
) == 0
|
||||
assert content.assets == []
|
||||
|
||||
|
||||
async def test_lane_error_is_survivable(aiohttp_client, monkeypatch):
|
||||
content = Content(fail=True)
|
||||
assert await _publish(content, aiohttp_client, monkeypatch) == 0 # nothing landed, no raise
|
||||
|
||||
|
||||
async def test_unreachable_lane_never_raises(monkeypatch):
|
||||
monkeypatch.setenv("STUDIO_CONTENT_PUBLISH", "1")
|
||||
monkeypatch.setattr(content_publish, "CLOUD_URL", "http://127.0.0.1:1") # nothing listening
|
||||
assert await content_publish.publish_render(
|
||||
org_id="karma", prompt_id=PROMPT_ID, workflow=WORKFLOW,
|
||||
history_result=HISTORY, iam_token=TOKEN, extra_data=EXTRA,
|
||||
) == 0
|
||||
|
||||
|
||||
async def test_empty_render_writes_nothing(aiohttp_client, monkeypatch):
|
||||
content = Content()
|
||||
assert await _publish(content, aiohttp_client, monkeypatch, history_result={"outputs": {}}) == 0
|
||||
assert content.assets == []
|
||||
|
||||
|
||||
# --- the token never leaks into history ----------------------------------
|
||||
|
||||
async def test_iam_token_is_a_sensitive_key_so_it_is_stripped_from_history():
|
||||
import execution
|
||||
assert "iam_token" in execution.SENSITIVE_EXTRA_DATA_KEYS
|
||||
Reference in New Issue
Block a user