amduat-api/scripts/test_graph_contract.sh

383 lines
13 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMPDIR="${TMPDIR:-/tmp}"
mkdir -p "${TMPDIR}"
if ! command -v grep >/dev/null 2>&1; then
echo "skip: grep not found" >&2
exit 77
fi
if ! command -v awk >/dev/null 2>&1; then
echo "skip: awk not found" >&2
exit 77
fi
# shellcheck source=/dev/null
source "${ROOT_DIR}/scripts/graph_client_helpers.sh"
graph_helpers_init "${ROOT_DIR}"
AMDUATD_BIN="${ROOT_DIR}/build/amduatd"
ASL_BIN="${ROOT_DIR}/build/vendor/amduat/amduat-asl"
if [[ ! -x "${ASL_BIN}" ]]; then
ASL_BIN="${ROOT_DIR}/vendor/amduat/build/amduat-asl"
fi
if [[ ! -x "${AMDUATD_BIN}" || ! -x "${ASL_BIN}" ]]; then
echo "missing binaries; build amduatd and amduat-asl first" >&2
exit 1
fi
tmp_root="$(mktemp -d -p "${TMPDIR}" amduatd-graph-contract-XXXXXX)"
root="${tmp_root}/root"
sock="${tmp_root}/amduatd.sock"
space_id="graphcontract"
log_file="${tmp_root}/amduatd.log"
cleanup() {
if [[ -n "${pid:-}" ]]; then
kill "${pid}" >/dev/null 2>&1 || true
wait "${pid}" >/dev/null 2>&1 || true
fi
rm -rf "${tmp_root}"
}
trap cleanup EXIT
extract_json_string() {
local key="$1"
awk -v k="\"${key}\":\"" '
{
pos = index($0, k);
if (pos > 0) {
rest = substr($0, pos + length(k));
end = index(rest, "\"");
if (end > 0) {
print substr(rest, 1, end - 1);
exit 0;
}
}
}
'
}
cursor_to_num() {
local token="$1"
if [[ -z "${token}" ]]; then
echo 0
return 0
fi
if [[ "${token}" == g1_* ]]; then
echo "${token#g1_}"
return 0
fi
echo "${token}"
}
cursor_plus_one() {
local token="$1"
local n
if [[ -z "${token}" ]]; then
return 1
fi
if [[ "${token}" == g1_* ]]; then
n="${token#g1_}"
printf 'g1_%s' "$((n + 1))"
return 0
fi
printf '%s' "$((token + 1))"
}
create_artifact() {
local payload="$1"
local resp
local ref
resp="$({
graph_http_post "${sock}" "/v1/artifacts" "${payload}" \
--header "Content-Type: application/octet-stream" \
--header "X-Amduat-Space: ${space_id}"
})"
ref="$(printf '%s\n' "${resp}" | extract_json_string "ref")"
if [[ -z "${ref}" ]]; then
echo "failed to parse artifact ref: ${resp}" >&2
exit 1
fi
printf '%s' "${ref}"
}
create_node() {
local name="$1"
local ref="$2"
graph_http_post "${sock}" "/v2/graph/nodes" "{\"name\":\"${name}\",\"ref\":\"${ref}\"}" \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}" >/dev/null
}
create_edge() {
local s="$1"
local p="$2"
local o="$3"
graph_http_post "${sock}" "/v2/graph/edges" "{\"subject\":\"${s}\",\"predicate\":\"${p}\",\"object\":\"${o}\"}" \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}" >/dev/null
}
mkdir -p "${root}"
"${ASL_BIN}" init --root "${root}" >/dev/null
"${AMDUATD_BIN}" --root "${root}" --sock "${sock}" --store-backend index --space "${space_id}" \
>"${log_file}" 2>&1 &
pid=$!
ready_rc=0
graph_wait_for_ready "${sock}" "${pid}" "${log_file}" || ready_rc=$?
if [[ ${ready_rc} -eq 77 ]]; then
exit 77
fi
if [[ ${ready_rc} -ne 0 ]]; then
echo "daemon not ready" >&2
exit 1
fi
ref_a="$(create_artifact "contract-a")"
ref_b="$(create_artifact "contract-b")"
ref_c="$(create_artifact "contract-c")"
ref_v1="$(create_artifact "contract-v1")"
ref_v2="$(create_artifact "contract-v2")"
create_node "gc-a" "${ref_a}"
create_node "gc-b" "${ref_b}"
create_node "gc-c" "${ref_c}"
create_node "gc-v" "${ref_v1}"
create_edge "gc-a" "ms.computed_by" "gc-b"
create_edge "gc-b" "ms.computed_by" "gc-c"
graph_http_post "${sock}" "/v2/graph/nodes/gc-v/versions" "{\"ref\":\"${ref_v2}\"}" \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}" >/dev/null
versions_cutoff_raw="$(
graph_http_get "${sock}" "/v2/graph/changes?event_types[]=version_published&limit=100" \
--header "X-Amduat-Space: ${space_id}"
)"
versions_cutoff="$(printf '%s\n' "${versions_cutoff_raw}" | extract_json_string "next_cursor")"
if [[ -z "${versions_cutoff}" ]]; then
echo "missing version cutoff cursor: ${versions_cutoff_raw}" >&2
exit 1
fi
versions_cutoff="$(cursor_plus_one "${versions_cutoff}")"
graph_http_post "${sock}" "/v2/graph/nodes/gc-v/versions/tombstone" "{\"ref\":\"${ref_v2}\"}" \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}" >/dev/null
node_default="$(
graph_http_get "${sock}" "/v2/graph/nodes/gc-v" \
--header "X-Amduat-Space: ${space_id}"
)"
node_default_latest="$(printf '%s\n' "${node_default}" | extract_json_string "latest_ref")"
if [[ "${node_default_latest}" != "${ref_v1}" ]]; then
echo "node default latest_ref mismatch (want ${ref_v1}): ${node_default}" >&2
exit 1
fi
if echo "${node_default}" | grep -q "\"ref\":\"${ref_v2}\""; then
echo "node default should hide tombstoned ${ref_v2}: ${node_default}" >&2
exit 1
fi
node_all="$(
graph_http_get "${sock}" "/v2/graph/nodes/gc-v?include_tombstoned=true" \
--header "X-Amduat-Space: ${space_id}"
)"
node_all_latest="$(printf '%s\n' "${node_all}" | extract_json_string "latest_ref")"
if [[ "${node_all_latest}" != "${ref_v2}" ]]; then
echo "node include_tombstoned latest_ref mismatch (want ${ref_v2}): ${node_all}" >&2
exit 1
fi
echo "${node_all}" | grep -q "\"ref\":\"${ref_v2}\"" || {
echo "node include_tombstoned should include ${ref_v2}: ${node_all}" >&2
exit 1
}
node_asof="$(
graph_http_get "${sock}" "/v2/graph/nodes/gc-v?as_of=${versions_cutoff}" \
--header "X-Amduat-Space: ${space_id}"
)"
node_asof_latest="$(printf '%s\n' "${node_asof}" | extract_json_string "latest_ref")"
if [[ "${node_asof_latest}" != "${ref_v2}" ]]; then
echo "node as_of before tombstone latest_ref mismatch (want ${ref_v2}): ${node_asof}" >&2
exit 1
fi
echo "${node_asof}" | grep -q "\"ref\":\"${ref_v2}\"" || {
echo "node as_of before tombstone should include ${ref_v2}: ${node_asof}" >&2
exit 1
}
history_default="$(
graph_http_get "${sock}" "/v2/graph/history/gc-v" \
--header "X-Amduat-Space: ${space_id}"
)"
history_default_latest="$(printf '%s\n' "${history_default}" | extract_json_string "latest_ref")"
if [[ "${history_default_latest}" != "${ref_v1}" ]]; then
echo "history default latest_ref mismatch (want ${ref_v1}): ${history_default}" >&2
exit 1
fi
if echo "${history_default}" | grep -q "\"ref\":\"${ref_v2}\""; then
echo "history default should hide tombstoned ${ref_v2}: ${history_default}" >&2
exit 1
fi
history_all="$(
graph_http_get "${sock}" "/v2/graph/history/gc-v?include_tombstoned=true" \
--header "X-Amduat-Space: ${space_id}"
)"
history_all_latest="$(printf '%s\n' "${history_all}" | extract_json_string "latest_ref")"
if [[ "${history_all_latest}" != "${ref_v2}" ]]; then
echo "history include_tombstoned latest_ref mismatch (want ${ref_v2}): ${history_all}" >&2
exit 1
fi
echo "${history_all}" | grep -q "\"ref\":\"${ref_v2}\"" || {
echo "history include_tombstoned should include ${ref_v2}: ${history_all}" >&2
exit 1
}
# schema strict: block predicate not in allowed list.
strict_policy='{"mode":"strict","predicates":[{"predicate":"ms.computed_by"}]}'
graph_http_post "${sock}" "/v2/graph/schema/predicates" "${strict_policy}" \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}" >/dev/null
strict_reject="$({
graph_http_post_allow "${sock}" "/v2/graph/edges" '{"subject":"gc-a","predicate":"ms.within_domain","object":"gc-c"}' \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}"
})"
echo "${strict_reject}" | grep -q 'predicate rejected by schema policy' || {
echo "strict mode should reject disallowed predicate: ${strict_reject}" >&2
exit 1
}
# schema warn: same write should pass.
warn_policy='{"mode":"warn","predicates":[{"predicate":"ms.computed_by"}]}'
graph_http_post "${sock}" "/v2/graph/schema/predicates" "${warn_policy}" \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}" >/dev/null
warn_accept="$({
graph_http_post "${sock}" "/v2/graph/edges" '{"subject":"gc-a","predicate":"ms.within_domain","object":"gc-c"}' \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}"
})"
echo "${warn_accept}" | grep -q '"edge_ref":"' || {
echo "warn mode should allow disallowed predicate: ${warn_accept}" >&2
exit 1
}
# provenance required: writes without metadata/provenance must fail with 422.
required_policy='{"mode":"warn","provenance_mode":"required","predicates":[{"predicate":"ms.computed_by"}]}'
graph_http_post "${sock}" "/v2/graph/schema/predicates" "${required_policy}" \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}" >/dev/null
schema_required="$(
graph_http_get "${sock}" "/v2/graph/schema/predicates" \
--header "X-Amduat-Space: ${space_id}"
)"
echo "${schema_required}" | grep -q '"provenance_mode":"required"' || {
echo "schema provenance_mode did not persist: ${schema_required}" >&2
exit 1
}
required_reject="$({
graph_http_post_allow "${sock}" "/v2/graph/edges" '{"subject":"gc-a","predicate":"ms.computed_by","object":"gc-b"}' \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}"
})"
echo "${required_reject}" | grep -q 'provenance required by schema policy' || {
echo "required provenance should reject missing attachment: ${required_reject}" >&2
exit 1
}
required_version_reject="$({
graph_http_post_allow "${sock}" "/v2/graph/nodes/gc-a/versions" "{\"ref\":\"${ref_v1}\"}" \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}"
})"
echo "${required_version_reject}" | grep -q 'provenance required by schema policy' || {
echo "required provenance should reject version write without attachment: ${required_version_reject}" >&2
exit 1
}
required_accept="$({
graph_http_post "${sock}" "/v2/graph/edges" '{"subject":"gc-a","predicate":"ms.computed_by","object":"gc-b","provenance":{"source_uri":"urn:test","extractor":"contract-test","observed_at":1,"ingested_at":2,"trace_id":"trace-required-1"}}' \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}"
})"
echo "${required_accept}" | grep -q '"edge_ref":"' || {
echo "required provenance should allow explicit provenance payload: ${required_accept}" >&2
exit 1
}
# reset to optional so remaining tests can use minimal payloads.
optional_policy='{"mode":"warn","provenance_mode":"optional","predicates":[{"predicate":"ms.computed_by"}]}'
graph_http_post "${sock}" "/v2/graph/schema/predicates" "${optional_policy}" \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}" >/dev/null
# batch idempotency replay must be deterministic.
idem_payload='{"idempotency_key":"gc-idem-1","mode":"continue_on_error","edges":[{"subject":"gc-a","predicate":"ms.computed_by","object":"gc-c"},{"subject":"gc-missing","predicate":"ms.computed_by","object":"gc-c"}]}'
idem_first="$(graph_batch_ingest "${sock}" "${space_id}" "${idem_payload}")"
idem_second="$(graph_batch_ingest "${sock}" "${space_id}" "${idem_payload}")"
if [[ "${idem_first}" != "${idem_second}" ]]; then
echo "idempotent replay mismatch" >&2
echo "first=${idem_first}" >&2
echo "second=${idem_second}" >&2
exit 1
fi
# payload mismatch on same idempotency key must fail.
idem_conflict="$({
graph_http_post_allow "${sock}" "/v2/graph/batch" '{"idempotency_key":"gc-idem-1","mode":"continue_on_error","edges":[{"subject":"gc-a","predicate":"ms.computed_by","object":"gc-b"}]}' \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space_id}"
})"
echo "${idem_conflict}" | grep -q 'idempotency_key reuse with different payload' || {
echo "idempotency conflict missing expected error: ${idem_conflict}" >&2
exit 1
}
# changes sync helper: cursor monotonic + resumable loop.
changes_1="$(graph_changes_sync_once "${sock}" "${space_id}" "" 2)"
cursor_1="$(printf '%s\n' "${changes_1}" | extract_json_string "next_cursor")"
if [[ -z "${cursor_1}" ]]; then
echo "changes first page missing next_cursor: ${changes_1}" >&2
exit 1
fi
changes_2="$(graph_changes_sync_once "${sock}" "${space_id}" "${cursor_1}" 2)"
cursor_2="$(printf '%s\n' "${changes_2}" | extract_json_string "next_cursor")"
if [[ -z "${cursor_2}" ]]; then
echo "changes second page missing next_cursor: ${changes_2}" >&2
exit 1
fi
num_1="$(cursor_to_num "${cursor_1}")"
num_2="$(cursor_to_num "${cursor_2}")"
if [[ "${num_2}" -lt "${num_1}" ]]; then
echo "changes cursor regressed: ${cursor_1} -> ${cursor_2}" >&2
exit 1
fi
# subgraph helper should return connected nodes.
subgraph_resp="$(graph_subgraph_fetch "${sock}" "${space_id}" "gc-a" 2 "ms.computed_by")"
echo "${subgraph_resp}" | grep -q '"name":"gc-a"' || {
echo "subgraph missing gc-a: ${subgraph_resp}" >&2
exit 1
}
echo "${subgraph_resp}" | grep -q '"name":"gc-b"' || {
echo "subgraph missing gc-b: ${subgraph_resp}" >&2
exit 1
}
echo "ok: v2 graph contract tests passed"