chore: monorepo inicial con arje + minga + yahweh absorbidos
Workspace en 4 ejes (core/modules/apps/shared):
- core/: 24 crates de arje (Init systemd-compatible: ente-card, ente-zero,
ente-kernel, ente-bus, ente-cas, ente-soma, ente-wasm, ente-snapshot,
ente-brain, ente-echo, ente-policy-provider, + 12 crates *-compat)
- modules/semantic_dht/: 5 crates de minga (minga-core con AST/CAS/MST,
minga-p2p con libp2p Kad, minga-store, minga-vfs, minga-cli)
- modules/ui_engine/: 11 crates de yahweh (libs/{core,theme,bus,providers},
widgets/{tree,splitter,tabs,tiled,container_core,text_input})
- apps/: 5 crates de yahweh (file_explorer, database_explorer, text_viewer,
image_viewer, yahweh-shell)
- shared_wit/protocol.wit: handshake/lifecycle inicial
Cargo.toml unificado: thiserror bumped a 2 (transparente para arje), tokio
"full", paths intra-workspace de yahweh redirigidos a su nueva ubicación.
cargo check --workspace: 0 errores, 17 warnings (dead code preexistente).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
//! Invariantes del `SledAttestationStore`.
|
||||
|
||||
use minga_core::{Attestation, AttestationError, ContentHash, Keypair};
|
||||
use minga_store::{SledAttestationStore, StoreError};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn open_store(path: &std::path::Path) -> (sled::Db, SledAttestationStore) {
|
||||
let db = sled::open(path).unwrap();
|
||||
let store = SledAttestationStore::open_tree(&db, "atts").unwrap();
|
||||
(db, store)
|
||||
}
|
||||
|
||||
fn kp(seed: u8) -> Keypair {
|
||||
Keypair::from_seed(&[seed; 32])
|
||||
}
|
||||
|
||||
fn ch(seed: u8) -> ContentHash {
|
||||
ContentHash([seed; 32])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_then_get_roundtrips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
|
||||
let alice = kp(1);
|
||||
let att = Attestation::create(&alice, ch(7));
|
||||
store.add(att.clone()).unwrap();
|
||||
|
||||
let retrieved = store.get(&ch(7)).unwrap();
|
||||
assert_eq!(retrieved, vec![att]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_signature_rejected() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
|
||||
let alice = kp(1);
|
||||
let mut att = Attestation::create(&alice, ch(7));
|
||||
att.signature.0[10] ^= 0xFF;
|
||||
|
||||
let r = store.add(att);
|
||||
assert!(matches!(
|
||||
r,
|
||||
Err(StoreError::Attestation(AttestationError::InvalidSignature))
|
||||
));
|
||||
assert_eq!(store.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_per_author_and_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
|
||||
let alice = kp(1);
|
||||
let att = Attestation::create(&alice, ch(5));
|
||||
store.add(att.clone()).unwrap();
|
||||
store.add(att.clone()).unwrap();
|
||||
store.add(att).unwrap();
|
||||
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_authors_per_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
let alice = kp(1);
|
||||
let bob = kp(2);
|
||||
let carol = kp(3);
|
||||
let h = ch(99);
|
||||
|
||||
store.add(Attestation::create(&alice, h)).unwrap();
|
||||
store.add(Attestation::create(&bob, h)).unwrap();
|
||||
store.add(Attestation::create(&carol, h)).unwrap();
|
||||
|
||||
assert_eq!(store.len(), 3);
|
||||
let authors = store.authors_of(&h).unwrap();
|
||||
assert_eq!(authors.len(), 3);
|
||||
assert!(authors.contains(&alice.did()));
|
||||
assert!(authors.contains(&bob.did()));
|
||||
assert!(authors.contains(&carol.did()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_persists_across_reopen() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
let alice = kp(42);
|
||||
let h = ch(11);
|
||||
|
||||
{
|
||||
let (db, store) = open_store(path);
|
||||
store.add(Attestation::create(&alice, h)).unwrap();
|
||||
store.flush().unwrap();
|
||||
drop(store);
|
||||
drop(db);
|
||||
}
|
||||
{
|
||||
let (_db, store) = open_store(path);
|
||||
let authors = store.authors_of(&h).unwrap();
|
||||
assert_eq!(authors, vec![alice.did()]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_content_returns_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
let authors = store.authors_of(&ch(0)).unwrap();
|
||||
assert!(authors.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Tests de persistencia del keypair cifrado en disco.
|
||||
|
||||
use minga_core::Keypair;
|
||||
use minga_store::keypair_file;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn save_then_load_preserves_identity() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("keypair");
|
||||
|
||||
let original = Keypair::from_seed(&[7; 32]);
|
||||
keypair_file::save(&original, &path, "secreto42").unwrap();
|
||||
|
||||
let loaded = keypair_file::load(&path, "secreto42").unwrap();
|
||||
assert_eq!(loaded.did(), original.did());
|
||||
|
||||
let msg = b"el peer sigue siendo el mismo";
|
||||
let sig = loaded.sign(msg);
|
||||
assert!(original.did().verify(msg, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_with_wrong_passphrase_errors() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("keypair");
|
||||
|
||||
let kp = Keypair::from_seed(&[3; 32]);
|
||||
keypair_file::save(&kp, &path, "correcta").unwrap();
|
||||
|
||||
let r = keypair_file::load(&path, "incorrecta");
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_missing_file_errors() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("no-existe");
|
||||
let r = keypair_file::load(&path, "x");
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_overwrites_existing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("keypair");
|
||||
|
||||
let first = Keypair::from_seed(&[1; 32]);
|
||||
keypair_file::save(&first, &path, "pass").unwrap();
|
||||
|
||||
let second = Keypair::from_seed(&[2; 32]);
|
||||
keypair_file::save(&second, &path, "pass").unwrap();
|
||||
|
||||
let loaded = keypair_file::load(&path, "pass").unwrap();
|
||||
assert_eq!(loaded.did(), second.did());
|
||||
assert_ne!(loaded.did(), first.did());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_size_is_compact() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("keypair");
|
||||
keypair_file::save(&Keypair::from_seed(&[5; 32]), &path, "p").unwrap();
|
||||
let size = std::fs::metadata(&path).unwrap().len();
|
||||
// 8 magic + 1 version + 16 salt + 12 nonce + 32 secret + 16 tag = 85.
|
||||
assert_eq!(size, 85);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Invariantes del `SledMstStore`. La propiedad clave: el `Mst`
|
||||
//! reconstruido desde disco produce el mismo `root_hash` que el `Mst`
|
||||
//! que insertamos — la estructura es derivable solo de las claves.
|
||||
|
||||
use minga_core::{ContentHash, Mst};
|
||||
use minga_store::SledMstStore;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn open_store(path: &std::path::Path) -> (sled::Db, SledMstStore) {
|
||||
let db = sled::open(path).unwrap();
|
||||
let store = SledMstStore::open_tree(&db, "mst").unwrap();
|
||||
(db, store)
|
||||
}
|
||||
|
||||
fn ch(seed: u64) -> ContentHash {
|
||||
let h = blake3::hash(&seed.to_le_bytes());
|
||||
ContentHash(*h.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_contains() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
|
||||
let h = ch(1);
|
||||
assert!(!store.contains(&h).unwrap());
|
||||
assert!(store.insert(h).unwrap());
|
||||
assert!(store.contains(&h).unwrap());
|
||||
|
||||
// Idempotencia: re-insertar devuelve false.
|
||||
assert!(!store.insert(h).unwrap());
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_returns_sorted_keys() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
|
||||
let hashes: Vec<ContentHash> = (0..32u64).map(ch).collect();
|
||||
for h in &hashes {
|
||||
store.insert(*h).unwrap();
|
||||
}
|
||||
|
||||
let collected: Vec<ContentHash> = store.iter().map(|r| r.unwrap()).collect();
|
||||
let mut sorted = hashes.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(collected, sorted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_hash_matches_in_memory_mst() {
|
||||
// La propiedad fundacional: persistir solo las claves y reconstruir
|
||||
// el árbol da exactamente el mismo `root_hash` que un `Mst`
|
||||
// construido en memoria con las mismas claves.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
|
||||
let mut in_memory = Mst::new();
|
||||
for i in 0..50u64 {
|
||||
let h = ch(i);
|
||||
store.insert(h).unwrap();
|
||||
in_memory.insert(h);
|
||||
}
|
||||
|
||||
let reconstructed = store.to_in_memory().unwrap();
|
||||
assert_eq!(reconstructed.root_hash(), in_memory.root_hash());
|
||||
assert_eq!(reconstructed.len(), in_memory.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_persists_across_reopen() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
let hashes: Vec<ContentHash> = (0..20u64).map(ch).collect();
|
||||
|
||||
let target_root_hash;
|
||||
{
|
||||
let (db, store) = open_store(path);
|
||||
for h in &hashes {
|
||||
store.insert(*h).unwrap();
|
||||
}
|
||||
target_root_hash = store.to_in_memory().unwrap().root_hash();
|
||||
store.flush().unwrap();
|
||||
drop(store);
|
||||
drop(db);
|
||||
}
|
||||
{
|
||||
let (_db, store) = open_store(path);
|
||||
let reconstructed = store.to_in_memory().unwrap();
|
||||
assert_eq!(reconstructed.root_hash(), target_root_hash);
|
||||
assert_eq!(reconstructed.len(), 20);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_independent_persistence() {
|
||||
// Insertar las mismas claves en orden distinto produce el mismo
|
||||
// `root_hash`. Equivalencia con la garantía del MST in-memory.
|
||||
let dir1 = TempDir::new().unwrap();
|
||||
let dir2 = TempDir::new().unwrap();
|
||||
|
||||
let hashes: Vec<ContentHash> = (0..30u64).map(ch).collect();
|
||||
|
||||
let (_db1, s1) = open_store(dir1.path());
|
||||
for h in &hashes {
|
||||
s1.insert(*h).unwrap();
|
||||
}
|
||||
|
||||
let (_db2, s2) = open_store(dir2.path());
|
||||
for h in hashes.iter().rev() {
|
||||
s2.insert(*h).unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
s1.to_in_memory().unwrap().root_hash(),
|
||||
s2.to_in_memory().unwrap().root_hash()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Invariantes del `SledNodeStore`. Cubre:
|
||||
//! - Round-trip estructural (lo que entra sale igual).
|
||||
//! - Hash consistente con `cas::hash_node`.
|
||||
//! - Idempotencia.
|
||||
//! - Persistencia tras cerrar y reabrir el DB.
|
||||
//! - Rechazo de `put_chunked` con hash inconsistente.
|
||||
|
||||
use minga_core::{cas::hash_components, hash_node, parse, ContentHash, StoredNode};
|
||||
use minga_store::{SledNodeStore, StoreError};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn open_store(path: &std::path::Path) -> (sled::Db, SledNodeStore) {
|
||||
let db = sled::open(path).unwrap();
|
||||
let store = SledNodeStore::open_tree(&db, "nodes").unwrap();
|
||||
(db, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_tree() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
let original = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap();
|
||||
let h = store.put(&original).unwrap();
|
||||
let reconstructed = store.reconstruct(&h).unwrap().unwrap();
|
||||
assert_eq!(original, reconstructed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_hash_matches_cas() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
let n = parse::rust("fn f() -> bool { true }").unwrap();
|
||||
let h_via_put = store.put(&n).unwrap();
|
||||
let h_via_cas = hash_node(&n);
|
||||
assert_eq!(h_via_put, h_via_cas);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
let n = parse::rust("fn f() { 1 + 2 + 3 }").unwrap();
|
||||
let h1 = store.put(&n).unwrap();
|
||||
let len_after_first = store.len();
|
||||
let h2 = store.put(&n).unwrap();
|
||||
let len_after_second = store.len();
|
||||
assert_eq!(h1, h2);
|
||||
assert_eq!(len_after_first, len_after_second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_persists_across_reopen() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
let original = parse::rust("fn squared(n: i32) -> i32 { n * n }").unwrap();
|
||||
let h;
|
||||
{
|
||||
let (db, store) = open_store(path);
|
||||
h = store.put(&original).unwrap();
|
||||
store.flush().unwrap();
|
||||
drop(store);
|
||||
drop(db);
|
||||
}
|
||||
{
|
||||
let (_db, store) = open_store(path);
|
||||
let reconstructed = store.reconstruct(&h).unwrap().unwrap();
|
||||
assert_eq!(reconstructed, original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_subtrees_dedup() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
|
||||
let a = parse::rust("fn alpha() -> i32 { 1 + 2 }").unwrap();
|
||||
let b = parse::rust("fn beta() -> i32 { 1 + 2 }").unwrap();
|
||||
|
||||
store.put(&a).unwrap();
|
||||
let count_after_a = store.len();
|
||||
store.put(&b).unwrap();
|
||||
let count_after_b = store.len();
|
||||
|
||||
// El cuerpo `{ 1 + 2 }` y subnodos son idénticos: comparten
|
||||
// entrada en sled. Crecimiento estricto pero menor que duplicar.
|
||||
assert!(
|
||||
count_after_b < 2 * count_after_a,
|
||||
"dedup falló: {} >= 2 * {}",
|
||||
count_after_b,
|
||||
count_after_a
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_chunked_rejects_hash_mismatch() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
|
||||
let stored = StoredNode {
|
||||
kind: "function_item".to_string(),
|
||||
field_name: None,
|
||||
leaf_text: None,
|
||||
children: Vec::new(),
|
||||
};
|
||||
let bogus_hash = ContentHash([0xAB; 32]);
|
||||
|
||||
let result = store.put_chunked(bogus_hash, &stored);
|
||||
assert!(matches!(result, Err(StoreError::HashMismatch)));
|
||||
assert!(!store.contains(&bogus_hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_chunked_accepts_correct_hash() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
|
||||
let stored = StoredNode {
|
||||
kind: "integer_literal".to_string(),
|
||||
field_name: None,
|
||||
leaf_text: Some(b"42".to_vec()),
|
||||
children: Vec::new(),
|
||||
};
|
||||
let real_hash = hash_components(
|
||||
&stored.kind,
|
||||
stored.field_name.as_deref(),
|
||||
stored.leaf_text.as_deref(),
|
||||
&stored.children,
|
||||
);
|
||||
|
||||
store.put_chunked(real_hash, &stored).unwrap();
|
||||
assert!(store.contains(&real_hash).unwrap());
|
||||
let retrieved = store.get(&real_hash).unwrap().unwrap();
|
||||
assert_eq!(retrieved, stored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_hash_returns_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_db, store) = open_store(dir.path());
|
||||
let bogus = ContentHash([0xFE; 32]);
|
||||
assert_eq!(store.get(&bogus).unwrap(), None);
|
||||
assert_eq!(store.reconstruct(&bogus).unwrap(), None);
|
||||
assert!(!store.contains(&bogus).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Test de integración del `PersistentRepo`: los tres stores conviven
|
||||
//! en una misma `sled::Db`, escritos en una sesión y recuperados
|
||||
//! intactos en la siguiente.
|
||||
|
||||
use minga_core::{parse, Attestation, ContentHash, Keypair};
|
||||
use minga_store::PersistentRepo;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn three_stores_persist_together_across_reopen() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
let alice = Keypair::from_seed(&[1; 32]);
|
||||
|
||||
// ── Sesión 1: poblamos el repo ──────────────────────────────────
|
||||
let function_hash;
|
||||
let target_root_hash;
|
||||
{
|
||||
let repo = PersistentRepo::open(path).unwrap();
|
||||
let n = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap();
|
||||
function_hash = repo.nodes.put(&n).unwrap();
|
||||
repo.mst.insert(function_hash).unwrap();
|
||||
repo.attestations
|
||||
.add(Attestation::create(&alice, function_hash))
|
||||
.unwrap();
|
||||
|
||||
target_root_hash = repo.mst.to_in_memory().unwrap().root_hash();
|
||||
repo.flush().unwrap();
|
||||
}
|
||||
|
||||
// ── Sesión 2: reabrimos y verificamos integridad ────────────────
|
||||
{
|
||||
let repo = PersistentRepo::open(path).unwrap();
|
||||
|
||||
// Nodo recuperable.
|
||||
let stored = repo.nodes.get(&function_hash).unwrap().unwrap();
|
||||
assert_eq!(stored.kind, "source_file");
|
||||
|
||||
// Reconstrucción completa idéntica al original.
|
||||
let reconstructed = repo.nodes.reconstruct(&function_hash).unwrap().unwrap();
|
||||
let original = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap();
|
||||
assert_eq!(reconstructed, original);
|
||||
|
||||
// MST: misma raíz tras reconstruir.
|
||||
assert_eq!(
|
||||
repo.mst.to_in_memory().unwrap().root_hash(),
|
||||
target_root_hash
|
||||
);
|
||||
|
||||
// Atestación: sigue ahí, sigue verificable.
|
||||
let authors = repo.attestations.authors_of(&function_hash).unwrap();
|
||||
assert_eq!(authors, vec![alice.did()]);
|
||||
let atts = repo.attestations.get(&function_hash).unwrap();
|
||||
assert!(atts[0].verify());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_supports_multiple_functions_and_authors() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let repo = PersistentRepo::open(dir.path()).unwrap();
|
||||
|
||||
let alice = Keypair::from_seed(&[1; 32]);
|
||||
let bob = Keypair::from_seed(&[2; 32]);
|
||||
|
||||
let mut hashes: Vec<ContentHash> = Vec::new();
|
||||
for src in &[
|
||||
"fn one() -> i32 { 1 }",
|
||||
"fn two() -> i32 { 2 }",
|
||||
"fn three(x: i32) -> i32 { x + 1 }",
|
||||
] {
|
||||
let n = parse::rust(src).unwrap();
|
||||
let h = repo.nodes.put(&n).unwrap();
|
||||
repo.mst.insert(h).unwrap();
|
||||
hashes.push(h);
|
||||
}
|
||||
|
||||
// Alice firma las tres; Bob firma solo la primera.
|
||||
for h in &hashes {
|
||||
repo.attestations
|
||||
.add(Attestation::create(&alice, *h))
|
||||
.unwrap();
|
||||
}
|
||||
repo.attestations
|
||||
.add(Attestation::create(&bob, hashes[0]))
|
||||
.unwrap();
|
||||
|
||||
repo.flush().unwrap();
|
||||
|
||||
assert_eq!(repo.mst.len(), 3);
|
||||
assert_eq!(repo.attestations.len(), 4);
|
||||
|
||||
// La función firmada por ambos tiene dos autores.
|
||||
let authors_first = repo.attestations.authors_of(&hashes[0]).unwrap();
|
||||
assert_eq!(authors_first.len(), 2);
|
||||
assert!(authors_first.contains(&alice.did()));
|
||||
assert!(authors_first.contains(&bob.did()));
|
||||
|
||||
// Las otras dos solo tienen a Alice.
|
||||
assert_eq!(
|
||||
repo.attestations.authors_of(&hashes[1]).unwrap(),
|
||||
vec![alice.did()]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user