fix(server): use char count (not byte length) for display_name limit
The leaderboard opt-in handler was calling `.len()` on the display name, which returns byte count. Multi-byte Unicode characters (emoji, CJK, etc.) would be rejected well before the 32-character visual limit and with a misleading error message. Switched to `.chars().count()` to enforce the limit in terms of Unicode scalar values as the error message advertises. test(core): add boundary tests for 7 uncovered achievement conditions test(server): add display_name validation integration tests (empty, too-long ASCII, 32-emoji succeeds, 33-emoji rejected) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -476,4 +476,105 @@ mod tests {
|
|||||||
assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win"));
|
assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win"));
|
||||||
assert!(achievement_by_id("nonexistent").is_none());
|
assert!(achievement_by_id("nonexistent").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn on_a_roll_requires_streak_of_3() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.win_streak_current = 2;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"on_a_roll"));
|
||||||
|
|
||||||
|
c.win_streak_current = 3;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"on_a_roll"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unstoppable_requires_streak_of_10() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.win_streak_current = 9;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"unstoppable"));
|
||||||
|
assert!(ids.contains(&"on_a_roll"), "streak 9 must still satisfy on_a_roll");
|
||||||
|
|
||||||
|
c.win_streak_current = 10;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"unstoppable"));
|
||||||
|
assert!(ids.contains(&"on_a_roll"), "streak 10 must also satisfy on_a_roll");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn century_requires_100_games_played() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.games_played = 99;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"century"));
|
||||||
|
|
||||||
|
c.games_played = 100;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"century"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn veteran_requires_500_games_played() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.games_played = 499;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"veteran"));
|
||||||
|
assert!(ids.contains(&"century"), "499 games must also satisfy century");
|
||||||
|
|
||||||
|
c.games_played = 500;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"veteran"));
|
||||||
|
assert!(ids.contains(&"century"), "500 games must also satisfy century");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn high_scorer_requires_best_single_score_of_5000() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.best_single_score = 4_999;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"high_scorer"));
|
||||||
|
|
||||||
|
c.best_single_score = 5_000;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"high_scorer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_machine_requires_50000_lifetime_score() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.lifetime_score = 49_999;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"point_machine"));
|
||||||
|
|
||||||
|
c.lifetime_score = 50_000;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"point_machine"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_three_master_requires_10_draw_three_wins() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.draw_three_wins = 9;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"draw_three_master"));
|
||||||
|
|
||||||
|
c.draw_three_wins = 10;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"draw_three_master"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speed_demon_boundary_at_180_seconds() {
|
||||||
|
let mut c = ctx();
|
||||||
|
c.games_won = 1;
|
||||||
|
c.last_win_time_seconds = 179;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(ids.contains(&"speed_demon"));
|
||||||
|
|
||||||
|
c.last_win_time_seconds = 180;
|
||||||
|
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
|
||||||
|
assert!(!ids.contains(&"speed_demon"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ pub async fn opt_in(
|
|||||||
if display_name.is_empty() {
|
if display_name.is_empty() {
|
||||||
return Err(AppError::BadRequest("display_name must not be empty".into()));
|
return Err(AppError::BadRequest("display_name must not be empty".into()));
|
||||||
}
|
}
|
||||||
if display_name.len() > DISPLAY_NAME_MAX {
|
if display_name.chars().count() > DISPLAY_NAME_MAX {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
"display_name must be at most {DISPLAY_NAME_MAX} characters"
|
"display_name must be at most {DISPLAY_NAME_MAX} characters"
|
||||||
)));
|
)));
|
||||||
|
|||||||
@@ -874,6 +874,118 @@ async fn opt_out_hides_then_opt_in_restores() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opting in with an empty display name returns 400.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opt_in_empty_display_name_returns_400() {
|
||||||
|
set_jwt_secret();
|
||||||
|
let app = build_test_router(test_pool().await);
|
||||||
|
let (access, _) = register_user(app.clone(), "empty_name", "pass1234").await;
|
||||||
|
|
||||||
|
let resp = post_authed(
|
||||||
|
app,
|
||||||
|
"/api/leaderboard/opt-in",
|
||||||
|
&access,
|
||||||
|
serde_json::json!({ "display_name": " " }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"whitespace-only display_name must return 400"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opting in with a display name longer than 32 characters returns 400.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opt_in_too_long_display_name_returns_400() {
|
||||||
|
set_jwt_secret();
|
||||||
|
let app = build_test_router(test_pool().await);
|
||||||
|
let (access, _) = register_user(app.clone(), "long_name", "pass1234").await;
|
||||||
|
|
||||||
|
let long_name = "a".repeat(33);
|
||||||
|
let resp = post_authed(
|
||||||
|
app,
|
||||||
|
"/api/leaderboard/opt-in",
|
||||||
|
&access,
|
||||||
|
serde_json::json!({ "display_name": long_name }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"33-char display_name must return 400"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exactly 32 ASCII characters is accepted.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opt_in_exactly_32_char_display_name_succeeds() {
|
||||||
|
set_jwt_secret();
|
||||||
|
let app = build_test_router(test_pool().await);
|
||||||
|
let (access, _) = register_user(app.clone(), "maxname", "pass1234").await;
|
||||||
|
|
||||||
|
let name = "a".repeat(32);
|
||||||
|
let resp = post_authed(
|
||||||
|
app,
|
||||||
|
"/api/leaderboard/opt-in",
|
||||||
|
&access,
|
||||||
|
serde_json::json!({ "display_name": name }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"32-char display_name must be accepted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A display name consisting of 32 Unicode emoji (multi-byte chars) must be
|
||||||
|
/// accepted — the limit is character count, not byte count.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opt_in_32_unicode_chars_display_name_succeeds() {
|
||||||
|
set_jwt_secret();
|
||||||
|
let app = build_test_router(test_pool().await);
|
||||||
|
let (access, _) = register_user(app.clone(), "unicode_name", "pass1234").await;
|
||||||
|
|
||||||
|
// 32 emoji — each is 4 bytes, so 128 bytes total.
|
||||||
|
// A byte-length check would incorrectly reject this.
|
||||||
|
let name = "🎉".repeat(32);
|
||||||
|
let resp = post_authed(
|
||||||
|
app,
|
||||||
|
"/api/leaderboard/opt-in",
|
||||||
|
&access,
|
||||||
|
serde_json::json!({ "display_name": name }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"32-emoji display_name (32 chars, 128 bytes) must be accepted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A display name with 33 Unicode emoji is rejected.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn opt_in_33_unicode_chars_display_name_returns_400() {
|
||||||
|
set_jwt_secret();
|
||||||
|
let app = build_test_router(test_pool().await);
|
||||||
|
let (access, _) = register_user(app.clone(), "unicode_long", "pass1234").await;
|
||||||
|
|
||||||
|
let name = "🎉".repeat(33);
|
||||||
|
let resp = post_authed(
|
||||||
|
app,
|
||||||
|
"/api/leaderboard/opt-in",
|
||||||
|
&access,
|
||||||
|
serde_json::json!({ "display_name": name }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"33-emoji display_name must return 400"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Login with leading/trailing whitespace in the username still succeeds.
|
/// Login with leading/trailing whitespace in the username still succeeds.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_trims_whitespace_from_username() {
|
async fn login_trims_whitespace_from_username() {
|
||||||
|
|||||||
Reference in New Issue
Block a user