ci / docker (push) Successful in 9s
- albums.owner_id (migration 0003, backfilled to the original user)
- owned::{album,photo,share} are the only admin data-access paths; another
tenant's resources are indistinguishable from nonexistent (404)
- every admin handler threaded through ownership; each ALLOWED_EMAILS entry
is now its own isolated workspace
- tenant-isolation integration test matrix (tests/tenancy.rs, env-gated on
TEST_DATABASE_URL) driving the real router
236 lines
8.1 KiB
Rust
236 lines
8.1 KiB
Rust
//! Tenant-isolation matrix: every admin endpoint must treat another user's
|
|
//! resources as nonexistent (404), and unauthenticated requests as 401.
|
|
//!
|
|
//! Needs a disposable Postgres database:
|
|
//! TEST_DATABASE_URL=postgres://photos:photos@localhost:5432/photos_test cargo test
|
|
//! Skips silently when TEST_DATABASE_URL is unset. S3 is never contacted —
|
|
//! the matrix stops at the ownership checks by construction.
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{header, Request, StatusCode};
|
|
use cookie::{Cookie, CookieJar, Key};
|
|
use sha2::{Digest, Sha512};
|
|
use tower::ServiceExt;
|
|
use uuid::Uuid;
|
|
|
|
use photos::config::Config;
|
|
use photos::state::AppState;
|
|
|
|
const SECRET: &str = "test-secret-test-secret-test-secret-1234";
|
|
|
|
fn test_config(database_url: String) -> Config {
|
|
Config {
|
|
database_url,
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
public_url: "http://localhost:8080".into(),
|
|
session_secret: SECRET.into(),
|
|
s3_bucket: "photos".into(),
|
|
// Unroutable on purpose: no test may reach S3.
|
|
s3_endpoint: Some("http://127.0.0.1:9".into()),
|
|
s3_region: "us-east-1".into(),
|
|
s3_access_key: "test".into(),
|
|
s3_secret_key: "test".into(),
|
|
s3_force_path_style: true,
|
|
oidc_issuer: "https://auth.invalid".into(),
|
|
oidc_client_id: "x".into(),
|
|
oidc_client_secret: "x".into(),
|
|
allowed_emails: vec!["a@test".into(), "b@test".into()],
|
|
static_dir: "frontend/dist".into(),
|
|
worker_concurrency: 1,
|
|
dev_autologin_email: None,
|
|
}
|
|
}
|
|
|
|
fn session_for(user_id: Uuid, email: &str) -> String {
|
|
let key = Key::from(&Sha512::digest(SECRET.as_bytes()));
|
|
let exp = chrono::Utc::now().timestamp() + 3600;
|
|
let mut jar = CookieJar::new();
|
|
jar.signed_mut(&key).add(Cookie::new(
|
|
"photos_session",
|
|
format!("{user_id}|{exp}|{email}"),
|
|
));
|
|
format!("photos_session={}", jar.get("photos_session").unwrap().value())
|
|
}
|
|
|
|
async fn request(
|
|
router: &axum::Router,
|
|
method: &str,
|
|
path: &str,
|
|
cookie: Option<&str>,
|
|
json: Option<serde_json::Value>,
|
|
) -> (StatusCode, serde_json::Value) {
|
|
let mut builder = Request::builder().method(method).uri(path);
|
|
if let Some(cookie) = cookie {
|
|
builder = builder.header(header::COOKIE, cookie);
|
|
}
|
|
let body = match json {
|
|
Some(value) => {
|
|
builder = builder.header(header::CONTENT_TYPE, "application/json");
|
|
Body::from(value.to_string())
|
|
}
|
|
// Zip endpoints take a form; everything else ignores the body.
|
|
None if path.ends_with("/zip") => {
|
|
builder = builder.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
|
|
Body::from("ids=")
|
|
}
|
|
None => Body::empty(),
|
|
};
|
|
let response = router
|
|
.clone()
|
|
.oneshot(builder.body(body).unwrap())
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let bytes = axum::body::to_bytes(response.into_body(), 1 << 20)
|
|
.await
|
|
.unwrap();
|
|
let value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
|
|
(status, value)
|
|
}
|
|
|
|
async fn seed_user(state: &AppState, email: &str) -> Uuid {
|
|
let (id,): (Uuid,) = sqlx::query_as(
|
|
"insert into users (oidc_subject, email) values ($1, $2) returning id",
|
|
)
|
|
.bind(format!("test:{email}"))
|
|
.bind(email)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.unwrap();
|
|
id
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tenant_isolation_matrix() {
|
|
let Ok(database_url) = std::env::var("TEST_DATABASE_URL") else {
|
|
eprintln!("TEST_DATABASE_URL not set — skipping tenancy matrix");
|
|
return;
|
|
};
|
|
let state = AppState::new(test_config(database_url)).await.unwrap();
|
|
sqlx::query("truncate users, albums, photos, shares, ratings, tags, jobs cascade")
|
|
.execute(&state.db)
|
|
.await
|
|
.unwrap();
|
|
|
|
let alice = seed_user(&state, "a@test").await;
|
|
let bob = seed_user(&state, "b@test").await;
|
|
let cookie_a = session_for(alice, "a@test");
|
|
let cookie_b = session_for(bob, "b@test");
|
|
let router = photos::routes::router(&state).with_state(state.clone());
|
|
|
|
// Alice creates an album through the real API.
|
|
let (status, album) = request(
|
|
&router,
|
|
"POST",
|
|
"/api/albums",
|
|
Some(&cookie_a),
|
|
Some(serde_json::json!({ "name": "Alice's Wedding" })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let album_id = album["id"].as_str().unwrap().to_string();
|
|
|
|
// Seed a photo (kept non-ready so no code path reaches S3) and a share.
|
|
let photo_id = Uuid::new_v4();
|
|
sqlx::query(
|
|
"insert into photos (id, album_id, filename, content_type, size_bytes, sha256)
|
|
values ($1, $2::uuid, 'a.jpg', 'image/jpeg', 3, 'hash-a')",
|
|
)
|
|
.bind(photo_id)
|
|
.bind(&album_id)
|
|
.execute(&state.db)
|
|
.await
|
|
.unwrap();
|
|
let share_id = Uuid::new_v4();
|
|
sqlx::query("insert into shares (id, album_id, token) values ($1, $2::uuid, 'tenanttesttoken123456789')")
|
|
.bind(share_id)
|
|
.bind(&album_id)
|
|
.execute(&state.db)
|
|
.await
|
|
.unwrap();
|
|
|
|
// ---- Bob vs Alice's resources: everything must be a 404 (or absent). ----
|
|
let bob_hits: &[(&str, String, Option<serde_json::Value>)] = &[
|
|
("GET", format!("/api/albums/{album_id}"), None),
|
|
(
|
|
"PATCH",
|
|
format!("/api/albums/{album_id}"),
|
|
Some(serde_json::json!({ "name": "stolen" })),
|
|
),
|
|
("DELETE", format!("/api/albums/{album_id}"), None),
|
|
("POST", format!("/api/albums/{album_id}/photos?filename=x.jpg"), None),
|
|
("GET", format!("/api/albums/{album_id}/shares"), None),
|
|
(
|
|
"POST",
|
|
format!("/api/albums/{album_id}/shares"),
|
|
Some(serde_json::json!({ "label": "x" })),
|
|
),
|
|
("POST", format!("/api/albums/{album_id}/zip"), None),
|
|
("GET", format!("/api/albums/{album_id}/photos/by-hash/hash-a"), None),
|
|
("DELETE", format!("/api/photos/{photo_id}"), None),
|
|
("POST", format!("/api/photos/{photo_id}/reprocess"), None),
|
|
("DELETE", format!("/api/shares/{share_id}"), None),
|
|
("POST", format!("/api/shares/{share_id}/reset-lock"), None),
|
|
];
|
|
for (method, path, body) in bob_hits {
|
|
let (status, _) = request(&router, method, path, Some(&cookie_b), body.clone()).await;
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::NOT_FOUND,
|
|
"cross-tenant {method} {path} must 404"
|
|
);
|
|
}
|
|
let (status, list) = request(&router, "GET", "/api/albums", Some(&cookie_b), None).await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(list.as_array().unwrap().len(), 0, "bob must see no albums");
|
|
|
|
// ---- Alice keeps full access to her own resources. ----
|
|
let (status, list) = request(&router, "GET", "/api/albums", Some(&cookie_a), None).await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(list.as_array().unwrap().len(), 1);
|
|
let (status, _) = request(
|
|
&router,
|
|
"GET",
|
|
&format!("/api/albums/{album_id}"),
|
|
Some(&cookie_a),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let (status, _) = request(
|
|
&router,
|
|
"GET",
|
|
&format!("/api/albums/{album_id}/photos/by-hash/hash-a"),
|
|
Some(&cookie_a),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
// Ownership check runs before the body is read: empty upload = 400, not 404.
|
|
let (status, _) = request(
|
|
&router,
|
|
"POST",
|
|
&format!("/api/albums/{album_id}/photos?filename=x.jpg"),
|
|
Some(&cookie_a),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
// No ready photos yet: zip is a 400 for the owner, never an S3 call.
|
|
let (status, _) = request(
|
|
&router,
|
|
"POST",
|
|
&format!("/api/albums/{album_id}/zip"),
|
|
Some(&cookie_a),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
|
|
// ---- No session at all: 401 on the admin surface. ----
|
|
for path in ["/api/albums", "/api/me"] {
|
|
let (status, _) = request(&router, "GET", path, None, None).await;
|
|
assert_eq!(status, StatusCode::UNAUTHORIZED, "{path} without session");
|
|
}
|
|
}
|