refactor(pds): integrate tranquil-lexicon for record validation

This commit is contained in:
Lewis
2026-03-13 19:53:18 +00:00
committed by Tangled
parent 83fda8b57a
commit 458b9b7e0d
17 changed files with 334 additions and 1107 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ members = [
]
[workspace.package]
version = "0.3.1"
version = "0.4.0"
edition = "2024"
license = "AGPL-3.0-or-later"
+10 -8
View File
@@ -169,19 +169,21 @@ impl CommsSender for EmailSender {
source: e,
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(email_content.as_bytes()).await.map_err(|e| {
SendError::ProcessSpawn {
stdin
.write_all(email_content.as_bytes())
.await
.map_err(|e| SendError::ProcessSpawn {
command: self.sendmail_path.clone(),
source: e,
}
})?;
})?;
}
let output = child.wait_with_output().await.map_err(|e| {
SendError::ProcessSpawn {
let output = child
.wait_with_output()
.await
.map_err(|e| SendError::ProcessSpawn {
command: self.sendmail_path.clone(),
source: e,
}
})?;
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SendError::ProcessFailed {
+1
View File
@@ -17,6 +17,7 @@ tranquil-oauth = { workspace = true }
tranquil-comms = { workspace = true }
tranquil-db = { workspace = true }
tranquil-db-traits = { workspace = true }
tranquil-lexicon = { workspace = true, features = ["resolve"] }
aes-gcm = { workspace = true }
async-trait = { workspace = true }
@@ -147,9 +147,7 @@ pub async fn create_account(
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len());
let validated_short_handle = if !input.handle.contains('.')
|| matched_domain.is_some()
{
let validated_short_handle = if !input.handle.contains('.') || matched_domain.is_some() {
let handle_to_validate = match matched_domain {
Some(domain) => input
.handle
+3 -1
View File
@@ -684,7 +684,9 @@ pub async fn update_handle(
.max_by_key(|d| d.len())
.cloned();
let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle);
let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some()) && !is_domain_itself {
let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some())
&& !is_domain_itself
{
let (short_part, full_handle) = match &matched_handle_domain {
Some(domain) => {
let suffix = format!(".{}", domain);
@@ -65,7 +65,9 @@ async fn process_single_write(
collection,
rkey.as_ref(),
validate.requires_lexicon(),
) {
)
.await
{
Ok(status) => Some(status),
Err(err_response) => return Err(*err_response),
}
@@ -116,7 +118,9 @@ async fn process_single_write(
collection,
Some(rkey),
validate.requires_lexicon(),
) {
)
.await
{
Ok(status) => Some(status),
Err(err_response) => return Err(*err_response),
}
@@ -3,29 +3,17 @@ use crate::types::{Nsid, Rkey};
use crate::validation::{RecordValidator, ValidationError, ValidationStatus};
use axum::response::Response;
pub fn validate_record(record: &serde_json::Value, collection: &Nsid) -> Result<(), Box<Response>> {
validate_record_with_rkey(record, collection, None)
}
pub fn validate_record_with_rkey(
record: &serde_json::Value,
collection: &Nsid,
rkey: Option<&Rkey>,
) -> Result<(), Box<Response>> {
let validator = RecordValidator::new();
validation_error_to_response(validator.validate_with_rkey(
record,
collection.as_str(),
rkey.map(|r| r.as_str()),
))
}
pub fn validate_record_with_status(
pub async fn validate_record_with_status(
record: &serde_json::Value,
collection: &Nsid,
rkey: Option<&Rkey>,
require_lexicon: bool,
) -> Result<ValidationStatus, Box<Response>> {
let registry = tranquil_lexicon::LexiconRegistry::global();
if !registry.has_schema(collection.as_str()) {
let _ = registry.resolve_dynamic(collection.as_str()).await;
}
let validator = RecordValidator::new().require_lexicon(require_lexicon);
match validator.validate_with_rkey(record, collection.as_str(), rkey.map(|r| r.as_str())) {
Ok(status) => Ok(status),
@@ -33,15 +21,6 @@ pub fn validate_record_with_status(
}
}
fn validation_error_to_response(
result: Result<ValidationStatus, ValidationError>,
) -> Result<(), Box<Response>> {
match result {
Ok(_) => Ok(()),
Err(e) => Err(validation_error_to_box_response(e)),
}
}
fn validation_error_to_box_response(e: ValidationError) -> Box<Response> {
use axum::response::IntoResponse;
let msg = match e {
@@ -136,7 +136,9 @@ pub async fn create_record(
&input.collection,
input.rkey.as_ref(),
input.validate.requires_lexicon(),
) {
)
.await
{
Ok(status) => Some(status),
Err(err_response) => return Ok(*err_response),
}
@@ -456,7 +458,9 @@ pub async fn put_record(
&input.collection,
Some(&input.rkey),
input.validate.requires_lexicon(),
) {
)
.await
{
Ok(status) => Some(status),
Err(err_response) => return Ok(*err_response),
}
+6 -3
View File
@@ -285,9 +285,12 @@ pub fn validate_full_domain_handle(handle: &str) -> Result<String, HandleValidat
}
let labels: Vec<&str> = handle.split('.').collect();
let has_invalid_label = labels
.iter()
.any(|label| label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH || label.starts_with('-') || label.ends_with('-'));
let has_invalid_label = labels.iter().any(|label| {
label.is_empty()
|| label.len() > MAX_DOMAIN_LABEL_LENGTH
|| label.starts_with('-')
|| label.ends_with('-')
});
if has_invalid_label {
return Err(HandleValidationError::InvalidCharacters);
}
+2 -7
View File
@@ -776,15 +776,10 @@ pub async fn check_handle_available(
let available_domains = tranquil_config::get().server.available_user_domain_list();
if let Some(ref d) = query.domain {
if !available_domains.iter().any(|ad| ad == d) {
return Err(ApiError::InvalidRequest(
"Unknown user domain".into(),
));
return Err(ApiError::InvalidRequest("Unknown user domain".into()));
}
}
let domain = query
.domain
.as_deref()
.unwrap_or(&available_domains[0]);
let domain = query.domain.as_deref().unwrap_or(&available_domains[0]);
let full_handle = format!("{}.{}", validated, domain);
let handle_typed: crate::types::Handle = match full_handle.parse() {
Ok(h) => h,
+147 -538
View File
@@ -1,5 +1,6 @@
use serde_json::Value;
use thiserror::Error;
use tranquil_lexicon::LexValidationError;
#[derive(Debug, Error)]
pub enum ValidationError {
@@ -75,375 +76,151 @@ impl RecordValidator {
collection: &str,
rkey: Option<&str>,
) -> Result<ValidationStatus, ValidationError> {
let obj = record.as_object().ok_or_else(|| {
ValidationError::InvalidRecord("Record must be an object".to_string())
})?;
let record_type = obj
.get("$type")
.and_then(|v| v.as_str())
.ok_or(ValidationError::MissingType)?;
if record_type != collection {
return Err(ValidationError::TypeMismatch {
expected: collection.to_string(),
actual: record_type.to_string(),
});
}
if let Some(created_at) = obj.get("createdAt").and_then(|v| v.as_str()) {
validate_datetime(created_at, "createdAt")?;
}
match record_type {
"app.bsky.feed.post" => Self::validate_post(obj)?,
"app.bsky.actor.profile" => Self::validate_profile(obj)?,
"app.bsky.feed.like" => Self::validate_like(obj)?,
"app.bsky.feed.repost" => Self::validate_repost(obj)?,
"app.bsky.graph.follow" => Self::validate_follow(obj)?,
"app.bsky.graph.block" => Self::validate_block(obj)?,
"app.bsky.graph.list" => Self::validate_list(obj)?,
"app.bsky.graph.listitem" => Self::validate_list_item(obj)?,
"app.bsky.feed.generator" => Self::validate_feed_generator(obj, rkey)?,
"app.bsky.feed.threadgate" => Self::validate_threadgate(obj)?,
"app.bsky.labeler.service" => Self::validate_labeler_service(obj)?,
"app.bsky.graph.starterpack" => Self::validate_starterpack(obj)?,
_ => {
let (record_type, obj) = validate_preamble(record, collection)?;
let registry = tranquil_lexicon::LexiconRegistry::global();
match tranquil_lexicon::validate_record(registry, record_type, record) {
Ok(()) => {
check_banned_content(record_type, obj, rkey)?;
Ok(ValidationStatus::Valid)
}
Err(LexValidationError::LexiconNotFound(_)) => {
if self.require_lexicon {
return Err(ValidationError::UnknownType(record_type.to_string()));
}
return Ok(ValidationStatus::Unknown);
}
}
Ok(ValidationStatus::Valid)
}
fn validate_post(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if !obj.contains_key("text") {
return Err(ValidationError::MissingField("text".to_string()));
}
if !obj.contains_key("createdAt") {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
let grapheme_count = text.chars().count();
if grapheme_count > 3000 {
return Err(ValidationError::InvalidField {
path: "text".to_string(),
message: format!(
"Text exceeds maximum length of 3000 characters (got {})",
grapheme_count
),
});
}
}
if let Some(langs) = obj.get("langs").and_then(|v| v.as_array())
&& langs.len() > 3
{
return Err(ValidationError::InvalidField {
path: "langs".to_string(),
message: "Maximum 3 languages allowed".to_string(),
});
}
if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) {
if tags.len() > 8 {
return Err(ValidationError::InvalidField {
path: "tags".to_string(),
message: "Maximum 8 tags allowed".to_string(),
});
}
for (i, tag) in tags.iter().enumerate() {
if let Some(tag_str) = tag.as_str() {
if tag_str.len() > 640 {
return Err(ValidationError::InvalidField {
path: format!("tags/{}", i),
message: "Tag exceeds maximum length of 640 bytes".to_string(),
});
}
if crate::moderation::has_explicit_slur(tag_str) {
return Err(ValidationError::BannedContent {
path: format!("tags/{}", i),
});
}
Err(ValidationError::UnknownType(record_type.to_string()))
} else {
check_banned_content(record_type, obj, rkey)?;
Ok(ValidationStatus::Unknown)
}
}
}
if let Some(facets) = obj.get("facets").and_then(|v| v.as_array()) {
for (i, facet) in facets.iter().enumerate() {
if let Some(features) = facet.get("features").and_then(|v| v.as_array()) {
for (j, feature) in features.iter().enumerate() {
let is_tag = feature
.get("$type")
.and_then(|v| v.as_str())
.is_some_and(|t| t == "app.bsky.richtext.facet#tag");
if is_tag
&& let Some(tag) = feature.get("tag").and_then(|v| v.as_str())
&& crate::moderation::has_explicit_slur(tag)
{
return Err(ValidationError::BannedContent {
path: format!("facets/{}/features/{}/tag", i, j),
});
}
}
}
Err(LexValidationError::MissingRequired { path }) => {
Err(ValidationError::MissingField(path))
}
Err(LexValidationError::InvalidField { path, message }) => {
Err(ValidationError::InvalidField { path, message })
}
Err(LexValidationError::RecursionDepthExceeded { path }) => {
Err(ValidationError::InvalidField {
path,
message: "recursion depth exceeded".to_string(),
})
}
}
Ok(())
}
fn validate_profile(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) {
let grapheme_count = display_name.chars().count();
if grapheme_count > 640 {
return Err(ValidationError::InvalidField {
path: "displayName".to_string(),
message: format!(
"Display name exceeds maximum length of 640 characters (got {})",
grapheme_count
),
});
}
if crate::moderation::has_explicit_slur(display_name) {
return Err(ValidationError::BannedContent {
path: "displayName".to_string(),
});
}
}
if let Some(description) = obj.get("description").and_then(|v| v.as_str()) {
let grapheme_count = description.chars().count();
if grapheme_count > 2560 {
return Err(ValidationError::InvalidField {
path: "description".to_string(),
message: format!(
"Description exceeds maximum length of 2560 characters (got {})",
grapheme_count
),
});
}
if crate::moderation::has_explicit_slur(description) {
return Err(ValidationError::BannedContent {
path: "description".to_string(),
});
}
}
Ok(())
}
fn validate_like(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if !obj.contains_key("subject") {
return Err(ValidationError::MissingField("subject".to_string()));
}
if !obj.contains_key("createdAt") {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
Self::validate_strong_ref(obj.get("subject"), "subject")?;
Ok(())
}
fn validate_repost(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if !obj.contains_key("subject") {
return Err(ValidationError::MissingField("subject".to_string()));
}
if !obj.contains_key("createdAt") {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
Self::validate_strong_ref(obj.get("subject"), "subject")?;
Ok(())
}
fn validate_follow(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if !obj.contains_key("subject") {
return Err(ValidationError::MissingField("subject".to_string()));
}
if !obj.contains_key("createdAt") {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
if let Some(subject) = obj.get("subject").and_then(|v| v.as_str())
&& !subject.starts_with("did:")
{
return Err(ValidationError::InvalidField {
path: "subject".to_string(),
message: "Subject must be a DID".to_string(),
});
}
Ok(())
}
fn validate_block(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if !obj.contains_key("subject") {
return Err(ValidationError::MissingField("subject".to_string()));
}
if !obj.contains_key("createdAt") {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
if let Some(subject) = obj.get("subject").and_then(|v| v.as_str())
&& !subject.starts_with("did:")
{
return Err(ValidationError::InvalidField {
path: "subject".to_string(),
message: "Subject must be a DID".to_string(),
});
}
Ok(())
}
fn validate_list(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if !obj.contains_key("name") {
return Err(ValidationError::MissingField("name".to_string()));
}
if !obj.contains_key("purpose") {
return Err(ValidationError::MissingField("purpose".to_string()));
}
if !obj.contains_key("createdAt") {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
if name.is_empty() || name.len() > 64 {
return Err(ValidationError::InvalidField {
path: "name".to_string(),
message: "Name must be 1-64 characters".to_string(),
});
}
if crate::moderation::has_explicit_slur(name) {
return Err(ValidationError::BannedContent {
path: "name".to_string(),
});
}
}
Ok(())
}
fn validate_list_item(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if !obj.contains_key("subject") {
return Err(ValidationError::MissingField("subject".to_string()));
}
if !obj.contains_key("list") {
return Err(ValidationError::MissingField("list".to_string()));
}
if !obj.contains_key("createdAt") {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
Ok(())
}
fn validate_feed_generator(
obj: &serde_json::Map<String, Value>,
rkey: Option<&str>,
) -> Result<(), ValidationError> {
if !obj.contains_key("did") {
return Err(ValidationError::MissingField("did".to_string()));
}
if !obj.contains_key("displayName") {
return Err(ValidationError::MissingField("displayName".to_string()));
}
if !obj.contains_key("createdAt") {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
if let Some(rkey) = rkey
&& crate::moderation::has_explicit_slur(rkey)
{
return Err(ValidationError::BannedContent {
path: "rkey".to_string(),
});
}
if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) {
if display_name.is_empty() || display_name.len() > 240 {
return Err(ValidationError::InvalidField {
path: "displayName".to_string(),
message: "displayName must be 1-240 characters".to_string(),
});
}
if crate::moderation::has_explicit_slur(display_name) {
return Err(ValidationError::BannedContent {
path: "displayName".to_string(),
});
}
}
Ok(())
}
fn validate_starterpack(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if !obj.contains_key("name") {
return Err(ValidationError::MissingField("name".to_string()));
}
if !obj.contains_key("createdAt") {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
if name.is_empty() || name.len() > 500 {
return Err(ValidationError::InvalidField {
path: "name".to_string(),
message: "name must be 1-500 characters".to_string(),
});
}
if crate::moderation::has_explicit_slur(name) {
return Err(ValidationError::BannedContent {
path: "name".to_string(),
});
}
}
if let Some(description) = obj.get("description").and_then(|v| v.as_str()) {
if description.len() > 3000 {
return Err(ValidationError::InvalidField {
path: "description".to_string(),
message: "description must be at most 3000 characters".to_string(),
});
}
if crate::moderation::has_explicit_slur(description) {
return Err(ValidationError::BannedContent {
path: "description".to_string(),
});
}
}
Ok(())
}
fn validate_threadgate(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if !obj.contains_key("post") {
return Err(ValidationError::MissingField("post".to_string()));
}
if !obj.contains_key("createdAt") {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
Ok(())
}
fn validate_labeler_service(
obj: &serde_json::Map<String, Value>,
) -> Result<(), ValidationError> {
if !obj.contains_key("policies") {
return Err(ValidationError::MissingField("policies".to_string()));
}
if !obj.contains_key("createdAt") {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
Ok(())
}
fn validate_strong_ref(value: Option<&Value>, path: &str) -> Result<(), ValidationError> {
let obj =
value
.and_then(|v| v.as_object())
.ok_or_else(|| ValidationError::InvalidField {
path: path.to_string(),
message: "Must be a strong reference object".to_string(),
})?;
if !obj.contains_key("uri") {
return Err(ValidationError::MissingField(format!("{}/uri", path)));
}
if !obj.contains_key("cid") {
return Err(ValidationError::MissingField(format!("{}/cid", path)));
}
if let Some(uri) = obj.get("uri").and_then(|v| v.as_str())
&& !uri.starts_with("at://")
{
return Err(ValidationError::InvalidField {
path: format!("{}/uri", path),
message: "URI must be an at:// URI".to_string(),
});
}
Ok(())
}
}
fn validate_preamble<'a>(
record: &'a Value,
collection: &str,
) -> Result<(&'a str, &'a serde_json::Map<String, Value>), ValidationError> {
let obj = record
.as_object()
.ok_or_else(|| ValidationError::InvalidRecord("Record must be an object".to_string()))?;
let record_type = obj
.get("$type")
.and_then(|v| v.as_str())
.ok_or(ValidationError::MissingType)?;
if record_type != collection {
return Err(ValidationError::TypeMismatch {
expected: collection.to_string(),
actual: record_type.to_string(),
});
}
if let Some(created_at) = obj.get("createdAt").and_then(|v| v.as_str()) {
validate_datetime(created_at, "createdAt")?;
}
Ok((record_type, obj))
}
fn check_banned_content(
record_type: &str,
obj: &serde_json::Map<String, Value>,
rkey: Option<&str>,
) -> Result<(), ValidationError> {
match record_type {
"app.bsky.feed.post" => {
check_post_banned_content(obj)?;
}
"app.bsky.actor.profile" => {
check_string_field(obj, "displayName")?;
check_string_field(obj, "description")?;
}
"app.bsky.graph.list" => {
check_string_field(obj, "name")?;
}
"app.bsky.graph.starterpack" => {
check_string_field(obj, "name")?;
check_string_field(obj, "description")?;
}
"app.bsky.feed.generator" => {
if let Some(rkey) = rkey {
if crate::moderation::has_explicit_slur(rkey) {
return Err(ValidationError::BannedContent {
path: "rkey".to_string(),
});
}
}
check_string_field(obj, "displayName")?;
}
_ => {}
}
Ok(())
}
fn check_post_banned_content(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) {
tags.iter().enumerate().try_for_each(|(i, tag)| {
if let Some(tag_str) = tag.as_str() {
if crate::moderation::has_explicit_slur(tag_str) {
return Err(ValidationError::BannedContent {
path: format!("tags/{}", i),
});
}
}
Ok(())
})?;
}
if let Some(facets) = obj.get("facets").and_then(|v| v.as_array()) {
facets.iter().enumerate().try_for_each(|(i, facet)| {
if let Some(features) = facet.get("features").and_then(|v| v.as_array()) {
features.iter().enumerate().try_for_each(|(j, feature)| {
let is_tag = feature
.get("$type")
.and_then(|v| v.as_str())
.is_some_and(|t| t == "app.bsky.richtext.facet#tag");
if is_tag {
if let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) {
if crate::moderation::has_explicit_slur(tag) {
return Err(ValidationError::BannedContent {
path: format!("facets/{}/features/{}/tag", i, j),
});
}
}
}
Ok(())
})?;
}
Ok(())
})?;
}
Ok(())
}
fn check_string_field(
obj: &serde_json::Map<String, Value>,
field: &str,
) -> Result<(), ValidationError> {
if let Some(value) = obj.get(field).and_then(|v| v.as_str()) {
if crate::moderation::has_explicit_slur(value) {
return Err(ValidationError::BannedContent {
path: field.to_string(),
});
}
}
Ok(())
}
fn validate_datetime(value: &str, path: &str) -> Result<(), ValidationError> {
if chrono::DateTime::parse_from_rfc3339(value).is_err() {
if !tranquil_lexicon::is_valid_datetime(value) {
return Err(ValidationError::InvalidDatetime {
path: path.to_string(),
});
@@ -452,84 +229,22 @@ fn validate_datetime(value: &str, path: &str) -> Result<(), ValidationError> {
}
pub fn validate_record_key(rkey: &str) -> Result<(), ValidationError> {
if rkey.is_empty() {
return Err(ValidationError::InvalidRecord(
"Record key cannot be empty".to_string(),
));
}
if rkey.len() > 512 {
return Err(ValidationError::InvalidRecord(
"Record key exceeds maximum length of 512".to_string(),
));
}
if rkey == "." || rkey == ".." {
return Err(ValidationError::InvalidRecord(
"Record key cannot be '.' or '..'".to_string(),
));
}
let valid_chars = rkey
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '~');
if !valid_chars {
return Err(ValidationError::InvalidRecord(
"Record key contains invalid characters (must be alphanumeric, '.', '-', '_', or '~')"
.to_string(),
));
}
Ok(())
}
pub fn is_valid_did(did: &str) -> bool {
if !did.starts_with("did:") {
return false;
}
let parts: Vec<&str> = did.splitn(3, ':').collect();
if parts.len() < 3 {
return false;
}
let method = parts[1];
if method.is_empty() || !method.chars().all(|c| c.is_ascii_lowercase()) {
return false;
}
let id = parts[2];
!id.is_empty()
}
pub fn validate_did(did: &str) -> Result<(), ValidationError> {
if !is_valid_did(did) {
return Err(ValidationError::InvalidField {
path: "did".to_string(),
message: "Invalid DID format".to_string(),
});
if !tranquil_lexicon::is_valid_record_key(rkey) {
return Err(ValidationError::InvalidRecord(format!(
"Invalid record key: '{}'",
rkey
)));
}
Ok(())
}
pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError> {
if collection.is_empty() {
return Err(ValidationError::InvalidRecord(
"Collection NSID cannot be empty".to_string(),
));
if !tranquil_lexicon::is_valid_nsid(collection) {
return Err(ValidationError::InvalidRecord(format!(
"Invalid collection NSID: '{}'",
collection
)));
}
let parts: Vec<&str> = collection.split('.').collect();
if parts.len() < 3 {
return Err(ValidationError::InvalidRecord(
"Collection NSID must have at least 3 segments".to_string(),
));
}
parts.iter().try_for_each(|part| {
if part.is_empty() {
return Err(ValidationError::InvalidRecord(
"Collection NSID segments cannot be empty".to_string(),
));
}
if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err(ValidationError::InvalidRecord(
"Collection NSID segments must be alphanumeric or hyphens".to_string(),
));
}
Ok(())
})?;
Ok(())
}
@@ -630,109 +345,3 @@ fn is_common_password(password: &str) -> bool {
let lower = password.to_lowercase();
COMMON_PASSWORDS.iter().any(|p| p.to_lowercase() == lower)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_validate_post() {
let validator = RecordValidator::new();
let valid_post = json!({
"$type": "app.bsky.feed.post",
"text": "Hello, world!",
"createdAt": "2024-01-01T00:00:00.000Z"
});
assert_eq!(
validator
.validate(&valid_post, "app.bsky.feed.post")
.unwrap(),
ValidationStatus::Valid
);
}
#[test]
fn test_validate_post_missing_text() {
let validator = RecordValidator::new();
let invalid_post = json!({
"$type": "app.bsky.feed.post",
"createdAt": "2024-01-01T00:00:00.000Z"
});
assert!(
validator
.validate(&invalid_post, "app.bsky.feed.post")
.is_err()
);
}
#[test]
fn test_validate_type_mismatch() {
let validator = RecordValidator::new();
let record = json!({
"$type": "app.bsky.feed.like",
"subject": {"uri": "at://did:plc:test/app.bsky.feed.post/123", "cid": "bafyrei..."},
"createdAt": "2024-01-01T00:00:00.000Z"
});
let result = validator.validate(&record, "app.bsky.feed.post");
assert!(matches!(result, Err(ValidationError::TypeMismatch { .. })));
}
#[test]
fn test_validate_unknown_type() {
let validator = RecordValidator::new();
let record = json!({
"$type": "com.example.custom",
"data": "test"
});
assert_eq!(
validator.validate(&record, "com.example.custom").unwrap(),
ValidationStatus::Unknown
);
}
#[test]
fn test_validate_unknown_type_strict() {
let validator = RecordValidator::new().require_lexicon(true);
let record = json!({
"$type": "com.example.custom",
"data": "test"
});
let result = validator.validate(&record, "com.example.custom");
assert!(matches!(result, Err(ValidationError::UnknownType(_))));
}
#[test]
fn test_validate_record_key() {
assert!(validate_record_key("valid-key_123").is_ok());
assert!(validate_record_key("3k2n5j2").is_ok());
assert!(validate_record_key(".").is_err());
assert!(validate_record_key("..").is_err());
assert!(validate_record_key("").is_err());
assert!(validate_record_key("invalid/key").is_err());
}
#[test]
fn test_validate_collection_nsid() {
assert!(validate_collection_nsid("app.bsky.feed.post").is_ok());
assert!(validate_collection_nsid("com.atproto.repo.record").is_ok());
assert!(validate_collection_nsid("invalid").is_err());
assert!(validate_collection_nsid("a.b").is_err());
assert!(validate_collection_nsid("").is_err());
}
#[test]
fn test_is_valid_did() {
assert!(is_valid_did("did:plc:1234567890abcdefghijk"));
assert!(is_valid_did("did:web:example.com"));
assert!(is_valid_did(
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
));
assert!(!is_valid_did(""));
assert!(!is_valid_did("plc:1234567890abcdefghijk"));
assert!(!is_valid_did("did:"));
assert!(!is_valid_did("did:plc:"));
assert!(!is_valid_did("did::something"));
assert!(!is_valid_did("DID:plc:test"));
}
}
+11 -44
View File
@@ -23,10 +23,7 @@ async fn describe_server_returns_configured_domain() {
let client = client();
let base = base_url_with_domain().await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.server.describeServer",
base
))
.get(format!("{}/xrpc/com.atproto.server.describeServer", base))
.send()
.await
.expect("describeServer request failed");
@@ -54,10 +51,7 @@ async fn short_handle_uses_configured_domain() {
"password": "Testpass123!"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base
))
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
.json(&payload)
.send()
.await
@@ -91,10 +85,7 @@ async fn full_handle_with_configured_domain_accepted() {
"password": "Testpass123!"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base
))
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
.json(&payload)
.send()
.await
@@ -122,10 +113,7 @@ async fn handle_with_pds_hostname_treated_as_custom() {
"password": "Testpass123!"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base
))
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
.json(&payload)
.send()
.await
@@ -150,10 +138,7 @@ async fn resolve_handle_works_with_configured_domain() {
"password": "Testpass123!"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base
))
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
.json(&payload)
.send()
.await
@@ -164,10 +149,7 @@ async fn resolve_handle_works_with_configured_domain() {
let full_handle = body["handle"].as_str().expect("No handle").to_string();
let res = client
.get(format!(
"{}/xrpc/com.atproto.identity.resolveHandle",
base
))
.get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base))
.query(&[("handle", full_handle.as_str())])
.send()
.await
@@ -201,10 +183,7 @@ async fn admin_update_handle_uses_configured_domain() {
assert_eq!(res.status(), StatusCode::OK);
let res = client
.get(format!(
"{}/xrpc/com.atproto.identity.resolveHandle",
base
))
.get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base))
.query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))])
.send()
.await
@@ -228,10 +207,7 @@ async fn update_handle_bare_uses_configured_domain() {
"password": "Testpass123!"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base
))
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
.json(&payload)
.send()
.await
@@ -243,10 +219,7 @@ async fn update_handle_bare_uses_configured_domain() {
let new_short = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let res = client
.post(format!(
"{}/xrpc/com.atproto.identity.updateHandle",
base
))
.post(format!("{}/xrpc/com.atproto.identity.updateHandle", base))
.bearer_auth(&access_jwt)
.header(header::CONTENT_TYPE, "application/json")
.json(&json!({ "handle": new_short }))
@@ -261,10 +234,7 @@ async fn update_handle_bare_uses_configured_domain() {
);
let res = client
.get(format!(
"{}/xrpc/com.atproto.identity.resolveHandle",
base
))
.get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base))
.query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))])
.send()
.await
@@ -292,10 +262,7 @@ async fn did_web_uses_handle_domain_not_hostname() {
"didType": "web"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base
))
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
.json(&payload)
.send()
.await
@@ -193,13 +193,13 @@ async fn test_record_crud_lifecycle() {
async fn test_profile_with_blob_lifecycle() {
let client = client();
let (did, jwt) = setup_new_user("profile-blob").await;
let blob_data = b"This is test blob data for a profile avatar";
let blob_data = b"\x89PNG\r\n\x1a\nfake image data for test";
let upload_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.uploadBlob",
base_url().await
))
.header(header::CONTENT_TYPE, "text/plain")
.header(header::CONTENT_TYPE, "image/png")
.bearer_auth(&jwt)
.body(blob_data.to_vec())
.send()
+62 -442
View File
@@ -9,467 +9,81 @@ fn now() -> String {
}
#[test]
fn test_post_record_validation() {
fn test_type_mismatch() {
let validator = RecordValidator::new();
let valid_post = json!({
"$type": "app.bsky.feed.post",
"text": "Hello world!",
let record = json!({
"$type": "com.example.other",
"createdAt": now()
});
assert!(matches!(
validator.validate(&record, "com.example.expected"),
Err(ValidationError::TypeMismatch { expected, actual })
if expected == "com.example.expected" && actual == "com.example.other"
));
}
#[test]
fn test_missing_type() {
let validator = RecordValidator::new();
let record = json!({"text": "Hello"});
assert!(matches!(
validator.validate(&record, "com.example.test"),
Err(ValidationError::MissingType)
));
}
#[test]
fn test_not_object() {
let validator = RecordValidator::new();
let record = json!("just a string");
assert!(matches!(
validator.validate(&record, "com.example.test"),
Err(ValidationError::InvalidRecord(_))
));
}
#[test]
fn test_unknown_type_lenient() {
let validator = RecordValidator::new();
let record = json!({"$type": "com.custom.record", "data": "test"});
assert_eq!(
validator
.validate(&valid_post, "app.bsky.feed.post")
.unwrap(),
ValidationStatus::Valid
);
let missing_text = json!({
"$type": "app.bsky.feed.post",
"createdAt": now()
});
assert!(
matches!(validator.validate(&missing_text, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "text")
);
let missing_created_at = json!({
"$type": "app.bsky.feed.post",
"text": "Hello"
});
assert!(
matches!(validator.validate(&missing_created_at, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "createdAt")
);
let text_too_long = json!({
"$type": "app.bsky.feed.post",
"text": "a".repeat(3001),
"createdAt": now()
});
assert!(
matches!(validator.validate(&text_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "text")
);
let text_at_limit = json!({
"$type": "app.bsky.feed.post",
"text": "a".repeat(3000),
"createdAt": now()
});
assert_eq!(
validator
.validate(&text_at_limit, "app.bsky.feed.post")
.unwrap(),
ValidationStatus::Valid
);
let too_many_langs = json!({
"$type": "app.bsky.feed.post",
"text": "Hello",
"createdAt": now(),
"langs": ["en", "fr", "de", "es"]
});
assert!(
matches!(validator.validate(&too_many_langs, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "langs")
);
let three_langs_ok = json!({
"$type": "app.bsky.feed.post",
"text": "Hello",
"createdAt": now(),
"langs": ["en", "fr", "de"]
});
assert_eq!(
validator
.validate(&three_langs_ok, "app.bsky.feed.post")
.unwrap(),
ValidationStatus::Valid
);
let too_many_tags = json!({
"$type": "app.bsky.feed.post",
"text": "Hello",
"createdAt": now(),
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8", "tag9"]
});
assert!(
matches!(validator.validate(&too_many_tags, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "tags")
);
let eight_tags_ok = json!({
"$type": "app.bsky.feed.post",
"text": "Hello",
"createdAt": now(),
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8"]
});
assert_eq!(
validator
.validate(&eight_tags_ok, "app.bsky.feed.post")
.unwrap(),
ValidationStatus::Valid
);
let tag_too_long = json!({
"$type": "app.bsky.feed.post",
"text": "Hello",
"createdAt": now(),
"tags": ["t".repeat(641)]
});
assert!(
matches!(validator.validate(&tag_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path.starts_with("tags/"))
validator.validate(&record, "com.custom.record").unwrap(),
ValidationStatus::Unknown
);
}
#[test]
fn test_profile_record_validation() {
let validator = RecordValidator::new();
let valid = json!({
"$type": "app.bsky.actor.profile",
"displayName": "Test User",
"description": "A test user profile"
});
assert_eq!(
validator
.validate(&valid, "app.bsky.actor.profile")
.unwrap(),
ValidationStatus::Valid
);
let empty_ok = json!({
"$type": "app.bsky.actor.profile"
});
assert_eq!(
validator
.validate(&empty_ok, "app.bsky.actor.profile")
.unwrap(),
ValidationStatus::Valid
);
let displayname_too_long = json!({
"$type": "app.bsky.actor.profile",
"displayName": "n".repeat(641)
});
assert!(
matches!(validator.validate(&displayname_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName")
);
let description_too_long = json!({
"$type": "app.bsky.actor.profile",
"description": "d".repeat(2561)
});
assert!(
matches!(validator.validate(&description_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "description")
);
fn test_unknown_type_strict() {
let validator = RecordValidator::new().require_lexicon(true);
let record = json!({"$type": "com.custom.record", "data": "test"});
assert!(matches!(
validator.validate(&record, "com.custom.record"),
Err(ValidationError::UnknownType(_))
));
}
#[test]
fn test_like_and_repost_validation() {
fn test_datetime_validation() {
let validator = RecordValidator::new();
let valid_like = json!({
"$type": "app.bsky.feed.like",
"subject": {
"uri": "at://did:plc:test/app.bsky.feed.post/123",
"cid": "bafyreig6xxxxxyyyyyzzzzzz"
},
"createdAt": now()
});
let valid = json!({"$type": "com.custom.record", "createdAt": "2024-01-15T10:30:00.000Z"});
assert_eq!(
validator.validate(&valid, "com.custom.record").unwrap(),
ValidationStatus::Unknown
);
let with_offset =
json!({"$type": "com.custom.record", "createdAt": "2024-01-15T10:30:00+05:30"});
assert_eq!(
validator
.validate(&valid_like, "app.bsky.feed.like")
.unwrap(),
ValidationStatus::Valid
);
let missing_subject = json!({
"$type": "app.bsky.feed.like",
"createdAt": now()
});
assert!(
matches!(validator.validate(&missing_subject, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f == "subject")
);
let missing_subject_uri = json!({
"$type": "app.bsky.feed.like",
"subject": {
"cid": "bafyreig6xxxxxyyyyyzzzzzz"
},
"createdAt": now()
});
assert!(
matches!(validator.validate(&missing_subject_uri, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f.contains("uri"))
);
let invalid_subject_uri = json!({
"$type": "app.bsky.feed.like",
"subject": {
"uri": "https://example.com/not-at-uri",
"cid": "bafyreig6xxxxxyyyyyzzzzzz"
},
"createdAt": now()
});
assert!(
matches!(validator.validate(&invalid_subject_uri, "app.bsky.feed.like"), Err(ValidationError::InvalidField { path, .. }) if path.contains("uri"))
);
let valid_repost = json!({
"$type": "app.bsky.feed.repost",
"subject": {
"uri": "at://did:plc:test/app.bsky.feed.post/123",
"cid": "bafyreig6xxxxxyyyyyzzzzzz"
},
"createdAt": now()
});
assert_eq!(
validator
.validate(&valid_repost, "app.bsky.feed.repost")
.unwrap(),
ValidationStatus::Valid
);
let repost_missing_subject = json!({
"$type": "app.bsky.feed.repost",
"createdAt": now()
});
assert!(
matches!(validator.validate(&repost_missing_subject, "app.bsky.feed.repost"), Err(ValidationError::MissingField(f)) if f == "subject")
);
}
#[test]
fn test_follow_and_block_validation() {
let validator = RecordValidator::new();
let valid_follow = json!({
"$type": "app.bsky.graph.follow",
"subject": "did:plc:test12345",
"createdAt": now()
});
assert_eq!(
validator
.validate(&valid_follow, "app.bsky.graph.follow")
.unwrap(),
ValidationStatus::Valid
);
let missing_follow_subject = json!({
"$type": "app.bsky.graph.follow",
"createdAt": now()
});
assert!(
matches!(validator.validate(&missing_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::MissingField(f)) if f == "subject")
);
let invalid_follow_subject = json!({
"$type": "app.bsky.graph.follow",
"subject": "not-a-did",
"createdAt": now()
});
assert!(
matches!(validator.validate(&invalid_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::InvalidField { path, .. }) if path == "subject")
);
let valid_block = json!({
"$type": "app.bsky.graph.block",
"subject": "did:plc:blocked123",
"createdAt": now()
});
assert_eq!(
validator
.validate(&valid_block, "app.bsky.graph.block")
.unwrap(),
ValidationStatus::Valid
);
let invalid_block_subject = json!({
"$type": "app.bsky.graph.block",
"subject": "not-a-did",
"createdAt": now()
});
assert!(
matches!(validator.validate(&invalid_block_subject, "app.bsky.graph.block"), Err(ValidationError::InvalidField { path, .. }) if path == "subject")
);
}
#[test]
fn test_list_and_graph_records_validation() {
let validator = RecordValidator::new();
let valid_list = json!({
"$type": "app.bsky.graph.list",
"name": "My List",
"purpose": "app.bsky.graph.defs#modlist",
"createdAt": now()
});
assert_eq!(
validator
.validate(&valid_list, "app.bsky.graph.list")
.unwrap(),
ValidationStatus::Valid
);
let list_name_too_long = json!({
"$type": "app.bsky.graph.list",
"name": "n".repeat(65),
"purpose": "app.bsky.graph.defs#modlist",
"createdAt": now()
});
assert!(
matches!(validator.validate(&list_name_too_long, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name")
);
let list_empty_name = json!({
"$type": "app.bsky.graph.list",
"name": "",
"purpose": "app.bsky.graph.defs#modlist",
"createdAt": now()
});
assert!(
matches!(validator.validate(&list_empty_name, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name")
);
let valid_list_item = json!({
"$type": "app.bsky.graph.listitem",
"subject": "did:plc:test123",
"list": "at://did:plc:owner/app.bsky.graph.list/mylist",
"createdAt": now()
});
assert_eq!(
validator
.validate(&valid_list_item, "app.bsky.graph.listitem")
.unwrap(),
ValidationStatus::Valid
);
}
#[test]
fn test_misc_record_types_validation() {
let validator = RecordValidator::new();
let valid_generator = json!({
"$type": "app.bsky.feed.generator",
"did": "did:web:example.com",
"displayName": "My Feed",
"createdAt": now()
});
assert_eq!(
validator
.validate(&valid_generator, "app.bsky.feed.generator")
.unwrap(),
ValidationStatus::Valid
);
let generator_displayname_too_long = json!({
"$type": "app.bsky.feed.generator",
"did": "did:web:example.com",
"displayName": "f".repeat(241),
"createdAt": now()
});
assert!(
matches!(validator.validate(&generator_displayname_too_long, "app.bsky.feed.generator"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName")
);
let valid_threadgate = json!({
"$type": "app.bsky.feed.threadgate",
"post": "at://did:plc:test/app.bsky.feed.post/123",
"createdAt": now()
});
assert_eq!(
validator
.validate(&valid_threadgate, "app.bsky.feed.threadgate")
.unwrap(),
ValidationStatus::Valid
);
let valid_labeler = json!({
"$type": "app.bsky.labeler.service",
"policies": {
"labelValues": ["spam", "nsfw"]
},
"createdAt": now()
});
assert_eq!(
validator
.validate(&valid_labeler, "app.bsky.labeler.service")
.unwrap(),
ValidationStatus::Valid
);
}
#[test]
fn test_type_and_format_validation() {
let validator = RecordValidator::new();
let strict_validator = RecordValidator::new().require_lexicon(true);
let custom_record = json!({
"$type": "com.custom.record",
"data": "test"
});
assert_eq!(
validator
.validate(&custom_record, "com.custom.record")
.validate(&with_offset, "com.custom.record")
.unwrap(),
ValidationStatus::Unknown
);
let invalid = json!({"$type": "com.custom.record", "createdAt": "2024/01/15"});
assert!(matches!(
strict_validator.validate(&custom_record, "com.custom.record"),
Err(ValidationError::UnknownType(_))
));
let type_mismatch = json!({
"$type": "app.bsky.feed.like",
"subject": {"uri": "at://test", "cid": "bafytest"},
"createdAt": now()
});
assert!(matches!(
validator.validate(&type_mismatch, "app.bsky.feed.post"),
Err(ValidationError::TypeMismatch { expected, actual }) if expected == "app.bsky.feed.post" && actual == "app.bsky.feed.like"
));
let missing_type = json!({
"text": "Hello"
});
assert!(matches!(
validator.validate(&missing_type, "app.bsky.feed.post"),
Err(ValidationError::MissingType)
));
let not_object = json!("just a string");
assert!(matches!(
validator.validate(&not_object, "app.bsky.feed.post"),
Err(ValidationError::InvalidRecord(_))
));
let valid_datetime = json!({
"$type": "app.bsky.feed.post",
"text": "Test",
"createdAt": "2024-01-15T10:30:00.000Z"
});
assert_eq!(
validator
.validate(&valid_datetime, "app.bsky.feed.post")
.unwrap(),
ValidationStatus::Valid
);
let datetime_with_offset = json!({
"$type": "app.bsky.feed.post",
"text": "Test",
"createdAt": "2024-01-15T10:30:00+05:30"
});
assert_eq!(
validator
.validate(&datetime_with_offset, "app.bsky.feed.post")
.unwrap(),
ValidationStatus::Valid
);
let invalid_datetime = json!({
"$type": "app.bsky.feed.post",
"text": "Test",
"createdAt": "2024/01/15"
});
assert!(matches!(
validator.validate(&invalid_datetime, "app.bsky.feed.post"),
validator.validate(&invalid, "com.custom.record"),
Err(ValidationError::InvalidDatetime { .. })
));
}
@@ -501,6 +115,12 @@ fn test_record_key_validation() {
Err(ValidationError::InvalidRecord(_))
));
assert!(validate_record_key(&"k".repeat(512)).is_ok());
assert!(
validate_record_key("key:with:colons").is_ok(),
"AT Protocol record keys allow colons"
);
assert!(validate_record_key("at:something").is_ok());
}
#[test]
+66 -22
View File
@@ -6,17 +6,58 @@ use helpers::*;
use reqwest::StatusCode;
use serde_json::{Value, json};
fn ensure_test_schemas() {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
let registry = tranquil_lexicon::LexiconRegistry::global();
let post_schema: tranquil_lexicon::LexiconDoc = serde_json::from_value(json!({
"lexicon": 1,
"id": "com.test.feed.post",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"required": ["text", "createdAt"],
"properties": {
"text": { "type": "string", "maxLength": 300, "maxGraphemes": 300 },
"createdAt": { "type": "string", "format": "datetime" },
"reply": { "type": "ref", "ref": "#replyRef" },
"embed": { "type": "union", "refs": [] },
"langs": { "type": "array", "maxLength": 3, "items": { "type": "string", "format": "language" } },
"tags": { "type": "array", "maxLength": 8, "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } },
"facets": { "type": "array", "items": { "type": "unknown" } }
}
}
},
"replyRef": {
"type": "object",
"required": ["root", "parent"],
"properties": {
"root": { "type": "unknown" },
"parent": { "type": "unknown" }
}
}
}
})).expect("invalid post schema");
registry.preload(post_schema);
});
}
#[tokio::test]
async fn test_create_record_response_schema() {
ensure_test_schemas();
let client = client();
let (did, jwt) = setup_new_user("conform-create").await;
let now = Utc::now().to_rfc3339();
let payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"collection": "com.test.feed.post",
"record": {
"$type": "app.bsky.feed.post",
"$type": "com.test.feed.post",
"text": "Testing conformance",
"createdAt": now
}
@@ -73,10 +114,10 @@ async fn test_create_record_no_validation_status_when_validate_false() {
let payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"collection": "com.test.feed.post",
"validate": false,
"record": {
"$type": "app.bsky.feed.post",
"$type": "com.test.feed.post",
"text": "Testing without validation",
"createdAt": now
}
@@ -106,16 +147,17 @@ async fn test_create_record_no_validation_status_when_validate_false() {
#[tokio::test]
async fn test_put_record_response_schema() {
ensure_test_schemas();
let client = client();
let (did, jwt) = setup_new_user("conform-put").await;
let now = Utc::now().to_rfc3339();
let payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"collection": "com.test.feed.post",
"rkey": "conformance-put",
"record": {
"$type": "app.bsky.feed.post",
"$type": "com.test.feed.post",
"text": "Testing putRecord conformance",
"createdAt": now
}
@@ -160,10 +202,10 @@ async fn test_delete_record_response_schema() {
let create_payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"collection": "com.test.feed.post",
"rkey": "to-delete",
"record": {
"$type": "app.bsky.feed.post",
"$type": "com.test.feed.post",
"text": "This will be deleted",
"createdAt": now
}
@@ -182,7 +224,7 @@ async fn test_delete_record_response_schema() {
let delete_payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"collection": "com.test.feed.post",
"rkey": "to-delete"
});
let delete_res = client
@@ -215,7 +257,7 @@ async fn test_delete_record_noop_response() {
let delete_payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"collection": "com.test.feed.post",
"rkey": "nonexistent-record"
});
let delete_res = client
@@ -240,6 +282,7 @@ async fn test_delete_record_noop_response() {
#[tokio::test]
async fn test_apply_writes_response_schema() {
ensure_test_schemas();
let client = client();
let (did, jwt) = setup_new_user("conform-apply").await;
let now = Utc::now().to_rfc3339();
@@ -249,20 +292,20 @@ async fn test_apply_writes_response_schema() {
"writes": [
{
"$type": "com.atproto.repo.applyWrites#create",
"collection": "app.bsky.feed.post",
"collection": "com.test.feed.post",
"rkey": "apply-test-1",
"value": {
"$type": "app.bsky.feed.post",
"$type": "com.test.feed.post",
"text": "First post",
"createdAt": now
}
},
{
"$type": "com.atproto.repo.applyWrites#create",
"collection": "app.bsky.feed.post",
"collection": "com.test.feed.post",
"rkey": "apply-test-2",
"value": {
"$type": "app.bsky.feed.post",
"$type": "com.test.feed.post",
"text": "Second post",
"createdAt": now
}
@@ -312,16 +355,17 @@ async fn test_apply_writes_response_schema() {
#[tokio::test]
async fn test_apply_writes_update_and_delete_results() {
ensure_test_schemas();
let client = client();
let (did, jwt) = setup_new_user("conform-apply-upd").await;
let now = Utc::now().to_rfc3339();
let create_payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"collection": "com.test.feed.post",
"rkey": "to-update",
"record": {
"$type": "app.bsky.feed.post",
"$type": "com.test.feed.post",
"text": "Original",
"createdAt": now
}
@@ -342,17 +386,17 @@ async fn test_apply_writes_update_and_delete_results() {
"writes": [
{
"$type": "com.atproto.repo.applyWrites#update",
"collection": "app.bsky.feed.post",
"collection": "com.test.feed.post",
"rkey": "to-update",
"value": {
"$type": "app.bsky.feed.post",
"$type": "com.test.feed.post",
"text": "Updated",
"createdAt": now
}
},
{
"$type": "com.atproto.repo.applyWrites#delete",
"collection": "app.bsky.feed.post",
"collection": "com.test.feed.post",
"rkey": "to-update"
}
]
@@ -415,7 +459,7 @@ async fn test_get_record_error_code() {
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("collection", "com.test.feed.post"),
("rkey", "nonexistent"),
])
.send()
@@ -520,14 +564,14 @@ async fn test_put_record_noop_same_content() {
let now = Utc::now().to_rfc3339();
let record = json!({
"$type": "app.bsky.feed.post",
"$type": "com.test.feed.post",
"text": "This content will not change",
"createdAt": now
});
let payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"collection": "com.test.feed.post",
"rkey": "noop-test",
"record": record.clone()
});
@@ -1,10 +1,9 @@
use tranquil_lexicon::is_valid_did;
use tranquil_pds::api::validation::{
HandleValidationError, MAX_DOMAIN_LABEL_LENGTH, MAX_EMAIL_LENGTH, MAX_LOCAL_PART_LENGTH,
MAX_SERVICE_HANDLE_LOCAL_PART, is_valid_email, validate_short_handle,
};
use tranquil_pds::validation::{
is_valid_did, validate_collection_nsid, validate_password, validate_record_key,
};
use tranquil_pds::validation::{validate_collection_nsid, validate_password, validate_record_key};
#[test]
fn test_record_key_boundary_min() {
@@ -59,7 +58,6 @@ fn test_record_key_invalid_chars() {
assert!(validate_record_key("a+b").is_err());
assert!(validate_record_key("a=b").is_err());
assert!(validate_record_key("a?b").is_err());
assert!(validate_record_key("a:b").is_err());
assert!(validate_record_key("a;b").is_err());
assert!(validate_record_key("a<b").is_err());
assert!(validate_record_key("a>b").is_err());
@@ -160,7 +158,7 @@ fn test_did_validation_method_case() {
#[test]
fn test_did_validation_method_chars() {
assert!(!is_valid_did("did:plc1:abc"));
assert!(is_valid_did("did:plc1:abc"));
assert!(!is_valid_did("did:plc-x:abc"));
assert!(!is_valid_did("did:plc_x:abc"));
}
+1
View File
@@ -59,6 +59,7 @@ export TRANQUIL_PDS_TEST_INFRA_READY="1"
export TRANQUIL_PDS_ALLOW_INSECURE_SECRETS="1"
export SKIP_IMPORT_VERIFICATION="true"
export DISABLE_RATE_LIMITING="1"
export TRANQUIL_LEXICON_OFFLINE="1"
EOF
echo ""
echo "Infrastructure ready!"