test(integration): add integration test on subject group

This commit is contained in:
Edouard Vanbelle
2026-05-31 22:45:03 +02:00
parent 03ee05a74a
commit e169f53218
7 changed files with 560 additions and 3 deletions
+29 -1
View File
@@ -125,7 +125,15 @@ jobs:
- uses: Swatinem/rust-cache@v2
- name: Initialize test database
run: psql -h localhost -U postgres -d oxicloud_test -f migrations/20260307000000_initial_schema.sql
# Apply every migration in lexical order so the schema matches what
# the running server would set up (the app applies sqlx migrations on
# startup, but `cargo test` runs without going through main()).
run: |
set -e
for f in migrations/*.sql; do
echo "Applying $f"
psql -h localhost -U postgres -d oxicloud_test -v ON_ERROR_STOP=1 -f "$f"
done
env:
PGPASSWORD: postgres
@@ -134,6 +142,26 @@ jobs:
env:
DATABASE_URL: "postgres://postgres:postgres@localhost/oxicloud_test"
# ── Integration tests (PG-backed, gated on `--cfg integration_tests`) ──
# These tests connect to the live postgres service above to exercise
# CITEXT, XOR, recursive-CTE cycle/depth checks, and transactional
# grant-cleanup. They depend on at least one row in `auth.users` so
# the `first_admin()` helper resolves to a real UUID.
- name: Seed admin row for integration tests
run: |
psql -h localhost -U postgres -d oxicloud_test -v ON_ERROR_STOP=1 -c "
INSERT INTO auth.users (username, email, password_hash, role)
VALUES ('ci-admin', 'ci-admin@example.test', 'placeholder-not-validated', 'admin')
ON CONFLICT (username) DO NOTHING;"
env:
PGPASSWORD: postgres
- name: Run integration tests
run: cargo test --all-features --workspace --tests
env:
DATABASE_URL: "postgres://postgres:postgres@localhost/oxicloud_test"
RUSTFLAGS: "-Dwarnings --cfg integration_tests"
rust-audit:
name: Security Audit
needs: changes
+1
View File
@@ -70,6 +70,7 @@ src/
## Further Reading
- [ReBAC Authorization →](/architecture/rebac-authorization)
- [Caching Architecture →](/architecture/caching)
- [Resource Listing API →](/architecture/resource-listing)
- [Storage Quotas →](/architecture/storage-quotas)
+7
View File
@@ -23,6 +23,13 @@ test:
test-mocks:
cargo test --features test_utils
# DB-dependent integration tests gated on `--cfg integration_tests`.
# Spins up the test postgres on port 5433 first. Requires one row in
# auth.users (start the server against the test DB once to seed).
test-integration:
bash tests/common/spawn-db.sh
RUSTFLAGS='--cfg integration_tests' cargo test --workspace --tests
test-one name:
cargo test {{name}}
+8 -1
View File
@@ -1105,7 +1105,14 @@ impl BatchOperationService {
}
}
#[cfg(integration_tests)]
// FIXME: this test rotted when `FileManagementService::new(file_write_repo)`
// was replaced by `FileManagementService::with_trash(...)` (6 args incl.
// `Arc<PgAclEngine>`). Re-enable by gating on `integration_tests` again and
// threading the new arguments — out of scope for the subject-groups test
// work, but tracked here so the next maintainer notices.
// `cfg(any())` is always-false; re-enable by switching back to
// `cfg(integration_tests)` after the constructor migration is fixed.
#[cfg(any())]
mod tests {
#[allow(unused_imports)]
use super::*;
@@ -380,3 +380,153 @@ fn map_repo_err(e: SubjectGroupRepositoryError) -> DomainError {
};
DomainError::new(kind, "SubjectGroup", msg)
}
// ────────────────────────────────────────────────────────────────────────────
// Integration tests — service layer behaviours that need a live DB.
//
// How to run:
// bash tests/common/spawn-db.sh
// RUSTFLAGS='--cfg integration_tests' cargo test \
// -p oxicloud --lib subject_group_service::integration_tests
// ────────────────────────────────────────────────────────────────────────────
#[cfg(integration_tests)]
#[allow(dead_code)]
mod integration_tests {
use super::*;
// INTERNAL_GROUP_ID is already in scope via `super::*` (re-exported
// through the file's top-level `use crate::domain::entities::subject_group::…`).
use sqlx::Row;
use sqlx::postgres::PgPoolOptions;
const DEFAULT_TEST_DB: &str =
"postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test";
async fn make_service() -> SubjectGroupService {
let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| DEFAULT_TEST_DB.to_string());
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("connect to test DB — run tests/common/spawn-db.sh first");
let pool = Arc::new(pool);
let repo = Arc::new(SubjectGroupPgRepository::new(pool.clone()));
SubjectGroupService::new(repo, pool)
}
async fn first_admin(pool: &sqlx::PgPool) -> Uuid {
let row = sqlx::query("SELECT id FROM auth.users LIMIT 1")
.fetch_optional(pool)
.await
.expect("query")
.expect("seed an admin user before running these tests");
row.get::<Uuid, _>("id")
}
fn rand_name(test: &str) -> String {
format!("rust-test-svc-{}-{}", test, &Uuid::new_v4().to_string()[..8])
}
// ── 9. Virtual group cannot be deleted ─────────────────────────────────
#[tokio::test]
async fn test_virtual_group_cannot_be_deleted() {
let svc = make_service().await;
let admin = first_admin(&svc.pool).await;
let err = svc
.delete(INTERNAL_GROUP_ID, admin)
.await
.expect_err("delete on Internal must be rejected");
assert_eq!(err.kind, ErrorKind::AccessDenied);
}
#[tokio::test]
async fn test_virtual_group_cannot_be_renamed() {
let svc = make_service().await;
let admin = first_admin(&svc.pool).await;
let err = svc
.rename(INTERNAL_GROUP_ID, "renamed", admin)
.await
.expect_err("rename on Internal must be rejected");
assert_eq!(err.kind, ErrorKind::AccessDenied);
}
#[tokio::test]
async fn test_virtual_group_cannot_add_member() {
let svc = make_service().await;
let admin = first_admin(&svc.pool).await;
let err = svc
.add_member(INTERNAL_GROUP_ID, GroupMember::User(admin), admin)
.await
.expect_err("add_member on Internal must be rejected");
assert_eq!(err.kind, ErrorKind::AccessDenied);
}
// ── 13. Grants are revoked atomically when a group is deleted ──────────
//
// The plan said "FK CASCADE", but there's no FK between `access_grants`
// and `subject_groups` (different schemas; the cascade is handled by the
// service's transactional DELETE). This test pins that behaviour.
#[tokio::test]
async fn test_grants_revoked_when_group_deleted() {
let svc = make_service().await;
let admin = first_admin(&svc.pool).await;
// Create a group and a fake grant referencing it.
let group = svc
.create(&rand_name("cleanup"), None, admin)
.await
.unwrap();
let resource_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO storage.access_grants \
(subject_type, subject_id, resource_type, resource_id, \
permission, granted_by) \
VALUES ('group', $1, 'folder', $2, 'read', $3)",
)
.bind(group.id)
.bind(resource_id)
.bind(admin)
.execute(svc.pool.as_ref())
.await
.expect("insert grant row");
// Sanity: the grant exists.
let pre: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM storage.access_grants \
WHERE subject_type = 'group' AND subject_id = $1",
)
.bind(group.id)
.fetch_one(svc.pool.as_ref())
.await
.unwrap();
assert_eq!(pre, 1);
// Delete the group — the same transaction nukes the grant.
svc.delete(group.id, admin).await.unwrap();
let post: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM storage.access_grants \
WHERE subject_type = 'group' AND subject_id = $1",
)
.bind(group.id)
.fetch_one(svc.pool.as_ref())
.await
.unwrap();
assert_eq!(post, 0, "grants must be revoked atomically with the group");
}
// Bonus: service-layer name validation runs before the DB round-trip.
#[tokio::test]
async fn test_service_rejects_invalid_name_locally() {
let svc = make_service().await;
let admin = first_admin(&svc.pool).await;
let err = svc
.create("name with space", None, admin)
.await
.expect_err("space must be rejected");
assert_eq!(err.kind, ErrorKind::InvalidInput);
}
}
@@ -574,3 +574,367 @@ impl SubjectGroupRepository for SubjectGroupPgRepository {
Ok(rows.iter().map(|r| r.get::<Uuid, _>("group_id")).collect())
}
}
// ────────────────────────────────────────────────────────────────────────────
// Integration tests — DB-dependent. Gated on `--cfg integration_tests` so
// they don't break the default `cargo test` run.
//
// How to run:
// bash tests/common/spawn-db.sh # one-time
// sqlx migrate run --database-url $TEST_DB # if needed
// RUSTFLAGS='--cfg integration_tests' cargo test \
// -p oxicloud --lib subject_group_pg_repository::integration_tests
//
// `TEST_DB` defaults to `postgres://oxicloud_test:oxicloud_test@localhost:5433/
// oxicloud_test` — the same DB used by `tests/api/run.sh`.
//
// Each test uses uniquely-suffixed group names (`rust-test-<uuid8>`) so
// concurrent runs and re-runs don't collide on the CITEXT unique constraint.
// Cleanup is by-id at the end of each test.
// ────────────────────────────────────────────────────────────────────────────
#[cfg(integration_tests)]
#[allow(dead_code)]
mod integration_tests {
use super::*;
use sqlx::postgres::PgPoolOptions;
const DEFAULT_TEST_DB: &str =
"postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test";
async fn test_pool() -> Arc<PgPool> {
let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| DEFAULT_TEST_DB.to_string());
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("connect to test DB — run tests/common/spawn-db.sh first");
Arc::new(pool)
}
async fn make_repo() -> SubjectGroupPgRepository {
SubjectGroupPgRepository::new(test_pool().await)
}
/// Find any existing admin or create a throw-away one, so memberships'
/// `added_by` FK is satisfied. Returns the admin's UUID.
async fn ensure_admin(pool: &PgPool) -> Uuid {
if let Ok(Some(row)) = sqlx::query("SELECT id FROM auth.users LIMIT 1")
.fetch_optional(pool)
.await
{
return row.get::<Uuid, _>("id");
}
// Fallback: build a minimal user. Schema permitting — if the test DB
// is fresh, the operator should have run the server once to seed.
panic!(
"no rows in auth.users — start the server against the test DB \
once (cargo run with DATABASE_URL=…) to seed the schema and \
create the initial admin, then re-run."
);
}
/// Unique name scoped to a single test invocation.
fn rand_name(test: &str) -> String {
let id = Uuid::new_v4();
format!("rust-test-{}-{}", test, &id.to_string()[..8])
}
/// Idempotent cleanup of a group by id (cascades to members via FK).
async fn drop_group(pool: &PgPool, id: Uuid) {
let _ = sqlx::query("DELETE FROM auth.subject_groups WHERE id = $1")
.bind(id)
.execute(pool)
.await;
}
// ── 1. CITEXT unique enforcement ────────────────────────────────────────
#[tokio::test]
async fn test_group_name_unique_case_insensitive() {
let repo = make_repo().await;
let name_lower = rand_name("citext-lower");
let name_upper = name_lower.to_uppercase();
let g1 = SubjectGroup::new(&name_lower, None).expect("valid name");
let created = repo.create(&g1).await.expect("first create succeeds");
// Second create with same name in different case must collide.
let g2 = SubjectGroup::new(&name_upper, None).expect("valid name shape");
let err = repo
.create(&g2)
.await
.expect_err("CITEXT must collide on different case");
assert!(
matches!(err, SubjectGroupRepositoryError::NameAlreadyExists(_)),
"expected NameAlreadyExists, got {:?}",
err
);
drop_group(repo.pool.as_ref(), created.id).await;
}
// ── 2. XOR constraint on members table ──────────────────────────────────
#[tokio::test]
async fn test_member_xor_check_at_db_level() {
let repo = make_repo().await;
let admin = ensure_admin(repo.pool.as_ref()).await;
let g = SubjectGroup::new(&rand_name("xor"), None).unwrap();
let group = repo.create(&g).await.unwrap();
let some_uuid = Uuid::new_v4();
// Both columns NULL → CHECK violation.
let res = sqlx::query(
"INSERT INTO auth.subject_group_members \
(group_id, member_user_id, member_group_id, added_by) \
VALUES ($1, NULL, NULL, $2)",
)
.bind(group.id)
.bind(admin)
.execute(repo.pool.as_ref())
.await;
assert!(res.is_err(), "both-null insert must fail XOR check");
// Both columns set → CHECK violation.
let res = sqlx::query(
"INSERT INTO auth.subject_group_members \
(group_id, member_user_id, member_group_id, added_by) \
VALUES ($1, $2, $3, $2)",
)
.bind(group.id)
.bind(admin)
.bind(some_uuid)
.execute(repo.pool.as_ref())
.await;
assert!(res.is_err(), "both-set insert must fail XOR check");
drop_group(repo.pool.as_ref(), group.id).await;
}
// ── 3. Direct loop: A∋A rejected ────────────────────────────────────────
#[tokio::test]
async fn test_cycle_check_rejects_direct_loop() {
let repo = make_repo().await;
let admin = ensure_admin(repo.pool.as_ref()).await;
let g = SubjectGroup::new(&rand_name("cycle-self"), None).unwrap();
let group = repo.create(&g).await.unwrap();
let err = repo
.add_member(group.id, GroupMember::Group(group.id), admin)
.await
.expect_err("self-add must be rejected");
// The `no_self` DB CHECK is the row-level guard for the degenerate
// case; it surfaces here as a StorageError. Longer cycles take the
// CTE/`Cycle` path. Accept either flavour.
assert!(
matches!(
err,
SubjectGroupRepositoryError::Cycle(_)
| SubjectGroupRepositoryError::StorageError(_)
),
"expected cycle/storage rejection, got {:?}",
err
);
drop_group(repo.pool.as_ref(), group.id).await;
}
// ── 4. Two-step loop: A∋B, B∋C, attempted C∋A rejected ──────────────────
#[tokio::test]
async fn test_cycle_check_rejects_two_step_loop() {
let repo = make_repo().await;
let admin = ensure_admin(repo.pool.as_ref()).await;
let a = repo
.create(&SubjectGroup::new(&rand_name("cyc2-a"), None).unwrap())
.await
.unwrap();
let b = repo
.create(&SubjectGroup::new(&rand_name("cyc2-b"), None).unwrap())
.await
.unwrap();
let c = repo
.create(&SubjectGroup::new(&rand_name("cyc2-c"), None).unwrap())
.await
.unwrap();
repo.add_member(a.id, GroupMember::Group(b.id), admin)
.await
.unwrap();
repo.add_member(b.id, GroupMember::Group(c.id), admin)
.await
.unwrap();
// C∋A would close the loop A→B→C→A.
let err = repo
.add_member(c.id, GroupMember::Group(a.id), admin)
.await
.expect_err("two-step cycle must be rejected");
assert!(matches!(err, SubjectGroupRepositoryError::Cycle(_)));
for id in [c.id, b.id, a.id] {
drop_group(repo.pool.as_ref(), id).await;
}
}
// ── 5. Long-chain cycle: chain of 8 + closing edge rejected ─────────────
#[tokio::test]
async fn test_cycle_check_rejects_eight_step_loop() {
let repo = make_repo().await;
let admin = ensure_admin(repo.pool.as_ref()).await;
let mut ids = Vec::with_capacity(8);
for i in 0..8 {
let g = repo
.create(
&SubjectGroup::new(&rand_name(&format!("cyc8-{i}")), None).unwrap(),
)
.await
.unwrap();
ids.push(g.id);
}
// Build the chain 0→1→2→…→7.
for i in 0..7 {
repo.add_member(ids[i], GroupMember::Group(ids[i + 1]), admin)
.await
.unwrap();
}
// Closing edge 7→0 should be rejected as a cycle.
let err = repo
.add_member(ids[7], GroupMember::Group(ids[0]), admin)
.await
.expect_err("eight-step cycle must be rejected");
assert!(
matches!(
err,
SubjectGroupRepositoryError::Cycle(_)
| SubjectGroupRepositoryError::DepthExceeded(_)
),
"expected cycle/depth rejection, got {:?}",
err
);
for id in ids.into_iter().rev() {
drop_group(repo.pool.as_ref(), id).await;
}
}
// ── 6. Depth cap at 8 ───────────────────────────────────────────────────
//
// Depth is enforced **per mutation, on the subtree under the parent being
// mutated** — not as a global chain-length invariant. A new edge is
// rejected when its proposed subtree under the parent would reach
// depth > MAX_GROUP_DEPTH. Top-down chain construction can therefore grow
// arbitrarily deep one edge at a time; the rejection fires when an
// existing deep subtree is *lifted* under a new outer parent.
//
// This test pins that behaviour:
// 1. Build a chain g[0] → g[1] → … → g[8] (9 nodes, 8 edges, max
// subtree depth 8 — exactly at the cap, still allowed).
// 2. Create an outer group `h`.
// 3. Attempt to add g[0] as a member of `h` — the subtree under `h`
// would now be 9 deep → DepthExceeded.
#[tokio::test]
async fn test_depth_cap_at_8() {
let repo = make_repo().await;
let admin = ensure_admin(repo.pool.as_ref()).await;
let len = (MAX_GROUP_DEPTH as usize) + 1;
let mut ids = Vec::with_capacity(len);
for i in 0..len {
let g = repo
.create(
&SubjectGroup::new(&rand_name(&format!("depth-{i}")), None).unwrap(),
)
.await
.unwrap();
ids.push(g.id);
}
// Build top-down: each insert only adds depth 1 under its parent, so
// every edge is allowed by the per-mutation depth check.
for i in 0..(len - 1) {
repo.add_member(ids[i], GroupMember::Group(ids[i + 1]), admin)
.await
.unwrap_or_else(|e| {
panic!("edge {i} should fit in the depth budget: {:?}", e)
});
}
// Lift the whole chain under a new outer group → subtree depth 9.
let outer = repo
.create(&SubjectGroup::new(&rand_name("depth-outer"), None).unwrap())
.await
.unwrap();
let err = repo
.add_member(outer.id, GroupMember::Group(ids[0]), admin)
.await
.expect_err("depth-9 subtree must be rejected");
assert!(
matches!(err, SubjectGroupRepositoryError::DepthExceeded(_)),
"expected DepthExceeded, got {:?}",
err
);
drop_group(repo.pool.as_ref(), outer.id).await;
for id in ids.into_iter().rev() {
drop_group(repo.pool.as_ref(), id).await;
}
}
// ── 7. Transitive expansion: A∋B, B∋C, U∈C → groups_for_user(U) ⊇ {A,B,C}
#[tokio::test]
async fn test_transitive_expansion_includes_indirect_groups() {
let repo = make_repo().await;
let admin = ensure_admin(repo.pool.as_ref()).await;
let a = repo
.create(&SubjectGroup::new(&rand_name("tx-a"), None).unwrap())
.await
.unwrap();
let b = repo
.create(&SubjectGroup::new(&rand_name("tx-b"), None).unwrap())
.await
.unwrap();
let c = repo
.create(&SubjectGroup::new(&rand_name("tx-c"), None).unwrap())
.await
.unwrap();
repo.add_member(a.id, GroupMember::Group(b.id), admin)
.await
.unwrap();
repo.add_member(b.id, GroupMember::Group(c.id), admin)
.await
.unwrap();
repo.add_member(c.id, GroupMember::User(admin), admin)
.await
.unwrap();
let expanded = repo.groups_for_user(admin).await.unwrap();
assert!(expanded.contains(&a.id), "expansion must include outer A");
assert!(expanded.contains(&b.id), "expansion must include middle B");
assert!(
expanded.contains(&c.id),
"expansion must include direct parent C"
);
drop_group(repo.pool.as_ref(), c.id).await;
drop_group(repo.pool.as_ref(), b.id).await;
drop_group(repo.pool.as_ref(), a.id).await;
}
// ── 8. Internal virtual group is seeded with the well-known UUID ────────
#[tokio::test]
async fn test_internal_group_is_seeded() {
use crate::domain::entities::subject_group::INTERNAL_GROUP_ID;
let repo = make_repo().await;
let row = repo
.get_by_id(INTERNAL_GROUP_ID)
.await
.expect("query OK")
.expect("Internal group row must exist");
assert!(row.is_virtual, "Internal must be flagged virtual");
assert_eq!(row.name, "Internal");
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// OxiCloud Service Worker
// FIXME: generate cache name according build ?
const CACHE_NAME = 'oxicloud-cache-v22';
const CACHE_NAME = 'oxicloud-cache-v23';
// Only cache static assets — NOT HTML files.
// HTML files are served network-first so browsers always get the latest