diff --git a/Cargo.toml b/Cargo.toml index ca9fdf0..ade5295 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,10 @@ config = "0.14" # Database rusqlite = { version = "0.32", features = ["bundled"] } +# Cryptography (API key hashing) +sha2 = "0.10" +hex = "0.4" + # Internal crates postghost = { path = "crates/postghost" } postghost-server = { path = "crates/postghost-server" } diff --git a/crates/postghost-cli/src/cli.rs b/crates/postghost-cli/src/cli.rs index 43730a0..e5b6da2 100644 --- a/crates/postghost-cli/src/cli.rs +++ b/crates/postghost-cli/src/cli.rs @@ -87,6 +87,30 @@ pub enum Commands { state: Option, }, + /// Manage agent API keys + Key { + #[command(subcommand)] + command: KeyCommands, + }, + /// Show server health and Iris connection status Doctor, } + +#[derive(Subcommand)] +pub enum KeyCommands { + /// Create a new agent API key + Create { + /// Human-readable name for the key + #[arg(long)] + name: String, + }, + /// List all agent API keys (without raw values) + List, + /// Revoke an agent API key by id + Revoke { + /// Key id + #[arg(long)] + id: String, + }, +} diff --git a/crates/postghost-cli/src/main.rs b/crates/postghost-cli/src/main.rs index 7756680..e08b126 100644 --- a/crates/postghost-cli/src/main.rs +++ b/crates/postghost-cli/src/main.rs @@ -178,6 +178,81 @@ async fn main() -> Result<()> { } } } + + Commands::Key { command } => match command { + cli::KeyCommands::Create { name } => { + let resp = client + .post(format!("{}/api/v1/keys", base)) + .json(&json!({ "name": name })) + .send() + .await?; + if resp.status().is_success() { + let body: serde_json::Value = resp.json().await?; + // Surface the raw key prominently — it's only returned once. + if let Some(key) = body.get("key").and_then(|v| v.as_str()) { + println!("API key created. Store this securely — it won't be shown again:"); + println!(" {}", key); + println!(); + println!( + "id: {}", + body.get("id").and_then(|v| v.as_str()).unwrap_or("?") + ); + println!( + "name: {}", + body.get("name").and_then(|v| v.as_str()).unwrap_or("?") + ); + println!( + "created_at: {}", + body.get("created_at") + .and_then(|v| v.as_str()) + .unwrap_or("?") + ); + } else { + println!("{}", serde_json::to_string_pretty(&body)?); + } + } else { + eprintln!("Error: HTTP {} — {}", resp.status(), resp.text().await?); + } + } + cli::KeyCommands::List => { + let resp = client.get(format!("{}/api/v1/keys", base)).send().await?; + if resp.status().is_success() { + let body: serde_json::Value = resp.json().await?; + if let Some(keys) = body.get("keys").and_then(|k| k.as_array()) { + if keys.is_empty() { + println!("No agent keys."); + } else { + for k in keys { + println!( + "{} {} created={} last_used={}", + k.get("id").and_then(|v| v.as_str()).unwrap_or("?"), + k.get("name").and_then(|v| v.as_str()).unwrap_or("?"), + k.get("created_at").and_then(|v| v.as_str()).unwrap_or("?"), + k.get("last_used_at") + .and_then(|v| v.as_str()) + .unwrap_or("never"), + ); + } + } + } + } else { + eprintln!("Error: HTTP {} — {}", resp.status(), resp.text().await?); + } + } + cli::KeyCommands::Revoke { id } => { + let resp = client + .delete(format!("{}/api/v1/keys/{}", base, id)) + .send() + .await?; + if resp.status() == reqwest::StatusCode::NO_CONTENT { + println!("Key {} revoked.", id); + } else if resp.status() == reqwest::StatusCode::NOT_FOUND { + eprintln!("Key {} not found.", id); + } else { + eprintln!("Error: HTTP {} — {}", resp.status(), resp.text().await?); + } + } + }, } Ok(()) diff --git a/crates/postghost-server/Cargo.toml b/crates/postghost-server/Cargo.toml index 45db616..6a5a1e0 100644 --- a/crates/postghost-server/Cargo.toml +++ b/crates/postghost-server/Cargo.toml @@ -23,3 +23,9 @@ config.workspace = true chrono.workspace = true uuid.workspace = true rusqlite.workspace = true +sha2.workspace = true +hex.workspace = true + +[dev-dependencies] +tower = { workspace = true, features = ["util"] } +http-body-util = "0.1" diff --git a/crates/postghost-server/src/agent_api.rs b/crates/postghost-server/src/agent_api.rs new file mode 100644 index 0000000..18eea61 --- /dev/null +++ b/crates/postghost-server/src/agent_api.rs @@ -0,0 +1,424 @@ +//! Agent API surface — LLM-first endpoints protected by API key auth. +//! +//! All `/agent/*` routes require a valid bearer token (see `auth.rs`). +//! These endpoints are designed for programmatic consumption: structured +//! JSON responses, machine-readable errors, and idempotent draft creation. + +use std::sync::Arc; + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::Json, + routing::{delete, get, post}, + Router, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::api::AppState; +use crate::auth::AuthenticatedAgent; +use crate::storage::AgentKeyRow; + +/// Idempotency window for `POST /agent/draft`: a repeat POST with the same +/// title+body within this window returns the existing content id instead of +/// creating a duplicate. +const IDEMPOTENCY_WINDOW_SECS: i64 = 60; + +// ============================================================================ +// Router +// ============================================================================ + +/// Build the `/agent/*` router. Routes are layered with `require_api_key` +/// middleware by the caller (see `api::build_router_from_arc`). +pub fn agent_routes() -> Router> { + Router::new() + .route("/agent/draft", post(create_draft)) + .route("/agent/posts", get(list_posts)) + .route( + "/agent/posts/{id}", + get(get_post).put(update_post).delete(delete_post), + ) + .route("/agent/posts/{id}/schedule", post(schedule_post)) + .route("/agent/calendar", get(calendar)) +} + +/// Build the `/api/v1/keys*` router. These routes are NOT behind agent auth — +/// they are intended for the local operator (via CLI) to bootstrap keys. +/// In a production deployment you would put these behind a different auth +/// layer or restrict to localhost. +pub fn key_management_routes() -> Router> { + Router::new() + .route("/api/v1/keys", get(list_keys).post(create_key)) + .route("/api/v1/keys/{id}", delete(delete_key)) +} + +// ============================================================================ +// Agent endpoints +// ============================================================================ + +#[derive(Debug, Deserialize)] +pub struct CreateDraftRequest { + pub title: String, + pub body: String, + #[serde(default = "default_format")] + pub format: String, + /// Optional platform targets for future formatting. + #[serde(default)] + pub platform_targets: Vec, + #[serde(default)] + pub tags: Vec, +} + +fn default_format() -> String { + "markdown".to_string() +} + +/// `POST /agent/draft` — create a draft. +/// +/// Idempotent: if a content item with the same title+body was created within +/// the last 60 seconds, returns the existing id with a 200 status instead of +/// creating a duplicate (which would return 201). +pub async fn create_draft( + State(state): State>, + _auth: axum::Extension, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + let now = Utc::now(); + let id = Uuid::new_v4(); + let content = postghost::Content { + id, + title: req.title.clone(), + body: req.body.clone(), + format: serde_json::from_str(&format!("\"{}\"", req.format)) + .unwrap_or(postghost::ContentFormat::Markdown), + tags: req.tags.clone(), + created_at: now, + updated_at: now, + variants: vec![], + }; + + // Atomic idempotent save: lookup + insert under a single mutex lock. + let (saved_id, was_duplicate) = state + .storage + .save_content_if_not_recent_duplicate(&content, IDEMPOTENCY_WINDOW_SECS) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let status = if was_duplicate { + StatusCode::OK + } else { + StatusCode::CREATED + }; + Ok(( + status, + Json(json!({ + "id": saved_id, + "title": req.title, + "format": content.format, + "tags": content.tags, + "platform_targets": req.platform_targets, + "created_at": content.created_at.to_rfc3339(), + "duplicate": was_duplicate, + "message": if was_duplicate { + "content with same title+body created within last 60s; returning existing id" + } else { + "" + }, + })), + )) +} + +/// Query filters for `GET /agent/posts`. +#[derive(Debug, Deserialize)] +pub struct ListPostsQuery { + /// Filter by workflow state (e.g. "draft", "in_review", "approved"). + pub state: Option, + /// Filter by platform (db key form, e.g. "twitter"). + pub platform: Option, + /// Maximum number of posts to return (default 50, max 200). + #[serde(default = "default_limit")] + pub limit: usize, +} + +fn default_limit() -> usize { + 50 +} + +/// `GET /agent/posts` — list posts with optional filters. +pub async fn list_posts( + State(state): State>, + _auth: axum::Extension, + Query(query): Query, +) -> Result, (StatusCode, String)> { + // Convert platform from db_key form if provided. + let platform_key = query + .platform + .as_deref() + .map(postghost::Platform::from_db_key) + .map(|p| p.to_db_key()); + + let items = state + .storage + .list_content_filtered( + query.state.as_deref(), + platform_key.as_deref(), + query.limit.min(200), + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(Json(json!({ "posts": items, "count": items.len() }))) +} + +/// `GET /agent/posts/:id` — fetch a single post. +pub async fn get_post( + State(state): State>, + _auth: axum::Extension, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let uuid = Uuid::parse_str(&id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + let content = state + .storage + .get_content(uuid) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + match content { + Some(c) => Ok(Json(serde_json::to_value(&c).unwrap_or_default())), + None => Err((StatusCode::NOT_FOUND, "content not found".to_string())), + } +} + +#[derive(Debug, Deserialize)] +pub struct UpdatePostRequest { + pub title: Option, + pub body: Option, + pub tags: Option>, +} + +/// `PUT /agent/posts/:id` — update mutable fields of a draft. +pub async fn update_post( + State(state): State>, + _auth: axum::Extension, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let uuid = Uuid::parse_str(&id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + // Verify existence first so we can return 404 distinctly from "0 rows updated". + let exists = state + .storage + .get_content(uuid) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .is_some(); + if !exists { + return Err((StatusCode::NOT_FOUND, "content not found".to_string())); + } + + let tags_ref = req.tags.as_deref(); + let updated = state + .storage + .update_content_fields(uuid, req.title.as_deref(), req.body.as_deref(), tags_ref) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + if !updated { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "update reported no rows changed despite existence check".to_string(), + )); + } + Ok(Json(json!({ + "id": uuid, + "updated": { + "title": req.title.is_some(), + "body": req.body.is_some(), + "tags": req.tags.is_some(), + }, + }))) +} + +/// `DELETE /agent/posts/:id` — delete a post. +pub async fn delete_post( + State(state): State>, + _auth: axum::Extension, + Path(id): Path, +) -> Result { + let uuid = Uuid::parse_str(&id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + let deleted = state + .storage + .delete_content(uuid) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + if deleted { + Ok(StatusCode::NO_CONTENT) + } else { + Err((StatusCode::NOT_FOUND, "content not found".to_string())) + } +} + +#[derive(Debug, Deserialize)] +pub struct SchedulePostRequest { + pub platform: String, + /// RFC 3339 datetime, e.g. "2026-07-24T15:30:00Z". + pub scheduled_for: String, +} + +/// `POST /agent/posts/:id/schedule` — schedule a post for a platform. +pub async fn schedule_post( + State(state): State>, + _auth: axum::Extension, + Path(id): Path, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + let content_id = Uuid::parse_str(&id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + // Verify content exists. + let exists = state + .storage + .get_content(content_id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .is_some(); + if !exists { + return Err((StatusCode::NOT_FOUND, "content not found".to_string())); + } + + let scheduled_for = chrono::DateTime::parse_from_rfc3339(&req.scheduled_for) + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? + .with_timezone(&Utc); + let platform = postghost::Platform::from_db_key(&req.platform); + let now = Utc::now(); + let schedule = postghost::Schedule { + id: Uuid::new_v4(), + content_id, + platform: platform.clone(), + scheduled_for, + status: postghost::ScheduleStatus::Pending, + created_at: now, + }; + state + .storage + .save_schedule(&schedule) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(( + StatusCode::CREATED, + Json(json!({ + "id": schedule.id, + "content_id": content_id, + "platform": platform.to_db_key(), + "scheduled_for": schedule.scheduled_for.to_rfc3339(), + "status": "pending", + "created_at": schedule.created_at.to_rfc3339(), + })), + )) +} + +/// `GET /agent/calendar` — upcoming scheduled content. +pub async fn calendar( + State(state): State>, + _auth: axum::Extension, +) -> Result, (StatusCode, String)> { + let upcoming = state + .storage + .list_upcoming_schedules() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(Json( + json!({ "upcoming": upcoming, "count": upcoming.len() }), + )) +} + +// ============================================================================ +// Key management (unauthenticated — for local operator via CLI) +// ============================================================================ + +#[derive(Debug, Deserialize)] +pub struct CreateKeyRequest { + pub name: String, +} + +#[derive(Debug, Serialize)] +pub struct CreateKeyResponse { + /// The raw key — ONLY returned at creation time. Never stored. + pub key: String, + pub id: String, + pub name: String, + pub created_at: String, +} + +/// `POST /api/v1/keys` — generate a new agent API key. +/// +/// The raw key is returned exactly once. Only its SHA-256 hash is persisted. +pub async fn create_key( + State(state): State>, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + if req.name.trim().is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "name must not be empty".to_string(), + )); + } + // Generate a raw key with a recognizable prefix. + let raw_key = format!("pgk_{}", Uuid::new_v4().simple()); + let hashed = crate::auth::hash_key(&raw_key); + let id = Uuid::new_v4().to_string(); + let created_at = Utc::now().to_rfc3339(); + + let row = AgentKeyRow { + id: id.clone(), + key_hash: hashed, + name: req.name.clone(), + created_at: created_at.clone(), + last_used_at: None, + }; + state + .storage + .create_agent_key(&row) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(( + StatusCode::CREATED, + Json(CreateKeyResponse { + key: raw_key, + id, + name: req.name, + created_at, + }), + )) +} + +/// `GET /api/v1/keys` — list agent keys (without raw key values). +/// +/// Returns id, name, created_at, and last_used_at. Hashes are omitted from +/// the API response (they're internal) but the CLI may print them for debugging. +pub async fn list_keys( + State(state): State>, +) -> Result, (StatusCode, String)> { + let keys = state + .storage + .list_agent_keys() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let safe: Vec = keys + .into_iter() + .map(|k| { + json!({ + "id": k.id, + "name": k.name, + "created_at": k.created_at, + "last_used_at": k.last_used_at, + }) + }) + .collect(); + Ok(Json(json!({ "keys": safe, "count": safe.len() }))) +} + +/// `DELETE /api/v1/keys/:id` — revoke an agent key. +pub async fn delete_key( + State(state): State>, + Path(id): Path, +) -> Result { + let removed = state + .storage + .delete_agent_key(&id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + if removed { + Ok(StatusCode::NO_CONTENT) + } else { + Err((StatusCode::NOT_FOUND, "key not found".to_string())) + } +} diff --git a/crates/postghost-server/src/api.rs b/crates/postghost-server/src/api.rs index 87ce247..8ce0eec 100644 --- a/crates/postghost-server/src/api.rs +++ b/crates/postghost-server/src/api.rs @@ -44,6 +44,15 @@ pub fn build_router_from_arc(state: Arc) -> Router { .route("/api/v1/content/:id/format", post(format_content_route)) .route("/api/v1/schedule", get(list_schedule).post(create_schedule)) .route("/api/v1/publish/:id", post(publish_content)) + // Agent API surface — protected by API key middleware. + .merge( + crate::agent_api::agent_routes().layer(axum::middleware::from_fn_with_state( + state.clone(), + crate::auth::require_api_key, + )), + ) + // Key management (unauthenticated — for local operator via CLI). + .merge(crate::agent_api::key_management_routes()) .with_state(state) } diff --git a/crates/postghost-server/src/auth.rs b/crates/postghost-server/src/auth.rs new file mode 100644 index 0000000..63cd70d --- /dev/null +++ b/crates/postghost-server/src/auth.rs @@ -0,0 +1,79 @@ +//! API key authentication for the agent API surface. +//! +//! Agent API routes (`/agent/*`) are protected by a bearer-token middleware +//! that hashes the incoming key with SHA-256 and looks it up in the +//! `agent_keys` table. Keys are never stored in plaintext — only their +//! SHA-256 hex digest. + +use axum::{ + extract::State, + http::{Request, StatusCode}, + middleware::Next, + response::Response, +}; +use sha2::{Digest, Sha256}; + +use crate::api::AppState; + +/// Compute the SHA-256 hex digest of a raw API key string. +pub fn hash_key(raw: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(raw.as_bytes()); + hex::encode(hasher.finalize()) +} + +/// Extract the bearer token from an `Authorization` header. +/// Returns `None` if the header is missing or not a `Bearer ` value. +/// +/// The scheme is matched case-insensitively per RFC 7235 — `bearer x`, +/// `BEARER x`, and `Bearer x` are all accepted. +pub fn extract_bearer(headers: &axum::http::HeaderMap) -> Option { + let value = headers.get(axum::http::header::AUTHORIZATION)?; + let s = value.to_str().ok()?; + // Split into scheme + token on the first space. + let (scheme, rest) = s.split_once(' ')?; + if !scheme.eq_ignore_ascii_case("bearer") { + return None; + } + let token = rest.trim(); + if token.is_empty() { + None + } else { + Some(token.to_string()) + } +} + +/// Middleware: require a valid agent API key on `/agent/*` requests. +/// +/// - Missing/malformed `Authorization` header → 401 +/// - Unknown key (no matching hash) → 401 +/// - Valid key → request proceeds; `last_used_at` is stamped by the storage lookup +pub async fn require_api_key( + State(state): State>, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(req.headers()).ok_or(( + StatusCode::UNAUTHORIZED, + "missing or malformed Authorization header (expected 'Bearer ')".to_string(), + ))?; + + let hashed = hash_key(&token); + let found = state + .storage + .lookup_agent_key(&hashed) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + match found { + Some(_) => { + req.extensions_mut().insert(AuthenticatedAgent); + Ok(next.run(req).await) + } + None => Err((StatusCode::UNAUTHORIZED, "invalid API key".to_string())), + } +} + +/// Extension inserted into the request after successful auth. +/// Handlers can extract it to confirm the request was authenticated. +#[derive(Debug, Clone, Copy)] +pub struct AuthenticatedAgent; diff --git a/crates/postghost-server/src/lib.rs b/crates/postghost-server/src/lib.rs index 841b3b7..03a35a3 100644 --- a/crates/postghost-server/src/lib.rs +++ b/crates/postghost-server/src/lib.rs @@ -1,4 +1,6 @@ +pub mod agent_api; pub mod api; +pub mod auth; pub mod config; pub mod format; pub mod iris; diff --git a/crates/postghost-server/src/storage.rs b/crates/postghost-server/src/storage.rs index 8af62e0..268c931 100644 --- a/crates/postghost-server/src/storage.rs +++ b/crates/postghost-server/src/storage.rs @@ -76,6 +76,14 @@ impl SqliteStorage { status TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS agent_keys ( + id TEXT PRIMARY KEY, + key_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT + ); "#, )?; Ok(()) @@ -256,10 +264,10 @@ impl SqliteStorage { Ok(rows > 0) } - pub fn delete_content(&self, id: ContentId) -> Result<()> { + pub fn delete_content(&self, id: ContentId) -> Result { let conn = self.conn.lock().unwrap(); - conn.execute("DELETE FROM content WHERE id = ?1", [id.to_string()])?; - Ok(()) + let rows = conn.execute("DELETE FROM content WHERE id = ?1", [id.to_string()])?; + Ok(rows > 0) } pub fn add_variant(&self, content_id: ContentId, variant: &PlatformVariant) -> Result<()> { @@ -351,6 +359,273 @@ impl SqliteStorage { } } + // --- Agent API key management --- + + /// Persist a new agent API key (pre-hashed). Returns whether it was stored. + pub fn create_agent_key(&self, key: &AgentKeyRow) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO agent_keys (id, key_hash, name, created_at, last_used_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + key.id, + key.key_hash, + key.name, + key.created_at, + key.last_used_at, + ], + )?; + Ok(()) + } + + /// Look up an agent key by its hash. Returns `Ok(Some)` if found, `Ok(None)` otherwise. + /// Also stamps `last_used_at` on a successful lookup (returned row reflects the stamp). + pub fn lookup_agent_key(&self, key_hash: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, key_hash, name, created_at, last_used_at FROM agent_keys WHERE key_hash = ?1", + )?; + let mut rows = stmt.query_map([key_hash], |row| { + Ok(AgentKeyRow { + id: row.get(0)?, + key_hash: row.get(1)?, + name: row.get(2)?, + created_at: row.get(3)?, + last_used_at: row.get(4)?, + }) + })?; + match rows.next() { + Some(row) => { + let mut row = row?; + // Stamp last_used_at best-effort — failure here shouldn't block auth. + let now = Utc::now().to_rfc3339(); + let _ = conn.execute( + "UPDATE agent_keys SET last_used_at = ?1 WHERE id = ?2", + rusqlite::params![&now, &row.id], + ); + // Reflect the stamp in the returned row. + row.last_used_at = Some(now); + Ok(Some(row)) + } + None => Ok(None), + } + } + + /// List all agent keys (without hashes). + pub fn list_agent_keys(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, key_hash, name, created_at, last_used_at FROM agent_keys ORDER BY created_at DESC", + )?; + let rows = stmt.query_map([], |row| { + Ok(AgentKeyRow { + id: row.get(0)?, + key_hash: row.get(1)?, + name: row.get(2)?, + created_at: row.get(3)?, + last_used_at: row.get(4)?, + }) + })?; + rows.collect::>>() + .map_err(Into::into) + } + + /// Delete an agent key by id. Returns whether a row was removed. + pub fn delete_agent_key(&self, id: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let rows = conn.execute("DELETE FROM agent_keys WHERE id = ?1", [id])?; + Ok(rows > 0) + } + + // --- Agent API support queries --- + + /// Atomically (under the storage mutex) look for content with the same title + /// and body created within the last `within_seconds`. Returns the id if found. + /// Used for idempotent `POST /agent/draft` (same title+body within 60s). + pub fn find_recent_content_by_title_body( + &self, + title: &str, + body: &str, + within_seconds: i64, + ) -> Result> { + let conn = self.conn.lock().unwrap(); + let cutoff = Utc::now() - chrono::Duration::seconds(within_seconds); + let mut stmt = conn.prepare( + "SELECT id FROM content WHERE title = ?1 AND body = ?2 AND created_at >= ?3 \ + ORDER BY created_at DESC LIMIT 1", + )?; + let mut rows = stmt + .query_map(rusqlite::params![title, body, cutoff.to_rfc3339()], |row| { + row.get::<_, String>(0) + })?; + match rows.next() { + Some(s) => Ok(Some(s?)), + None => Ok(None), + } + } + + /// Idempotent content save: look for a recent duplicate (same title+body + /// within `within_seconds`), returning its id if found. Otherwise insert + /// the new content and return its id. The lookup and insert happen under + /// a single mutex lock to prevent a TOCTOU race where two concurrent + /// identical requests both pass the check and both insert. + /// + /// Returns `Ok((id, was_duplicate))`. + pub fn save_content_if_not_recent_duplicate( + &self, + content: &Content, + within_seconds: i64, + ) -> Result<(String, bool)> { + let conn = self.conn.lock().unwrap(); + let cutoff = Utc::now() - chrono::Duration::seconds(within_seconds); + + // Check for existing duplicate within the lock. + { + let mut stmt = conn.prepare( + "SELECT id FROM content WHERE title = ?1 AND body = ?2 AND created_at >= ?3 \ + ORDER BY created_at DESC LIMIT 1", + )?; + let mut rows = stmt.query_map( + rusqlite::params![&content.title, &content.body, cutoff.to_rfc3339()], + |row| row.get::<_, String>(0), + )?; + if let Some(existing_id) = rows.next() { + return Ok((existing_id?, true)); + } + } + + // No duplicate found — insert the new content. + conn.execute( + "INSERT OR REPLACE INTO content (id, title, body, format, tags, workflow_state, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + content.id.to_string(), + content.title, + content.body, + enum_to_str(&content.format)?, + serde_json::to_string(&content.tags)?, + enum_to_str(&WorkflowState::Draft)?, + content.created_at.to_rfc3339(), + content.updated_at.to_rfc3339(), + ], + )?; + Ok((content.id.to_string(), false)) + } + + /// List content summaries filtered by workflow state, with optional + /// platform filter (content that has a variant for the given platform db key). + /// Both filters are optional — pass `None` to skip. + pub fn list_content_filtered( + &self, + workflow_state: Option<&str>, + platform_key: Option<&str>, + limit: usize, + ) -> Result> { + let conn = self.conn.lock().unwrap(); + // Build query dynamically based on provided filters. + let mut sql = String::from( + "SELECT c.id, c.title, c.format, c.workflow_state, c.created_at, c.updated_at, \ + (SELECT COUNT(*) FROM content_variants cv WHERE cv.content_id = c.id) AS variant_count \ + FROM content c", + ); + let mut conditions: Vec = Vec::new(); + let mut params: Vec> = Vec::new(); + let mut param_idx = 1; + + if let Some(state) = workflow_state { + conditions.push(format!("c.workflow_state = ?{}", param_idx)); + params.push(Box::new(state.to_string())); + param_idx += 1; + } + if let Some(pk) = platform_key { + conditions.push(format!( + "EXISTS (SELECT 1 FROM content_variants cv WHERE cv.content_id = c.id AND cv.platform = ?{})", + param_idx + )); + params.push(Box::new(pk.to_string())); + param_idx += 1; + } + if !conditions.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&conditions.join(" AND ")); + } + sql.push_str(&format!(" ORDER BY c.updated_at DESC LIMIT ?{}", param_idx)); + params.push(Box::new(limit as i64)); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(param_refs.as_slice(), |row| { + Ok(ContentSummary { + id: row.get(0)?, + title: row.get(1)?, + format: row.get(2)?, + workflow_state: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + variant_count: row.get(6)?, + }) + })?; + rows.collect::>>() + .map_err(Into::into) + } + + /// Update the mutable fields of a content item (title, body, tags). + /// Bumps `updated_at`. Returns `Ok(true)` if the row existed. + pub fn update_content_fields( + &self, + id: ContentId, + title: Option<&str>, + body: Option<&str>, + tags: Option<&[String]>, + ) -> Result { + let conn = self.conn.lock().unwrap(); + // Build SET clause dynamically so we only update provided fields. + let mut sets: Vec<&str> = Vec::with_capacity(4); + let mut params: Vec> = Vec::with_capacity(5); + if let Some(t) = title { + sets.push("title = ?"); + params.push(Box::new(t.to_string())); + } + if let Some(b) = body { + sets.push("body = ?"); + params.push(Box::new(b.to_string())); + } + if let Some(tg) = tags { + sets.push("tags = ?"); + params.push(Box::new(serde_json::to_string(tg)?)); + } + sets.push("updated_at = ?"); + params.push(Box::new(Utc::now().to_rfc3339())); + params.push(Box::new(id.to_string())); + + let sql = format!("UPDATE content SET {} WHERE id = ?", sets.join(", ")); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let rows = conn.execute(&sql, param_refs.as_slice())?; + Ok(rows > 0) + } + + /// List upcoming schedules (pending or in_progress) ordered by scheduled_for ASC. + /// Used by the agent `/agent/calendar` endpoint. + pub fn list_upcoming_schedules(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, content_id, platform, scheduled_for, status, created_at \ + FROM schedules WHERE status IN ('pending', 'in_progress') \ + ORDER BY scheduled_for ASC", + )?; + let rows = stmt.query_map([], |row| { + Ok(ScheduleRow { + id: row.get(0)?, + content_id: row.get(1)?, + platform: row.get(2)?, + scheduled_for: row.get(3)?, + status: row.get(4)?, + created_at: row.get(5)?, + }) + })?; + rows.collect::>>() + .map_err(Into::into) + } + /// Test-only helper: insert a schedule row that references a non-existent /// content_id, bypassing the FK constraint. Used to test scheduler /// robustness when content is removed out-of-band. @@ -403,6 +678,18 @@ pub struct ScheduleRow { pub created_at: String, } +/// An agent API key row. `key_hash` is the SHA-256 hex of the raw key. +/// Serialized for the key management API but `key_hash` should never be +/// surfaced to API consumers — only to local CLI output. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AgentKeyRow { + pub id: String, + pub key_hash: String, + pub name: String, + pub created_at: String, + pub last_used_at: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -695,4 +982,167 @@ mod tests { .unwrap(); assert_eq!(ghost, None); } + + #[test] + fn test_agent_key_crud() { + // COD-379: agent_keys table CRUD. + let storage = SqliteStorage::in_memory().unwrap(); + + // Initially empty. + assert!(storage.list_agent_keys().unwrap().is_empty()); + + // Create. + let row = AgentKeyRow { + id: "k1".to_string(), + key_hash: "abc123".to_string(), + name: "test".to_string(), + created_at: Utc::now().to_rfc3339(), + last_used_at: None, + }; + storage.create_agent_key(&row).unwrap(); + + // Lookup by hash. + let found = storage.lookup_agent_key("abc123").unwrap().unwrap(); + assert_eq!(found.id, "k1"); + assert_eq!(found.name, "test"); + // last_used_at should now be set. + assert!(found.last_used_at.is_some()); + + // List. + let all = storage.list_agent_keys().unwrap(); + assert_eq!(all.len(), 1); + + // Unknown hash returns None. + assert!(storage.lookup_agent_key("nonexistent").unwrap().is_none()); + + // Delete. + assert!(storage.delete_agent_key("k1").unwrap()); + assert!(!storage.delete_agent_key("k1").unwrap()); // already gone + assert!(storage.list_agent_keys().unwrap().is_empty()); + } + + #[test] + fn test_find_recent_content_by_title_body() { + // COD-379: idempotency lookup. + let storage = SqliteStorage::in_memory().unwrap(); + let now = Utc::now(); + let id = Uuid::new_v4(); + let content = Content { + id, + title: "Unique Title".to_string(), + body: "unique body".to_string(), + format: ContentFormat::Markdown, + tags: vec![], + created_at: now, + updated_at: now, + variants: vec![], + }; + storage.save_content(&content).unwrap(); + + // Should find it within a 60s window. + let found = storage + .find_recent_content_by_title_body("Unique Title", "unique body", 60) + .unwrap(); + assert_eq!(found.as_deref(), Some(id.to_string().as_str())); + + // Different title → not found. + let not_found = storage + .find_recent_content_by_title_body("Other", "unique body", 60) + .unwrap(); + assert!(not_found.is_none()); + + // Window of 0 seconds should exclude just-created content (edge case). + // Using a negative-ish window by passing 0 — content created "now" has + // created_at >= now-0s, so it WILL match. This is correct behavior. + } + + #[test] + fn test_update_content_fields() { + // COD-379: partial update of content fields. + let storage = SqliteStorage::in_memory().unwrap(); + let now = Utc::now(); + let id = Uuid::new_v4(); + let content = Content { + id, + title: "Original".to_string(), + body: "original body".to_string(), + format: ContentFormat::Markdown, + tags: vec!["a".to_string()], + created_at: now, + updated_at: now, + variants: vec![], + }; + storage.save_content(&content).unwrap(); + + // Update title only. + let updated = storage + .update_content_fields(id, Some("New Title"), None, None) + .unwrap(); + assert!(updated); + + let retrieved = storage.get_content(id).unwrap().unwrap(); + assert_eq!(retrieved.title, "New Title"); + assert_eq!(retrieved.body, "original body"); // unchanged + assert_eq!(retrieved.tags, vec!["a".to_string()]); // unchanged + + // Update body + tags. + let new_tags = vec!["x".to_string(), "y".to_string()]; + storage + .update_content_fields(id, None, Some("new body"), Some(&new_tags)) + .unwrap(); + let retrieved = storage.get_content(id).unwrap().unwrap(); + assert_eq!(retrieved.title, "New Title"); // unchanged + assert_eq!(retrieved.body, "new body"); + assert_eq!(retrieved.tags, vec!["x".to_string(), "y".to_string()]); + + // Non-existent id returns false. + let ghost = storage + .update_content_fields(Uuid::new_v4(), Some("x"), None, None) + .unwrap(); + assert!(!ghost); + } + + #[test] + fn test_list_upcoming_schedules() { + // COD-379: calendar endpoint support. + let storage = SqliteStorage::in_memory().unwrap(); + let now = Utc::now(); + let content = Content { + id: Uuid::new_v4(), + title: "Cal Test".to_string(), + body: "b".to_string(), + format: ContentFormat::Markdown, + tags: vec![], + created_at: now, + updated_at: now, + variants: vec![], + }; + storage.save_content(&content).unwrap(); + + // Pending schedule. + let s1 = Schedule { + id: Uuid::new_v4(), + content_id: content.id, + platform: Platform::Twitter, + scheduled_for: now + chrono::Duration::hours(1), + status: ScheduleStatus::Pending, + created_at: now, + }; + storage.save_schedule(&s1).unwrap(); + + // Published schedule (should NOT appear in upcoming). + let s2 = Schedule { + id: Uuid::new_v4(), + content_id: content.id, + platform: Platform::LinkedIn, + scheduled_for: now + chrono::Duration::hours(2), + status: ScheduleStatus::Published, + created_at: now, + }; + storage.save_schedule(&s2).unwrap(); + + let upcoming = storage.list_upcoming_schedules().unwrap(); + assert_eq!(upcoming.len(), 1); + assert_eq!(upcoming[0].id, s1.id.to_string()); + } } diff --git a/crates/postghost-server/tests/agent_api_test.rs b/crates/postghost-server/tests/agent_api_test.rs new file mode 100644 index 0000000..31bf484 --- /dev/null +++ b/crates/postghost-server/tests/agent_api_test.rs @@ -0,0 +1,565 @@ +//! Integration tests for the agent API surface (COD-379). +//! +//! These tests exercise the `/agent/*` routes and `/api/v1/keys*` routes +//! through the full Axum router using `tower::ServiceExt::oneshot` against +//! in-memory SQLite storage. No network calls. + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use postghost_server::agent_api::{agent_routes, key_management_routes}; +use postghost_server::api::AppState; +use postghost_server::auth; +use postghost_server::iris::IrisClient; +use postghost_server::storage::SqliteStorage; +use std::sync::Arc; +use tower::util::ServiceExt; + +use serde_json::{json, Value}; + +fn test_state() -> Arc { + let storage = SqliteStorage::in_memory().expect("in-memory db"); + let iris_client = IrisClient::new("http://localhost:8090".to_string()); + Arc::new(AppState { + storage, + iris_client, + config: postghost_server::config::ServerConfig::default(), + }) +} + +/// Build a test router that mirrors the production wiring: agent routes behind +/// auth middleware, key management routes unauthenticated. +fn test_router(state: Arc) -> axum::Router { + use axum::routing::Router; + Router::new() + .merge(agent_routes().layer(axum::middleware::from_fn_with_state( + state.clone(), + auth::require_api_key, + ))) + .merge(key_management_routes()) + .with_state(state) +} + +async fn body_to_json(body: Body) -> Value { + let bytes = body.collect().await.expect("collect body").to_bytes(); + serde_json::from_slice(&bytes).expect("valid json") +} + +/// Build a router and fire a single request through it, returning the response. +/// Each call builds a fresh router because `oneshot` consumes it. +async fn send( + state: Arc, + method: &str, + uri: &str, + key: Option<&str>, + body: Option, +) -> axum::response::Response { + let router = test_router(state); + let mut b = Request::builder().method(method).uri(uri); + if let Some(k) = key { + b = b.header("authorization", format!("Bearer {}", k)); + } + let body = match body { + Some(v) => { + b = b.header("content-type", "application/json"); + Body::from(serde_json::to_vec(&v).unwrap()) + } + None => Body::empty(), + }; + router.oneshot(b.body(body).unwrap()).await.unwrap() +} + +/// Convenience wrapper that uses a created key for auth. +async fn send_authed( + state: Arc, + method: &str, + uri: &str, + key: &str, + body: Option, +) -> axum::response::Response { + send(state, method, uri, Some(key), body).await +} + +/// Create a key via the management API and return (id, raw_key). +async fn create_key(state: Arc) -> (String, String) { + let resp = send( + state, + "POST", + "/api/v1/keys", + None, + Some(json!({ "name": "test-key" })), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let v = body_to_json(resp.into_body()).await; + ( + v["id"].as_str().unwrap().to_string(), + v["key"].as_str().unwrap().to_string(), + ) +} + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_missing_auth_returns_401() { + let state = test_state(); + let resp = send(state, "GET", "/agent/posts", None, None).await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_invalid_key_returns_401() { + let state = test_state(); + let resp = send_authed(state, "GET", "/agent/posts", "pgk_bogus", None).await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_valid_key_passes_auth() { + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + let resp = send_authed(state, "GET", "/agent/posts", &key, None).await; + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_malformed_auth_header_returns_401() { + let state = test_state(); + // Not a Bearer token. + let resp = send(state, "GET", "/agent/posts", Some("Basic abc123"), None).await; + // The middleware extracts Bearer; "Basic abc123" doesn't match Bearer prefix + // but extract_bearer will treat it as having no token → 401. + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +// --------------------------------------------------------------------------- +// Key management +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_key_hash_not_stored_plaintext() { + let state = test_state(); + let (_id, raw_key) = create_key(state.clone()).await; + let hashed = auth::hash_key(&raw_key); + assert_ne!(hashed, raw_key); + assert_eq!(hashed.len(), 64); // SHA-256 hex +} + +#[tokio::test] +async fn test_list_keys_omits_hash() { + let state = test_state(); + create_key(state.clone()).await; + let resp = send(state, "GET", "/api/v1/keys", None, None).await; + assert_eq!(resp.status(), StatusCode::OK); + let v = body_to_json(resp.into_body()).await; + assert_eq!(v["count"].as_u64(), Some(1)); + assert!(v["keys"][0].get("key_hash").is_none()); + assert!(v["keys"][0].get("key").is_none()); +} + +#[tokio::test] +async fn test_revoke_key_blocks_future_auth() { + let state = test_state(); + let (id, key) = create_key(state.clone()).await; + + let resp = send_authed(state.clone(), "GET", "/agent/posts", &key, None).await; + assert_eq!(resp.status(), StatusCode::OK); + + let resp = send( + state.clone(), + "DELETE", + &format!("/api/v1/keys/{}", id), + None, + None, + ) + .await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = send_authed(state, "GET", "/agent/posts", &key, None).await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_create_key_rejects_empty_name() { + let state = test_state(); + let resp = send( + state, + "POST", + "/api/v1/keys", + None, + Some(json!({ "name": " " })), + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +// --------------------------------------------------------------------------- +// Draft creation + idempotency +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_create_draft_returns_201() { + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + let resp = send_authed( + state, + "POST", + "/agent/draft", + &key, + Some(json!({ "title": "Hello", "body": "World" })), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let v = body_to_json(resp.into_body()).await; + assert!(v["id"].is_string()); + assert_eq!(v["title"].as_str(), Some("Hello")); +} + +#[tokio::test] +async fn test_create_draft_idempotent_within_window() { + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + + let resp = send_authed( + state.clone(), + "POST", + "/agent/draft", + &key, + Some(json!({ "title": "Dup", "body": "Body" })), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let first_id = body_to_json(resp.into_body()).await["id"] + .as_str() + .unwrap() + .to_string(); + + let resp = send_authed( + state, + "POST", + "/agent/draft", + &key, + Some(json!({ "title": "Dup", "body": "Body" })), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let v = body_to_json(resp.into_body()).await; + assert_eq!(v["id"].as_str(), Some(first_id.as_str())); + assert_eq!(v["duplicate"].as_bool(), Some(true)); +} + +#[tokio::test] +async fn test_create_draft_different_body_not_idempotent() { + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + + let _ = send_authed( + state.clone(), + "POST", + "/agent/draft", + &key, + Some(json!({ "title": "T", "body": "A" })), + ) + .await; + + let resp = send_authed( + state, + "POST", + "/agent/draft", + &key, + Some(json!({ "title": "T", "body": "B" })), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let v = body_to_json(resp.into_body()).await; + assert!(v.get("duplicate").is_none() || v["duplicate"].as_bool() == Some(false)); +} + +// --------------------------------------------------------------------------- +// List / get / update / delete posts +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_list_posts_filter_by_state() { + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + + for i in 0..2 { + let _ = send_authed( + state.clone(), + "POST", + "/agent/draft", + &key, + Some(json!({ "title": format!("P{}", i), "body": "b" })), + ) + .await; + } + + let resp = send_authed(state.clone(), "GET", "/agent/posts?state=draft", &key, None).await; + assert_eq!(resp.status(), StatusCode::OK); + let v = body_to_json(resp.into_body()).await; + assert_eq!(v["count"].as_u64(), Some(2)); + + let resp = send_authed(state, "GET", "/agent/posts?state=published", &key, None).await; + let v = body_to_json(resp.into_body()).await; + assert_eq!(v["count"].as_u64(), Some(0)); +} + +#[tokio::test] +async fn test_get_post_404_on_missing() { + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + let bogus = uuid::Uuid::new_v4(); + let resp = send_authed(state, "GET", &format!("/agent/posts/{}", bogus), &key, None).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_update_post() { + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + + let resp = send_authed( + state.clone(), + "POST", + "/agent/draft", + &key, + Some(json!({ "title": "Old", "body": "old body" })), + ) + .await; + let id = body_to_json(resp.into_body()).await["id"] + .as_str() + .unwrap() + .to_string(); + + let resp = send_authed( + state.clone(), + "PUT", + &format!("/agent/posts/{}", id), + &key, + Some(json!({ "title": "New Title" })), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let resp = send_authed(state, "GET", &format!("/agent/posts/{}", id), &key, None).await; + let v = body_to_json(resp.into_body()).await; + assert_eq!(v["title"].as_str(), Some("New Title")); + assert_eq!(v["body"].as_str(), Some("old body")); +} + +#[tokio::test] +async fn test_delete_post() { + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + + let resp = send_authed( + state.clone(), + "POST", + "/agent/draft", + &key, + Some(json!({ "title": "ToDelete", "body": "x" })), + ) + .await; + let id = body_to_json(resp.into_body()).await["id"] + .as_str() + .unwrap() + .to_string(); + + let resp = send_authed( + state.clone(), + "DELETE", + &format!("/agent/posts/{}", id), + &key, + None, + ) + .await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = send_authed(state, "GET", &format!("/agent/posts/{}", id), &key, None).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_delete_post_returns_404_on_missing() { + // COD-379 review fix: delete on nonexistent ID must return 404, not 204. + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + let bogus = uuid::Uuid::new_v4(); + let resp = send_authed( + state, + "DELETE", + &format!("/agent/posts/{}", bogus), + &key, + None, + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_bearer_scheme_case_insensitive() { + // COD-379 review fix: Bearer scheme is case-insensitive per RFC 7235. + let state = test_state(); + let (_id, raw_key) = create_key(state.clone()).await; + + let router = test_router(state); + let resp = router + .oneshot( + Request::builder() + .method("GET") + .uri("/agent/posts") + .header("authorization", format!("bearer {}", raw_key)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_list_posts_platform_filter() { + // COD-379 review fix: platform filter must actually filter. + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + + let resp = send_authed( + state.clone(), + "POST", + "/agent/draft", + &key, + Some(json!({ "title": "WithVariant", "body": "v" })), + ) + .await; + let content_id = body_to_json(resp.into_body()).await["id"] + .as_str() + .unwrap() + .to_string(); + let content_uuid = uuid::Uuid::parse_str(&content_id).unwrap(); + + // Add a twitter variant directly via storage (the variant route isn't on the + // agent test router). + use postghost::PlatformVariant; + let variant = PlatformVariant { + platform: postghost::Platform::Twitter, + formatted_text: "thread".to_string(), + metadata: serde_json::json!({}), + formatted_at: chrono::Utc::now(), + }; + state.storage.add_variant(content_uuid, &variant).unwrap(); + + // Filter by platform=twitter → should find the content. + let resp = send_authed( + state.clone(), + "GET", + "/agent/posts?platform=twitter", + &key, + None, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let v = body_to_json(resp.into_body()).await; + assert_eq!(v["count"].as_u64(), Some(1)); + + // Filter by platform=linked_in → should find nothing. + let resp = send_authed(state, "GET", "/agent/posts?platform=linked_in", &key, None).await; + let v = body_to_json(resp.into_body()).await; + assert_eq!(v["count"].as_u64(), Some(0)); +} + +// --------------------------------------------------------------------------- +// Schedule + calendar +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_schedule_post() { + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + + let resp = send_authed( + state.clone(), + "POST", + "/agent/draft", + &key, + Some(json!({ "title": "Sched", "body": "y" })), + ) + .await; + let content_id = body_to_json(resp.into_body()).await["id"] + .as_str() + .unwrap() + .to_string(); + + let resp = send_authed( + state, + "POST", + &format!("/agent/posts/{}/schedule", content_id), + &key, + Some(json!({ + "platform": "twitter", + "scheduled_for": "2026-12-01T09:00:00Z", + })), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let v = body_to_json(resp.into_body()).await; + assert_eq!(v["status"].as_str(), Some("pending")); + assert_eq!(v["platform"].as_str(), Some("twitter")); +} + +#[tokio::test] +async fn test_schedule_post_404_on_missing_content() { + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + let bogus = uuid::Uuid::new_v4(); + let resp = send_authed( + state, + "POST", + &format!("/agent/posts/{}/schedule", bogus), + &key, + Some(json!({ + "platform": "twitter", + "scheduled_for": "2026-12-01T09:00:00Z", + })), + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_calendar_lists_upcoming() { + let state = test_state(); + let (_id, key) = create_key(state.clone()).await; + + let resp = send_authed( + state.clone(), + "POST", + "/agent/draft", + &key, + Some(json!({ "title": "Cal", "body": "z" })), + ) + .await; + let content_id = body_to_json(resp.into_body()).await["id"] + .as_str() + .unwrap() + .to_string(); + + let _ = send_authed( + state.clone(), + "POST", + &format!("/agent/posts/{}/schedule", content_id), + &key, + Some(json!({ + "platform": "linked_in", + "scheduled_for": "2026-11-01T10:00:00Z", + })), + ) + .await; + + let resp = send_authed(state, "GET", "/agent/calendar", &key, None).await; + assert_eq!(resp.status(), StatusCode::OK); + let v = body_to_json(resp.into_body()).await; + assert_eq!(v["count"].as_u64(), Some(1)); +}