re-uploading work
This commit is contained in:
686
crates/api/tests/pack_registry_tests.rs
Normal file
686
crates/api/tests/pack_registry_tests.rs
Normal file
@@ -0,0 +1,686 @@
|
||||
//! Integration tests for pack registry system
|
||||
//!
|
||||
//! This module tests:
|
||||
//! - End-to-end pack installation from all sources (git, archive, local, registry)
|
||||
//! - Dependency validation during installation
|
||||
//! - Installation metadata tracking
|
||||
//! - Checksum verification
|
||||
//! - Error handling and edge cases
|
||||
|
||||
mod helpers;
|
||||
|
||||
use attune_common::{
|
||||
models::Pack,
|
||||
pack_registry::calculate_directory_checksum,
|
||||
repositories::{pack::PackRepository, pack_installation::PackInstallationRepository, List},
|
||||
};
|
||||
use helpers::{Result, TestContext};
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Helper to create a test pack directory with pack.yaml
|
||||
fn create_test_pack_dir(name: &str, version: &str) -> Result<TempDir> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let pack_yaml = format!(
|
||||
r#"
|
||||
ref: {}
|
||||
name: Test Pack {}
|
||||
version: {}
|
||||
description: Test pack for integration tests
|
||||
author: Test Author
|
||||
email: test@example.com
|
||||
keywords:
|
||||
- test
|
||||
- integration
|
||||
dependencies: []
|
||||
python: "3.8"
|
||||
actions:
|
||||
test_action:
|
||||
entry_point: test.py
|
||||
runner_type: python-script
|
||||
"#,
|
||||
name, name, version
|
||||
);
|
||||
|
||||
fs::write(temp_dir.path().join("pack.yaml"), pack_yaml)?;
|
||||
|
||||
// Create a simple action file
|
||||
let action_content = r#"
|
||||
#!/usr/bin/env python3
|
||||
print("Test action executed")
|
||||
"#;
|
||||
fs::write(temp_dir.path().join("test.py"), action_content)?;
|
||||
|
||||
Ok(temp_dir)
|
||||
}
|
||||
|
||||
/// Helper to create a pack with dependencies
|
||||
fn create_pack_with_deps(name: &str, deps: &[&str]) -> Result<TempDir> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let deps_yaml = deps
|
||||
.iter()
|
||||
.map(|d| format!(" - {}", d))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let pack_yaml = format!(
|
||||
r#"
|
||||
ref: {}
|
||||
name: Test Pack {}
|
||||
version: 1.0.0
|
||||
description: Test pack with dependencies
|
||||
author: Test Author
|
||||
dependencies:
|
||||
{}
|
||||
python: "3.8"
|
||||
actions:
|
||||
test_action:
|
||||
entry_point: test.py
|
||||
runner_type: python-script
|
||||
"#,
|
||||
name, name, deps_yaml
|
||||
);
|
||||
|
||||
fs::write(temp_dir.path().join("pack.yaml"), pack_yaml)?;
|
||||
fs::write(temp_dir.path().join("test.py"), "print('test')")?;
|
||||
|
||||
Ok(temp_dir)
|
||||
}
|
||||
|
||||
/// Helper to create a pack with specific runtime requirements
|
||||
fn create_pack_with_runtime(
|
||||
name: &str,
|
||||
python: Option<&str>,
|
||||
nodejs: Option<&str>,
|
||||
) -> Result<TempDir> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
let python_line = python
|
||||
.map(|v| format!("python: \"{}\"", v))
|
||||
.unwrap_or_default();
|
||||
let nodejs_line = nodejs
|
||||
.map(|v| format!("nodejs: \"{}\"", v))
|
||||
.unwrap_or_default();
|
||||
|
||||
let pack_yaml = format!(
|
||||
r#"
|
||||
ref: {}
|
||||
name: Test Pack {}
|
||||
version: 1.0.0
|
||||
description: Test pack with runtime requirements
|
||||
author: Test Author
|
||||
{}
|
||||
{}
|
||||
actions:
|
||||
test_action:
|
||||
entry_point: test.py
|
||||
runner_type: python-script
|
||||
"#,
|
||||
name, name, python_line, nodejs_line
|
||||
);
|
||||
|
||||
fs::write(temp_dir.path().join("pack.yaml"), pack_yaml)?;
|
||||
fs::write(temp_dir.path().join("test.py"), "print('test')")?;
|
||||
|
||||
Ok(temp_dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_from_local_directory() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
// Create a test pack directory
|
||||
let pack_dir = create_test_pack_dir("local-test", "1.0.0")?;
|
||||
let pack_path = pack_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Install pack from local directory
|
||||
let response = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": pack_path,
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
let body_text = response.text().await?;
|
||||
|
||||
if status != 200 {
|
||||
eprintln!("Error response (status {}): {}", status, body_text);
|
||||
}
|
||||
assert_eq!(status, 200, "Installation should succeed");
|
||||
|
||||
let body: serde_json::Value = serde_json::from_str(&body_text)?;
|
||||
assert_eq!(body["data"]["pack"]["ref"], "local-test");
|
||||
assert_eq!(body["data"]["pack"]["version"], "1.0.0");
|
||||
assert_eq!(body["data"]["tests_skipped"], true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_with_dependency_validation_success() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
// First, install a dependency pack
|
||||
let dep_pack_dir = create_test_pack_dir("core", "1.0.0")?;
|
||||
let dep_path = dep_pack_dir.path().to_string_lossy().to_string();
|
||||
|
||||
ctx.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": dep_path,
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Now install a pack that depends on it
|
||||
let pack_dir = create_pack_with_deps("dependent-pack", &["core"])?;
|
||||
let pack_path = pack_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": pack_path,
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": false // Enable dependency validation
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
200,
|
||||
"Installation should succeed when dependencies are met"
|
||||
);
|
||||
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
assert_eq!(body["data"]["pack"]["ref"], "dependent-pack");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_with_missing_dependency_fails() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
// Create a pack with an unmet dependency
|
||||
let pack_dir = create_pack_with_deps("dependent-pack", &["missing-pack"])?;
|
||||
let pack_path = pack_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": pack_path,
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": false // Enable dependency validation
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Should fail with 400 Bad Request
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
400,
|
||||
"Installation should fail when dependencies are missing"
|
||||
);
|
||||
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
let error_msg = body["error"].as_str().unwrap();
|
||||
assert!(
|
||||
error_msg.contains("dependency validation failed") || error_msg.contains("missing-pack"),
|
||||
"Error should mention dependency validation failure"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_skip_deps_bypasses_validation() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
// Create a pack with an unmet dependency
|
||||
let pack_dir = create_pack_with_deps("dependent-pack", &["missing-pack"])?;
|
||||
let pack_path = pack_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": pack_path,
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true // Skip dependency validation
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Should succeed because validation is skipped
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
200,
|
||||
"Installation should succeed when validation is skipped"
|
||||
);
|
||||
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
assert_eq!(body["data"]["pack"]["ref"], "dependent-pack");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_with_runtime_validation() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
// Create a pack with reasonable runtime requirements
|
||||
let pack_dir = create_pack_with_runtime("runtime-test", Some("3.8"), None)?;
|
||||
let pack_path = pack_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": pack_path,
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": false // Enable validation
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Result depends on whether Python 3.8+ is available in test environment
|
||||
// We just verify the response is well-formed
|
||||
let status = response.status();
|
||||
assert!(
|
||||
status == 200 || status == 400,
|
||||
"Should either succeed or fail gracefully"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_metadata_tracking() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
// Install a pack
|
||||
let pack_dir = create_test_pack_dir("metadata-test", "1.0.0")?;
|
||||
let pack_path = pack_dir.path().to_string_lossy().to_string();
|
||||
let original_checksum = calculate_directory_checksum(pack_dir.path())?;
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": pack_path,
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), 200);
|
||||
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
let pack_id = body["data"]["pack"]["id"].as_i64().unwrap();
|
||||
|
||||
// Verify installation metadata was created
|
||||
let installation_repo = PackInstallationRepository::new(ctx.pool.clone());
|
||||
let installation = installation_repo
|
||||
.get_by_pack_id(pack_id)
|
||||
.await?
|
||||
.expect("Should have installation record");
|
||||
|
||||
assert_eq!(installation.pack_id, pack_id);
|
||||
assert_eq!(installation.source_type, "local_directory");
|
||||
assert!(installation.source_url.is_some());
|
||||
assert!(installation.checksum.is_some());
|
||||
|
||||
// Verify checksum matches
|
||||
let stored_checksum = installation.checksum.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
stored_checksum, &original_checksum,
|
||||
"Stored checksum should match calculated checksum"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_force_reinstall() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
let pack_dir = create_test_pack_dir("force-test", "1.0.0")?;
|
||||
let pack_path = pack_dir.path().to_string_lossy().to_string();
|
||||
|
||||
// Install once
|
||||
let response1 = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": &pack_path,
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response1.status(), 200);
|
||||
|
||||
// Try to install again without force - should work but might replace
|
||||
let response2 = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": &pack_path,
|
||||
"force": true,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response2.status(), 200, "Force reinstall should succeed");
|
||||
|
||||
// Verify pack exists
|
||||
let packs = PackRepository::list(&ctx.pool).await?;
|
||||
let force_test_packs: Vec<&Pack> = packs.iter().filter(|p| p.r#ref == "force-test").collect();
|
||||
assert_eq!(
|
||||
force_test_packs.len(),
|
||||
1,
|
||||
"Should have exactly one force-test pack"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_storage_path_created() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
let pack_dir = create_test_pack_dir("storage-test", "2.3.4")?;
|
||||
let pack_path = pack_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": pack_path,
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), 200);
|
||||
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
let pack_id = body["data"]["pack"]["id"].as_i64().unwrap();
|
||||
|
||||
// Verify installation metadata has storage path
|
||||
let installation_repo = PackInstallationRepository::new(ctx.pool.clone());
|
||||
let installation = installation_repo
|
||||
.get_by_pack_id(pack_id)
|
||||
.await?
|
||||
.expect("Should have installation record");
|
||||
|
||||
let storage_path = &installation.storage_path;
|
||||
assert!(
|
||||
storage_path.contains("storage-test"),
|
||||
"Storage path should contain pack ref"
|
||||
);
|
||||
assert!(
|
||||
storage_path.contains("2.3.4"),
|
||||
"Storage path should contain version"
|
||||
);
|
||||
|
||||
// Note: We can't verify the actual filesystem without knowing the config path
|
||||
// but we verify the path structure is correct
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_invalid_source() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": "/nonexistent/path/to/pack",
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
404,
|
||||
"Should fail with not found status for nonexistent path"
|
||||
);
|
||||
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
assert!(body["error"].is_string(), "Should have error message");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_missing_pack_yaml() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
// Create directory without pack.yaml
|
||||
let temp_dir = TempDir::new()?;
|
||||
fs::write(temp_dir.path().join("readme.txt"), "No pack.yaml here")?;
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": temp_dir.path().to_string_lossy(),
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), 400, "Should fail with bad request");
|
||||
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
let error = body["error"].as_str().unwrap();
|
||||
assert!(
|
||||
error.contains("pack.yaml"),
|
||||
"Error should mention pack.yaml"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_invalid_pack_yaml() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
// Create pack.yaml with invalid content
|
||||
let temp_dir = TempDir::new()?;
|
||||
fs::write(temp_dir.path().join("pack.yaml"), "invalid: yaml: content:")?;
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": temp_dir.path().to_string_lossy(),
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Should fail with error status
|
||||
assert!(response.status().is_client_error() || response.status().is_server_error());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_without_auth_fails() -> Result<()> {
|
||||
let ctx = TestContext::new().await?; // No auth
|
||||
|
||||
let pack_dir = create_test_pack_dir("auth-test", "1.0.0")?;
|
||||
let pack_path = pack_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": pack_path,
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
None, // No token
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), 401, "Should require authentication");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_pack_installations() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
// Install multiple packs
|
||||
for i in 1..=3 {
|
||||
let pack_dir = create_test_pack_dir(&format!("multi-pack-{}", i), "1.0.0")?;
|
||||
let pack_path = pack_dir.path().to_string_lossy().to_string();
|
||||
|
||||
let response = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": pack_path,
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
200,
|
||||
"Pack {} installation should succeed",
|
||||
i
|
||||
);
|
||||
}
|
||||
|
||||
// Verify all packs are installed
|
||||
let packs = <PackRepository as List>::list(&ctx.pool).await?;
|
||||
let multi_packs: Vec<&Pack> = packs
|
||||
.iter()
|
||||
.filter(|p| p.r#ref.starts_with("multi-pack-"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
multi_packs.len(),
|
||||
3,
|
||||
"Should have 3 multi-pack installations"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_pack_version_upgrade() -> Result<()> {
|
||||
let ctx = TestContext::new().await?.with_auth().await?;
|
||||
let token = ctx.token().unwrap();
|
||||
|
||||
// Install version 1.0.0
|
||||
let pack_dir_v1 = create_test_pack_dir("version-test", "1.0.0")?;
|
||||
let response1 = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": pack_dir_v1.path().to_string_lossy(),
|
||||
"force": false,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response1.status(), 200);
|
||||
|
||||
// Install version 2.0.0 with force
|
||||
let pack_dir_v2 = create_test_pack_dir("version-test", "2.0.0")?;
|
||||
let response2 = ctx
|
||||
.post(
|
||||
"/api/v1/packs/install",
|
||||
json!({
|
||||
"source": pack_dir_v2.path().to_string_lossy(),
|
||||
"force": true,
|
||||
"skip_tests": true,
|
||||
"skip_deps": true
|
||||
}),
|
||||
Some(token),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response2.status(), 200);
|
||||
|
||||
let body: serde_json::Value = response2.json().await?;
|
||||
assert_eq!(
|
||||
body["data"]["pack"]["version"], "2.0.0",
|
||||
"Should be upgraded to version 2.0.0"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user