383 lines
13 KiB
Bash
Executable file
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"
|