agent build
This commit is contained in:
@@ -15,11 +15,19 @@
|
||||
*/
|
||||
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/* The frontend checkout. Overridable so this runs anywhere the two repos are
|
||||
checked out side by side, which is the layout it defaults to. */
|
||||
/* fileURLToPath, not .pathname: a URL percent-encodes, so a checkout under a
|
||||
directory with a space in it resolved to "/Users/.../Krow%20Project%20/..."
|
||||
— a path that does not exist. Vite then started with a root pointing nowhere
|
||||
and failed on the first import, which reads as a missing source file rather
|
||||
than a broken path. The effect was that this script could not run at all on
|
||||
such a checkout, and the conformance test went on passing against whatever
|
||||
oracle happened to be committed. */
|
||||
const FRONTEND = process.env.KROW_FRONTEND
|
||||
|| new URL('../../krow-demo', import.meta.url).pathname;
|
||||
|| fileURLToPath(new URL('../../krow-demo', import.meta.url));
|
||||
const { createServer } = await import(join(FRONTEND, 'node_modules/vite/dist/node/index.js'));
|
||||
const { CASES } = await import(new URL('./cases.mjs', import.meta.url).href);
|
||||
|
||||
@@ -70,6 +78,10 @@ const projectAgent = (a) => ({
|
||||
trigger: a.trigger,
|
||||
webSearch: a.webSearch,
|
||||
skills: a.skills,
|
||||
/* Capability, and the field the two parsers most need to agree on: the
|
||||
backend resolves an agent's tools from exactly this list, so a divergence
|
||||
here is an agent that can do something in one process and not the other. */
|
||||
tools: a.tools,
|
||||
subagents: a.subagents,
|
||||
starters: a.starters,
|
||||
permissions: a.permissions,
|
||||
|
||||
303
scripts/verify-deploy.py
Executable file
303
scripts/verify-deploy.py
Executable file
@@ -0,0 +1,303 @@
|
||||
#!/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"],
|
||||
"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)
|
||||
198
scripts/verify-deployment.sh
Executable file
198
scripts/verify-deployment.sh
Executable file
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Verify a Krow API deployment, from the outside.
|
||||
#
|
||||
# KROW_EMAIL=you@example.com KROW_PASSWORD=... ./scripts/verify-deployment.sh
|
||||
# KROW_BASE=https://mcp.krowforce.com ./scripts/verify-deployment.sh --write
|
||||
#
|
||||
# WHAT THIS IS FOR. The 404 that started this was invisible to every
|
||||
# unauthenticated probe: authentication wraps the whole mux, so a route that
|
||||
# does not exist and a route you are not signed in for answer identically. The
|
||||
# only way to tell them apart is to hold a session and ask. That is what this
|
||||
# does, and it is why it needs credentials.
|
||||
#
|
||||
# READ-ONLY BY DEFAULT. Everything below is a GET unless --write is passed.
|
||||
# With --write it creates ONE job posting with status "draft" — drafts are
|
||||
# invisible to talent and to the public listing, and the JobPosting resource
|
||||
# has no DELETE operation, so the row is permanent. That is the whole reason
|
||||
# the write test is opt-in rather than default: verifying a deployment should
|
||||
# not silently leave records in a production database.
|
||||
#
|
||||
# Exit status is the number of failed checks, so it can gate a rollout.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
BASE="${KROW_BASE:-https://mcp.krowforce.com}"
|
||||
API="$BASE/api/v1"
|
||||
JAR="$(mktemp -t krowjar.XXXXXX)"
|
||||
DO_WRITE=false
|
||||
[[ "${1:-}" == "--write" ]] && DO_WRITE=true
|
||||
|
||||
trap 'rm -f "$JAR"' EXIT
|
||||
|
||||
pass=0; fail=0; skip=0
|
||||
ok() { printf ' \033[32mok\033[0m %s\n' "$1"; pass=$((pass+1)); }
|
||||
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; [[ -n "${2:-}" ]] && printf ' %s\n' "$2"; fail=$((fail+1)); }
|
||||
note() { printf ' \033[33mskip\033[0m %s\n' "$1"; skip=$((skip+1)); }
|
||||
head_() { printf '\n\033[1m%s\033[0m\n' "$1"; }
|
||||
|
||||
# status <method> <path> [body] — prints the HTTP status, keeps the body in $BODY
|
||||
BODY=""
|
||||
status() {
|
||||
local method="$1" path="$2" body="${3:-}" code
|
||||
if [[ -n "$body" ]]; then
|
||||
code=$(curl -sS -m 20 -o /tmp/krowbody.$$ -w '%{http_code}' -X "$method" "$API$path" \
|
||||
-b "$JAR" -c "$JAR" -H 'Content-Type: application/json' -H 'Accept: application/json' -d "$body")
|
||||
else
|
||||
code=$(curl -sS -m 20 -o /tmp/krowbody.$$ -w '%{http_code}' -X "$method" "$API$path" \
|
||||
-b "$JAR" -c "$JAR" -H 'Accept: application/json')
|
||||
fi
|
||||
BODY=$(cat /tmp/krowbody.$$ 2>/dev/null); rm -f /tmp/krowbody.$$
|
||||
printf '%s' "$code"
|
||||
}
|
||||
|
||||
printf '\033[1mKrow deployment verification\033[0m\n'
|
||||
printf 'target %s\n' "$BASE"
|
||||
printf 'mode %s\n' "$([[ $DO_WRITE == true ]] && echo 'read + one draft write' || echo 'read-only')"
|
||||
|
||||
# ── 1. Reachability, before any credential ─────────────────────────────────
|
||||
head_ '1 · Reachability'
|
||||
|
||||
health=$(curl -sS -m 20 -o /tmp/h.$$ -w '%{http_code}' "$BASE/health"); hbody=$(cat /tmp/h.$$); rm -f /tmp/h.$$
|
||||
if [[ "$health" == "200" ]]; then ok "/health → 200 $(printf '%s' "$hbody" | tr -d ' \n')"
|
||||
else bad "/health → $health (want 200)" "$hbody"; fi
|
||||
|
||||
# The status word matters: "degraded" means the process is fine and the schema
|
||||
# is not — an unmigrated or dirty database. Deploying the binary before the
|
||||
# migration is exactly how that happens.
|
||||
if printf '%s' "$hbody" | grep -q 'degraded'; then
|
||||
bad "schema is not current — run migrations before rolling out the binary"
|
||||
fi
|
||||
|
||||
# ── 2. Session ─────────────────────────────────────────────────────────────
|
||||
head_ '2 · Authentication'
|
||||
|
||||
if [[ -z "${KROW_EMAIL:-}" || -z "${KROW_PASSWORD:-}" ]]; then
|
||||
printf ' \033[31mKROW_EMAIL / KROW_PASSWORD are not set.\033[0m\n'
|
||||
printf ' Every check below needs a session: an unauthenticated request to a\n'
|
||||
printf ' route that exists and one to a route that does not are both 401, so\n'
|
||||
printf ' without credentials this script cannot tell you what is deployed.\n\n'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
login=$(status POST /auth/login "$(printf '{"email":%s,"password":%s,"remember_me":false}' \
|
||||
"$(printf '%s' "$KROW_EMAIL" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
|
||||
"$(printf '%s' "$KROW_PASSWORD" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")")
|
||||
|
||||
if [[ "$login" == "200" ]]; then ok "POST /auth/login → 200"
|
||||
else bad "POST /auth/login → $login" "$BODY"; printf '\nCannot continue without a session.\n'; exit 1; fi
|
||||
|
||||
# The cookie decides whether a browser will ever send this session again.
|
||||
cookie_line=$(grep -i 'krow_session' "$JAR" | head -1)
|
||||
if [[ -n "$cookie_line" ]]; then ok "session cookie issued"; else bad "no krow_session cookie in the response"; fi
|
||||
|
||||
me=$(status GET /me)
|
||||
if [[ "$me" == "200" ]]; then
|
||||
role=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"].get("role","?"))' 2>/dev/null)
|
||||
ok "GET /me → 200, role=$role"
|
||||
# Authorization is decided by `role`, never by `account_type`. A talent role
|
||||
# is refused every operator write, which presents as an app that "does not
|
||||
# work" rather than as a permission problem.
|
||||
if [[ "$role" == "talent" ]]; then
|
||||
bad "this account's role is 'talent'" \
|
||||
"operator writes will 403 and the positions list will show only active postings"
|
||||
fi
|
||||
else bad "GET /me → $me" "$BODY"; fi
|
||||
|
||||
# ── 3. Which version is deployed ───────────────────────────────────────────
|
||||
head_ '3 · Deployed version'
|
||||
|
||||
# The Owliver suggestions route is the discriminator: it exists only from
|
||||
# commit b6f8655 onward. Authenticated, so 404 means "not in this binary"
|
||||
# rather than "not signed in".
|
||||
sug=$(status GET '/owliver/suggestions?page=positions&query=pipeline')
|
||||
case "$sug" in
|
||||
200) ok "GET /owliver/suggestions → 200 — b6f8655 or later is deployed" ;;
|
||||
404) bad "GET /owliver/suggestions → 404 — the deployed binary predates b6f8655" \
|
||||
"this is the deployment lag; the route exists in go-api/internal/httpserver/owliver.go" ;;
|
||||
*) bad "GET /owliver/suggestions → $sug (want 200)" "$BODY" ;;
|
||||
esac
|
||||
|
||||
# The resource is job-postings. /api/v1/positions has never existed in this
|
||||
# API, and asserting that here stops anyone "fixing" a 404 by adding it.
|
||||
pos=$(status GET /positions)
|
||||
if [[ "$pos" == "404" ]]; then ok "GET /positions → 404 — correct, the resource is job-postings"
|
||||
else bad "GET /positions → $pos (want 404)" "a duplicate positions route may have been added"; fi
|
||||
|
||||
# ── 4. The job-posting resource ────────────────────────────────────────────
|
||||
head_ '4 · Job postings (the position resource)'
|
||||
|
||||
list=$(status GET '/job-postings?limit=5')
|
||||
if [[ "$list" == "200" ]]; then
|
||||
count=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"]))' 2>/dev/null)
|
||||
ok "GET /job-postings → 200, $count row(s)"
|
||||
else bad "GET /job-postings → $list" "$BODY"; fi
|
||||
|
||||
if [[ "$DO_WRITE" == true ]]; then
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
# status draft: not published, not visible to talent. The least invasive
|
||||
# record that still proves the whole write path reaches PostgreSQL.
|
||||
payload=$(printf '{"title":"Deployment verification %s","company":"Verification","role_category":"Server","location":"n/a","status":"draft","headcount":1,"pay_range_min":0,"pay_range_max":0,"min_experience_years":0,"english_required":"basic","priority":"normal"}' "$stamp")
|
||||
created=$(status POST /job-postings "$payload")
|
||||
if [[ "$created" == "201" ]]; then
|
||||
id=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["id"])' 2>/dev/null)
|
||||
ok "POST /job-postings → 201, id=$id"
|
||||
back=$(status GET "/job-postings/$id")
|
||||
if [[ "$back" == "200" ]]; then ok "GET /job-postings/$id → 200 — persisted in PostgreSQL"
|
||||
else bad "created row not readable back → $back"; fi
|
||||
printf ' note: draft row %s is permanent (JobPosting has no DELETE)\n' "$id"
|
||||
elif [[ "$created" == "403" ]]; then
|
||||
bad "POST /job-postings → 403" "this account's role is not an operator (admin or employer)"
|
||||
else
|
||||
bad "POST /job-postings → $created" "$BODY"
|
||||
fi
|
||||
else
|
||||
note "write test skipped (pass --write to create one draft posting)"
|
||||
fi
|
||||
|
||||
# ── 5. Owliver, in both modes ──────────────────────────────────────────────
|
||||
head_ '5 · Owliver suggestions'
|
||||
|
||||
if [[ "$sug" == "200" ]]; then
|
||||
n=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"]["suggestions"]))' 2>/dev/null)
|
||||
if [[ "${n:-x}" =~ ^[0-9]+$ ]] && (( n <= 3 )); then ok "typed query returned $n suggestion(s), cap is 3"
|
||||
else bad "typed query returned $n suggestions" "the contract caps this at 3"; fi
|
||||
|
||||
# No query: this is the path that reads the organization's state out of
|
||||
# PostgreSQL, so it is the one that proves context building works.
|
||||
untyped=$(status GET '/owliver/suggestions?page=positions')
|
||||
if [[ "$untyped" == "200" ]]; then
|
||||
m=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"]["suggestions"]))' 2>/dev/null)
|
||||
ok "untyped query → 200, $m suggestion(s) from live data"
|
||||
else bad "untyped query → $untyped" "$BODY"; fi
|
||||
|
||||
bad_page=$(status GET '/owliver/suggestions?page=not-a-real-surface')
|
||||
if [[ "$bad_page" == "400" ]]; then ok "unknown page → 400 invalid_query"
|
||||
else bad "unknown page → $bad_page (want 400)"; fi
|
||||
else
|
||||
note "suggestion detail checks skipped — the route is not deployed"
|
||||
fi
|
||||
|
||||
# ── 6. Cookie posture ──────────────────────────────────────────────────────
|
||||
head_ '6 · Session cookie posture'
|
||||
|
||||
logout_hdrs=$(curl -sS -m 20 -D - -o /dev/null -X POST "$API/auth/logout" -b "$JAR")
|
||||
setc=$(printf '%s' "$logout_hdrs" | grep -i '^set-cookie:' | head -1)
|
||||
printf ' %s\n' "${setc:-(no Set-Cookie)}"
|
||||
if printf '%s' "$setc" | grep -qi 'SameSite=None'; then
|
||||
printf ' \033[33mSameSite=None\033[0m — the cookie travels cross-site. That is required only\n'
|
||||
printf ' for a frontend on a DIFFERENT registrable domain. platform.krowforce.com\n'
|
||||
printf ' and mcp.krowforce.com are the same site, so Lax would suffice for them —\n'
|
||||
printf ' and SameSite is the only CSRF protection this API has.\n'
|
||||
elif printf '%s' "$setc" | grep -qi 'SameSite=Lax'; then
|
||||
ok "SameSite=Lax — CSRF protection retained"
|
||||
fi
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────────────────
|
||||
printf '\n\033[1m%d passed, %d failed, %d skipped\033[0m\n' "$pass" "$fail" "$skip"
|
||||
exit "$fail"
|
||||
Reference in New Issue
Block a user