Files
cozystack/hack/check-rabbitmq-rd-secrets.bats
T
Myasnikov Daniil cc942fed68 test(tests): run unit suites with Bats and retain POSIX coverage
The shell translator rewrites test bodies and loses Bats reporting.
The unit lane needs complete TAP results and JUnit reports without
changing how live-cluster suites run.

Sourced production helpers still need a dash pass, and unit test bodies
need nounset without exporting shell options into subprocesses.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-10-07 11:04:00 +03:00

190 lines
9.4 KiB
Bash

#!/usr/bin/env bats
# Unit test: the rabbitmq trust anchor reaches tenants as a key-free projection,
# and the key-BEARING Secrets around it never do.
#
# The chart issues rabbitmq-<name>-ca (CA certificate AND CA private key) and
# rabbitmq-<name>-tls (server certificate AND server private key); the operator
# issues rabbitmq-<name>-erlang-cookie, the cluster's shared secret. None of the
# three may be handed to a tenant: read access to the CA key would let the holder
# issue certificates for anything rather than merely verify the server.
#
# What a client needs is ca.crt alone. cluster-operator produces no CA object of
# its own (spec.tls.caSecretName is an input it only consumes), so the chart mints
# the CA through cert-manager and names that Secret in a TenantProjection
# sentinel. The label selected on below is stamped by the CA-extraction
# controller on the projection it writes, never on the CA Secret.
#
# These assertions exist because both halves are one edit away from breaking with
# a fully green chart suite. The ResourceDefinition is rendered by the rabbitmq-rd
# chart inlining cozyrds/*, and that chart carries no tests directory; the
# dashboard Role is rendered by a chart that has one, but no suite there asserts
# on the whole Role.
#
# The grants are asserted as WHITELISTS against whole values rather than as a
# blacklist of forbidden names over source text. A blacklist grepping for
# "rabbitmq-{{ .name }}-ca" is defeated by quoting the scalar, by a different
# templating of the name, or by a label selector that happens to match it -- all
# of which grant a private key while the guard stays green.
load test_helper
REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)"
COZYRDS="$REPO_ROOT/packages/system/rabbitmq-rd/cozyrds/rabbitmq.yaml"
CHART="$REPO_ROOT/packages/apps/rabbitmq"
# The complete set of grants the tenant holds, asserted as one value rather than
# per-dimension: a name whitelist alone passes an added label selector, and a
# label whitelist alone passes an added name. Either dimension reaches a
# key-bearing Secret -- controller.cert-manager.io/fao is stamped on both the CA
# and the leaf, so a selector on it is a one-line grant of the CA private key.
EXPECTED_RD_INCLUDE='[{"resourceNames":["rabbitmq-{{ .name }}-default-user"]},{"matchLabels":{"internal.cozystack.io/tenant-ca":"true"}},{"matchLabels":{"apps.cozystack.io/user-secret":"true"}}]'
# exclude is evaluated before include and wins, so it is the structural backstop
# against a mis-stamped label on a key-bearing object. Asserted whole for the same
# reason as the include: dropping one entry is the edit that has to be caught.
EXPECTED_RD_EXCLUDE='[{"resourceNames":["rabbitmq-{{ .name }}-ca","rabbitmq-{{ .name }}-tls","rabbitmq-{{ .name }}-erlang-cookie"]}]'
# The dashboard template's entire RBAC output: every rule of the Role, and the
# kinds it renders. Asserted whole rather than filtered down to the rules that
# mention secrets, because every filter admits the spelling it did not anticipate:
# a rule with no resourceNames names no Secret, a rule with resources ["*"] names
# no resource, and a ClusterRole is not a Role. Each of those reaches both
# key-bearing Secrets, and one comparison against the whole output needs no filter.
EXPECTED_RBAC_KINDS='["Role","RoleBinding"]'
EXPECTED_ROLE_RULES='[{"apiGroups":[""],"resources":["secrets"],"resourceNames":["rabbitmq-test-default-user"],"verbs":["get","list","watch"]},{"apiGroups":[""],"resources":["services"],"resourceNames":["rabbitmq-test"],"verbs":["get","list","watch"]},{"apiGroups":["cozystack.io"],"resources":["workloadmonitors"],"resourceNames":["rabbitmq-test"],"verbs":["get","list","watch"]}]'
# The secrets rule is the one place in this Role that grows from values, so the
# whole-value assertion above only covers it while users is empty. This pins what
# a declared user is allowed to add: its own credentials Secret and nothing else.
EXPECTED_ROLE_RULES_WITH_USER='[{"apiGroups":[""],"resources":["secrets"],"resourceNames":["rabbitmq-test-default-user","rabbitmq-test-alice-credentials"],"verbs":["get","list","watch"]},{"apiGroups":[""],"resources":["services"],"resourceNames":["rabbitmq-test"],"verbs":["get","list","watch"]},{"apiGroups":["cozystack.io"],"resources":["workloadmonitors"],"resourceNames":["rabbitmq-test"],"verbs":["get","list","watch"]}]'
render_rbac() {
# stderr is deliberately not discarded: when the render breaks, helm's own
# message is the only thing that says why, and a CI red showing just
# "helm template failed" sends the reader back here to rerun it by hand.
helm template rabbitmq-test "$CHART" \
--namespace tenant-test \
--set '_cluster.cluster-domain=cozy.local' \
"$@" \
--show-only templates/dashboard-resourcemap.yaml
}
# Subsumed by the whitelist below, which cannot match without this entry. Kept
# because the runner stops at the first failing test, so this one fires first and
# says what the loss costs rather than printing two JSON blobs to diff.
@test "rabbitmq-rd cozyrds selects the key-free tenant-CA projection by label" {
if [ ! -f "$COZYRDS" ]; then
echo "ResourceDefinition not found at $COZYRDS -- did it move?" >&2
exit 1
fi
count=$(yq eval \
'[.spec.secrets.include[] | select(.matchLabels."internal.cozystack.io/tenant-ca" == "true")] | length' \
"$COZYRDS") || exit 1
if [ "$count" -lt 1 ]; then
echo 'rabbitmq-rd does not select internal.cozystack.io/tenant-ca: "true"' >&2
echo "Without it the tenant cannot read ca.crt and TLS verification is impossible." >&2
exit 1
fi
}
@test "rabbitmq-rd cozyrds grants nothing beyond the default user, the trust anchor and user credentials" {
if [ ! -f "$COZYRDS" ]; then
echo "ResourceDefinition not found at $COZYRDS -- did it move?" >&2
exit 1
fi
include=$(yq eval --output-format=json --indent=0 '.spec.secrets.include' "$COZYRDS") || exit 1
if [ "$include" != "$EXPECTED_RD_INCLUDE" ]; then
echo "Unexpected rabbitmq-rd spec.secrets.include: $include" >&2
echo "Expected exactly: $EXPECTED_RD_INCLUDE" >&2
echo "rabbitmq-<name>-ca holds the CA private key and rabbitmq-<name>-tls the" >&2
echo "server key; neither may be granted to a tenant, by name or by label." >&2
exit 1
fi
}
@test "rabbitmq-rd cozyrds keeps the key-bearing Secrets excluded" {
if [ ! -f "$COZYRDS" ]; then
echo "ResourceDefinition not found at $COZYRDS -- did it move?" >&2
exit 1
fi
exclude=$(yq eval --output-format=json --indent=0 '.spec.secrets.exclude' "$COZYRDS") || exit 1
if [ "$exclude" != "$EXPECTED_RD_EXCLUDE" ]; then
echo "Unexpected rabbitmq-rd spec.secrets.exclude: $exclude" >&2
echo "Expected exactly: $EXPECTED_RD_EXCLUDE" >&2
echo "exclude wins over include, so it is what stops a mis-stamped tenant-ca" >&2
echo "label on the CA, the leaf or the erlang cookie from reaching a tenant." >&2
exit 1
fi
}
# Asserted against RENDERED output, so the spelling used in the template --
# quoted, helper-derived, or otherwise -- cannot smuggle a name past the check.
@test "rabbitmq dashboard Role renders exactly the grants it is supposed to" {
if [ ! -d "$CHART" ]; then
echo "Chart not found at $CHART -- did it move?" >&2
exit 1
fi
rendered=$(render_rbac) || {
echo "helm template failed for $CHART" >&2
exit 1
}
# eval-all aggregates the documents into one result, so the Role may sit at any
# position among them.
kinds=$(printf '%s\n' "$rendered" | yq eval-all --output-format=json --indent=0 \
'[.kind]' -)
if [ "$kinds" != "$EXPECTED_RBAC_KINDS" ]; then
echo "Unexpected RBAC kinds rendered by the rabbitmq dashboard template: $kinds" >&2
echo "Expected exactly: $EXPECTED_RBAC_KINDS" >&2
echo "A ClusterRole here would grant across namespaces and is not covered by" >&2
echo "the rule comparison below, which reads the namespaced Role." >&2
exit 1
fi
# Fails closed on an empty list: a Role that stopped granting anything, or a
# render that silently produced nothing, does not match the expected rules.
rules=$(printf '%s\n' "$rendered" | yq eval-all --output-format=json --indent=0 \
'[select(.kind == "Role") | .rules[]]' -)
if [ "$rules" != "$EXPECTED_ROLE_RULES" ]; then
echo "Unexpected rules in the rabbitmq dashboard Role: $rules" >&2
echo "Expected exactly: $EXPECTED_ROLE_RULES" >&2
echo "rabbitmq-<release>-ca holds the CA PRIVATE KEY, rabbitmq-<release>-tls the" >&2
echo "server private key, and rabbitmq-<release>-erlang-cookie the cluster secret." >&2
echo "The trust anchor reaches the tenant as the key-free <release>.tenant-ca" >&2
echo "projection via tenantsecrets, not by a Role grant." >&2
exit 1
fi
}
@test "a declared rabbitmq user adds only its own credentials Secret to the Role" {
if [ ! -d "$CHART" ]; then
echo "Chart not found at $CHART -- did it move?" >&2
exit 1
fi
rendered=$(render_rbac --set 'users.alice.password=placeholder') || {
echo "helm template failed for $CHART" >&2
exit 1
}
rules=$(printf '%s\n' "$rendered" | yq eval-all --output-format=json --indent=0 \
'[select(.kind == "Role") | .rules[]]' -)
if [ "$rules" != "$EXPECTED_ROLE_RULES_WITH_USER" ]; then
echo "Unexpected rules with one declared user: $rules" >&2
echo "Expected exactly: $EXPECTED_ROLE_RULES_WITH_USER" >&2
echo "The secrets rule is the only part of this Role that grows from values," >&2
echo "so a name reaching it from the users loop is granted to the tenant." >&2
exit 1
fi
}