From 5954bbab969e27943372da37d55bfe9056f28a14 Mon Sep 17 00:00:00 2001 From: Mit Shah Date: Fri, 23 Oct 2026 20:14:44 +0530 Subject: [PATCH 1/6] feat(cpp): expose get and delete personal access tokens --- foreign/cpp/include/iggy.hpp | 56 ++++++++++++++++++++++++++++ foreign/cpp/src/client.cpp | 16 ++++++++ foreign/cpp/src/client.rs | 25 +++++++++++++ foreign/cpp/src/lib.rs | 10 ++++- foreign/cpp/src/type_conversion.rs | 32 +++++++++++++++- foreign/cpp/src/type_conversions.cpp | 8 ++++ foreign/cpp/tests/e2e/client.cpp | 45 ++++++++++++++++++++++ 7 files changed, 189 insertions(+), 3 deletions(-) diff --git a/foreign/cpp/include/iggy.hpp b/foreign/cpp/include/iggy.hpp index bc32ca52d1..b7fa99d6ac 100644 --- a/foreign/cpp/include/iggy.hpp +++ b/foreign/cpp/include/iggy.hpp @@ -71,6 +71,7 @@ class GlobalPermissions; class Permissions; class StreamPermissions; class TopicPermissions; +class PersonalAccessTokenInfo; class UserInfo; class UserInfoDetails; class ConsumerGroup; @@ -1324,6 +1325,40 @@ class UserInfoDetails final { ResourceOptions options_; }; +/** + * @brief Snapshot of one personal access token's metadata. + * + * GetPersonalAccessTokens() returns one value for each token owned by the + * caller. The secret token string is never included. ExpiresAt() is expressed + * in microseconds since the Unix epoch and is empty when the token never + * expires. + */ +class PersonalAccessTokenInfo final { + public: + /** + * @brief Returns the token name. + * @return The name the token was created with. + */ + [[nodiscard]] const std::string &Name() const noexcept { return name_; } + + /** + * @brief Returns the expiry timestamp. + * @return Timestamp in microseconds since the Unix epoch, or empty when the token never expires. + */ + [[nodiscard]] std::optional ExpiresAt() const noexcept { return expires_at_; } + + private: + PersonalAccessTokenInfo(std::string name, std::optional expires_at) + : name_(std::move(name)), expires_at_(expires_at) {} + + static PersonalAccessTokenInfo FromFfi(ffi::PersonalAccessTokenInfo token); + + friend class IggyBlockingClient; + + std::string name_; + std::optional expires_at_; +}; + /** * @brief Snapshot of one topic's metadata and aggregate statistics. * @@ -5960,6 +5995,27 @@ class IggyBlockingClient final { */ std::vector GetUsers(); + /** + * @brief Lists the personal access tokens owned by the authenticated caller. + * + * The result carries token names and expiry only. The secret token strings + * are returned once, when a token is created. + * + * @return One entry per token, empty when the caller has none. + * @throws IggyException if the client is unavailable or unauthenticated, or + * the request fails. + */ + std::vector GetPersonalAccessTokens(); + + /** + * @brief Deletes one of the caller's personal access tokens. + * + * @param name Name of the token to delete. + * @throws IggyException if the client is unavailable or unauthenticated; + * the token does not exist; or the request fails. + */ + void DeletePersonalAccessToken(const std::string &name); + /** * @brief Creates a user account. * diff --git a/foreign/cpp/src/client.cpp b/foreign/cpp/src/client.cpp index a0b1605579..35b8a1092d 100644 --- a/foreign/cpp/src/client.cpp +++ b/foreign/cpp/src/client.cpp @@ -162,6 +162,22 @@ std::vector IggyBlockingClient::GetUsers() { }); } +std::vector IggyBlockingClient::GetPersonalAccessTokens() { + return RethrowAsIggyException([this] { + std::vector tokens; + auto ffi_tokens = Handle()->get_personal_access_tokens(); + tokens.reserve(ffi_tokens.size()); + for (auto &token : ffi_tokens) { + tokens.push_back(PersonalAccessTokenInfo::FromFfi(std::move(token))); + } + return tokens; + }); +} + +void IggyBlockingClient::DeletePersonalAccessToken(const std::string &name) { + RethrowAsIggyException([this, &name] { Handle()->delete_personal_access_token(name); }); +} + UserInfoDetails IggyBlockingClient::CreateUser(std::string username, std::string password, UserStatus status, diff --git a/foreign/cpp/src/client.rs b/foreign/cpp/src/client.rs index cfc1eae393..8058b90b54 100644 --- a/foreign/cpp/src/client.rs +++ b/foreign/cpp/src/client.rs @@ -1340,6 +1340,31 @@ impl Client { }) } + pub fn get_personal_access_tokens(&self) -> Result, String> { + RUNTIME.block_on(async { + let tokens = self + .inner + .get_personal_access_tokens() + .await + .map_err(|error| format!("Could not get personal access tokens: {error}"))?; + Ok(tokens + .into_iter() + .map(ffi::PersonalAccessTokenInfo::from) + .collect()) + }) + } + + pub fn delete_personal_access_token(&self, name: String) -> Result<(), String> { + RUNTIME.block_on(async { + self.inner + .delete_personal_access_token(&name) + .await + .map_err(|error| { + format!("Could not delete personal access token '{name}': {error}") + }) + }) + } + pub fn get_user(&self, user_id: ffi::Identifier) -> Result { let rust_user_id = RustIdentifier::try_from(user_id) .map_err(|error| format!("Could not get user: invalid user identifier: {error}"))?; diff --git a/foreign/cpp/src/lib.rs b/foreign/cpp/src/lib.rs index 040a119730..a91347682e 100644 --- a/foreign/cpp/src/lib.rs +++ b/foreign/cpp/src/lib.rs @@ -468,6 +468,12 @@ mod ffi { derived_options: Vec, } + struct PersonalAccessTokenInfo { + name: String, + has_expiry: bool, + expiry_at: u64, + } + struct LoginInfo { user_id: u32, has_access_token: bool, @@ -1063,14 +1069,14 @@ mod ffi { error_info: &mut ErrorInfo, ) -> Result<()>; fn get_cluster_metadata(self: &Client) -> Result; - // fn get_personal_access_tokens(self: &Client) -> Result>; + fn get_personal_access_tokens(self: &Client) -> Result>; fn create_personal_access_token( self: &Client, name: String, expiry_kind: String, expiry_value: u64, ) -> Result; - // fn delete_personal_access_token(self: &Client, name: String) -> Result<()>; + fn delete_personal_access_token(self: &Client, name: String) -> Result<()>; // fn login_with_personal_access_token(self: &Client, token: String) -> Result; unsafe fn delete_client(client: *mut Client); diff --git a/foreign/cpp/src/type_conversion.rs b/foreign/cpp/src/type_conversion.rs index 809a2b44e7..477b3f284a 100644 --- a/foreign/cpp/src/type_conversion.rs +++ b/foreign/cpp/src/type_conversion.rs @@ -60,7 +60,8 @@ use iggy_common::{ MetadataPartitionsItem, MetadataSession, MetadataSessionClosed, MetadataStreamChange, MetadataStreamDeleted, MetadataTokenChange, MetadataTokenDeleted, MetadataTopicChange, MetadataTopicDeleted, MetadataTopicItem, MetadataUserChange, MetadataUserIdentity, - MetadataUserPermissionsUpdated, Permissions as RustPermissions, Stats as RustStats, + MetadataUserPermissionsUpdated, Permissions as RustPermissions, + PersonalAccessTokenInfo as RustPersonalAccessTokenInfo, Stats as RustStats, StreamPermissions as RustStreamPermissions, TopicPermissions as RustTopicPermissions, TransportEndpoints as RustTransportEndpoints, UserInfo as RustUserInfo, UserInfoDetails as RustUserInfoDetails, UserStatus as RustUserStatus, @@ -202,6 +203,17 @@ impl From for ffi::UserInfoDetails { } } +impl From for ffi::PersonalAccessTokenInfo { + fn from(token: RustPersonalAccessTokenInfo) -> Self { + let has_expiry = token.expiry_at.is_some(); + ffi::PersonalAccessTokenInfo { + name: token.name, + has_expiry, + expiry_at: token.expiry_at.map_or(0, |expiry_at| expiry_at.as_micros()), + } + } +} + impl From for ffi::UserStatus { fn from(status: RustUserStatus) -> Self { match status { @@ -2101,4 +2113,22 @@ mod tests { Err(IggyError::InvalidCommand) ); } + + #[test] + fn given_token_with_and_without_expiry_when_converting_should_preserve_presence() { + let expiring = ffi::PersonalAccessTokenInfo::from(RustPersonalAccessTokenInfo { + name: "ci-bot".to_owned(), + expiry_at: Some(IggyTimestamp::from(1_700_000_000_000_000)), + }); + assert_eq!(expiring.name, "ci-bot"); + assert!(expiring.has_expiry); + assert_eq!(expiring.expiry_at, 1_700_000_000_000_000); + + let permanent = ffi::PersonalAccessTokenInfo::from(RustPersonalAccessTokenInfo { + name: "laptop".to_owned(), + expiry_at: None, + }); + assert!(!permanent.has_expiry); + assert_eq!(permanent.expiry_at, 0); + } } diff --git a/foreign/cpp/src/type_conversions.cpp b/foreign/cpp/src/type_conversions.cpp index 523af49cc6..41afe6492c 100644 --- a/foreign/cpp/src/type_conversions.cpp +++ b/foreign/cpp/src/type_conversions.cpp @@ -161,6 +161,14 @@ Permissions Permissions::FromFfi(const ffi::Permissions &permissions) { return result; } +PersonalAccessTokenInfo PersonalAccessTokenInfo::FromFfi(ffi::PersonalAccessTokenInfo token) { + std::optional expires_at; + if (token.has_expiry) { + expires_at = token.expiry_at; + } + return PersonalAccessTokenInfo(std::string(token.name.c_str(), token.name.size()), expires_at); +} + UserInfo UserInfo::FromFfi(ffi::UserInfo user) { return UserInfo(user.id, user.created_at, static_cast(user.status), std::string(user.username.c_str(), user.username.size()), diff --git a/foreign/cpp/tests/e2e/client.cpp b/foreign/cpp/tests/e2e/client.cpp index 6adb66d046..37dd9e265b 100644 --- a/foreign/cpp/tests/e2e/client.cpp +++ b/foreign/cpp/tests/e2e/client.cpp @@ -1214,6 +1214,51 @@ TEST_F(E2E_Client, GetUsersBeforeLoginThrows) { ASSERT_THROW(client.GetUsers(), std::exception); } +TEST_F(E2E_Client, GetPersonalAccessTokensBeforeLoginThrows) { + RecordProperty("description", "Rejects listing personal access tokens without an active authenticated session."); + auto client = GetLoggedOutHighLevelClient(); + + ASSERT_THROW(client.GetPersonalAccessTokens(), std::exception); + ASSERT_NO_THROW(client.Connect()); + ASSERT_THROW(client.GetPersonalAccessTokens(), std::exception); + ASSERT_NO_THROW(client.Login("iggy", "iggy")); + ASSERT_NO_THROW(client.Logout()); + ASSERT_THROW(client.GetPersonalAccessTokens(), std::exception); + ASSERT_NO_THROW(client.Login("iggy", "iggy")); + ASSERT_NO_THROW(client.Disconnect()); + ASSERT_THROW(client.GetPersonalAccessTokens(), std::exception); +} + +TEST_F(E2E_Client, DeletePersonalAccessTokenBeforeLoginThrows) { + RecordProperty("description", "Rejects deleting a personal access token without an active authenticated session."); + auto client = GetLoggedOutHighLevelClient(); + const std::string token_name = GetRandomName(50); + + ASSERT_THROW(client.DeletePersonalAccessToken(token_name), std::exception); + ASSERT_NO_THROW(client.Connect()); + ASSERT_THROW(client.DeletePersonalAccessToken(token_name), std::exception); + ASSERT_NO_THROW(client.Login("iggy", "iggy")); + ASSERT_NO_THROW(client.Logout()); + ASSERT_THROW(client.DeletePersonalAccessToken(token_name), std::exception); + ASSERT_NO_THROW(client.Login("iggy", "iggy")); + ASSERT_NO_THROW(client.Disconnect()); + ASSERT_THROW(client.DeletePersonalAccessToken(token_name), std::exception); +} + +TEST_F(E2E_Client, GetPersonalAccessTokensAfterLoginSucceeds) { + RecordProperty("description", "Lists personal access tokens for an authenticated session."); + auto client = GetLoggedInHighLevelClient(); + + ASSERT_NO_THROW(client.GetPersonalAccessTokens()); +} + +TEST_F(E2E_Client, DeleteUnknownPersonalAccessTokenThrows) { + RecordProperty("description", "Rejects deleting a personal access token that does not exist."); + auto client = GetLoggedInHighLevelClient(); + + ASSERT_THROW(client.DeletePersonalAccessToken(GetRandomName(50)), std::exception); +} + TEST_F(E2E_Client, GetUserAllowsSelfLookupWithoutReadUsersPermission) { RecordProperty("description", "Allows a user without read_users permission to read its own details."); auto root_client = GetLoggedInHighLevelClient(); From d7d7e30039fd943e6745320ba9251ede44722fb3 Mon Sep 17 00:00:00 2001 From: Mit Shah Date: Sat, 24 Oct 2026 16:09:21 +0530 Subject: [PATCH 2/6] fix(cpp): report error details from DeletePersonalAccessToken --- foreign/cpp/include/iggy.hpp | 10 ++++++---- foreign/cpp/src/client.cpp | 3 ++- foreign/cpp/src/client.rs | 8 +++++++- foreign/cpp/src/lib.rs | 6 +++++- foreign/cpp/tests/e2e/client.cpp | 10 +++++++++- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/foreign/cpp/include/iggy.hpp b/foreign/cpp/include/iggy.hpp index b7fa99d6ac..aee6c6ab33 100644 --- a/foreign/cpp/include/iggy.hpp +++ b/foreign/cpp/include/iggy.hpp @@ -5728,6 +5728,7 @@ class PollingStrategy final { * * - Login(), Logout(), ResolveOperation(), and AbandonOperation(). * - CreateUser(), UpdateUser(), and DeleteUser(). + * - DeletePersonalAccessToken(). * - EncryptionKeyFingerprint() and invalid keys passed to CreateStream(). * - CreateStream(), UpdateStream(), and DeleteStream(). * - CreateTopic(), UpdateTopic(), and DeleteTopic(). @@ -5998,8 +5999,8 @@ class IggyBlockingClient final { /** * @brief Lists the personal access tokens owned by the authenticated caller. * - * The result carries token names and expiry only. The secret token strings - * are returned once, when a token is created. + * The result carries token names and expiry only. The server shows a + * secret token string only when the token is created. * * @return One entry per token, empty when the caller has none. * @throws IggyException if the client is unavailable or unauthenticated, or @@ -6011,8 +6012,9 @@ class IggyBlockingClient final { * @brief Deletes one of the caller's personal access tokens. * * @param name Name of the token to delete. - * @throws IggyException if the client is unavailable or unauthenticated; - * the token does not exist; or the request fails. + * @throws IggyException if @p name is empty or exceeds 255 bytes; the + * client is unavailable or unauthenticated; the token does not + * exist; or the request fails. */ void DeletePersonalAccessToken(const std::string &name); diff --git a/foreign/cpp/src/client.cpp b/foreign/cpp/src/client.cpp index 35b8a1092d..de3fd18e8d 100644 --- a/foreign/cpp/src/client.cpp +++ b/foreign/cpp/src/client.cpp @@ -175,7 +175,8 @@ std::vector IggyBlockingClient::GetPersonalAccessTokens } void IggyBlockingClient::DeletePersonalAccessToken(const std::string &name) { - RethrowAsIggyException([this, &name] { Handle()->delete_personal_access_token(name); }); + RethrowAsIggyException( + [this, &name](ffi::ErrorInfo &error_info) { Handle()->delete_personal_access_token(name, error_info); }); } UserInfoDetails IggyBlockingClient::CreateUser(std::string username, diff --git a/foreign/cpp/src/client.rs b/foreign/cpp/src/client.rs index 8058b90b54..e7f1492f7d 100644 --- a/foreign/cpp/src/client.rs +++ b/foreign/cpp/src/client.rs @@ -1354,11 +1354,17 @@ impl Client { }) } - pub fn delete_personal_access_token(&self, name: String) -> Result<(), String> { + pub fn delete_personal_access_token( + &self, + name: String, + error_info: &mut ffi::ErrorInfo, + ) -> Result<(), String> { + *error_info = ffi::ErrorInfo::default(); RUNTIME.block_on(async { self.inner .delete_personal_access_token(&name) .await + .inspect_err(|error| error_info.record(error)) .map_err(|error| { format!("Could not delete personal access token '{name}': {error}") }) diff --git a/foreign/cpp/src/lib.rs b/foreign/cpp/src/lib.rs index a91347682e..2f48e23aa5 100644 --- a/foreign/cpp/src/lib.rs +++ b/foreign/cpp/src/lib.rs @@ -1076,7 +1076,11 @@ mod ffi { expiry_kind: String, expiry_value: u64, ) -> Result; - fn delete_personal_access_token(self: &Client, name: String) -> Result<()>; + fn delete_personal_access_token( + self: &Client, + name: String, + error_info: &mut ErrorInfo, + ) -> Result<()>; // fn login_with_personal_access_token(self: &Client, token: String) -> Result; unsafe fn delete_client(client: *mut Client); diff --git a/foreign/cpp/tests/e2e/client.cpp b/foreign/cpp/tests/e2e/client.cpp index 37dd9e265b..180e647bef 100644 --- a/foreign/cpp/tests/e2e/client.cpp +++ b/foreign/cpp/tests/e2e/client.cpp @@ -1256,7 +1256,15 @@ TEST_F(E2E_Client, DeleteUnknownPersonalAccessTokenThrows) { RecordProperty("description", "Rejects deleting a personal access token that does not exist."); auto client = GetLoggedInHighLevelClient(); - ASSERT_THROW(client.DeletePersonalAccessToken(GetRandomName(50)), std::exception); + try { + client.DeletePersonalAccessToken(GetRandomName(50)); + FAIL() << "Deleting an unknown personal access token must be rejected"; + } catch (const iggy::IggyException &error) { + constexpr std::uint32_t resource_not_found_code = 20; + EXPECT_EQ(error.Code(), resource_not_found_code); + EXPECT_EQ(error.Kind(), "resource_not_found"); + EXPECT_FALSE(error.OperationId().has_value()); + } } TEST_F(E2E_Client, GetUserAllowsSelfLookupWithoutReadUsersPermission) { From 07c401b80315932912933b744be759cc01f085c4 Mon Sep 17 00:00:00 2001 From: Mit Shah Date: Sun, 25 Oct 2026 13:48:14 +0530 Subject: [PATCH 3/6] fix(sdk): percent-encode the token name in the HTTP delete path --- core/sdk/src/http/personal_access_tokens.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/sdk/src/http/personal_access_tokens.rs b/core/sdk/src/http/personal_access_tokens.rs index 2e54c99e7c..7a2c8469f5 100644 --- a/core/sdk/src/http/personal_access_tokens.rs +++ b/core/sdk/src/http/personal_access_tokens.rs @@ -17,6 +17,7 @@ use crate::http::http_client::HttpClient; use crate::http::http_transport::HttpTransport; +use crate::http::path::encode_segment; use crate::prelude::IggyError; use async_trait::async_trait; use iggy_common::IdentityInfo; @@ -62,7 +63,8 @@ impl PersonalAccessTokenClient for HttpClient { } async fn delete_personal_access_token(&self, name: &str) -> Result<(), IggyError> { - self.delete(&format!("{PATH}/{name}")).await?; + self.delete(&format!("{PATH}/{}", encode_segment(name))) + .await?; Ok(()) } From 52c5f6542e26e61fbe085b8f2177be746ba00ee5 Mon Sep 17 00:00:00 2001 From: Mit Shah Date: Sun, 25 Oct 2026 13:48:20 +0530 Subject: [PATCH 4/6] docs(cpp): warn that DeletePersonalAccessToken may commit on transport failure --- foreign/cpp/include/iggy.hpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/foreign/cpp/include/iggy.hpp b/foreign/cpp/include/iggy.hpp index aee6c6ab33..5e5ca9ca7a 100644 --- a/foreign/cpp/include/iggy.hpp +++ b/foreign/cpp/include/iggy.hpp @@ -6011,6 +6011,9 @@ class IggyBlockingClient final { /** * @brief Deletes one of the caller's personal access tokens. * + * A failed or unknown transport outcome can leave the deletion committed; + * list the tokens before retrying. + * * @param name Name of the token to delete. * @throws IggyException if @p name is empty or exceeds 255 bytes; the * client is unavailable or unauthenticated; the token does not From 01d9f7bc239a2c0eea16afc77c4b1e0d802d09bc Mon Sep 17 00:00:00 2001 From: Mit Shah Date: Mon, 26 Oct 2026 23:48:04 +0530 Subject: [PATCH 5/6] fix(cpp): address review on PAT list/delete, reject dot names and add tests --- core/sdk/src/http/personal_access_tokens.rs | 34 ++++++++++++++++++-- foreign/cpp/README.md | 1 + foreign/cpp/include/iggy.hpp | 6 ++-- foreign/cpp/src/client.cpp | 2 +- foreign/cpp/src/client.rs | 4 +-- foreign/cpp/src/lib.rs | 4 +-- foreign/cpp/src/type_conversion.rs | 6 ++-- foreign/cpp/src/type_conversions.cpp | 4 +-- foreign/cpp/tests/e2e/client.cpp | 35 +++++++++++++++++++++ 9 files changed, 81 insertions(+), 15 deletions(-) diff --git a/core/sdk/src/http/personal_access_tokens.rs b/core/sdk/src/http/personal_access_tokens.rs index 7a2c8469f5..b3f0cf07fb 100644 --- a/core/sdk/src/http/personal_access_tokens.rs +++ b/core/sdk/src/http/personal_access_tokens.rs @@ -30,6 +30,10 @@ use secrecy::SecretString; const PATH: &str = "/personal-access-tokens"; +fn delete_path(name: &str) -> String { + format!("{PATH}/{}", encode_segment(name)) +} + #[async_trait] impl PersonalAccessTokenClient for HttpClient { async fn get_personal_access_tokens(&self) -> Result, IggyError> { @@ -63,8 +67,11 @@ impl PersonalAccessTokenClient for HttpClient { } async fn delete_personal_access_token(&self, name: &str) -> Result<(), IggyError> { - self.delete(&format!("{PATH}/{}", encode_segment(name))) - .await?; + // The URL parser resolves "." and ".." even when percent-encoded, so the request would hit another route. + if matches!(name, "." | "..") { + return Err(IggyError::InvalidPersonalAccessTokenName); + } + self.delete(&delete_path(name)).await?; Ok(()) } @@ -88,3 +95,26 @@ impl PersonalAccessTokenClient for HttpClient { Ok(identity_info) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_reserved_characters_when_building_delete_path_should_percent_encode() { + assert_eq!(delete_path("abc?x"), "/personal-access-tokens/abc%3Fx"); + assert_eq!(delete_path("a/b"), "/personal-access-tokens/a%2Fb"); + } + + #[tokio::test] + async fn given_dot_segment_name_when_deleting_should_fail_before_sending() { + let client = HttpClient::default(); + + for name in [".", ".."] { + assert!(matches!( + client.delete_personal_access_token(name).await, + Err(IggyError::InvalidPersonalAccessTokenName) + )); + } + } +} diff --git a/foreign/cpp/README.md b/foreign/cpp/README.md index 46e1a642c1..61ad40b177 100644 --- a/foreign/cpp/README.md +++ b/foreign/cpp/README.md @@ -101,6 +101,7 @@ errors from these methods: - `Login`, `Logout`, `ResolveOperation`, and `AbandonOperation`. - `CreateUser`, `UpdateUser`, and `DeleteUser`. +- `DeletePersonalAccessToken`. - `CreateStream`, `UpdateStream`, and `DeleteStream`. - `CreateTopic`, `UpdateTopic`, and `DeleteTopic`. - `CreatePartitions`, `DeletePartitions`, and `DeleteSegments`. diff --git a/foreign/cpp/include/iggy.hpp b/foreign/cpp/include/iggy.hpp index 5e5ca9ca7a..da6b712351 100644 --- a/foreign/cpp/include/iggy.hpp +++ b/foreign/cpp/include/iggy.hpp @@ -1329,7 +1329,7 @@ class UserInfoDetails final { * @brief Snapshot of one personal access token's metadata. * * GetPersonalAccessTokens() returns one value for each token owned by the - * caller. The secret token string is never included. ExpiresAt() is expressed + * caller. The secret token string is never included. Expiry() is expressed * in microseconds since the Unix epoch and is empty when the token never * expires. */ @@ -1345,7 +1345,7 @@ class PersonalAccessTokenInfo final { * @brief Returns the expiry timestamp. * @return Timestamp in microseconds since the Unix epoch, or empty when the token never expires. */ - [[nodiscard]] std::optional ExpiresAt() const noexcept { return expires_at_; } + [[nodiscard]] std::optional Expiry() const noexcept { return expires_at_; } private: PersonalAccessTokenInfo(std::string name, std::optional expires_at) @@ -6019,7 +6019,7 @@ class IggyBlockingClient final { * client is unavailable or unauthenticated; the token does not * exist; or the request fails. */ - void DeletePersonalAccessToken(const std::string &name); + void DeletePersonalAccessToken(std::string name); /** * @brief Creates a user account. diff --git a/foreign/cpp/src/client.cpp b/foreign/cpp/src/client.cpp index de3fd18e8d..0435a41076 100644 --- a/foreign/cpp/src/client.cpp +++ b/foreign/cpp/src/client.cpp @@ -174,7 +174,7 @@ std::vector IggyBlockingClient::GetPersonalAccessTokens }); } -void IggyBlockingClient::DeletePersonalAccessToken(const std::string &name) { +void IggyBlockingClient::DeletePersonalAccessToken(std::string name) { RethrowAsIggyException( [this, &name](ffi::ErrorInfo &error_info) { Handle()->delete_personal_access_token(name, error_info); }); } diff --git a/foreign/cpp/src/client.rs b/foreign/cpp/src/client.rs index e7f1492f7d..1587cfc25e 100644 --- a/foreign/cpp/src/client.rs +++ b/foreign/cpp/src/client.rs @@ -1356,13 +1356,13 @@ impl Client { pub fn delete_personal_access_token( &self, - name: String, + name: &str, error_info: &mut ffi::ErrorInfo, ) -> Result<(), String> { *error_info = ffi::ErrorInfo::default(); RUNTIME.block_on(async { self.inner - .delete_personal_access_token(&name) + .delete_personal_access_token(name) .await .inspect_err(|error| error_info.record(error)) .map_err(|error| { diff --git a/foreign/cpp/src/lib.rs b/foreign/cpp/src/lib.rs index 2f48e23aa5..27a1b5e675 100644 --- a/foreign/cpp/src/lib.rs +++ b/foreign/cpp/src/lib.rs @@ -471,7 +471,7 @@ mod ffi { struct PersonalAccessTokenInfo { name: String, has_expiry: bool, - expiry_at: u64, + expiry: u64, } struct LoginInfo { @@ -1078,7 +1078,7 @@ mod ffi { ) -> Result; fn delete_personal_access_token( self: &Client, - name: String, + name: &str, error_info: &mut ErrorInfo, ) -> Result<()>; // fn login_with_personal_access_token(self: &Client, token: String) -> Result; diff --git a/foreign/cpp/src/type_conversion.rs b/foreign/cpp/src/type_conversion.rs index 477b3f284a..6395a658bd 100644 --- a/foreign/cpp/src/type_conversion.rs +++ b/foreign/cpp/src/type_conversion.rs @@ -209,7 +209,7 @@ impl From for ffi::PersonalAccessTokenInfo { ffi::PersonalAccessTokenInfo { name: token.name, has_expiry, - expiry_at: token.expiry_at.map_or(0, |expiry_at| expiry_at.as_micros()), + expiry: token.expiry_at.map_or(0, |expiry_at| expiry_at.as_micros()), } } } @@ -2122,13 +2122,13 @@ mod tests { }); assert_eq!(expiring.name, "ci-bot"); assert!(expiring.has_expiry); - assert_eq!(expiring.expiry_at, 1_700_000_000_000_000); + assert_eq!(expiring.expiry, 1_700_000_000_000_000); let permanent = ffi::PersonalAccessTokenInfo::from(RustPersonalAccessTokenInfo { name: "laptop".to_owned(), expiry_at: None, }); assert!(!permanent.has_expiry); - assert_eq!(permanent.expiry_at, 0); + assert_eq!(permanent.expiry, 0); } } diff --git a/foreign/cpp/src/type_conversions.cpp b/foreign/cpp/src/type_conversions.cpp index 41afe6492c..7ed71a9c1e 100644 --- a/foreign/cpp/src/type_conversions.cpp +++ b/foreign/cpp/src/type_conversions.cpp @@ -164,9 +164,9 @@ Permissions Permissions::FromFfi(const ffi::Permissions &permissions) { PersonalAccessTokenInfo PersonalAccessTokenInfo::FromFfi(ffi::PersonalAccessTokenInfo token) { std::optional expires_at; if (token.has_expiry) { - expires_at = token.expiry_at; + expires_at = token.expiry; } - return PersonalAccessTokenInfo(std::string(token.name.c_str(), token.name.size()), expires_at); + return PersonalAccessTokenInfo(std::string(token.name.data(), token.name.size()), expires_at); } UserInfo UserInfo::FromFfi(ffi::UserInfo user) { diff --git a/foreign/cpp/tests/e2e/client.cpp b/foreign/cpp/tests/e2e/client.cpp index 180e647bef..598a918fbb 100644 --- a/foreign/cpp/tests/e2e/client.cpp +++ b/foreign/cpp/tests/e2e/client.cpp @@ -1252,6 +1252,41 @@ TEST_F(E2E_Client, GetPersonalAccessTokensAfterLoginSucceeds) { ASSERT_NO_THROW(client.GetPersonalAccessTokens()); } +TEST_F(E2E_Client, PersonalAccessTokenCanBeListedAndDeleted) { + RecordProperty("description", + "Lists a created personal access token with its expiry, deletes it, and lists again."); + constexpr std::uint32_t create_personal_access_token_command_code = 42; + constexpr std::uint64_t expiry_micros = 3'600'000'000; + iggy::ffi::Client *raw_client = GetLoggedInClient(); + auto client = GetLoggedInHighLevelClient(); + const std::string token_name = GetRandomName(50); + + rust::Vec payload; + payload.push_back(static_cast(token_name.size())); + for (const char character : token_name) { + payload.push_back(static_cast(character)); + } + for (int shift = 0; shift < 64; shift += 8) { + payload.push_back(static_cast(expiry_micros >> shift)); + } + iggy::ffi::ErrorInfo error_info{}; + ASSERT_NO_THROW(raw_client->send_binary_request(create_personal_access_token_command_code, payload, error_info)); + + const auto tokens = client.GetPersonalAccessTokens(); + const auto found = + std::find_if(tokens.begin(), tokens.end(), [&](const auto &token) { return token.Name() == token_name; }); + EXPECT_NE(found, tokens.end()); + if (found != tokens.end()) { + EXPECT_TRUE(found->Expiry().has_value()); + } + + ASSERT_NO_THROW(client.DeletePersonalAccessToken(token_name)); + const auto remaining = client.GetPersonalAccessTokens(); + EXPECT_EQ(std::count_if(remaining.begin(), remaining.end(), + [&](const auto &token) { return token.Name() == token_name; }), + 0); +} + TEST_F(E2E_Client, DeleteUnknownPersonalAccessTokenThrows) { RecordProperty("description", "Rejects deleting a personal access token that does not exist."); auto client = GetLoggedInHighLevelClient(); From 3bab3db91d37a28266a782d073d8b92a2932b4b0 Mon Sep 17 00:00:00 2001 From: Mit Shah Date: Wed, 28 Oct 2026 18:52:52 +0530 Subject: [PATCH 6/6] test(cpp): create the token with CreatePersonalAccessToken in the list and delete test --- foreign/cpp/tests/e2e/client.cpp | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/foreign/cpp/tests/e2e/client.cpp b/foreign/cpp/tests/e2e/client.cpp index 598a918fbb..fdda975e78 100644 --- a/foreign/cpp/tests/e2e/client.cpp +++ b/foreign/cpp/tests/e2e/client.cpp @@ -1255,22 +1255,10 @@ TEST_F(E2E_Client, GetPersonalAccessTokensAfterLoginSucceeds) { TEST_F(E2E_Client, PersonalAccessTokenCanBeListedAndDeleted) { RecordProperty("description", "Lists a created personal access token with its expiry, deletes it, and lists again."); - constexpr std::uint32_t create_personal_access_token_command_code = 42; - constexpr std::uint64_t expiry_micros = 3'600'000'000; - iggy::ffi::Client *raw_client = GetLoggedInClient(); - auto client = GetLoggedInHighLevelClient(); - const std::string token_name = GetRandomName(50); - - rust::Vec payload; - payload.push_back(static_cast(token_name.size())); - for (const char character : token_name) { - payload.push_back(static_cast(character)); - } - for (int shift = 0; shift < 64; shift += 8) { - payload.push_back(static_cast(expiry_micros >> shift)); - } - iggy::ffi::ErrorInfo error_info{}; - ASSERT_NO_THROW(raw_client->send_binary_request(create_personal_access_token_command_code, payload, error_info)); + auto client = GetLoggedInHighLevelClient(); + const std::string token_name = GetRandomName(50); + + ASSERT_NO_THROW((void)client.CreatePersonalAccessToken(token_name, iggy::Expiry::Duration(3'600'000'000))); const auto tokens = client.GetPersonalAccessTokens(); const auto found =