Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/_rust-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,7 @@ jobs:
run: cargo test --lib

- name: Run integration tests
run: cargo test --test integration_tests --features test-helpers
run: cargo test --tests --features test-helpers -- --skip backcompat

- name: Run backwards compatibility tests
run: cargo test --test backcompat_tests --features test-helpers
2 changes: 1 addition & 1 deletion passwords/api/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ mod tests {
// Test the duplicate key detection logic used in add_stored_password
#[test]
fn test_duplicate_key_detection() {
let passwords = vec![
let passwords = [
PasswordKV {
key: "gmail".to_string(),
en_password: "enc1".to_string(),
Expand Down
104 changes: 104 additions & 0 deletions passwords/api/tests/backcompat_setup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//! One-time setup for the backwards-compatibility test user.
//!
//! This test is `#[ignore]`d so it only runs when explicitly requested:
//!
//! ```sh
//! cargo test --test backcompat_setup -- --ignored --features test-helpers
//! ```
//!
//! It creates a permanent user with known credentials and stored passwords.
//! If the user already exists (duplicate → 404 under uniform error policy),
//! the test succeeds gracefully.

mod common;

use axum::body::Body;
use common::backcompat::{BACKCOMPAT_PW, BACKCOMPAT_USER, OLD_RAW_USER, EXPECTED_PASSWORDS};
use common::{app, body_string, run, WithAuth};
use http::{Request, StatusCode};
use tower::ServiceExt;

#[test]
#[ignore]
fn setup_backcompat_user() {
run(async {
// 0. Clean up the old broken user that was created with the raw
// (unhashed) username. Ignore errors — the user may not exist.
let req = Request::builder()
.method("DELETE")
.uri("/api/v2/user")
.header("x-username", OLD_RAW_USER)
.header("x-password", "unused")
.body(Body::empty())
.unwrap();
let res = app().oneshot(req).await.unwrap();
eprintln!(
"Old user cleanup: status {}",
res.status()
);

// 1. Create the user with hashed credentials (as the frontend would
// send after `encryptMaster()`). If it already exists the API
// returns 404 (uniform error responses), which we treat as success.
let req = Request::builder()
.method("POST")
.uri("/api/v2/user")
.auth(BACKCOMPAT_USER, BACKCOMPAT_PW)
.body(Body::empty())
.unwrap();
let res = app().oneshot(req).await.unwrap();
let status = res.status();
let body = body_string(res).await;
match status {
StatusCode::OK => eprintln!("Created backcompat user"),
StatusCode::NOT_FOUND => {
eprintln!("Backcompat user already exists (got 404): {body}");
}
other => panic!("Unexpected status {other} creating backcompat user: {body}"),
}

// 2. Verify we can authenticate as the backcompat user.
let req = Request::builder()
.method("GET")
.uri("/api/v2/user/verify")
.auth(BACKCOMPAT_USER, BACKCOMPAT_PW)
.body(Body::empty())
.unwrap();
let res = app().oneshot(req).await.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"backcompat user must be verifiable after creation"
);

// 3. Add stored passwords with AES-encrypted values (encrypted with
// SHA-256 of the plaintext password as key). If a key already
// exists the API returns 404 (duplicate key → uniform error),
// which we skip gracefully.
for (key, enc_pw) in EXPECTED_PASSWORDS {
let req = Request::builder()
.method("POST")
.uri(format!("/api/v2/passwords/{key}"))
.auth(BACKCOMPAT_USER, BACKCOMPAT_PW)
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"encrypted_password":"{enc_pw}"}}"#
)))
.unwrap();
let res = app().oneshot(req).await.unwrap();
let status = res.status();
match status {
StatusCode::OK => eprintln!("Added key '{key}'"),
StatusCode::NOT_FOUND => {
eprintln!("Key '{key}' already exists (got 404), skipping");
}
other => {
let body = body_string(res).await;
panic!("Unexpected status {other} adding key '{key}': {body}");
}
}
}

eprintln!("Backcompat setup complete.");
});
}
95 changes: 95 additions & 0 deletions passwords/api/tests/backcompat_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//! Backwards-compatibility tests for the permanent backcompat test user.
//!
//! These tests verify that a user created by `backcompat_setup.rs` can still
//! authenticate and retrieve their stored passwords. This guards against
//! breaking changes to auth, encryption, or data format.
//!
//! ## Prerequisites
Comment thread
xumaple marked this conversation as resolved.
Outdated
//!
//! Run the setup once before these tests:
//!
//! ```sh
//! cargo test --test backcompat_setup -- --ignored --features test-helpers
//! ```
//!
//! ## Running
//!
//! ```sh
//! cargo test --test backcompat_tests --features test-helpers
//! ```

mod common;

use axum::body::Body;
use common::backcompat::{BACKCOMPAT_PW, BACKCOMPAT_USER, EXPECTED_KEYS, EXPECTED_PASSWORDS};
use common::{app, body_string, parse_json, run, WithAuth};
use http::{Request, StatusCode};
use tower::ServiceExt;

// ── Tests ───────────────────────────────────────────────────────────────────

#[test]
fn test_backcompat_user_can_authenticate() {
run(async {
let req = Request::builder()
.method("GET")
.uri("/api/v2/user/verify")
.auth(BACKCOMPAT_USER, BACKCOMPAT_PW)
.body(Body::empty())
.unwrap();
let res = app().oneshot(req).await.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"backcompat user should authenticate with known credentials"
);
});
}

#[test]
fn test_backcompat_user_keys_exist() {
run(async {
let req = Request::builder()
.method("GET")
.uri("/api/v2/keys")
.auth(BACKCOMPAT_USER, BACKCOMPAT_PW)
.body(Body::empty())
.unwrap();
let res = app().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);

let keys: Vec<String> = parse_json(&body_string(res).await);
for expected_key in EXPECTED_KEYS {
assert!(
keys.contains(&(*expected_key).to_string()),
"expected key '{expected_key}' not found in keys: {keys:?}"
);
}
});
}

#[test]
fn test_backcompat_user_passwords_retrievable() {
run(async {
for (key, expected_value) in EXPECTED_PASSWORDS {
let req = Request::builder()
.method("GET")
.uri(format!("/api/v2/passwords/{key}"))
.auth(BACKCOMPAT_USER, BACKCOMPAT_PW)
.body(Body::empty())
.unwrap();
let res = app().oneshot(req).await.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"GET /passwords/{key} should succeed"
);

let value: String = parse_json(&body_string(res).await);
assert_eq!(
value, *expected_value,
"password for key '{key}' does not match expected value"
);
}
});
}
37 changes: 37 additions & 0 deletions passwords/api/tests/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
//! Provides a shared tokio runtime, Axum router, RAII test user cleanup,
//! and common helpers. Import via `mod common;` from any test file.

// Each test binary compiles this module independently and may not use every
// item. Suppress dead-code warnings that arise from partial usage.
#![allow(dead_code)]
Comment thread
xumaple marked this conversation as resolved.
Outdated

use axum::body::Body;
use axum::{Router, middleware::from_fn, extract::ConnectInfo};
use http::Request;
Expand All @@ -14,6 +18,33 @@ use std::sync::LazyLock;

pub const TEST_PW: &str = "test_password_abc123";

// ── Backcompat test constants ──────────────────────────────────────────────
// Shared between backcompat_setup.rs and backcompat_tests.rs.

pub mod backcompat {
/// Plaintext credentials — used by e2e tests that log in through the UI.
pub const BACKCOMPAT_PLAINTEXT_USER: &str = "backcompat_test_user";
pub const BACKCOMPAT_PLAINTEXT_PW: &str = "backcompat_password_123";

/// Client-side SHA-3 hashed credentials (output of `encryptMaster()`).
/// These are what the frontend sends as `x-username` / `x-password` headers.
pub const BACKCOMPAT_USER: &str = "93aba7f07aa6cd38";
pub const BACKCOMPAT_PW: &str = "e6c146a2e22f5e2e";

/// The old raw username used by the previous (broken) backcompat user.
Comment thread
xumaple marked this conversation as resolved.
Outdated
/// Used only for cleanup in `backcompat_setup.rs`.
pub const OLD_RAW_USER: &str = "__backcompat_test_user__";

pub const EXPECTED_KEYS: &[&str] = &["email", "bank", "social"];

/// Encrypted password values (AES with SHA-256 of the plaintext password as key).
pub const EXPECTED_PASSWORDS: &[(&str, &str)] = &[
("email", "U2FsdGVkX184eJIaOi3wqeiw22+VTItwS6ujyQjQl6yr6kSW9UKrtq5sFLoCe7aD"),
("bank", "U2FsdGVkX19Szi+RIYAHWUjHPgLM3EKL43CrEJB8zyfb2GY6u+Pn4dw/3uSMeZQk"),
("social", "U2FsdGVkX1/0UvpMeilf4CXaAppupUSgA6di9fjBv26F1pdUyPLJiJmQTMdx6n4K"),
];
}

// ---------------------------------------------------------------------------
// Single shared runtime – keeps the MongoDB connection pool alive across tests.
// Shared Axum router + DB connection (initialized once on the shared runtime).
Expand Down Expand Up @@ -73,6 +104,12 @@ pub struct TestUser {
password: String,
}

impl Default for TestUser {
fn default() -> Self {
Self::new()
}
}

impl TestUser {
pub fn new() -> Self {
Self {
Expand Down
90 changes: 90 additions & 0 deletions passwords/app/e2e/passwords.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,96 @@ test.describe.serial("Full user journey", () => {
});
});

// ── Backwards-compatibility tests ───────────────────────────────────────────
//
// These tests verify that the permanent backcompat test user (created by the
// Rust `backcompat_setup` test) can still log in through the real UI and
// retrieve its stored passwords. The user was created with client-side hashed
// credentials (SHA-3 via encryptMaster), so logging in with the plaintext
// credentials exercises the full frontend crypto pipeline.

const BACKCOMPAT_PLAINTEXT_USER = "backcompat_test_user";
const BACKCOMPAT_PLAINTEXT_PW = "backcompat_password_123";
const BACKCOMPAT_EXPECTED_KEYS = ["email", "bank", "social"];
Comment thread
xumaple marked this conversation as resolved.
Outdated

test.describe.serial("Backwards compatibility", () => {
/** @type {import('@playwright/test').Page} */
let page;

test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});

test.afterAll(async () => {
await page.close();
});

test("backcompat user can log in through the UI", async () => {
await page.goto("/");
await expect(page.getByText("Welcome to MapoPass")).toBeVisible();

// Fill in the plaintext credentials — the frontend hashes them via
// encryptMaster() before sending to the API.
await page.getByLabel("username").fill(BACKCOMPAT_PLAINTEXT_USER);
await page.getByLabel("password").fill(BACKCOMPAT_PLAINTEXT_PW);

await page.getByRole("button", { name: "Log In" }).click();

// Wait for the account view to load.
await expect(
page.getByText("Select a password to retrieve:")
).toBeVisible({ timeout: 15_000 });
});

test("backcompat user keys are present in dropdown", async () => {
// Open the autocomplete dropdown to see all available keys.
const autocomplete = page.getByRole("combobox", {
name: "Select a password key",
});
await autocomplete.click();

// Verify each expected key appears as an option.
for (const expectedKey of BACKCOMPAT_EXPECTED_KEYS) {
await expect(
page.getByRole("option", { name: expectedKey })
).toBeVisible({ timeout: 5_000 });
}

// Close the dropdown by pressing Escape.
await page.keyboard.press("Escape");
});

test("backcompat user passwords decrypt correctly through the UI", async () => {
// Grant clipboard permissions for reading the decrypted value.
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);

for (const key of BACKCOMPAT_EXPECTED_KEYS) {
const autocomplete = page.getByRole("combobox", {
name: "Select a password key",
});
await autocomplete.click();
await autocomplete.fill("");
await autocomplete.fill(key);
await page.getByRole("option", { name: key }).click();

// Wait for the "Retrieved password for <key>!" alert — this only
// appears when client-side AES decryption succeeds.
await expect(
page.getByText(`Retrieved password for ${key}!`)
).toBeVisible({ timeout: 10_000 });

// Click to copy the decrypted password to clipboard.
await page.getByText("Click here to copy.").click();

// Read the clipboard and verify it contains a non-empty decrypted value.
const clipboardText = await page.evaluate(() =>
navigator.clipboard.readText()
);
expect(clipboardText.length).toBeGreaterThan(0);
}
});
});

// ── Helpers ──────────────────────────────────────────────────────────────────

/**
Expand Down
Loading