Files
Oxicloud/tests/api/groups_effective_members.hurl
2026-08-21 23:56:25 +02:00

165 lines
7.7 KiB
Plaintext

# =============================================================
# OxiCloud — Baseline: subject-group effective-members
# =============================================================
# Pins the transitive group-expansion contract that the ReBAC
# permissions story relies on. The interesting case is nesting:
# adding *user_X* into *Group_B*, and *Group_B* into *Group_A*,
# must make *user_X* visible from `GET /api/groups/{A}/effective-members`
# — the call site that authz lookups walk.
#
# Coverage:
# 1. Admin creates `Group_A` and `Group_B`
# 2. Admin creates fixture user `dora-eff`
# 3. PUT dora into Group_B (direct membership)
# 4. PUT Group_B into Group_A (nested membership)
# 5. GET /api/groups/{B}/members (direct only) → dora
# 6. GET /api/groups/{A}/members (direct only) → Group_B
# NOT dora (she's transitive)
# 7. GET /api/groups/{A}/effective-members → contains dora
# 8. Cleanup: remove user, remove group-member, delete groups + user
#
# This single nested scenario is the load-bearing one — if the
# transitive walk regresses, the ReBAC engine silently grants
# 0 permissions to nested members.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup — admin login
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# 1 — Create both groups. Names use kebab-case so the RFC-5321
# local-part validator accepts them.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/groups
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "eff-group-a", "description": "outer group" }
HTTP 201
[Captures]
group_a_id: jsonpath "$.id"
POST {{base_url}}/api/groups
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "eff-group-b", "description": "inner group" }
HTTP 201
[Captures]
group_b_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# 2 — Create the fixture user. "dora-eff" — distinct from any
# user created by other test files, so this is order-safe.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"username": "dora-eff",
"password": "DoraPassword1!",
"email": "dora-eff@example.com",
"role": "user"
}
HTTP 201
[Captures]
dora_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
# 3 — Put dora into Group_B (direct user member)
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/groups/{{group_b_id}}/members
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "user_id": "{{dora_id}}" }
HTTP 201
# ─────────────────────────────────────────────────────────────
# 4 — Put Group_B into Group_A (nested group member)
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/groups/{{group_a_id}}/members
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "group_id": "{{group_b_id}}" }
HTTP 201
# ─────────────────────────────────────────────────────────────
# 5 — Group_B direct membership: ONLY dora.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/groups/{{group_b_id}}/members
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
body contains "{{dora_id}}"
# ─────────────────────────────────────────────────────────────
# 6 — Group_A direct membership: Group_B, NOT dora. The
# direct-members endpoint is non-transitive by contract;
# mixing in transitive members here would silently
# conflate the two surfaces.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/groups/{{group_a_id}}/members
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
body contains "{{group_b_id}}"
body not contains "{{dora_id}}"
# ─────────────────────────────────────────────────────────────
# 7 — HEADLINE: Group_A effective-members reaches dora.
# A regression here is the canary for any change that
# breaks transitive expansion in the ReBAC layer.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/groups/{{group_a_id}}/effective-members
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
body contains "{{dora_id}}"
# ─────────────────────────────────────────────────────────────
# 8 — Teardown.
# The D3a self-defense in `subject_group_service::remove_member`
# refuses any individual membership removal that would empty a
# seeded group's transitive user set — which BOTH of these
# would (removing Group_B from A leaves A with no users;
# removing dora from B leaves B with no users). So we skip the
# manual member-by-member unwind and just DELETE the groups
# directly. `delete_group` cascades through `subject_group_members`
# via FK and isn't subject to the per-remove guard.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/groups/{{group_a_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
DELETE {{base_url}}/api/groups/{{group_b_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
DELETE {{base_url}}/api/admin/users/{{dora_id}}
Authorization: Bearer {{admin_token}}
HTTP 200