Files
krow_backend/scripts/verify-deploy.py
Suriyakumarvijayanayagam dc785b917c
Some checks failed
CI / test (push) Failing after 4m41s
CI / fixture (push) Failing after 8s
Separate what a worker does from what a company needs filled
Owliver could offer neither create. The Create Position flow worked and no chip
anywhere suggested it, because the chip row is entirely the backend's static
catalogue and no intent in it wrote anything. The gap was never in the
frontend's trigger matching — every phrasing already routed.

`employee_roles` is the supply side of `job_postings`. A posting is what the
ORGANIZATION needs filled; this is what a WORKER says they do. They share a
vocabulary and almost nothing else: "3 years" on a posting is a minimum an
applicant must clear, and the same words here are what the person has. There is
deliberately no foreign key between them — supply and demand already meet
through `job_applications`, which carries the funnel, the interview and the
outcome, and a second weaker link would disagree with it the first time
somebody withdrew.

NO NEW COMPANY ENTITY, AND THAT IS THE LOAD-BEARING DECISION. "Create a company
position" reads like it needs a client record. `organizations` is the TENANT —
absent from the resource table, absent from the policy map, written only by the
seeder — so creating a row there from a chat flow would provision a new tenant,
and the position would carry an org_id the operator's session cannot see. The
operator could never view the record they just created. That breaks I5 and I1
to add a feature nobody asked for. The client stays free text on the posting,
per blueprint decision D2, and the flow simply offers the clients this
organization already staffs for as chips. No schema change, no endpoint change.

Create is operators-only, and that is an I1 decision rather than a deferral.
The worker is named explicitly on the row and is deliberately NOT derived from
the session, because an operator recording a role on somebody's behalf is the
whole point of the flow. Granting talent the same Create would let a talent
caller write a role under any worker_email in the tenant — the attribution hole
Phase 3D closed elsewhere. Talent reads its own via a ScopeEmail predicate,
which is in place now so the grant is one line when a talent console exists.

`created_by` is in gen_resources.py's SERVER_OWNED as well as the policy's
Derived list. Both are required and the pairing is easy to miss: Derived fills
the column from the session, SERVER_OWNED is what makes the descriptor ReadOnly
so a request body cannot set it in the first place. Without it,
TestDerivedColumnsAreReadOnlyOrTalentScoped fails — verified by mutation, not
by reading.

The two catalogue intents carry PHRASE terms only. A bare "position" or "role"
term scores 10, the same as every reading on that page, and wins the tie on
declaration order — so a create chip would have arrived by evicting
`positions-attention` from the exact ordered result TestPositionsSuggestions
asserts. An offer to create something must not displace the reading a person
actually asked for. Neither declares a Subject, on the precedent of
`position-spec-steps`: a Subject would let the bare query "summarize" match
through matchShape and survive filterOnTopic. Neither declares a Signal, so an
empty composer still reports what the organization needs rather than proposing
paperwork.

Chip text is the coupling with nothing else holding it together: no page
context declares `capabilities`, so every server suggestion dispatches as its
own TEXT and is answered by whichever skill's trigger that text matches. A
renamed chip would open nothing, silently. Asserted on the frontend side.

The down migration drops `employee_role_status` and keeps `english_level`,
which is shared with job_postings.english_required and
job_applications.english_level. Rolled back and re-applied against the
database to prove it, not asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-09-02 15:29:25 +05:30

305 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Verify a deployed Krow API, endpoint by endpoint.
KROW_EMAIL=you@example.com KROW_PASSWORD=... \
python3 scripts/verify-deploy.py https://mcp.krowforce.com
make verify-deploy BASE=https://mcp.krowforce.com
Credentials come from the environment, never from an argument, so they do not
land in shell history or in a process list.
READ-ONLY by default. The two write paths (hire, assignment) are exercised only
with --write, because they change tenant data and a smoke test that mutates the
thing it is checking is not a smoke test.
Why this exists: this API runs its auth middleware BEFORE routing, so an
unauthenticated probe answers 401 for every path — including paths that do not
exist. `curl` against a deployed host therefore cannot tell a missing endpoint
from a guarded one, and the only honest check is an authenticated one.
Exit code is non-zero if any check fails.
"""
import json, os, sys, time, urllib.request, urllib.parse, urllib.error, http.cookiejar
BASE = (sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-")
else os.environ.get("KROW_BASE_URL", "http://127.0.0.1:8080")).rstrip("/")
WRITE = "--write" in sys.argv
class BrowserLikePolicy(http.cookiejar.DefaultCookiePolicy):
"""Accept Secure cookies over http://localhost, as every browser does.
Browsers treat localhost as a potentially-trustworthy origin, so a Secure
cookie set through a dev-server proxy is stored and sent. Python's default
policy refuses it, which makes a perfectly working frontend look like a
broken session: login returns 200 and the very next request is 401.
Only localhost. Anywhere else, a Secure cookie over plaintext is refused as
it should be.
"""
@staticmethod
def _trustworthy(request):
host = urllib.parse.urlparse(request.get_full_url()).hostname or ""
return host in ("localhost", "127.0.0.1", "::1", "[::1]")
def set_ok_secure(self, cookie, request):
return self._trustworthy(request) or super().set_ok_secure(cookie, request)
def return_ok_secure(self, cookie, request):
return self._trustworthy(request) or super().return_ok_secure(cookie, request)
jar = http.cookiejar.CookieJar(policy=BrowserLikePolicy())
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
results = []
def call(method, path, body=None, accept="application/json", timeout=45):
"""Returns (status, text, headers). Never raises for an HTTP status."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Content-Type", "application/json")
req.add_header("Accept", accept)
try:
with opener.open(req, timeout=timeout) as r:
return r.status, r.read().decode("utf-8", "replace"), dict(r.headers)
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace"), dict(e.headers)
except Exception as e:
return 0, f"{type(e).__name__}: {e}", {}
def check(name, ok, detail=""):
results.append((name, bool(ok), detail))
print(f"[{' ok ' if ok else ' FAIL '}] {name}" + (f" — {detail}" if detail else ""))
return ok
def group(title):
print(f"\n── {title} " + "─" * max(0, 66 - len(title)))
def as_json(text):
try:
return json.loads(text)
except Exception:
return None
# resource -> the operations it declares (internal/domain/resources_gen.go).
# Anything not declared is deliberately unregistered: "the database having a
# table is never a reason for an endpoint to exist" (api.go). `badges` declares
# nothing at all, so every badges path is correctly a 404.
RESOURCE_OPS = {
"job-postings": ["List", "Get", "Create", "Update"],
"job-applications": ["List", "Create", "Update", "Delete"],
"ai-interviews": ["List", "Create"],
"staff": ["List", "Create", "Update"],
"worker-profiles": ["List", "Create", "Update"],
"employee-roles": ["List", "Get", "Create", "Update"],
"courses": ["List", "Get", "Create", "Update"],
"learning-paths": ["List"],
"role-categories": ["List", "Create"],
"certifications": ["List", "Create", "Delete"],
"user-activity": ["List", "Create"],
"evidence": ["List", "Create", "Update"],
"assignments": ["List", "Create"],
"shift-records": ["List"],
"badges": [],
}
RESOURCES = [r for r, ops in RESOURCE_OPS.items() if "List" in ops]
print(f"Verifying {BASE} ({'read/write' if WRITE else 'read-only'})")
# ── 1. Reachable, and guarded ────────────────────────────────────────────────
group("Reachable and guarded")
s, t, _ = call("GET", "/health")
health = as_json(t) or {}
check("/health answers 200", s == 200, f"{s} {health.get('status', t[:40])}")
check("/health reports a healthy database", health.get("status") == "ok",
f"status={health.get('status')} (degraded = schema unmigrated or dirty)")
s, _, _ = call("GET", "/api/v1/job-postings")
check("a protected endpoint refuses an anonymous caller", s in (401, 403), f"{s}")
# ── 2. Sign in ───────────────────────────────────────────────────────────────
group("Authentication")
email, password = os.environ.get("KROW_EMAIL"), os.environ.get("KROW_PASSWORD")
if not (email and password):
print("\nKROW_EMAIL / KROW_PASSWORD are not set — cannot check anything behind auth.")
print("Everything below needs a session. Set them and re-run.")
sys.exit(2)
s, t, _ = call("POST", "/api/v1/auth/login", {"email": email, "password": password})
if not check("sign-in succeeds", s == 200, str(s)):
print("\nNo session — stopping. Every remaining check needs one.")
sys.exit(1)
check("a session cookie was set", len(jar) > 0, f"{len(jar)} cookie(s)")
s, t, _ = call("GET", "/api/v1/me")
me = (as_json(t) or {}).get("data") or {}
check("GET /api/v1/me returns the signed-in user", s == 200 and bool(me), f"{s}")
check("...and it is the account that signed in",
str(me.get("email", "")).lower() == email.lower(), me.get("email", "?"))
role = me.get("role", "?")
print(f" signed in as {me.get('email','?')} (role: {role})")
s, _, _ = call("GET", "/api/v1/me/preferences")
check("GET /api/v1/me/preferences", s == 200, f"{s}")
# Which build is actually serving. Without this, "did my deploy land?" has no
# answer and a redeploy that silently rolled back looks identical to one that
# worked. Set KROW_EXPECT_VERSION to make a stale deployment a failure.
s, t, _ = call("GET", "/api/v1/version")
build = (as_json(t) or {}).get("data") or {}
running = build.get("version", "")
check("GET /api/v1/version reports the running build", s == 200 and bool(running),
f"{s}, version={running or 'none'}, env={build.get('env')}, "
f"endpoints={build.get('endpoints')}")
if running == "unknown":
check("...and the build was actually stamped", False,
"reports \"unknown\" — built without -X main.version, so it cannot be traced")
expected = os.environ.get("KROW_EXPECT_VERSION")
if expected:
check("...and it is the build you expected", running == expected,
f"running {running!r}, expected {expected!r}")
# ── 3. Every resource collection ─────────────────────────────────────────────
group(f"Resource endpoints ({len(RESOURCES)} collections)")
first_ids = {}
for r in RESOURCES:
s, t, _ = call("GET", f"/api/v1/{r}")
body = as_json(t) or {}
recs = body.get("data")
ok = s == 200 and isinstance(recs, list) and isinstance(body.get("meta"), dict)
total = (body.get("meta") or {}).get("total")
check(f"GET /api/v1/{r}", ok, f"{s}" + (f", {len(recs)} records, meta.total={total}" if ok else f" {t[:90]}"))
if ok and recs:
first_ids[r] = recs[0].get("id")
group("Reading one record by id (only where the resource declares Get)")
for r, rid in first_ids.items():
s, t, _ = call("GET", f"/api/v1/{r}/{rid}")
if "Get" in RESOURCE_OPS[r]:
check(f"GET /api/v1/{r}/{{id}}", s == 200, f"{s}")
else:
check(f"GET /api/v1/{r}/{{id}} is refused — it declares no Get",
s in (404, 405), f"{s}")
group("A resource that declares nothing exposes nothing")
for r, ops in RESOURCE_OPS.items():
if ops:
continue
s, _, _ = call("GET", f"/api/v1/{r}")
check(f"GET /api/v1/{r} → 404", s == 404, f"{s}")
group("An id that does not exist is 404, not 500")
s, _, _ = call("GET", "/api/v1/job-postings/00000000-0000-0000-0000-000000000000")
check("unknown id → 404", s == 404, f"{s}")
s, _, _ = call("GET", "/api/v1/job-postings/not-a-uuid")
check("malformed id → 4xx, never 5xx", 400 <= s < 500, f"{s}")
# ── 4. The agent layer ───────────────────────────────────────────────────────
group("Agent and skill registry")
s, t, _ = call("GET", "/api/v1/agent-definitions")
body = as_json(t) or {}
agents = body.get("data") if isinstance(body, dict) else body
agents = agents if isinstance(agents, list) else []
check("GET /api/v1/agent-definitions", s == 200 and isinstance(agents, list), f"{s}, {len(agents)} agents")
if agents:
print(" " + ", ".join(sorted(str(a.get("definition_id") or a.get("id")) for a in agents)))
# Two different keys, deliberately. The registry endpoint is a CRUD resource
# keyed by uuid (repo.GetAgent: WHERE id = $1::uuid); the run endpoint is
# addressed by the stable definition_id a spec author writes. Passing the
# definition_id to the registry endpoint is a 404, which is correct.
row_uuid = agents[0].get("id")
s, _, _ = call("GET", f"/api/v1/agent-definitions/{row_uuid}")
check("GET /api/v1/agent-definitions/{uuid}", s == 200, f"{s}")
s, _, _ = call("GET", f"/api/v1/agent-definitions/{agents[0].get('definition_id')}")
check("...and the definition_id is not a uuid, so it is refused there", s == 404, f"{s}")
s, t, _ = call("GET", "/api/v1/skill-definitions")
body = as_json(t) or {}
skills = body.get("data") if isinstance(body, dict) else body
skills = skills if isinstance(skills, list) else []
check("GET /api/v1/skill-definitions", s == 200, f"{s}, {len(skills)} skills")
s, t, _ = call("GET", "/api/v1/owliver/suggestions?page=control-center")
check("GET /api/v1/owliver/suggestions?page=...", s == 200, f"{s}")
s, _, _ = call("GET", "/api/v1/owliver/suggestions")
check("...and it requires a page rather than guessing one", s == 400, f"{s}")
s, _, _ = call("GET", "/api/v1/owliver/suggestions?page=not-a-real-page")
check("...and rejects a page that does not exist", s == 400, f"{s}")
# ── 5. An actual agent run ───────────────────────────────────────────────────
group("Running an agent (this calls the model — it costs tokens)")
run_id = None
if not agents:
check("an agent run completes", False, "no agents are published on this deployment")
else:
aid = agents[0].get("definition_id") or agents[0].get("id")
t0 = time.time()
s, t, _ = call("POST", f"/api/v1/agents/{aid}/runs",
{"input": "What can you help me with? Answer in one sentence."})
body = as_json(t) or {}
took = time.time() - t0
ok = s == 200 and body.get("termination") is not None
check(f"POST /api/v1/agents/{aid}/runs", ok,
f"{s}, termination={body.get('termination')}, {took:.1f}s" if ok else f"{s} {t[:160]}")
if ok:
run_id = body.get("runId") or body.get("run_id")
check("...the run terminated cleanly",
body.get("termination") in ("Completed", "ConfirmationPending"),
str(body.get("termination")))
check("...and it produced an answer",
bool(body.get("output") or body.get("message") or body.get("confirmations")),
(body.get("output") or body.get("message") or "")[:70] or "confirmation proposed")
usage = body.get("usage") or {}
check("...with token accounting attached",
(usage.get("inputTokens", 0) or 0) > 0,
f"in={usage.get('inputTokens')} out={usage.get('outputTokens')}")
if run_id:
s, t, _ = call("GET", f"/api/v1/runs/{run_id}")
rb = as_json(t) or {}
check("GET /api/v1/runs/{id} returns the trajectory", s == 200, f"{s}")
entries = rb.get("entries")
check("...with the trajectory persisted",
isinstance(entries, list) and len(entries) > 0,
f"{len(entries) if isinstance(entries, list) else 0} entries, "
f"termination={rb.get('termination')}, model={rb.get('model') or '?'}")
check("...and the run pins the agent version it started with",
isinstance(rb.get("agentVersion"), int) and rb["agentVersion"] > 0,
f"v{rb.get('agentVersion')}")
# Streaming is the path the chat panel actually uses.
s, t, h = call("POST", f"/api/v1/agents/{aid}/runs",
{"input": "Say hello in five words."}, accept="text/event-stream")
ctype = (h.get("Content-Type") or h.get("content-type") or "")
check("the same endpoint streams on Accept: text/event-stream",
s == 200 and "event-stream" in ctype, f"{s}, content-type={ctype or 'none'}")
check("...and the stream carries more than one event",
t.count("data:") > 1, f"{t.count('data:')} data frames")
# ── 6. Writes (only with --write) ────────────────────────────────────────────
group("Write paths")
if not WRITE:
print(" skipped — re-run with --write to exercise hire and assignment")
else:
s, t, _ = call("POST", "/api/v1/job-postings/x/assignments", {})
check("POST assignments rejects a bad request rather than 500", 400 <= s < 500, f"{s}")
s, t, _ = call("POST", "/api/v1/job-applications/x/hire", {})
check("POST hire rejects a bad request rather than 500", 400 <= s < 500, f"{s}")
# ── 7. Sign out ──────────────────────────────────────────────────────────────
group("Sign out")
s, _, _ = call("POST", "/api/v1/auth/logout")
check("POST /api/v1/auth/logout", s in (200, 204), f"{s}")
s, _, _ = call("GET", "/api/v1/me")
check("the session is dead afterwards", s in (401, 403), f"{s}")
# ── Summary ──────────────────────────────────────────────────────────────────
failed = [r for r in results if not r[1]]
print(f"\n{len(results) - len(failed)}/{len(results)} checks passed")
if failed:
print("\nFailed:")
for name, _, detail in failed:
print(f" - {name}{f' ({detail})' if detail else ''}")
sys.exit(1)