Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ jobs:
VITE_TEST_DEVELOPER_PASSWORD: ${{ secrets.VITE_TEST_DEVELOPER_PASSWORD }}
VITE_TEST_DEVELOPER_NAME: ${{ secrets.VITE_TEST_DEVELOPER_NAME }}
VITE_TEST_DEVELOPER_INVITE_CODE: ${{ secrets.VITE_TEST_DEVELOPER_INVITE_CODE }}
run: bun test
run: bun test --timeout 30000
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ The `useOpenSecret` hook provides access to the OpenSecret API. It returns an ob
- `signUp(email: string, password: string, inviteCode: string, name?: string): Promise<void>`: Signs up a new user with the provided email, password, invite code, and optional name.
- `signInGuest(id: string, password: string): Promise<void>`: Signs in a guest user with their ID and password. Guest accounts are scoped to the project specified by `clientId`.
- `signUpGuest(password: string, inviteCode: string): Promise<LoginResponse>`: Creates a new guest account with just a password and invite code. Returns a response containing the guest's ID, access token, and refresh token. The guest account will be associated with the project specified by `clientId`.
- `convertGuestToUserAccount(email: string, password: string, name?: string): Promise<void>`: Converts current guest account to a regular account with email authentication. Optionally sets the user's name. The account remains associated with the same project it was created under.
- `signOut(): Promise<void>`: Signs out the current user.

#### Key-Value Storage Methods
Expand Down Expand Up @@ -481,4 +480,3 @@ Common issues:
## License

This project is licensed under the MIT License.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opensecret/react",
"version": "3.1.1",
"version": "3.2.0",
"license": "MIT",
"type": "module",
"files": [
Expand All @@ -21,6 +21,7 @@
"pack": "bun run build && bun pm pack",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"test": "bun test --timeout 30000",
"docs:dev": "cd website && bun run start",
"docs:build": "cd website && bun run build",
"docs:serve": "cd website && bun run serve",
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "opensecret"
version = "3.1.1"
version = "3.2.0"
edition = "2021"
authors = ["OpenSecret"]
description = "Rust SDK for OpenSecret - secure AI API interactions with nitro attestation"
Expand Down
77 changes: 58 additions & 19 deletions rust/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1217,9 +1217,17 @@ impl OpenSecretClient {
current_password,
new_password,
};
let _: serde_json::Value = self
let response: CredentialUpdateResponse = self
.authenticated_api_call("/protected/change_password", "POST", Some(request))
.await?;
if let Some(access_token) = response.access_token {
let refresh_token = match response.refresh_token {
Some(refresh_token) => Some(refresh_token),
None => self.session_manager.get_refresh_token()?,
};
self.session_manager
.set_tokens(access_token, refresh_token)?;
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Ok(())
}

Expand Down Expand Up @@ -1265,24 +1273,6 @@ impl OpenSecretClient {
Ok(())
}

/// Converts a guest account to an email account
pub async fn convert_guest_to_email(
&self,
email: String,
password: String,
name: Option<String>,
) -> Result<()> {
let request = ConvertGuestToEmailRequest {
email,
password,
name,
};
let _: serde_json::Value = self
.authenticated_api_call("/protected/convert_guest", "POST", Some(request))
.await?;
Ok(())
}

/// Verifies an email address with the code from the verification email
/// Note: This does not require authentication but still uses encryption
pub async fn verify_email(&self, code: String) -> Result<()> {
Expand Down Expand Up @@ -2357,6 +2347,55 @@ mod tests {
assert!(client.get_refresh_token().unwrap().is_none());
}

#[tokio::test]
async fn test_change_password_preserves_refresh_token_when_response_omits_one() {
let mock_server = MockServer::start().await;
let client = OpenSecretClient::new(mock_server.uri()).unwrap();
let session_id = Uuid::new_v4();
let session_key = [24u8; 32];

client
.session_manager
.set_session(session_id, session_key)
.unwrap();
client
.session_manager
.set_tokens(
"old_access_token".to_string(),
Some("old_refresh_token".to_string()),
)
.unwrap();

Mock::given(method("POST"))
.and(path("/protected/change_password"))
.and(header("authorization", "Bearer old_access_token"))
.and(header("x-session-id", session_id.to_string()))
.respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response(
&session_key,
&json!({
"message": "updated",
"access_token": "new_access_token"
}),
)))
.expect(1)
.mount(&mock_server)
.await;

client
.change_password("old-credential".to_string(), "new-credential".to_string())
.await
.unwrap();

assert_eq!(
client.get_access_token().unwrap().as_deref(),
Some("new_access_token")
);
assert_eq!(
client.get_refresh_token().unwrap().as_deref(),
Some("old_refresh_token")
);
}

#[tokio::test]
async fn test_authenticated_calls_refresh_and_retry_seamlessly() {
let mock_server = MockServer::start().await;
Expand Down
25 changes: 18 additions & 7 deletions rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ pub struct RefreshResponse {
pub refresh_token: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialUpdateResponse {
#[serde(default)]
pub message: String,
pub access_token: Option<String>,
pub refresh_token: Option<String>,
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

// Auth Types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginCredentials {
Expand Down Expand Up @@ -477,13 +485,6 @@ pub struct PasswordResetConfirmRequest {
pub client_id: Uuid,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvertGuestToEmailRequest {
pub email: String,
pub password: String,
pub name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestVerificationCodeRequest {}

Expand Down Expand Up @@ -1132,4 +1133,14 @@ mod tests {
})
);
}

#[test]
fn credential_update_response_tolerates_missing_message() {
let response: CredentialUpdateResponse =
serde_json::from_value(json!({ "access_token": "new-access" })).unwrap();

assert_eq!(response.message, "");
assert_eq!(response.access_token.as_deref(), Some("new-access"));
assert_eq!(response.refresh_token, None);
}
}
76 changes: 48 additions & 28 deletions rust/tests/account_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,34 @@ use opensecret::{OpenSecretClient, Result};
use std::env;
use uuid::Uuid;

fn load_test_env() {
let env_path = std::path::Path::new("../.env.local");
if env_path.exists() {
dotenv::from_path(env_path).ok();
} else {
dotenv::dotenv().ok();
}
}

async fn setup_client() -> Result<OpenSecretClient> {
load_test_env();

let base_url = env::var("VITE_OPEN_SECRET_API_URL")
.unwrap_or_else(|_| "http://localhost:3000".to_string());
let client = OpenSecretClient::new(base_url)?;
client.perform_attestation_handshake().await?;
Ok(client)
}

fn test_client_id() -> Uuid {
load_test_env();

env::var("VITE_TEST_CLIENT_ID")
.ok()
.and_then(|id| Uuid::parse_str(&id).ok())
.expect("VITE_TEST_CLIENT_ID must be set in .env.local or .env")
}

#[tokio::test]
async fn test_account_management_apis_exist() {
// This test verifies that all account management methods exist and are callable
Expand All @@ -21,7 +41,6 @@ async fn test_account_management_apis_exist() {
// - client.change_password(current_password, new_password)
// - client.request_password_reset(email, hashed_secret, client_id)
// - client.confirm_password_reset(email, code, secret, new_password, client_id)
// - client.convert_guest_to_email(email, password, name)
// - client.verify_email(code)
// - client.request_new_verification_code()
// - client.request_account_deletion(hashed_secret)
Expand All @@ -31,18 +50,34 @@ async fn test_account_management_apis_exist() {
}

#[tokio::test]
#[ignore = "Destructive operation - would change account password permanently"]
async fn test_change_password() {
// This test is skipped because:
// 1. It would permanently change the test account's password
// 2. Future test runs would fail with the old password
// 3. There's no way to reliably reset it without the password reset flow

// If this test were to run, it would:
// 1. Login with current credentials
// 2. Call change_password with old and new passwords
// 3. Verify the response is successful
// 4. Attempt to login with the new password to confirm
async fn test_guest_change_password_keeps_authenticated_token_state() -> Result<()> {
let client_id = test_client_id();
let client = setup_client().await?;
let original_password = "test_guest_change_password_123";
let new_password = format!(
"new_guest_password_{}",
chrono::Utc::now().timestamp_millis()
);

let guest_response = client
.register_guest(original_password.to_string(), client_id)
.await?;

client
.change_password(original_password.to_string(), new_password.clone())
.await?;

let user_response = client.get_user().await?;
assert_eq!(user_response.user.id, guest_response.id);
assert!(user_response.user.email.is_none());

let relogin_client = setup_client().await?;
let relogin_response = relogin_client
.login_with_id(guest_response.id, new_password, client_id)
.await?;
assert_eq!(relogin_response.id, guest_response.id);

Ok(())
}

#[tokio::test]
Expand All @@ -60,21 +95,6 @@ async fn test_password_reset_flow() {
// 4. Verify login works with new password
}

#[tokio::test]
#[ignore = "One-time operation - can only convert guest account once"]
async fn test_convert_guest_to_email() {
// This test is skipped because:
// 1. A guest account can only be converted once
// 2. After conversion, it's no longer a guest account
// 3. This would permanently alter the test account state

// If this test were to run, it would:
// 1. Create a new guest account
// 2. Login as guest
// 3. Call convert_guest_to_email
// 4. Verify the account now has an email
}

#[tokio::test]
#[ignore = "Requires email verification code from actual email"]
async fn test_email_verification() {
Expand Down
Loading
Loading