Compare commits
3 Commits
08f74d1e25
...
566b112d9e
| Author | SHA1 | Date | |
|---|---|---|---|
| 566b112d9e | |||
| 198df75f94 | |||
| 40d07122ba |
+10
-8
@@ -1,6 +1,6 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Solitaire Quest — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-12 — Sync rate limiting + mirror_achievement removal + theme import scan shipped (`6e6f3ef`). HEAD locally: `6e6f3ef`. Push pending.
|
**Last updated:** 2026-05-12 — WASM build script + push-retry test shipped (`198df75`). HEAD locally: `198df75`. Push pending.
|
||||||
|
|
||||||
Phase 8 closes the self-hosted-server connection arc end-to-end: login/register
|
Phase 8 closes the self-hosted-server connection arc end-to-end: login/register
|
||||||
modal, re-auth on token expiry, account deletion flow, server deployment
|
modal, re-auth on token expiry, account deletion flow, server deployment
|
||||||
@@ -12,8 +12,8 @@ and full server integration tests.
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- **HEAD locally:** `6e6f3ef` (feat: sync rate limiting).
|
- **HEAD locally:** `198df75` (test: push retry + build_test_pool).
|
||||||
- **HEAD on origin:** `b129664` (pushed — 4 commits ahead).
|
- **HEAD on origin:** `08f74d1` (pushed — 3 commits ahead).
|
||||||
- **Working tree:** `SESSION_HANDOFF.md` modified, uncommitted.
|
- **Working tree:** `SESSION_HANDOFF.md` modified, uncommitted.
|
||||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||||
- **Tests:** **1300+ passing / 0 failing** across the workspace.
|
- **Tests:** **1300+ passing / 0 failing** across the workspace.
|
||||||
@@ -83,16 +83,18 @@ Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
|
|||||||
- [x] **`mirror_achievement` removed.** Done (`549a817`): method was a no-op
|
- [x] **`mirror_achievement` removed.** Done (`549a817`): method was a no-op
|
||||||
default never overridden and never called; achievements already sync via
|
default never overridden and never called; achievements already sync via
|
||||||
`SyncPayload` push. Deleted from trait and blanket impl.
|
`SyncPayload` push. Deleted from trait and blanket impl.
|
||||||
- **WASM build script.** `web/pkg/` contains compiled WASM committed to git.
|
- [x] **WASM build script.** Done (`40d0712`): `build_wasm.sh` at repo root
|
||||||
Need a `build_wasm.sh` or Makefile target documenting the `wasm-pack build`
|
documents `wasm-pack build --target web`, cleans up pkg metadata files,
|
||||||
invocation to regenerate it.
|
includes dependency guard + install instructions.
|
||||||
- **Server password reset.** No admin endpoint or CLI tool for resetting a
|
- **Server password reset.** No admin endpoint or CLI tool for resetting a
|
||||||
user's password. Self-hosters have no recovery path short of direct SQLite
|
user's password. Self-hosters have no recovery path short of direct SQLite
|
||||||
edits.
|
edits.
|
||||||
|
|
||||||
### 6. Testing gaps
|
### 6. Testing gaps
|
||||||
- **Server 401 → refresh → retry path** — the `pull`/`push` retry logic in
|
- [x] **Server 401 → refresh → retry path.** Done (`198df75`): both
|
||||||
`SolitaireServerClient` has no integration test.
|
`jwt_refresh_on_401_succeeds` (pull) and
|
||||||
|
`push_retries_after_401_on_expired_access_token` (push) in
|
||||||
|
`solitaire_data/tests/sync_round_trip.rs`.
|
||||||
- **WASM winning-replay step-through** — current tests cover 2 stock clicks;
|
- **WASM winning-replay step-through** — current tests cover 2 stock clicks;
|
||||||
a test stepping through a full winning sequence would catch
|
a test stepping through a full winning sequence would catch
|
||||||
`GameState`/`ReplayMove` compatibility regressions.
|
`GameState`/`ReplayMove` compatibility regressions.
|
||||||
|
|||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rebuild the solitaire_wasm crate and install the output into
|
||||||
|
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# cargo install wasm-pack
|
||||||
|
# rustup target add wasm32-unknown-unknown
|
||||||
|
#
|
||||||
|
# Run from the repo root:
|
||||||
|
# ./build_wasm.sh
|
||||||
|
#
|
||||||
|
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
|
||||||
|
# committed to git so self-hosters who don't touch the WASM crate can
|
||||||
|
# skip this step. Regenerate after any change to solitaire_wasm/ or
|
||||||
|
# solitaire_core/.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
|
||||||
|
|
||||||
|
if ! command -v wasm-pack &> /dev/null; then
|
||||||
|
echo "error: wasm-pack not found." >&2
|
||||||
|
echo " Install with: cargo install wasm-pack" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building solitaire_wasm (target: web)..."
|
||||||
|
wasm-pack build \
|
||||||
|
--target web \
|
||||||
|
--out-dir "$OUT_DIR" \
|
||||||
|
--no-typescript \
|
||||||
|
"$REPO_ROOT/solitaire_wasm"
|
||||||
|
|
||||||
|
# wasm-pack writes a package.json and .gitignore into the output dir.
|
||||||
|
# Remove them — we manage the output directory ourselves.
|
||||||
|
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
|
||||||
|
|
||||||
|
echo "Done. Output:"
|
||||||
|
ls -lh "$OUT_DIR"
|
||||||
@@ -418,3 +418,56 @@ async fn pull_after_account_deletion_returns_default_or_error() {
|
|||||||
|
|
||||||
let _ = delete_tokens(username);
|
let _ = delete_tokens(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Push retry on 401.**
|
||||||
|
///
|
||||||
|
/// Mirrors `jwt_refresh_on_401_succeeds` but for the `push()` path.
|
||||||
|
/// We install an expired access token so the first push attempt returns 401,
|
||||||
|
/// the client refreshes, and the retry push succeeds.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn push_retries_after_401_on_expired_access_token() {
|
||||||
|
ensure_mock_keyring();
|
||||||
|
|
||||||
|
let base = spawn_test_server().await;
|
||||||
|
let username = "rt_push_expiring";
|
||||||
|
|
||||||
|
let (_real_access, real_refresh) =
|
||||||
|
register_user_raw(&base, username, "pushexpirepass1!").await;
|
||||||
|
let user_id = decode_sub(&_real_access);
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: usize,
|
||||||
|
kind: String,
|
||||||
|
}
|
||||||
|
let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
|
||||||
|
let expired_access = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&Claims {
|
||||||
|
sub: user_id.clone(),
|
||||||
|
exp,
|
||||||
|
kind: "access".into(),
|
||||||
|
},
|
||||||
|
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||||||
|
)
|
||||||
|
.expect("failed to encode expired access token");
|
||||||
|
|
||||||
|
store_tokens(username, &expired_access, &real_refresh)
|
||||||
|
.expect("storing tokens in mock keyring must succeed");
|
||||||
|
|
||||||
|
let client = SolitaireServerClient::new(&base, username);
|
||||||
|
let payload = make_payload(&user_id, 17);
|
||||||
|
|
||||||
|
// Push: server returns 401, client refreshes, retries, succeeds.
|
||||||
|
let push_resp = client
|
||||||
|
.push(&payload)
|
||||||
|
.await
|
||||||
|
.expect("push must succeed after the client transparently refreshes the access token");
|
||||||
|
assert_eq!(
|
||||||
|
push_resp.merged.stats.games_played, 17,
|
||||||
|
"merged games_played must reflect what was pushed after auto-refresh"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = delete_tokens(username);
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,28 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
/// Construct the router without rate limiting.
|
/// Construct the router without rate limiting.
|
||||||
///
|
///
|
||||||
/// Intended for integration tests only — do not use in production.
|
/// Intended for integration tests only — do not use in production.
|
||||||
|
/// Create an in-memory SQLite pool and run all pending migrations.
|
||||||
|
///
|
||||||
|
/// `max_connections(1)` is required for SQLite in-memory databases: every
|
||||||
|
/// additional connection sees an empty schema.
|
||||||
|
///
|
||||||
|
/// Exposed so integration tests in other crates (e.g. `solitaire_data`) can
|
||||||
|
/// boot a real server without duplicating the migration boilerplate.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub async fn build_test_pool() -> SqlitePool {
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("failed to connect to in-memory SQLite database");
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("failed to run database migrations");
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
/// Uses a fixed test JWT secret (`"test_secret_32_chars_minimum_ok!"`) so
|
/// Uses a fixed test JWT secret (`"test_secret_32_chars_minimum_ok!"`) so
|
||||||
/// integration tests do not need to set `JWT_SECRET` in the environment.
|
/// integration tests do not need to set `JWT_SECRET` in the environment.
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
|
|||||||
Reference in New Issue
Block a user