feat(api): admin auth surface (login/logout/me/users/publish) on tower-sessions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,11 @@ rust-version.workspace = true
|
|||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
utoipa.workspace = true
|
utoipa.workspace = true
|
||||||
|
time.workspace = true
|
||||||
|
tower-sessions.workspace = true
|
||||||
|
tower-sessions-sqlx-store.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
auth = { path = "../auth" }
|
||||||
db = { path = "../db" }
|
db = { path = "../db" }
|
||||||
domain = { path = "../domain" }
|
domain = { path = "../domain" }
|
||||||
|
|
||||||
@@ -17,3 +22,4 @@ tower.workspace = true
|
|||||||
http-body-util.workspace = true
|
http-body-util.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
|
auth = { path = "../auth" }
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
//! Admin (authenticated) surface: login/logout/session, user listing, and publishing.
|
||||||
|
|
||||||
|
use auth::{AuthUser, Authorized, ManageUsers, PublishObjects};
|
||||||
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use domain::{AuditActor, ObjectId, Visibility};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Credentials for password login.
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub(crate) struct LoginRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A user as exposed on the admin surface (no password material).
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct UserView {
|
||||||
|
pub id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desired visibility for a publish/unpublish request.
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub(crate) struct VisibilityRequest {
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub visibility: Visibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log in with email + password. On success establishes a session (Set-Cookie) and
|
||||||
|
/// returns 204; on failure 401 with no detail (no user enumeration).
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/admin/login",
|
||||||
|
request_body = LoginRequest,
|
||||||
|
responses((status = 204, description = "Logged in"), (status = 401, description = "Invalid credentials"))
|
||||||
|
)]
|
||||||
|
pub(crate) async fn login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
session: Session,
|
||||||
|
Json(req): Json<LoginRequest>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let normalized = req.email.trim().to_lowercase();
|
||||||
|
|
||||||
|
let credentials = db::users::credentials_by_email(state.db.pool(), &normalized)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let verified = match &credentials {
|
||||||
|
Some((_, hash)) => auth::verify_password(&req.password, hash),
|
||||||
|
None => {
|
||||||
|
auth::verify_dummy(&req.password);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verified {
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (user, _) = credentials.expect("verified implies Some");
|
||||||
|
|
||||||
|
auth::establish_session(&session, user.id, &user.email, user.role)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log out: clear the session.
|
||||||
|
#[utoipa::path(post, path = "/api/admin/logout", responses((status = 204, description = "Logged out")))]
|
||||||
|
pub(crate) async fn logout(session: Session) -> Result<StatusCode, StatusCode> {
|
||||||
|
session
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current authenticated user.
|
||||||
|
#[utoipa::path(get, path = "/api/admin/me", responses((status = 200, body = UserView), (status = 401)))]
|
||||||
|
pub(crate) async fn me(user: AuthUser) -> Json<UserView> {
|
||||||
|
Json(UserView {
|
||||||
|
id: user.id.to_string(),
|
||||||
|
email: user.email.as_str().to_owned(),
|
||||||
|
role: user.role.as_str().to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all users (Admin only).
|
||||||
|
#[utoipa::path(get, path = "/api/admin/users", responses((status = 200, body = [UserView]), (status = 401), (status = 403)))]
|
||||||
|
pub(crate) async fn list_users(
|
||||||
|
_auth: Authorized<ManageUsers>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<UserView>>, StatusCode> {
|
||||||
|
let users = db::users::list_users(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
users
|
||||||
|
.into_iter()
|
||||||
|
.map(|u| UserView {
|
||||||
|
id: u.id.to_string(),
|
||||||
|
email: u.email.as_str().to_owned(),
|
||||||
|
role: u.role.as_str().to_owned(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change an object's visibility (publish/unpublish). Requires `PublishObjects`.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/admin/objects/{id}/visibility",
|
||||||
|
params(("id" = String, Path, description = "Object id (UUID)")),
|
||||||
|
request_body = VisibilityRequest,
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "Visibility changed"),
|
||||||
|
(status = 401), (status = 403),
|
||||||
|
(status = 404, description = "No such object"),
|
||||||
|
(status = 409, description = "Illegal visibility transition")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn set_visibility(
|
||||||
|
_auth: Authorized<PublishObjects>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(req): Json<VisibilityRequest>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let object_id = id.parse::<ObjectId>().map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.pool()
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
// TODO(#7): record the per-user actor (AuthUser carries the id) once auth-event
|
||||||
|
// auditing lands; System for now.
|
||||||
|
let result =
|
||||||
|
db::catalog::set_visibility(&mut tx, AuditActor::System, object_id, req.visibility).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND),
|
||||||
|
Err(db::catalog::VisibilityError::Illegal(_)) => Err(StatusCode::CONFLICT),
|
||||||
|
Err(db::catalog::VisibilityError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin routes, parameterized over [`AppState`].
|
||||||
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/admin/login", post(login))
|
||||||
|
.route("/api/admin/logout", post(logout))
|
||||||
|
.route("/api/admin/me", get(me))
|
||||||
|
.route("/api/admin/users", get(list_users))
|
||||||
|
.route("/api/admin/objects/{id}/visibility", post(set_visibility))
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
//! HTTP API: router, handlers, and OpenAPI document.
|
//! HTTP API: router, handlers, and OpenAPI document.
|
||||||
|
|
||||||
|
mod admin;
|
||||||
mod health;
|
mod health;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
mod public;
|
mod public;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use db::Db;
|
use db::Db;
|
||||||
|
use time::Duration;
|
||||||
|
use tower_sessions::cookie::SameSite;
|
||||||
|
use tower_sessions::{Expiry, SessionManagerLayer};
|
||||||
|
use tower_sessions_sqlx_store::PostgresStore;
|
||||||
|
|
||||||
/// Shared application state passed to handlers.
|
/// Shared application state passed to handlers.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -14,13 +19,34 @@ pub struct AppState {
|
|||||||
pub db: Db,
|
pub db: Db,
|
||||||
/// User-facing product name (from config). Never hardcoded.
|
/// User-facing product name (from config). Never hardcoded.
|
||||||
pub app_name: String,
|
pub app_name: String,
|
||||||
|
/// Whether the session cookie carries the `Secure` attribute (default true;
|
||||||
|
/// disable only for plain-HTTP self-hosting).
|
||||||
|
pub cookie_secure: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the application router from shared state.
|
/// Build the application router from shared state.
|
||||||
pub fn build_app(state: AppState) -> Router {
|
pub fn build_app(state: AppState) -> Router {
|
||||||
|
let store = PostgresStore::new(state.db.pool().clone());
|
||||||
|
|
||||||
|
let session_layer = SessionManagerLayer::new(store)
|
||||||
|
.with_name("id")
|
||||||
|
.with_http_only(true)
|
||||||
|
.with_secure(state.cookie_secure)
|
||||||
|
.with_same_site(SameSite::Strict)
|
||||||
|
.with_expiry(Expiry::OnInactivity(Duration::hours(8)));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(health::routes())
|
.merge(health::routes())
|
||||||
.merge(openapi::routes())
|
.merge(openapi::routes())
|
||||||
.merge(public::routes())
|
.merge(public::routes())
|
||||||
|
.merge(admin::routes())
|
||||||
|
.layer(session_layer)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create the session store's table if absent. Run once at startup (and in tests
|
||||||
|
/// before exercising auth). Separate from `Db::migrate` — the session library's own
|
||||||
|
/// bookkeeping table.
|
||||||
|
pub async fn migrate_sessions(db: &Db) -> Result<(), sqlx::Error> {
|
||||||
|
PostgresStore::new(db.pool().clone()).migrate().await
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
use axum::{Json, Router, extract::State, routing::get};
|
use axum::{Json, Router, extract::State, routing::get};
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
use crate::{AppState, health, public};
|
use crate::{AppState, admin, health, public};
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(health::live, health::ready, public::list_objects, public::get_object),
|
paths(
|
||||||
|
health::live,
|
||||||
|
health::ready,
|
||||||
|
public::list_objects,
|
||||||
|
public::get_object,
|
||||||
|
admin::login,
|
||||||
|
admin::logout,
|
||||||
|
admin::me,
|
||||||
|
admin::list_users,
|
||||||
|
admin::set_visibility
|
||||||
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
health::Live,
|
health::Live,
|
||||||
health::Ready,
|
health::Ready,
|
||||||
public::PublicView,
|
public::PublicView,
|
||||||
public::PublicObjectPage
|
public::PublicObjectPage,
|
||||||
|
admin::LoginRequest,
|
||||||
|
admin::UserView,
|
||||||
|
admin::VisibilityRequest
|
||||||
)),
|
)),
|
||||||
info(title = "Collection Management System", version = "0.0.0")
|
info(title = "Collection Management System", version = "0.0.0")
|
||||||
)]
|
)]
|
||||||
@@ -20,7 +33,9 @@ struct ApiDoc;
|
|||||||
/// product name is never hardcoded.
|
/// product name is never hardcoded.
|
||||||
async fn openapi_json(State(state): State<AppState>) -> Json<utoipa::openapi::OpenApi> {
|
async fn openapi_json(State(state): State<AppState>) -> Json<utoipa::openapi::OpenApi> {
|
||||||
let mut doc = ApiDoc::openapi();
|
let mut doc = ApiDoc::openapi();
|
||||||
|
|
||||||
doc.info.title = state.app_name.clone();
|
doc.info.title = state.app_name.clone();
|
||||||
|
|
||||||
Json(doc)
|
Json(doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
use api::{AppState, build_app, migrate_sessions};
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode, header};
|
||||||
|
use db::{catalog, users};
|
||||||
|
use domain::{AuditActor, Email, NewUser, ObjectInput, Role, Visibility};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn state(pool: PgPool) -> AppState {
|
||||||
|
AppState {
|
||||||
|
db: db::Db::from_pool(pool),
|
||||||
|
app_name: "Test".into(),
|
||||||
|
cookie_secure: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
|
||||||
|
let db = db::Db::from_pool(pool.clone());
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
|
||||||
|
users::create_user(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&NewUser {
|
||||||
|
email: Email::parse(email).unwrap(),
|
||||||
|
password_hash: auth::hash_password(password).unwrap(),
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_request(email: &str, password: &str) -> Request<Body> {
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/admin/login")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(format!(
|
||||||
|
r#"{{"email":"{email}","password":"{password}"}}"#
|
||||||
|
)))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_cookie(resp: &axum::http::Response<Body>) -> String {
|
||||||
|
let raw = resp
|
||||||
|
.headers()
|
||||||
|
.get(header::SET_COOKIE)
|
||||||
|
.expect("Set-Cookie")
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
raw.split(';').next().unwrap().to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn login_then_me_returns_identity(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "admin@example.com", "s3cret-passw0rd", Role::Admin).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(login_request("admin@example.com", "s3cret-passw0rd"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let cookie = session_cookie(&resp);
|
||||||
|
|
||||||
|
let me = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/admin/me")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(me.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let json: serde_json::Value =
|
||||||
|
serde_json::from_slice(&me.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
assert_eq!(json["email"], "admin@example.com");
|
||||||
|
assert_eq!(json["role"], "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn me_without_session_is_401(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/admin/me")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn wrong_password_is_401(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "admin@example.com", "right", Role::Admin).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.oneshot(login_request("admin@example.com", "wrong"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn editor_cannot_list_users_but_admin_can(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
seed_user(&pool, "admin@example.com", "pw-admin-123", Role::Admin).await;
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(login_request("editor@example.com", "pw-editor-123"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let editor_cookie = session_cookie(&resp);
|
||||||
|
|
||||||
|
let listed = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/admin/users")
|
||||||
|
.header(header::COOKIE, &editor_cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(listed.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(login_request("admin@example.com", "pw-admin-123"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let admin_cookie = session_cookie(&resp);
|
||||||
|
|
||||||
|
let listed = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/admin/users")
|
||||||
|
.header(header::COOKIE, &admin_cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(listed.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn editor_can_publish_via_admin_endpoint(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
|
||||||
|
|
||||||
|
let db = db::Db::from_pool(pool.clone());
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
|
||||||
|
let id = catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&ObjectInput {
|
||||||
|
object_number: "P-1".into(),
|
||||||
|
object_name: "vase".into(),
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: None,
|
||||||
|
current_location: None,
|
||||||
|
current_owner: None,
|
||||||
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
|
visibility: Visibility::Internal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(login_request("editor@example.com", "pw-editor-123"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let cookie = session_cookie(&resp);
|
||||||
|
|
||||||
|
let publish = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(format!("/api/admin/objects/{id}/visibility"))
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"visibility":"public"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(publish.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(obj.visibility, Visibility::Public);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ fn state(pool: PgPool, app_name: &str) -> AppState {
|
|||||||
AppState {
|
AppState {
|
||||||
db: db::Db::from_pool(pool),
|
db: db::Db::from_pool(pool),
|
||||||
app_name: app_name.to_string(),
|
app_name: app_name.to_string(),
|
||||||
|
cookie_secure: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
AppState {
|
AppState {
|
||||||
db: db::Db::from_pool(pool),
|
db: db::Db::from_pool(pool),
|
||||||
app_name: "Test".to_string(),
|
app_name: "Test".to_string(),
|
||||||
|
cookie_secure: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user