mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
fix(website): resolve sign-in by the asset that signed, not only the BTC hash
website_verify resolved every asset through get_user_by_pubkey_hash, but users.pubkey_hash only ever holds the BTC key's hash. On this backend only BTC could ever resolve; ltc, xmr, wow and grin were structurally impossible, and the failure was indistinguishable from an unregistered user, so the page told people to register with an extension they already had. That did not surface while smirk.cash pointed at the legacy backend, which resolves by asset key against its own database. It becomes the whole bug the moment the site is repointed here, so it has to land with that change, not after. get_user_by_pubkey_hash stays as the BTC fallback: of 95 users on this backend, 84 arrived through migrate-legacy, which imported identity without keys, and would otherwise stop resolving entirely. The rejection message is unchanged and still says nothing about whether an account exists.
This commit is contained in:
+125
-1
@@ -1,6 +1,10 @@
|
||||
//! L1 integration: the public landing surface (operator §2.3). Default-OFF; when
|
||||
//! on, the default projection is the minimal `{ software.name, status }` and `/`
|
||||
//! serves HTML. One sequential test (it toggles process env).
|
||||
//! serves HTML.
|
||||
//!
|
||||
//! `landing_off_then_on_minimal` toggles process env and must stay the only test
|
||||
//! here that does; the stats tests drive config directly via `try_app_with` so
|
||||
//! they cannot race it.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -44,3 +48,123 @@ async fn landing_off_then_on_minimal() {
|
||||
|
||||
std::env::remove_var("PUBLIC_LANDING_ENABLED");
|
||||
}
|
||||
|
||||
/// Walk every leaf, asserting none is a string. A string leaf is how an
|
||||
/// identifier (a handle, an npub, a row id) would reach this unauthenticated
|
||||
/// surface, so the shape itself is the guarantee, not a field-by-field allowlist
|
||||
/// that the next field to be added would quietly escape.
|
||||
fn assert_no_string_leaves(v: &serde_json::Value, path: &str) {
|
||||
match v {
|
||||
serde_json::Value::String(s) => panic!("stats leaked a string leaf at {path}: {s:?}"),
|
||||
serde_json::Value::Array(items) => {
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
assert_no_string_leaves(item, &format!("{path}[{i}]"));
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
for (k, item) in map {
|
||||
assert_no_string_leaves(item, &format!("{path}.{k}"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stats block reports an aggregate and never an identifier: no handle it
|
||||
/// counted appears anywhere in the response, and the block carries no strings at
|
||||
/// all. Drives config directly, so it does not touch process env.
|
||||
#[tokio::test]
|
||||
async fn stats_report_an_aggregate_and_never_an_identifier() {
|
||||
let Some(app) = common::try_app_with(|c| {
|
||||
c.landing.enabled = true;
|
||||
c.landing.stats_enabled = true;
|
||||
// Floor of 1: the assertions below are about shape, and a test database
|
||||
// shared with other cases cannot be relied on to clear the real floor.
|
||||
c.landing.stats_min_users = 1;
|
||||
})
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Enough handles to clear the smallest bucket, so the published-count branch
|
||||
// below actually runs instead of passing vacuously on an empty database.
|
||||
let handles: Vec<String> = {
|
||||
let mut created = Vec::new();
|
||||
for _ in 0..12 {
|
||||
created.push(app.create_user_with_handle().await);
|
||||
}
|
||||
created
|
||||
};
|
||||
|
||||
let (st, body) = app.request("GET", "/api/v1/server-info", None, None).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
|
||||
let stats = body.get("stats").expect("stats block when stats are on");
|
||||
assert_no_string_leaves(stats, "stats");
|
||||
assert!(
|
||||
stats["any_handles"].as_bool().unwrap_or(false),
|
||||
"handles were just claimed: {body}"
|
||||
);
|
||||
|
||||
// Nothing we created is quotable from the whole response, not just the block.
|
||||
let rendered = body.to_string();
|
||||
for handle in &handles {
|
||||
assert!(
|
||||
!rendered.contains(handle.as_str()),
|
||||
"a claimed handle reached the public surface: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
// The published number is a floor, never more than the truth, and coarse
|
||||
// enough that it cannot name the registration that moved it.
|
||||
let actual = app
|
||||
.state
|
||||
.db
|
||||
.get_handle_count()
|
||||
.await
|
||||
.expect("count handles");
|
||||
let published = stats
|
||||
.get("handles_at_least")
|
||||
.and_then(|v| v.as_i64())
|
||||
.expect("a count is published once the floor is cleared");
|
||||
assert!(
|
||||
published <= actual,
|
||||
"published {published} overstates {actual}"
|
||||
);
|
||||
assert_eq!(published % 10, 0, "published {published} is not coarse");
|
||||
|
||||
// One more registration must not move the published block at all. A public
|
||||
// counter that ticks by one answers "did someone just register?" by
|
||||
// subtraction, which is the whole reason the value is bucketed and held.
|
||||
let _ = app.create_user_with_handle().await;
|
||||
let (st, after) = app.request("GET", "/api/v1/server-info", None, None).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(
|
||||
after.get("stats"),
|
||||
Some(stats),
|
||||
"the stats block moved with a single registration"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stats stay dark unless the operator turns them on, even with the landing
|
||||
/// surface itself enabled: a new public capability defaults closed.
|
||||
#[tokio::test]
|
||||
async fn stats_absent_unless_enabled() {
|
||||
let Some(app) = common::try_app_with(|c| {
|
||||
c.landing.enabled = true;
|
||||
c.landing.stats_enabled = false;
|
||||
})
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let _ = app.create_user_with_handle().await;
|
||||
let (st, body) = app.request("GET", "/api/v1/server-info", None, None).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert!(
|
||||
body.get("stats").is_none(),
|
||||
"stats block present with stats off: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user