Server-side foundation for Phase-2 groups (no group-owned resources yet):
Shared types:
- GroupId, Group; App gains group_id; AppRole::{precedence,max} for
folding the highest effective role across the membership chain.
Repos:
- group_repo: tree CRUD with reparent (ancestor-walk cycle guard under a
coarse instance-wide structural advisory lock; slug frozen; bumps
structure_version) and delete=RESTRICT (refuses non-empty groups).
- group_members_repo: per-(user, group) role grants, mirroring app_members.
Hierarchy-aware authz (§5.3):
- AuthzRepo gains effective_app_role / effective_group_role (default to
direct membership / none, so the ~18 existing test stubs are untouched);
the Postgres impl resolves each via one depth-bounded recursive CTE that
MAXes the app's own row with every ancestor group_members row.
- can(): the Member path now folds inherited group roles, so a group_admin
on any ancestor is implicitly app_admin beneath it. New Capability
variants InstanceCreateGroup / Group{Read,Write,Admin}; group caps carry
no app_id (bound API keys can't manage groups). 8 new unit tests.
Admin API:
- groups_api: group CRUD + reparent (admin at both source and destination
parent, §5.6) + per-group members, all capability-gated.
- apps: POST /apps takes an optional parent group (default root); app
responses carry group_id; my_role now reflects the effective role.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
751 lines
24 KiB
Rust
751 lines
24 KiB
Rust
//! `/api/v1/admin/apps/{id}/topics*` — topic registration admin
|
|
//! endpoints (v1.1.6).
|
|
//!
|
|
//! These manage the `topics` table: the explicit registry of which
|
|
//! pub/sub topics are externally subscribable over SSE (design notes
|
|
//! §5). Internal-only topics never appear here.
|
|
//!
|
|
//! * `POST /apps/{id}/topics` — register a topic.
|
|
//! * `GET /apps/{id}/topics` — list registered topics.
|
|
//! * `PATCH /apps/{id}/topics/{name}` — flip external/auth_mode.
|
|
//! * `DELETE /apps/{id}/topics/{name}` — unregister + disconnect.
|
|
//!
|
|
//! The PATCH endpoint is deliberately its OWN surface (not folded into a
|
|
//! generic topic update) so every change to externally-subscribable
|
|
//! status is a discrete, watchable/auditable API call (§5 commitment).
|
|
//!
|
|
//! Create / update / delete are gated by `AppTopicManage` (→ `app:admin`
|
|
//! scope); list is gated by the existing `AppRead`. DELETE also drops
|
|
//! the topic's in-process broadcast channel so live SSE subscribers
|
|
//! disconnect cleanly.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::extract::{Path, State};
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Json, Response};
|
|
use axum::routing::{get, patch};
|
|
use axum::{Extension, Router};
|
|
use picloud_shared::{AppId, Principal, RealtimeBroadcaster};
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
|
|
use crate::app_repo::AppRepository;
|
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
|
use crate::topic_repo::{Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
|
|
|
#[derive(Clone)]
|
|
pub struct TopicsState {
|
|
pub topics: Arc<dyn TopicRepo>,
|
|
pub apps: Arc<dyn AppRepository>,
|
|
pub authz: Arc<dyn AuthzRepo>,
|
|
pub broadcaster: Arc<dyn RealtimeBroadcaster>,
|
|
}
|
|
|
|
pub fn topics_router(state: TopicsState) -> Router {
|
|
Router::new()
|
|
.route("/apps/{app_id}/topics", get(list_topics).post(create_topic))
|
|
.route(
|
|
"/apps/{app_id}/topics/{name}",
|
|
patch(update_topic).delete(delete_topic),
|
|
)
|
|
.with_state(state)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateTopicRequest {
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub external_subscribable: bool,
|
|
#[serde(default = "default_auth_mode")]
|
|
pub auth_mode: TopicAuthMode,
|
|
}
|
|
|
|
const fn default_auth_mode() -> TopicAuthMode {
|
|
TopicAuthMode::Public
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateTopicRequest {
|
|
#[serde(default)]
|
|
pub external_subscribable: Option<bool>,
|
|
#[serde(default)]
|
|
pub auth_mode: Option<TopicAuthMode>,
|
|
}
|
|
|
|
/// Topic names are concrete (external pattern subscription is v1.2), so
|
|
/// reject empties and `*` wildcards at registration.
|
|
fn validate_topic_name(name: &str) -> Result<(), TopicsApiError> {
|
|
if name.trim().is_empty() {
|
|
return Err(TopicsApiError::Invalid(
|
|
"topic name must not be empty".into(),
|
|
));
|
|
}
|
|
if name.contains('*') {
|
|
return Err(TopicsApiError::Invalid(
|
|
"topic name must be a concrete topic, not a pattern (no '*')".into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn create_topic(
|
|
State(s): State<TopicsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Json(input): Json<CreateTopicRequest>,
|
|
) -> Result<(StatusCode, Json<Topic>), TopicsApiError> {
|
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppTopicManage(app_id),
|
|
)
|
|
.await?;
|
|
validate_topic_name(&input.name)?;
|
|
let topic = s
|
|
.topics
|
|
.create(
|
|
app_id,
|
|
input.name.trim(),
|
|
input.external_subscribable,
|
|
input.auth_mode,
|
|
)
|
|
.await?;
|
|
Ok((StatusCode::CREATED, Json(topic)))
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
struct ListTopicsResponse {
|
|
topics: Vec<Topic>,
|
|
}
|
|
|
|
async fn list_topics(
|
|
State(s): State<TopicsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
) -> Result<Json<ListTopicsResponse>, TopicsApiError> {
|
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
|
require(s.authz.as_ref(), &principal, Capability::AppRead(app_id)).await?;
|
|
let topics = s.topics.list(app_id).await?;
|
|
Ok(Json(ListTopicsResponse { topics }))
|
|
}
|
|
|
|
async fn update_topic(
|
|
State(s): State<TopicsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((id_or_slug, name)): Path<(String, String)>,
|
|
Json(input): Json<UpdateTopicRequest>,
|
|
) -> Result<Json<Topic>, TopicsApiError> {
|
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppTopicManage(app_id),
|
|
)
|
|
.await?;
|
|
let topic = s
|
|
.topics
|
|
.update(app_id, &name, input.external_subscribable, input.auth_mode)
|
|
.await?
|
|
.ok_or(TopicsApiError::NotFound)?;
|
|
Ok(Json(topic))
|
|
}
|
|
|
|
async fn delete_topic(
|
|
State(s): State<TopicsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((id_or_slug, name)): Path<(String, String)>,
|
|
) -> Result<StatusCode, TopicsApiError> {
|
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppTopicManage(app_id),
|
|
)
|
|
.await?;
|
|
if !s.topics.delete(app_id, &name).await? {
|
|
return Err(TopicsApiError::NotFound);
|
|
}
|
|
// Disconnect any live SSE subscribers for the now-unregistered topic.
|
|
s.broadcaster.drop_topic(app_id, &name).await;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, TopicsApiError> {
|
|
crate::app_repo::resolve_app(apps, ident)
|
|
.await
|
|
.map_err(|e| TopicsApiError::Backend(e.to_string()))?
|
|
.map(|l| l.app.id)
|
|
.ok_or(TopicsApiError::AppNotFound)
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum TopicsApiError {
|
|
#[error("app not found")]
|
|
AppNotFound,
|
|
#[error("topic not found")]
|
|
NotFound,
|
|
#[error("{0}")]
|
|
AlreadyExists(String),
|
|
#[error("invalid request: {0}")]
|
|
Invalid(String),
|
|
#[error("forbidden")]
|
|
Forbidden,
|
|
#[error("authorization repo error: {0}")]
|
|
AuthzRepo(String),
|
|
#[error("topics backend: {0}")]
|
|
Backend(String),
|
|
}
|
|
|
|
impl From<AuthzDenied> for TopicsApiError {
|
|
fn from(d: AuthzDenied) -> Self {
|
|
match d {
|
|
AuthzDenied::Denied => Self::Forbidden,
|
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<AuthzError> for TopicsApiError {
|
|
fn from(e: AuthzError) -> Self {
|
|
Self::AuthzRepo(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<TopicRepoError> for TopicsApiError {
|
|
fn from(e: TopicRepoError) -> Self {
|
|
match e {
|
|
TopicRepoError::AlreadyExists(name) => {
|
|
Self::AlreadyExists(format!("a topic named {name:?} already exists in this app"))
|
|
}
|
|
other => Self::Backend(other.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for TopicsApiError {
|
|
fn into_response(self) -> Response {
|
|
let (status, body) = match &self {
|
|
Self::AppNotFound | Self::NotFound => {
|
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
|
}
|
|
Self::AlreadyExists(_) => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
|
Self::Invalid(_) => (
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
json!({ "error": self.to_string() }),
|
|
),
|
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
|
Self::AuthzRepo(e) => {
|
|
tracing::error!(error = %e, "topics admin authz repo error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
json!({ "error": "internal error" }),
|
|
)
|
|
}
|
|
Self::Backend(e) => {
|
|
tracing::error!(error = %e, "topics admin backend error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
json!({ "error": "internal error" }),
|
|
)
|
|
}
|
|
};
|
|
(status, Json(body)).into_response()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
//! In-memory handler tests: capability enforcement, the
|
|
//! `external_subscribable` default, the flip being its own endpoint,
|
|
//! cross-app isolation, and DELETE disconnecting subscribers. The
|
|
//! Postgres repo is exercised by the schema + integration suites.
|
|
|
|
use super::*;
|
|
use crate::repo::ScriptRepositoryError;
|
|
use async_trait::async_trait;
|
|
use chrono::Utc;
|
|
use picloud_shared::{
|
|
AdminUserId, App, AppRole, BroadcasterError, InstanceRole, RealtimeEvent, UserId,
|
|
};
|
|
use std::collections::HashMap;
|
|
use std::sync::Mutex as StdMutex;
|
|
use tokio::sync::Mutex;
|
|
|
|
#[derive(Default)]
|
|
struct InMemoryTopicRepo {
|
|
inner: Mutex<HashMap<(AppId, String), Topic>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl TopicRepo for InMemoryTopicRepo {
|
|
async fn create(
|
|
&self,
|
|
app_id: AppId,
|
|
name: &str,
|
|
external_subscribable: bool,
|
|
auth_mode: TopicAuthMode,
|
|
) -> Result<Topic, TopicRepoError> {
|
|
let mut g = self.inner.lock().await;
|
|
if g.contains_key(&(app_id, name.to_string())) {
|
|
return Err(TopicRepoError::AlreadyExists(name.to_string()));
|
|
}
|
|
let now = Utc::now();
|
|
let t = Topic {
|
|
name: name.to_string(),
|
|
external_subscribable,
|
|
auth_mode,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
g.insert((app_id, name.to_string()), t.clone());
|
|
Ok(t)
|
|
}
|
|
async fn list(&self, app_id: AppId) -> Result<Vec<Topic>, TopicRepoError> {
|
|
let g = self.inner.lock().await;
|
|
let mut v: Vec<Topic> = g
|
|
.iter()
|
|
.filter(|((a, _), _)| *a == app_id)
|
|
.map(|(_, t)| t.clone())
|
|
.collect();
|
|
v.sort_by(|a, b| a.name.cmp(&b.name));
|
|
Ok(v)
|
|
}
|
|
async fn get(&self, app_id: AppId, name: &str) -> Result<Option<Topic>, TopicRepoError> {
|
|
Ok(self
|
|
.inner
|
|
.lock()
|
|
.await
|
|
.get(&(app_id, name.to_string()))
|
|
.cloned())
|
|
}
|
|
async fn update(
|
|
&self,
|
|
app_id: AppId,
|
|
name: &str,
|
|
external_subscribable: Option<bool>,
|
|
auth_mode: Option<TopicAuthMode>,
|
|
) -> Result<Option<Topic>, TopicRepoError> {
|
|
let mut g = self.inner.lock().await;
|
|
let Some(t) = g.get_mut(&(app_id, name.to_string())) else {
|
|
return Ok(None);
|
|
};
|
|
if let Some(e) = external_subscribable {
|
|
t.external_subscribable = e;
|
|
}
|
|
if let Some(m) = auth_mode {
|
|
t.auth_mode = m;
|
|
}
|
|
t.updated_at = Utc::now();
|
|
Ok(Some(t.clone()))
|
|
}
|
|
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, TopicRepoError> {
|
|
Ok(self
|
|
.inner
|
|
.lock()
|
|
.await
|
|
.remove(&(app_id, name.to_string()))
|
|
.is_some())
|
|
}
|
|
}
|
|
|
|
/// In-memory `AppRepository` for handler-level tests. Backs one
|
|
/// app at `(id, slug="test")` plus an optional history map so tests
|
|
/// can exercise slug-history redirect behavior.
|
|
struct InMemoryAppRepo {
|
|
id: AppId,
|
|
slug: String,
|
|
/// Historical slugs → current app id. Empty unless the test
|
|
/// explicitly registers one.
|
|
history: std::sync::Mutex<std::collections::HashMap<String, AppId>>,
|
|
}
|
|
impl InMemoryAppRepo {
|
|
fn new(id: AppId) -> Self {
|
|
Self {
|
|
id,
|
|
slug: "test".into(),
|
|
history: std::sync::Mutex::new(std::collections::HashMap::new()),
|
|
}
|
|
}
|
|
#[allow(dead_code)]
|
|
fn with_history(self, old_slug: &str) -> Self {
|
|
self.history
|
|
.lock()
|
|
.unwrap()
|
|
.insert(old_slug.to_string(), self.id);
|
|
self
|
|
}
|
|
fn current_app(&self) -> App {
|
|
let now = Utc::now();
|
|
App {
|
|
id: self.id,
|
|
slug: self.slug.clone(),
|
|
name: "test".into(),
|
|
description: None,
|
|
group_id: picloud_shared::GroupId::new(),
|
|
created_at: now,
|
|
updated_at: now,
|
|
}
|
|
}
|
|
}
|
|
#[async_trait]
|
|
impl AppRepository for InMemoryAppRepo {
|
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn list_for_group(
|
|
&self,
|
|
_: picloud_shared::GroupId,
|
|
) -> Result<Vec<App>, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
|
if id != self.id {
|
|
return Ok(None);
|
|
}
|
|
Ok(Some(self.current_app()))
|
|
}
|
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
|
if slug == self.slug {
|
|
Ok(Some(self.current_app()))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
async fn get_by_slug_or_history(
|
|
&self,
|
|
slug: &str,
|
|
) -> Result<Option<crate::app_repo::AppLookup>, ScriptRepositoryError> {
|
|
if slug == self.slug {
|
|
return Ok(Some(crate::app_repo::AppLookup {
|
|
app: self.current_app(),
|
|
redirected: false,
|
|
}));
|
|
}
|
|
let history = self.history.lock().unwrap();
|
|
if history.contains_key(slug) {
|
|
return Ok(Some(crate::app_repo::AppLookup {
|
|
app: self.current_app(),
|
|
redirected: true,
|
|
}));
|
|
}
|
|
Ok(None)
|
|
}
|
|
async fn slug_in_history(&self, _: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn create(
|
|
&self,
|
|
_: &str,
|
|
_: &str,
|
|
_: Option<&str>,
|
|
_: picloud_shared::GroupId,
|
|
) -> Result<App, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn create_with_takeover(
|
|
&self,
|
|
_: &str,
|
|
_: &str,
|
|
_: Option<&str>,
|
|
_: picloud_shared::GroupId,
|
|
) -> Result<App, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn update(
|
|
&self,
|
|
_: AppId,
|
|
_: Option<&str>,
|
|
_: Option<Option<&str>>,
|
|
) -> Result<App, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn rename_slug(
|
|
&self,
|
|
_: AppId,
|
|
_: &str,
|
|
_: bool,
|
|
) -> Result<App, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn delete(&self, _: AppId) -> Result<(), ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn delete_cascade(&self, _: AppId) -> Result<(), ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn count_scripts_in_app(&self, _: AppId) -> Result<i64, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
}
|
|
|
|
/// Grants `AppAdmin` only for `granted_app`; denies elsewhere — used
|
|
/// for the cross-app isolation test.
|
|
struct PerAppAuthzRepo {
|
|
granted_app: AppId,
|
|
}
|
|
#[async_trait]
|
|
impl AuthzRepo for PerAppAuthzRepo {
|
|
async fn membership(
|
|
&self,
|
|
_: UserId,
|
|
app_id: AppId,
|
|
) -> Result<Option<AppRole>, AuthzError> {
|
|
Ok((app_id == self.granted_app).then_some(AppRole::AppAdmin))
|
|
}
|
|
}
|
|
|
|
struct DenyAuthzRepo;
|
|
#[async_trait]
|
|
impl AuthzRepo for DenyAuthzRepo {
|
|
async fn membership(&self, _: UserId, _: AppId) -> Result<Option<AppRole>, AuthzError> {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct RecordingBroadcaster {
|
|
dropped: StdMutex<Vec<(AppId, String)>>,
|
|
}
|
|
#[async_trait]
|
|
impl RealtimeBroadcaster for RecordingBroadcaster {
|
|
async fn subscribe(
|
|
&self,
|
|
_: AppId,
|
|
_: &str,
|
|
) -> Result<tokio::sync::broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
|
|
unimplemented!()
|
|
}
|
|
async fn publish(&self, _: AppId, _: &str, _: RealtimeEvent) {}
|
|
async fn drop_topic(&self, app_id: AppId, topic: &str) {
|
|
self.dropped
|
|
.lock()
|
|
.unwrap()
|
|
.push((app_id, topic.to_string()));
|
|
}
|
|
}
|
|
|
|
fn member() -> Principal {
|
|
Principal {
|
|
user_id: AdminUserId::new(),
|
|
instance_role: InstanceRole::Member,
|
|
scopes: None,
|
|
app_binding: None,
|
|
}
|
|
}
|
|
|
|
fn state(app_id: AppId, authz: Arc<dyn AuthzRepo>) -> (TopicsState, Arc<RecordingBroadcaster>) {
|
|
let bc = Arc::new(RecordingBroadcaster::default());
|
|
let state = TopicsState {
|
|
topics: Arc::new(InMemoryTopicRepo::default()),
|
|
apps: Arc::new(InMemoryAppRepo::new(app_id)),
|
|
authz,
|
|
broadcaster: bc.clone(),
|
|
};
|
|
(state, bc)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn register_defaults_external_subscribable_false() {
|
|
let app = AppId::new();
|
|
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
|
let (status, Json(topic)) = create_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path(app.to_string()),
|
|
Json(CreateTopicRequest {
|
|
name: "chat".into(),
|
|
external_subscribable: false,
|
|
auth_mode: TopicAuthMode::Public,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
assert!(!topic.external_subscribable);
|
|
assert_eq!(topic.name, "chat");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn flip_requires_app_admin_role() {
|
|
let app = AppId::new();
|
|
// Topic exists; the caller has no role → PATCH is forbidden.
|
|
let (s, _) = state(app, Arc::new(DenyAuthzRepo));
|
|
s.topics
|
|
.create(app, "chat", false, TopicAuthMode::Public)
|
|
.await
|
|
.unwrap();
|
|
let err = update_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path((app.to_string(), "chat".to_string())),
|
|
Json(UpdateTopicRequest {
|
|
external_subscribable: Some(true),
|
|
auth_mode: None,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, TopicsApiError::Forbidden));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn flip_is_its_own_endpoint_and_toggles_external() {
|
|
// The PATCH handler is a distinct surface from create; flipping
|
|
// external_subscribable false→true is a single discrete call.
|
|
let app = AppId::new();
|
|
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
|
s.topics
|
|
.create(app, "chat", false, TopicAuthMode::Public)
|
|
.await
|
|
.unwrap();
|
|
let Json(updated) = update_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path((app.to_string(), "chat".to_string())),
|
|
Json(UpdateTopicRequest {
|
|
external_subscribable: Some(true),
|
|
auth_mode: Some(TopicAuthMode::Token),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(updated.external_subscribable);
|
|
assert_eq!(updated.auth_mode, TopicAuthMode::Token);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_disconnects_subscribers() {
|
|
let app = AppId::new();
|
|
let (s, bc) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
|
s.topics
|
|
.create(app, "chat", true, TopicAuthMode::Public)
|
|
.await
|
|
.unwrap();
|
|
let status = delete_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path((app.to_string(), "chat".to_string())),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(status, StatusCode::NO_CONTENT);
|
|
assert_eq!(
|
|
bc.dropped.lock().unwrap().as_slice(),
|
|
&[(app, "chat".to_string())]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cross_app_admin_cannot_manage_other_app() {
|
|
let app_a = AppId::new();
|
|
let app_b = AppId::new();
|
|
// Caller is admin of app A only; both apps exist via separate state.
|
|
let authz = Arc::new(PerAppAuthzRepo { granted_app: app_a });
|
|
// App-B-scoped state, but the caller only has A's grant.
|
|
let (s, _) = state(app_b, authz);
|
|
let err = create_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path(app_b.to_string()),
|
|
Json(CreateTopicRequest {
|
|
name: "chat".into(),
|
|
external_subscribable: true,
|
|
auth_mode: TopicAuthMode::Public,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, TopicsApiError::Forbidden));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pattern_name_rejected() {
|
|
let app = AppId::new();
|
|
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
|
let err = create_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path(app.to_string()),
|
|
Json(CreateTopicRequest {
|
|
name: "user.*".into(),
|
|
external_subscribable: true,
|
|
auth_mode: TopicAuthMode::Public,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, TopicsApiError::Invalid(_)));
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Slug-or-UUID path acceptance (post-aa493b9 refactor).
|
|
// Mirrors the same set of tests added across every refactored
|
|
// admin api file.
|
|
// ----------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn list_accepts_slug_path() {
|
|
let app = AppId::new();
|
|
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
|
let Json(resp) = list_topics(State(s), Extension(member()), Path("test".into()))
|
|
.await
|
|
.expect("slug path should resolve");
|
|
assert_eq!(resp.topics.len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_unknown_slug_returns_app_not_found() {
|
|
let app = AppId::new();
|
|
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
|
let err = list_topics(State(s), Extension(member()), Path("does-not-exist".into()))
|
|
.await
|
|
.expect_err("unknown slug should 404");
|
|
assert!(matches!(err, TopicsApiError::AppNotFound));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_historical_slug_resolves_silently() {
|
|
// Locks in current behavior: a request via an old slug after a
|
|
// rename returns OK with the renamed app's data, no signal back
|
|
// to the caller. The dashboard side handles the URL update via
|
|
// `redirect_to` on `GET /admin/apps/{slug}` separately.
|
|
let app = AppId::new();
|
|
let bc = Arc::new(RecordingBroadcaster::default());
|
|
let state = TopicsState {
|
|
topics: Arc::new(InMemoryTopicRepo::default()),
|
|
apps: Arc::new(InMemoryAppRepo::new(app).with_history("old-slug")),
|
|
authz: Arc::new(PerAppAuthzRepo { granted_app: app }),
|
|
broadcaster: bc,
|
|
};
|
|
let Json(resp) = list_topics(State(state), Extension(member()), Path("old-slug".into()))
|
|
.await
|
|
.expect("historical slug should resolve to the renamed app");
|
|
assert_eq!(resp.topics.len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_accepts_slug_path() {
|
|
let app = AppId::new();
|
|
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
|
let (status, Json(topic)) = create_topic(
|
|
State(s),
|
|
Extension(member()),
|
|
Path("test".into()),
|
|
Json(CreateTopicRequest {
|
|
name: "chat".into(),
|
|
external_subscribable: false,
|
|
auth_mode: TopicAuthMode::Public,
|
|
}),
|
|
)
|
|
.await
|
|
.expect("slug-pathed mutation should succeed");
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
assert_eq!(topic.name, "chat");
|
|
}
|
|
}
|