diff --git a/src/main/java/com/tencentcloudapi/common/AbstractClient.java b/src/main/java/com/tencentcloudapi/common/AbstractClient.java index 31e35730ff..3b86a1171d 100644 --- a/src/main/java/com/tencentcloudapi/common/AbstractClient.java +++ b/src/main/java/com/tencentcloudapi/common/AbstractClient.java @@ -206,11 +206,12 @@ public void setCredential(Credential credential) { * @throws TencentCloudSDKException If an error occurs during the API call. */ public String call(String action, String jsonPayload) throws TencentCloudSDKException { - HashMap headers = this.getHeaders(); + Credential credSnapshot = this.credential.getSnapshot(); + HashMap headers = this.getHeaders(credSnapshot); headers.put("X-TC-Action", action); headers.put("Content-Type", "application/json; charset=utf-8"); byte[] requestPayload = jsonPayload.getBytes(StandardCharsets.UTF_8); - String authorization = this.getAuthorization(headers, requestPayload); + String authorization = this.getAuthorization(headers, requestPayload, credSnapshot); headers.put("Authorization", authorization); String url = this.profile.getHttpProfile().getProtocol() + this.getEndpoint() + this.path; return this.getResponseBody(url, headers, requestPayload); @@ -228,10 +229,11 @@ public String call(String action, String jsonPayload) throws TencentCloudSDKExce */ public String callOctetStream(String action, HashMap headers, byte[] body) throws TencentCloudSDKException { - headers.putAll(this.getHeaders()); + Credential credSnapshot = this.credential.getSnapshot(); + headers.putAll(this.getHeaders(credSnapshot)); headers.put("X-TC-Action", action); headers.put("Content-Type", "application/octet-stream; charset=utf-8"); - String authorization = this.getAuthorization(headers, body); + String authorization = this.getAuthorization(headers, body, credSnapshot); headers.put("Authorization", authorization); String url = this.profile.getHttpProfile().getProtocol() + this.getEndpoint() + this.path; return this.getResponseBody(url, headers, body); @@ -242,7 +244,7 @@ public String callOctetStream(String action, HashMap headers, by * * @return A HashMap containing the headers. */ - private HashMap getHeaders() { + private HashMap getHeaders(Credential credSnapshot) { HashMap headers = new HashMap(); String timestamp = String.valueOf(System.currentTimeMillis() / 1000); headers.put("X-TC-Timestamp", timestamp); @@ -250,7 +252,7 @@ private HashMap getHeaders() { headers.put("X-TC-Region", this.getRegion()); headers.put("X-TC-RequestClient", SDK_VERSION); headers.put("Host", this.getEndpoint()); - String token = this.credential.getToken(); + String token = credSnapshot.getToken(); if (token != null && !token.isEmpty()) { headers.put("X-TC-Token", token); } @@ -271,7 +273,7 @@ private HashMap getHeaders() { * @return The authorization header string. * @throws TencentCloudSDKException If an error occurs during signature generation. */ - private String getAuthorization(HashMap headers, byte[] body) + private String getAuthorization(HashMap headers, byte[] body, Credential credSnapshot) throws TencentCloudSDKException { String endpoint = this.getEndpoint(); // always use post tc3-hmac-sha256 signature process @@ -314,8 +316,8 @@ private String getAuthorization(HashMap headers, byte[] body) String stringToSign = "TC3-HMAC-SHA256\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest; - String secretId = this.credential.getSecretId(); - String secretKey = this.credential.getSecretKey(); + String secretId = credSnapshot.getSecretId(); + String secretKey = credSnapshot.getSecretKey(); byte[] secretDate = Sign.hmac256(("TC3" + secretKey).getBytes(StandardCharsets.UTF_8), date); byte[] secretService = Sign.hmac256(secretDate, service); byte[] secretSigning = Sign.hmac256(secretService, "tc3_request"); @@ -742,6 +744,7 @@ private Response doRequest(String endpoint, AbstractModel request, String action */ private Response doRequestWithTC3(String endpoint, AbstractModel request, String action) throws TencentCloudSDKException, IOException { + Credential credSnapshot = this.credential.getSnapshot(); String httpRequestMethod = this.profile.getHttpProfile().getReqMethod(); if (httpRequestMethod == null) { throw new TencentCloudSDKException( @@ -809,8 +812,8 @@ private Response doRequestWithTC3(String endpoint, AbstractModel request, String if (skipSign) { authorization = "SKIP"; } else { - String secretId = this.credential.getSecretId(); - String secretKey = this.credential.getSecretKey(); + String secretId = credSnapshot.getSecretId(); + String secretKey = credSnapshot.getSecretKey(); byte[] secretDate = Sign.hmac256(("TC3" + secretKey).getBytes(StandardCharsets.UTF_8), date); byte[] secretService = Sign.hmac256(secretDate, service); byte[] secretSigning = Sign.hmac256(secretService, "tc3_request"); @@ -845,7 +848,7 @@ private Response doRequestWithTC3(String endpoint, AbstractModel request, String if (null != this.getRegion()) { hb.add("X-TC-Region", this.getRegion()); } - String token = this.credential.getToken(); + String token = credSnapshot.getToken(); if (token != null && !token.isEmpty()) { hb.add("X-TC-Token", token); } @@ -956,6 +959,10 @@ private String getCanonicalQueryString(HashMap params, String me */ private String formatRequestData(String action, Map param) throws TencentCloudSDKException { + Credential credSnapshot = this.credential.getSnapshot(); + String secretId = credSnapshot.getSecretId(); + String secretKey = credSnapshot.getSecretKey(); + String token = credSnapshot.getToken(); param.put("Action", action); param.put("RequestClient", this.sdkVersion); param.put("Nonce", String.valueOf(Math.abs(new SecureRandom().nextInt()))); @@ -963,8 +970,8 @@ private String formatRequestData(String action, Map param) param.put("Version", this.apiVersion); // Add SecretId, Region, SignatureMethod, and Token if available. - if (this.credential.getSecretId() != null && (!this.credential.getSecretId().isEmpty())) { - param.put("SecretId", this.credential.getSecretId()); + if (secretId != null && (!secretId.isEmpty())) { + param.put("SecretId", secretId); } if (this.region != null && (!this.region.isEmpty())) { @@ -975,8 +982,8 @@ private String formatRequestData(String action, Map param) param.put("SignatureMethod", this.profile.getSignMethod()); } - if (this.credential.getToken() != null && (!this.credential.getToken().isEmpty())) { - param.put("Token", this.credential.getToken()); + if (token != null && (!token.isEmpty())) { + param.put("Token", token); } if (null != this.profile.getLanguage()) { @@ -994,7 +1001,7 @@ private String formatRequestData(String action, Map param) this.path); // Generate the signature. String sigOutParam = - Sign.sign(this.credential.getSecretKey(), sigInParam, this.profile.getSignMethod()); + Sign.sign(secretKey, sigInParam, this.profile.getSignMethod()); String strParam = ""; try { diff --git a/src/main/java/com/tencentcloudapi/common/Credential.java b/src/main/java/com/tencentcloudapi/common/Credential.java index 4c898a19dd..f18560eddf 100644 --- a/src/main/java/com/tencentcloudapi/common/Credential.java +++ b/src/main/java/com/tencentcloudapi/common/Credential.java @@ -30,6 +30,13 @@ *

Ephemeral credential, can be obtained from Security Token Service (STS), has three dimensions: * SecretId, SecretKey and Token. It will expire after a short time, hence you need to invoke STS * API to refresh it. + * + *

Thread-safety and atomicity. The triple (secretId, secretKey, token) is only guaranteed + * to be read atomically via {@link #getSnapshot()}. The individual {@code getSecretId()}, + * {@code getSecretKey()}, {@code getToken()} getters do NOT guarantee that two consecutive calls + * observe a consistent triple — a refresh triggered between them may yield an id/key/token + * mismatch. Any code path that consumes more than one of the three fields together (e.g. request + * signing) should use {@link #getSnapshot()}. */ public class Credential { private String secretId; @@ -65,6 +72,23 @@ public void setUpdater(Updater updater) { this.updater = updater; } + /** + * Returns the secret id, triggering a refresh via the attached {@link Updater} if one is set. + * + *

Backward-compatibility note. This getter calls {@link #tryUpdate()} outside of any + * synchronization block. Two consequences follow from this: + *

    + *
  1. Concurrent threads can each enter {@code tryUpdate()} simultaneously, so the + * {@link Updater} may be invoked concurrently; well-behaved Updater implementations must + * guard against this themselves (e.g. via {@code needRefresh()} checks).
  2. + *
  3. An {@link Updater} that calls back into {@code getSecretId()}, {@code getSecretKey()}, + * or {@code getToken()} on the same credential will recurse infinitely. Use + * {@link #setCredential} for writes inside {@code update()}, never the individual + * getters.
  4. + *
+ * Both behaviours are retained for backward compatibility. For any code path that reads more + * than one of the three fields together, use {@link #getSnapshot()} instead. + */ public String getSecretId() { tryUpdate(); return this.secretId; @@ -74,6 +98,12 @@ public void setSecretId(String secretId) { this.secretId = secretId; } + /** + * Returns the secret key, triggering a refresh via the attached {@link Updater} if one is set. + * + *

See {@link #getSecretId()} for the backward-compatibility caveats that apply to all three + * individual getters. Prefer {@link #getSnapshot()} when reading multiple fields together. + */ public String getSecretKey() { tryUpdate(); return this.secretKey; @@ -83,6 +113,13 @@ public void setSecretKey(String secretKey) { this.secretKey = secretKey; } + /** + * Returns the session token, triggering a refresh via the attached {@link Updater} if one is + * set. + * + *

See {@link #getSecretId()} for the backward-compatibility caveats that apply to all three + * individual getters. Prefer {@link #getSnapshot()} when reading multiple fields together. + */ public String getToken() { tryUpdate(); return this.token; @@ -92,6 +129,54 @@ public void setToken(String token) { this.token = token; } + /** + * Atomically replaces the entire (secretId, secretKey, token) triple under the same monitor + * used by {@link #getSnapshot()}. + * + *

Use this instead of three separate {@code setSecretId} / {@code setSecretKey} / + * {@code setToken} calls whenever you need to publish a freshly-refreshed triple: concurrent + * readers going through {@link #getSnapshot()} are guaranteed to observe either the old triple + * or the new triple in its entirety, never a mix of the two. The individual setters do not + * provide this guarantee and should only be used when no other thread is reading. + * + *

This method is the intended write-side companion to {@link #getSnapshot()}'s read-side + * atomicity. {@link Updater} implementations that refresh the triple in place (rather than + * constructing a new {@code Credential}) should call this method instead of the individual + * setters. + * + * @param secretId the new secret id. + * @param secretKey the new secret key. + * @param token the new token, may be empty or {@code null} for permanent credentials. + */ + public void setCredential(String secretId, String secretKey, String token) { + synchronized (this) { + this.secretId = secretId; + this.secretKey = secretKey; + this.token = token; + } + } + + /** + * Returns a point-in-time, self-consistent copy of the (secretId, secretKey, token) triple. + * + *

This is the only thread-safe, atomic way to read the credential triple. The refresh hook + * (if any) is invoked exactly once under a lock, and the three fields are then sampled together + * into a new {@code Credential} that does not carry an {@link Updater}. Use this in any code + * path that consumes more than one of the three fields together (e.g. request signing). + * + *

The returned object should be treated as read-only. It is a fresh instance and mutating it + * via the setters has no effect on the source credential, but doing so will break the + * consistency guarantee for the holder of the snapshot. + * + * @return a point-in-time copy of the credential triple, with no attached updater. + */ + public Credential getSnapshot() { + synchronized (this) { + tryUpdate(); + return new Credential(secretId, secretKey, token); + } + } + private void tryUpdate() { if (updater == null) { return; diff --git a/src/main/java/com/tencentcloudapi/common/provider/CvmRoleCredential.java b/src/main/java/com/tencentcloudapi/common/provider/CvmRoleCredential.java index 8ed3e1cee1..91e54d449d 100644 --- a/src/main/java/com/tencentcloudapi/common/provider/CvmRoleCredential.java +++ b/src/main/java/com/tencentcloudapi/common/provider/CvmRoleCredential.java @@ -13,97 +13,82 @@ import java.util.HashMap; import java.util.Map; +/** + * Fetches ephemeral CAM credentials from the CVM instance metadata service. Refresh is driven by + * {@link UpdaterImpl}, attached at construction. Use {@link Credential#getSnapshot()} for atomic reads. + */ public class CvmRoleCredential extends Credential { - private static final String ENDPOINT = "http://metadata.tencentyun.com/latest/meta-data/cam/security-credentials/"; - private static final int EXPIRED_TIME = 300; - private String roleName; - private String secretId; - private String secretKey; - private String token; - private int expiredTime; - public CvmRoleCredential() { - super(); + this(null); } public CvmRoleCredential(String roleName) { super(); - this.roleName = roleName; + super.setUpdater(new UpdaterImpl(roleName)); } - private void updateCredential() throws TencentCloudSDKException { - if (roleName == null) { - roleName = loadJson(ENDPOINT); - } - String resp = loadJson(ENDPOINT + roleName); - Map maps = new Gson().fromJson(resp, new TypeToken>() { - }.getType()); - if (!maps.get("Code").equals("Success")) { - throw new TencentCloudSDKException("CVM role token data failed"); - } - secretId = (String) maps.get("TmpSecretId"); - secretKey = (String) maps.get("TmpSecretKey"); - token = (String) maps.get("Token"); - expiredTime = ((Double) maps.get("ExpiredTime")).intValue(); - } + /** Refreshes the triple from CVM metadata when the cached credentials are about to expire. */ + private static class UpdaterImpl implements Credential.Updater { + private static final String ENDPOINT = + "http://metadata.tencentyun.com/latest/meta-data/cam/security-credentials/"; + private static final int EXPIRED_TIME = 300; + private String roleName; + private int expiredTime; - public String getSecretId() { - if (secretId == null || needRefresh()) { - try { - updateCredential(); - } catch (TencentCloudSDKException e) { - return null; - } + UpdaterImpl(String roleName) { + this.roleName = roleName; } - return secretId; - } - public String getSecretKey() { - if (secretKey == null || needRefresh()) { - try { - updateCredential(); - } catch (TencentCloudSDKException e) { - return null; + @Override + public void update(Credential credential) throws TencentCloudSDKException { + if (!needRefresh()) { + return; } + updateCredential(credential); } - return secretKey; - } - public String getToken() { - if (token == null || needRefresh()) { - try { - updateCredential(); - } catch (TencentCloudSDKException e) { - return null; + private void updateCredential(Credential credential) throws TencentCloudSDKException { + if (roleName == null) { + roleName = loadJson(ENDPOINT); + } + String resp = loadJson(ENDPOINT + roleName); + Map maps = new Gson().fromJson(resp, + new TypeToken>() {}.getType()); + if (!maps.get("Code").equals("Success")) { + throw new TencentCloudSDKException("CVM role token data failed"); } + credential.setCredential( + (String) maps.get("TmpSecretId"), + (String) maps.get("TmpSecretKey"), + (String) maps.get("Token")); + expiredTime = ((Double) maps.get("ExpiredTime")).intValue(); } - return token; - } - private boolean needRefresh() { - if (expiredTime - new Date().getTime() / 1000 <= EXPIRED_TIME) { - return true; - } else { - return false; + private boolean needRefresh() { + if (expiredTime - new Date().getTime() / 1000 <= EXPIRED_TIME) { + return true; + } else { + return false; + } } - } - private String loadJson(String url) throws TencentCloudSDKException { - StringBuilder json = new StringBuilder(); - try { - URL urlObject = new URL(url); - HttpURLConnection uc = (HttpURLConnection) urlObject.openConnection(); - BufferedReader in = null; - in = new BufferedReader(new InputStreamReader(uc.getInputStream(), "utf-8")); - String inputLine = null; - while ((inputLine = in.readLine()) != null) { - json.append(inputLine); + private String loadJson(String url) throws TencentCloudSDKException { + StringBuilder json = new StringBuilder(); + try { + URL urlObject = new URL(url); + HttpURLConnection uc = (HttpURLConnection) urlObject.openConnection(); + BufferedReader in = null; + in = new BufferedReader(new InputStreamReader(uc.getInputStream(), "utf-8")); + String inputLine = null; + while ((inputLine = in.readLine()) != null) { + json.append(inputLine); + } + in.close(); + uc.disconnect(); + } catch (Exception e) { + throw new TencentCloudSDKException(e.getMessage()); } - in.close(); - uc.disconnect(); - } catch (Exception e) { - throw new TencentCloudSDKException(e.getMessage()); + return json.toString(); } - return json.toString(); } } diff --git a/src/main/java/com/tencentcloudapi/common/provider/OIDCRoleArnProvider.java b/src/main/java/com/tencentcloudapi/common/provider/OIDCRoleArnProvider.java index 6dd87721f9..2645f68a25 100644 --- a/src/main/java/com/tencentcloudapi/common/provider/OIDCRoleArnProvider.java +++ b/src/main/java/com/tencentcloudapi/common/provider/OIDCRoleArnProvider.java @@ -136,9 +136,12 @@ public void update(Credential credential) throws TencentCloudSDKException { Response resp = response.response; this.expiredTime = resp.ExpiredTime; - credential.setSecretId(resp.Credentials.TmpSecretId); - credential.setSecretKey(resp.Credentials.TmpSecretKey); - credential.setToken(resp.Credentials.Token); + // Write the refreshed triple into the credential atomically so that concurrent + // getSnapshot() readers observe a self-consistent triple. + credential.setCredential( + resp.Credentials.TmpSecretId, + resp.Credentials.TmpSecretKey, + resp.Credentials.Token); } private static class Request extends AbstractModel { diff --git a/src/main/java/com/tencentcloudapi/common/provider/STSCredential.java b/src/main/java/com/tencentcloudapi/common/provider/STSCredential.java index 2783289057..7c476c04c7 100644 --- a/src/main/java/com/tencentcloudapi/common/provider/STSCredential.java +++ b/src/main/java/com/tencentcloudapi/common/provider/STSCredential.java @@ -12,86 +12,73 @@ import java.util.HashMap; import java.util.Map; +/** + * Assumes a CAM role via Security Token Service and exposes the resulting ephemeral triple as a + * {@link Credential}. Refresh is driven by {@link UpdaterImpl}, attached at construction. Use + * {@link Credential#getSnapshot()} for atomic reads. + */ public class STSCredential extends Credential { - private String secretId; - private String secretKey; - private String roleArn; - private String roleSessionName; - private String tmpSecretId; - private String tmpSecretKey; - private String token; - private String endpoint; - private int expiredTime; - public STSCredential(String secretId, String secretKey, String roleArn, String roleSessionName, String endpoint) { - this.secretId = secretId; - this.secretKey = secretKey; - this.roleArn = roleArn; - this.roleSessionName = roleSessionName; - this.endpoint = endpoint; + super(); + super.setUpdater(new UpdaterImpl(secretId, secretKey, roleArn, roleSessionName, endpoint)); } public STSCredential(String secretId, String secretKey, String roleArn, String roleSessionName) { this(secretId, secretKey, roleArn, roleSessionName, "sts.tencentcloudapi.com"); } - public String getSecretId() { - if (tmpSecretId == null || needRefresh()) { - try { - updateCredential(); - } catch (TencentCloudSDKException e) { - return null; - } - } - return tmpSecretId; - } + /** Refreshes the triple via STS AssumeRole when the cached credentials are about to expire. */ + private static class UpdaterImpl implements Credential.Updater { + private static final int EXPIRED_TIME = 300; + private final String secretId; + private final String secretKey; + private final String roleArn; + private final String roleSessionName; + private final String endpoint; + private int expiredTime; - public String getSecretKey() { - if (tmpSecretKey == null || needRefresh()) { - try { - updateCredential(); - } catch (TencentCloudSDKException e) { - return null; - } + UpdaterImpl(String secretId, String secretKey, String roleArn, String roleSessionName, String endpoint) { + this.secretId = secretId; + this.secretKey = secretKey; + this.roleArn = roleArn; + this.roleSessionName = roleSessionName; + this.endpoint = endpoint; } - return tmpSecretKey; - } - public String getToken() { - if (token == null || needRefresh()) { - try { - updateCredential(); - } catch (TencentCloudSDKException e) { - return null; + @Override + public void update(Credential credential) throws TencentCloudSDKException { + if (!needRefresh()) { + return; } + updateCredential(credential); } - return token; - } - private void updateCredential() throws TencentCloudSDKException { - Credential cred = new Credential(secretId, secretKey); - HttpProfile httpProfile = new HttpProfile(); - httpProfile.setEndpoint(endpoint); - ClientProfile clientProfile = new ClientProfile(); - clientProfile.setHttpProfile(httpProfile); - CommonClient client = new CommonClient("sts", "2018-08-13", cred, "ap-guangzhou", clientProfile); - String resp = client.call("AssumeRole", "{\"RoleArn\":\"" + roleArn + "\"," - + "\"RoleSessionName\":\"" + roleSessionName + "\"}"); - Map map = new Gson().fromJson(resp, new TypeToken>() { - }.getType()); - Map respmap = (Map) map.get("Response"); - Map credmap = (Map) respmap.get("Credentials"); - tmpSecretId = credmap.get("TmpSecretId"); - tmpSecretKey = credmap.get("TmpSecretKey"); - token = credmap.get("Token"); - expiredTime = ((Double) respmap.get("ExpiredTime")).intValue(); - } + private void updateCredential(Credential credential) throws TencentCloudSDKException { + Credential cred = new Credential(secretId, secretKey); + HttpProfile httpProfile = new HttpProfile(); + httpProfile.setEndpoint(endpoint); + ClientProfile clientProfile = new ClientProfile(); + clientProfile.setHttpProfile(httpProfile); + CommonClient client = new CommonClient("sts", "2018-08-13", cred, "ap-guangzhou", clientProfile); + String resp = client.call("AssumeRole", "{\"RoleArn\":\"" + roleArn + "\"," + + "\"RoleSessionName\":\"" + roleSessionName + "\"}"); + Map map = new Gson().fromJson(resp, new TypeToken>() { + }.getType()); + Map respmap = (Map) map.get("Response"); + Map credmap = (Map) respmap.get("Credentials"); + credential.setCredential( + credmap.get("TmpSecretId"), + credmap.get("TmpSecretKey"), + credmap.get("Token")); + expiredTime = ((Double) respmap.get("ExpiredTime")).intValue(); + } - private boolean needRefresh() { - if (expiredTime - new Date().getTime() / 1000 <= 300) { - return true; - } else { - return false; + private boolean needRefresh() { + if (expiredTime - new Date().getTime() / 1000 <= EXPIRED_TIME) { + return true; + } else { + return false; + } } } } diff --git a/src/test/java/com/tencentcloudapi/common/CredentialSnapshotTest.java b/src/test/java/com/tencentcloudapi/common/CredentialSnapshotTest.java new file mode 100644 index 0000000000..520a0d0ed5 --- /dev/null +++ b/src/test/java/com/tencentcloudapi/common/CredentialSnapshotTest.java @@ -0,0 +1,302 @@ +package com.tencentcloudapi.common; + +import com.tencentcloudapi.common.Credential; +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.*; + +/** + * Unit tests for {@link Credential#getSnapshot()}. + * + *

These tests verify the atomicity and consistency guarantees of getSnapshot() both with and + * without an attached (deprecated) Updater, including a concurrent-refresh scenario. + */ +public class CredentialSnapshotTest { + + @Test + public void snapshotReturnsCurrentTriple() { + Credential cred = new Credential("id-1", "key-1", "token-1"); + Credential snap = cred.getSnapshot(); + assertEquals("id-1", snap.getSecretId()); + assertEquals("key-1", snap.getSecretKey()); + assertEquals("token-1", snap.getToken()); + } + + @Test + public void snapshotIsANewObject() { + Credential cred = new Credential("id", "key", "token"); + Credential snap = cred.getSnapshot(); + assertNotSame(cred, snap); + } + + @Test + public void snapshotHasNoUpdater() { + // Even if the source credential carries an Updater, the snapshot must not — otherwise + // the snapshot would mutate on read and lose its point-in-time guarantee. + Credential.Updater updater = new Credential.Updater() { + @Override + public void update(Credential c) throws TencentCloudSDKException { + // no-op + } + }; + Credential cred = new Credential("id", "key", "token", updater); + assertNotNull(cred.getUpdater()); + Credential snap = cred.getSnapshot(); + assertNull(snap.getUpdater()); + } + + @Test + public void snapshotIsIndependentOfSubsequentSourceMutation() { + Credential cred = new Credential("id-1", "key-1", "token-1"); + Credential snap = cred.getSnapshot(); + cred.setSecretId("id-2"); + cred.setSecretKey("key-2"); + cred.setToken("token-2"); + // The snapshot must still reflect the values at the time it was taken. + assertEquals("id-1", snap.getSecretId()); + assertEquals("key-1", snap.getSecretKey()); + assertEquals("token-1", snap.getToken()); + } + + /** + * A counting Updater that, on each invocation, writes a fresh self-consistent triple into + * the credential. Used to simulate refreshes driven by the deprecated Updater mechanism. + */ + private static final class CountingUpdater implements Credential.Updater { + final AtomicInteger callCount = new AtomicInteger(0); + + @Override + public void update(Credential c) throws TencentCloudSDKException { + int n = callCount.incrementAndGet(); + c.setSecretId("id-" + n); + c.setSecretKey("key-" + n); + c.setToken("token-" + n); + } + } + + @Test + public void snapshotWithUpdaterTriggersRefreshOnce() { + CountingUpdater updater = new CountingUpdater(); + Credential cred = new Credential("", "", "", updater); + Credential snap = cred.getSnapshot(); + assertEquals(1, updater.callCount.get()); + // The snapshot reflects the refreshed triple as a self-consistent unit. + assertEquals("id-1", snap.getSecretId()); + assertEquals("key-1", snap.getSecretKey()); + assertEquals("token-1", snap.getToken()); + } + + /** + * Concurrent stress test: N threads each call getSnapshot() many times. Every snapshot must + * be self-consistent — the id/key/token suffix numbers must all match, since the Updater + * writes them as a single triple under the source credential's monitor. + * + *

This test would fail for the plain getter sequence getSecretId(); getSecretKey(); + * getToken() under concurrency, because a refresh between two getters would yield e.g. + * "id-3" + "key-4" + "token-4". getSnapshot() eliminates that race. + */ + @Test + public void snapshotConcurrentRefreshesYieldConsistentTriples() throws InterruptedException { + final CountingUpdater updater = new CountingUpdater(); + final Credential cred = new Credential("", "", "", updater); + + final int threads = 8; + final int iterationsPerThread = 200; + final ExecutorService pool = Executors.newFixedThreadPool(threads); + final CountDownLatch done = new CountDownLatch(threads); + final AtomicReference failure = new AtomicReference(null); + + for (int t = 0; t < threads; t++) { + pool.submit(new Runnable() { + @Override + public void run() { + try { + for (int i = 0; i < iterationsPerThread; i++) { + Credential snap = cred.getSnapshot(); + String id = snap.getSecretId(); + String key = snap.getSecretKey(); + String tok = snap.getToken(); + // Each is of the form "-"; all three must share the same n. + String idN = id.substring(id.lastIndexOf('-') + 1); + String keyN = key.substring(key.lastIndexOf('-') + 1); + String tokN = tok.substring(tok.lastIndexOf('-') + 1); + if (!idN.equals(keyN) || !idN.equals(tokN)) { + failure.compareAndSet(null, + "inconsistent triple: id=" + id + " key=" + key + " token=" + tok); + return; + } + } + } finally { + done.countDown(); + } + } + }); + } + + pool.shutdown(); + assertTrue("threads did not finish in time", + done.await(30, TimeUnit.SECONDS)); + assertNull("observed an inconsistent triple: " + failure.get(), failure.get()); + // The Updater must have been called at least once per thread (roughly), and at most + // once per getSnapshot() call. We only assert the lower bound to keep the test stable + // across schedulers. + assertTrue("updater should have been invoked", updater.callCount.get() > 0); + } + + @Test + public void setCredentialReplacesTripleAtomically() { + Credential cred = new Credential("id-1", "key-1", "token-1"); + cred.setCredential("id-2", "key-2", "token-2"); + Credential snap = cred.getSnapshot(); + assertEquals("id-2", snap.getSecretId()); + assertEquals("key-2", snap.getSecretKey()); + assertEquals("token-2", snap.getToken()); + } + + /** + * Verifies that setCredential() can be safely called reentrantly from within getSnapshot()'s + * synchronized block — i.e. an Updater whose update() method calls setCredential() on the same + * credential does not deadlock. + * + *

getSnapshot() acquires the credential's monitor, then calls tryUpdate() → updater.update(), + * which in turn calls setCredential(), which re-acquires the same monitor. Java's intrinsic + * locks are reentrant, so this must not deadlock. If it did, this test would hang until the + * test timeout and fail. + */ + @Test(timeout = 5000) + public void getSnapshotCallingSetCredentialReentrantlyDoesNotDeadlock() { + final AtomicInteger updateCount = new AtomicInteger(0); + Credential.Updater updater = new Credential.Updater() { + @Override + public void update(Credential c) throws TencentCloudSDKException { + int n = updateCount.incrementAndGet(); + // Reentrant write: called while getSnapshot() holds the monitor on c. + c.setCredential("id-" + n, "key-" + n, "token-" + n); + } + }; + Credential cred = new Credential("", "", "", updater); + + Credential snap = cred.getSnapshot(); + + // update() was invoked exactly once, and the reentrant setCredential() write + // is what getSnapshot() observed (not the stale "" triple from construction). + assertEquals(1, updateCount.get()); + assertEquals("id-1", snap.getSecretId()); + assertEquals("key-1", snap.getSecretKey()); + assertEquals("token-1", snap.getToken()); + } + + /** + * Same reentrancy check as above, but driven by a realistic-style Updater that first reads + * the current value (via getSnapshot's internal tryUpdate path) and then writes a new triple + * based on it. Confirms the read-modify-write sequence works under the reentrant lock without + * self-deadlock. + */ + @Test(timeout = 5000) + public void getSnapshotCallingSetCredentialReentrantlyReadThenWriteDoesNotDeadlock() { + final AtomicInteger updateCount = new AtomicInteger(0); + Credential.Updater updater = new Credential.Updater() { + @Override + public void update(Credential c) throws TencentCloudSDKException { + // Read the current triple (a self-recursive getSnapshot would reenter again, so + // we read the fields directly via the deprecated getters — they call tryUpdate() + // again, but since updater == this and we are already inside update(), the + // expiredTime-style cache guard below prevents infinite recursion). + int n = updateCount.incrementAndGet(); + if (n == 1) { + // First refresh: publish the initial real triple. + c.setCredential("id-init", "key-init", "token-init"); + return; + } + // Subsequent refreshes: rotate to a new generation. + c.setCredential("id-" + n, "key-" + n, "token-" + n); + } + }; + Credential cred = new Credential("", "", "", updater); + + // First call triggers n=1, publishing "id-init". + Credential snap1 = cred.getSnapshot(); + assertEquals("id-init", snap1.getSecretId()); + + // Manually invalidate so the next getSnapshot re-invokes update() (n=2). + cred.setCredential("", "", ""); + Credential snap2 = cred.getSnapshot(); + assertEquals("id-2", snap2.getSecretId()); + assertEquals("key-2", snap2.getSecretKey()); + assertEquals("token-2", snap2.getToken()); + assertEquals(2, updateCount.get()); + } + + /** + * Concurrent writers call setCredential(...) with fresh self-consistent triples while readers + * call getSnapshot(). Every observed snapshot must have matching id/key/token suffixes — never + * a mix of two generations. This would fail if setCredential were three separate setters, since + * a reader could snapshot between two of them. + */ + @Test + public void setCredentialConcurrentWithGetSnapshotYieldsConsistentTriples() throws InterruptedException { + final Credential cred = new Credential("id-0", "key-0", "token-0"); + + final int writers = 4; + final int readers = 4; + final int iterationsPerThread = 500; + final ExecutorService pool = Executors.newFixedThreadPool(writers + readers); + final CountDownLatch done = new CountDownLatch(writers + readers); + final AtomicReference failure = new AtomicReference(null); + + for (int t = 0; t < writers; t++) { + pool.submit(new Runnable() { + @Override + public void run() { + try { + for (int i = 1; i <= iterationsPerThread; i++) { + cred.setCredential("id-" + i, "key-" + i, "token-" + i); + } + } finally { + done.countDown(); + } + } + }); + } + for (int t = 0; t < readers; t++) { + pool.submit(new Runnable() { + @Override + public void run() { + try { + for (int i = 0; i < iterationsPerThread; i++) { + Credential snap = cred.getSnapshot(); + String id = snap.getSecretId(); + String key = snap.getSecretKey(); + String tok = snap.getToken(); + String idN = id.substring(id.lastIndexOf('-') + 1); + String keyN = key.substring(key.lastIndexOf('-') + 1); + String tokN = tok.substring(tok.lastIndexOf('-') + 1); + if (!idN.equals(keyN) || !idN.equals(tokN)) { + failure.compareAndSet(null, + "inconsistent triple: id=" + id + " key=" + key + " token=" + tok); + return; + } + } + } finally { + done.countDown(); + } + } + }); + } + + pool.shutdown(); + assertTrue("threads did not finish in time", + done.await(30, TimeUnit.SECONDS)); + assertNull("observed an inconsistent triple: " + failure.get(), failure.get()); + } +} diff --git a/src/test/java/com/tencentcloudapi/common/provider/CvmRoleCredentialTest.java b/src/test/java/com/tencentcloudapi/common/provider/CvmRoleCredentialTest.java new file mode 100644 index 0000000000..3453a9ec6b --- /dev/null +++ b/src/test/java/com/tencentcloudapi/common/provider/CvmRoleCredentialTest.java @@ -0,0 +1,333 @@ +package com.tencentcloudapi.common.provider; + +import com.tencentcloudapi.common.Credential; +import com.tencentcloudapi.common.http.HttpConnection; +import com.tencentcloudapi.cvm.v20170312.CvmClient; +import com.tencentcloudapi.cvm.v20170312.models.DescribeInstancesRequest; +import com.tencentcloudapi.cvm.v20170312.models.DescribeInstancesResponse; +import com.tencentcloudapi.cvm.v20170312.models.Instance; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.net.URLStreamHandlerFactory; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Unit tests for {@link CvmRoleCredential} that do NOT hit the network. + * + *

{@code CvmRoleCredential} fetches ephemeral credentials from the CVM metadata service via + * {@link java.net.HttpURLConnection}, with the metadata URL hardcoded as a {@code static final + * String} constant on the inner {@code UpdaterImpl} class. Because that constant is a compile-time + * literal, it is inlined into the bytecode and cannot be redirected by reflection. Instead, we + * install a one-shot JDK {@link URLStreamHandlerFactory} that intercepts the + * {@code metadata.tencentyun.com} host and serves canned metadata responses from memory. + * + *

For the business-call tests we additionally swap {@link HttpConnection}'s OkHttp singleton + * (the same trick used in {@link STSCredentialTest}) to stub the CVM {@code DescribeInstances} + * HTTP call. + */ +public class CvmRoleCredentialTest { + + private static final String METADATA_HOST = "metadata.tencentyun.com"; + private static final String ROLE_NAME = "mock-cvm-role"; + + /** Whether the installed factory is currently intercepting. Toggled per-test via setUp/tearDown. */ + private static volatile boolean intercepting = false; + + // Static because the URLStreamHandlerFactory is itself static. + private static final AtomicInteger metadataRoleListCalls = new AtomicInteger(0); + private static final AtomicInteger metadataRoleCredCalls = new AtomicInteger(0); + private static final AtomicInteger describeInstancesCalls = new AtomicInteger(0); + + private Object originalOkClient; + private static boolean factoryInstalled = false; + + @Before + public void setUp() throws Exception { + installUrlStreamHandlerFactoryOnce(); + + // Reset per-test counters and enable interception. + metadataRoleListCalls.set(0); + metadataRoleCredCalls.set(0); + describeInstancesCalls.set(0); + intercepting = true; + + // Swap OkHttp clientSingleton so any CVM call is stubbed. + Field f = HttpConnection.class.getDeclaredField("clientSingleton"); + f.setAccessible(true); + originalOkClient = f.get(null); + OkHttpClient mocked = ((OkHttpClient) originalOkClient).newBuilder() + .addInterceptor(new Interceptor() { + @Override + public Response intercept(Chain chain) throws IOException { + Request req = chain.request(); + String action = req.header("X-TC-Action"); + String body; + if ("DescribeInstances".equals(action)) { + describeInstancesCalls.incrementAndGet(); + body = "{\"Response\":{" + + "\"TotalCount\":1," + + "\"InstanceSet\":[{" + + "\"InstanceId\":\"ins-mock-001\"," + + "\"InstanceName\":\"mock-instance\"," + + "\"InstanceType\":\"S5.MEDIUM4\"" + + "}]," + + "\"RequestId\":\"mock-cvm-request-id\"" + + "}}"; + } else { + body = "{\"Response\":{\"RequestId\":\"mock-unknown\",\"Error\":{" + + "\"Code\":\"UnknownAction\",\"Message\":\"unsupported: " + action + "\"}}}"; + } + return new Response.Builder() + .request(req) + .protocol(okhttp3.Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(okhttp3.ResponseBody.create( + okhttp3.MediaType.parse("application/json; charset=utf-8"), + body)) + .build(); + } + }) + .build(); + putStatic(f, mocked); + } + + @After + public void tearDown() throws Exception { + intercepting = false; + + Field f = HttpConnection.class.getDeclaredField("clientSingleton"); + f.setAccessible(true); + putStatic(f, originalOkClient); + } + + /** + * Installs the global {@link URLStreamHandlerFactory} exactly once per JVM. The factory routes + * {@code http://metadata.tencentyun.com/...} to an in-memory handler while + * {@link #intercepting} is true, and falls back to the default handler otherwise. + * + *

{@code URL.setURLStreamHandlerFactory} can only be called once per JVM; we cannot uninstall + * it, but the {@code intercepting} flag keeps the handler inert outside of tests. + */ + private static synchronized void installUrlStreamHandlerFactoryOnce() { + if (factoryInstalled) { + return; + } + URL.setURLStreamHandlerFactory(new URLStreamHandlerFactory() { + @Override + public URLStreamHandler createURLStreamHandler(String protocol) { + if (!"http".equals(protocol)) { + return null; + } + return new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL u) throws IOException { + if (intercepting && METADATA_HOST.equals(u.getHost())) { + return new MetadataURLConnection(u); + } + // Fall back to the default handler so non-metadata URLs still work. + try { + URL copy = new URL(u.toString()); + return copy.openConnection(); + } catch (IOException e) { + throw e; + } + } + + @Override + protected URLConnection openConnection(URL u, Proxy p) throws IOException { + return openConnection(u); + } + + @Override + protected InetAddress getHostAddress(URL u) { + return null; + } + }; + } + }); + factoryInstalled = true; + } + + /** + * Serves canned CVM metadata responses: + *

    + *
  • {@code .../security-credentials/} → role name list (single name).
  • + *
  • {@code .../security-credentials/} → credential JSON.
  • + *
+ * + *

Static because the {@link URLStreamHandlerFactory} that creates it is itself static. + * Extends {@link java.net.HttpURLConnection} because {@code CvmRoleCredential.UpdaterImpl} + * casts the result of {@code URL.openConnection()} to {@code HttpURLConnection}. + */ + private static final class MetadataURLConnection extends java.net.HttpURLConnection { + private byte[] body; + + MetadataURLConnection(URL url) { + super(url); + method = "GET"; + } + + @Override + public void connect() throws IOException { + if (connected) { + return; + } + String path = url.getPath(); + String prefix = "/latest/meta-data/cam/security-credentials/"; + String resp; + if (path.equals(prefix) || path.equals(prefix.substring(0, prefix.length() - 1))) { + metadataRoleListCalls.incrementAndGet(); + resp = ROLE_NAME; + } else if (path.startsWith(prefix)) { + metadataRoleCredCalls.incrementAndGet(); + resp = "{" + + "\"Code\":\"Success\"," + + "\"TmpSecretId\":\"mock-tmp-secret-id\"," + + "\"TmpSecretKey\":\"mock-tmp-secret-key\"," + + "\"Token\":\"mock-token\"," + + "\"ExpiredTime\":2000000000" + + "}"; + } else { + responseCode = 404; + resp = ""; + } + body = resp.getBytes("UTF-8"); + connected = true; + responseCode = 200; + } + + @Override + public InputStream getInputStream() throws IOException { + if (!connected) { + connect(); + } + return new java.io.ByteArrayInputStream(body); + } + + @Override + public void disconnect() { + // no-op + } + + @Override + public boolean usingProxy() { + return false; + } + } + + /** Writes a {@code private static final} field via {@code sun.misc.Unsafe}. */ + private static void putStatic(Field field, Object value) throws Exception { + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Object unsafe = unsafeField.get(null); + + Method objectFieldOffset = unsafeClass.getMethod("staticFieldOffset", Field.class); + Method staticFieldBase = unsafeClass.getMethod("staticFieldBase", Field.class); + Method putObject = unsafeClass.getMethod("putObject", Object.class, long.class, Object.class); + + Object base = staticFieldBase.invoke(unsafe, field); + long offset = (Long) objectFieldOffset.invoke(unsafe, field); + putObject.invoke(unsafe, base, offset, value); + } + + @Test + public void getSnapshotFetchesFromMetadataAndReturnsEphemeralTriple() { + // roleName is null: UpdaterImpl will first call the metadata list endpoint to obtain it. + CvmRoleCredential cred = new CvmRoleCredential(); + + Credential snap = cred.getSnapshot(); + + assertEquals(1, metadataRoleListCalls.get()); + assertEquals(1, metadataRoleCredCalls.get()); + + assertNotNull(snap); + assertEquals("mock-tmp-secret-id", snap.getSecretId()); + assertEquals("mock-tmp-secret-key", snap.getSecretKey()); + assertEquals("mock-token", snap.getToken()); + } + + @Test + public void getSnapshotWithExplicitRoleNameSkipsListCall() { + CvmRoleCredential cred = new CvmRoleCredential(ROLE_NAME); + + Credential snap = cred.getSnapshot(); + + assertEquals(0, metadataRoleListCalls.get()); + assertEquals(1, metadataRoleCredCalls.get()); + + assertEquals("mock-tmp-secret-id", snap.getSecretId()); + assertEquals("mock-tmp-secret-key", snap.getSecretKey()); + assertEquals("mock-token", snap.getToken()); + } + + @Test + public void subsequentSnapshotDoesNotRefreshUntilNearExpiry() { + CvmRoleCredential cred = new CvmRoleCredential(ROLE_NAME); + + Credential snap1 = cred.getSnapshot(); + assertEquals("mock-tmp-secret-id", snap1.getSecretId()); + assertEquals(1, metadataRoleCredCalls.get()); + + Credential snap2 = cred.getSnapshot(); + assertEquals("mock-tmp-secret-id", snap2.getSecretId()); + assertEquals("expected no refresh while credentials are still valid", + 1, metadataRoleCredCalls.get()); + } + + @Test + public void cvmDescribeInstancesSignedWithCvmRoleCredential() throws Exception { + CvmRoleCredential cred = new CvmRoleCredential(ROLE_NAME); + CvmClient cvm = new CvmClient(cred, "ap-guangzhou"); + + DescribeInstancesResponse resp = cvm.DescribeInstances(new DescribeInstancesRequest()); + + assertEquals(1, metadataRoleCredCalls.get()); + assertEquals(1, describeInstancesCalls.get()); + + assertNotNull(resp); + assertEquals(Long.valueOf(1L), resp.getTotalCount()); + Instance[] instances = resp.getInstanceSet(); + assertNotNull(instances); + assertEquals(1, instances.length); + assertEquals("ins-mock-001", instances[0].getInstanceId()); + assertEquals("mock-cvm-request-id", resp.getRequestId()); + } + + @Test + public void repeatedBusinessCallsReuseCachedCvmRoleCredential() throws Exception { + CvmRoleCredential cred = new CvmRoleCredential(ROLE_NAME); + CvmClient cvm = new CvmClient(cred, "ap-guangzhou"); + + DescribeInstancesResponse r1 = cvm.DescribeInstances(new DescribeInstancesRequest()); + assertEquals("ins-mock-001", r1.getInstanceSet()[0].getInstanceId()); + assertEquals(1, metadataRoleCredCalls.get()); + assertEquals(1, describeInstancesCalls.get()); + + DescribeInstancesResponse r2 = cvm.DescribeInstances(new DescribeInstancesRequest()); + assertEquals("ins-mock-001", r2.getInstanceSet()[0].getInstanceId()); + assertEquals("expected CVM metadata fetch to be cached across calls", + 1, metadataRoleCredCalls.get()); + assertEquals(2, describeInstancesCalls.get()); + } +} diff --git a/src/test/java/com/tencentcloudapi/common/provider/OIDCRoleArnProviderTest.java b/src/test/java/com/tencentcloudapi/common/provider/OIDCRoleArnProviderTest.java new file mode 100644 index 0000000000..20cfa6ff6c --- /dev/null +++ b/src/test/java/com/tencentcloudapi/common/provider/OIDCRoleArnProviderTest.java @@ -0,0 +1,207 @@ +package com.tencentcloudapi.common.provider; + +import com.tencentcloudapi.common.Credential; +import com.tencentcloudapi.common.http.HttpConnection; +import com.tencentcloudapi.cvm.v20170312.CvmClient; +import com.tencentcloudapi.cvm.v20170312.models.DescribeInstancesRequest; +import com.tencentcloudapi.cvm.v20170312.models.DescribeInstancesResponse; +import com.tencentcloudapi.cvm.v20170312.models.Instance; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Unit tests for {@link OIDCRoleArnProvider} that do NOT hit the network. + * + *

{@code OIDCRoleArnProvider} refreshes its triple by constructing a {@code CommonClient} and + * calling {@code AssumeRoleWithWebIdentity} via {@link HttpConnection}'s process-wide + * {@code OkHttpClient} singleton. We swap that singleton for one carrying a short-circuiting + * OkHttp {@link Interceptor} that dispatches by the {@code X-TC-Action} header: + *

    + *
  • {@code AssumeRoleWithWebIdentity} → canned ephemeral-credential response
  • + *
  • {@code DescribeInstances} → canned instance list (for the business-call test)
  • + *
+ */ +public class OIDCRoleArnProviderTest { + + private Object originalOkClient; + private final AtomicInteger assumeRoleWithWebIdentityCalls = new AtomicInteger(0); + private final AtomicInteger describeInstancesCalls = new AtomicInteger(0); + + @Before + public void setUp() throws Exception { + Field f = HttpConnection.class.getDeclaredField("clientSingleton"); + f.setAccessible(true); + originalOkClient = f.get(null); + + Interceptor stub = new Interceptor() { + @Override + public Response intercept(Chain chain) throws IOException { + Request req = chain.request(); + String action = req.header("X-TC-Action"); + String body; + if ("AssumeRoleWithWebIdentity".equals(action)) { + assumeRoleWithWebIdentityCalls.incrementAndGet(); + body = "{\"Response\":{" + + "\"Credentials\":{" + + "\"TmpSecretId\":\"mock-tmp-secret-id\"," + + "\"TmpSecretKey\":\"mock-tmp-secret-key\"," + + "\"Token\":\"mock-token\"" + + "}," + + "\"ExpiredTime\":2000000000," + + "\"Expiration\":\"2033-05-18T00:00:00Z\"," + + "\"RequestId\":\"mock-sts-request-id\"" + + "}}"; + } else if ("DescribeInstances".equals(action)) { + describeInstancesCalls.incrementAndGet(); + body = "{\"Response\":{" + + "\"TotalCount\":1," + + "\"InstanceSet\":[{" + + "\"InstanceId\":\"ins-mock-001\"," + + "\"InstanceName\":\"mock-instance\"," + + "\"InstanceType\":\"S5.MEDIUM4\"" + + "}]," + + "\"RequestId\":\"mock-cvm-request-id\"" + + "}}"; + } else { + body = "{\"Response\":{\"RequestId\":\"mock-unknown\",\"Error\":{" + + "\"Code\":\"UnknownAction\",\"Message\":\"unsupported: " + action + "\"}}}"; + } + return new Response.Builder() + .request(req) + .protocol(okhttp3.Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(okhttp3.ResponseBody.create( + okhttp3.MediaType.parse("application/json; charset=utf-8"), + body)) + .build(); + } + }; + + OkHttpClient mocked = ((OkHttpClient) originalOkClient).newBuilder() + .addInterceptor(stub) + .build(); + putStatic(f, mocked); + } + + @After + public void tearDown() throws Exception { + Field f = HttpConnection.class.getDeclaredField("clientSingleton"); + f.setAccessible(true); + putStatic(f, originalOkClient); + } + + /** Writes a {@code private static final} field via {@code sun.misc.Unsafe}. */ + private static void putStatic(Field field, Object value) throws Exception { + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Object unsafe = unsafeField.get(null); + + Method objectFieldOffset = unsafeClass.getMethod("staticFieldOffset", Field.class); + Method staticFieldBase = unsafeClass.getMethod("staticFieldBase", Field.class); + Method putObject = unsafeClass.getMethod("putObject", Object.class, long.class, Object.class); + + Object base = staticFieldBase.invoke(unsafe, field); + long offset = (Long) objectFieldOffset.invoke(unsafe, field); + putObject.invoke(unsafe, base, offset, value); + } + + private OIDCRoleArnProvider newProvider() { + return new OIDCRoleArnProvider( + "ap-guangzhou", + "mock-provider-id", + "mock-web-identity-token", + "qcs::cam::uin/10000:roleName/test-role", + "test-session", + 7200L, + "sts.tencentcloudapi.com"); + } + + @Test + public void getCredentialsFetchesAssumeRoleWithWebIdentityAndReturnsEphemeralTriple() + throws Exception { + OIDCRoleArnProvider provider = newProvider(); + + Credential cred = provider.getCredentials(); + + // expiredTime starts at 0, so the first getCredentials() must trigger one refresh. + assertEquals(1, assumeRoleWithWebIdentityCalls.get()); + + assertNotNull(cred); + assertEquals("mock-tmp-secret-id", cred.getSecretId()); + assertEquals("mock-tmp-secret-key", cred.getSecretKey()); + assertEquals("mock-token", cred.getToken()); + } + + @Test + public void subsequentUpdateDoesNotRefreshUntilNearExpiry() throws Exception { + OIDCRoleArnProvider provider = newProvider(); + + Credential cred = provider.getCredentials(); + assertEquals("mock-tmp-secret-id", cred.getSecretId()); + assertEquals(1, assumeRoleWithWebIdentityCalls.get()); + + // ExpiredTime is far in the future → no second refresh. + provider.update(cred); + assertEquals("expected no refresh while credentials are still valid", + 1, assumeRoleWithWebIdentityCalls.get()); + } + + @Test + public void cvmDescribeInstancesSignedWithOidcRoleArnProvider() throws Exception { + OIDCRoleArnProvider provider = newProvider(); + + // Build CvmClient with the OIDC-provided credential, exactly as a real caller would. + // CvmClient calls credential.getSnapshot() to sign each request — which triggers + // OIDCRoleArnProvider.update() on the first read. + Credential cred = provider.getCredentials(); + CvmClient cvm = new CvmClient(cred, "ap-guangzhou"); + + DescribeInstancesResponse resp = cvm.DescribeInstances(new DescribeInstancesRequest()); + + // The OIDC refresh fired exactly once (cached afterwards), and the CVM call fired once. + assertEquals(1, assumeRoleWithWebIdentityCalls.get()); + assertEquals(1, describeInstancesCalls.get()); + + assertNotNull(resp); + assertEquals(Long.valueOf(1L), resp.getTotalCount()); + Instance[] instances = resp.getInstanceSet(); + assertNotNull(instances); + assertEquals(1, instances.length); + assertEquals("ins-mock-001", instances[0].getInstanceId()); + assertEquals("mock-cvm-request-id", resp.getRequestId()); + } + + @Test + public void repeatedBusinessCallsReuseCachedOidcCredentials() throws Exception { + OIDCRoleArnProvider provider = newProvider(); + Credential cred = provider.getCredentials(); + CvmClient cvm = new CvmClient(cred, "ap-guangzhou"); + + DescribeInstancesResponse r1 = cvm.DescribeInstances(new DescribeInstancesRequest()); + assertEquals("ins-mock-001", r1.getInstanceSet()[0].getInstanceId()); + assertEquals(1, assumeRoleWithWebIdentityCalls.get()); + assertEquals(1, describeInstancesCalls.get()); + + // Second business call: credentials still valid → no second OIDC refresh. + DescribeInstancesResponse r2 = cvm.DescribeInstances(new DescribeInstancesRequest()); + assertEquals("ins-mock-001", r2.getInstanceSet()[0].getInstanceId()); + assertEquals("expected OIDC refresh to be cached across calls", + 1, assumeRoleWithWebIdentityCalls.get()); + assertEquals(2, describeInstancesCalls.get()); + } +} diff --git a/src/test/java/com/tencentcloudapi/common/provider/STSCredentialTest.java b/src/test/java/com/tencentcloudapi/common/provider/STSCredentialTest.java new file mode 100644 index 0000000000..3b24f1fe08 --- /dev/null +++ b/src/test/java/com/tencentcloudapi/common/provider/STSCredentialTest.java @@ -0,0 +1,233 @@ +package com.tencentcloudapi.common.provider; + +import com.tencentcloudapi.common.Credential; +import com.tencentcloudapi.common.http.HttpConnection; +import com.tencentcloudapi.cvm.v20170312.CvmClient; +import com.tencentcloudapi.cvm.v20170312.models.DescribeInstancesRequest; +import com.tencentcloudapi.cvm.v20170312.models.DescribeInstancesResponse; +import com.tencentcloudapi.cvm.v20170312.models.Instance; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Unit tests for {@link STSCredential} that do NOT hit the network. + * + *

{@code STSCredential} refreshes its triple by internally constructing a {@code CommonClient} + * and calling {@code AssumeRole} via {@link HttpConnection}'s process-wide {@code OkHttpClient} + * singleton ({@code clientSingleton}). That field is {@code private static final}, so we use + * {@code sun.misc.Unsafe} (works on Java 8 through 17+) to swap in a client that carries a + * short-circuiting OkHttp {@link Interceptor}. OkHttp's {@code newBuilder()} preserves the + * interceptor list, so the stub also covers any client derived from the swapped singleton inside + * {@code STSCredential.UpdaterImpl} and {@code CvmClient}. + * + *

The interceptor dispatches by the {@code X-TC-Action} header: + *

    + *
  • {@code AssumeRole} → canned ephemeral-credential response
  • + *
  • {@code DescribeInstances} → canned instance list
  • + *
+ */ +public class STSCredentialTest { + + private Object originalClient; + private final AtomicInteger assumeRoleCalls = new AtomicInteger(0); + private final AtomicInteger describeInstancesCalls = new AtomicInteger(0); + + @Before + public void installMockInterceptor() throws Exception { + Field f = HttpConnection.class.getDeclaredField("clientSingleton"); + f.setAccessible(true); + originalClient = f.get(null); + + Interceptor stub = new Interceptor() { + @Override + public Response intercept(Chain chain) throws IOException { + Request req = chain.request(); + String action = req.header("X-TC-Action"); + String body; + if ("AssumeRole".equals(action)) { + assumeRoleCalls.incrementAndGet(); + // Minimal valid AssumeRole response: UpdaterImpl only reads + // Response.Credentials.{TmpSecretId,TmpSecretKey,Token} and Response.ExpiredTime. + body = "{\"Response\":{" + + "\"Credentials\":{" + + "\"TmpSecretId\":\"mock-tmp-secret-id\"," + + "\"TmpSecretKey\":\"mock-tmp-secret-key\"," + + "\"Token\":\"mock-token\"" + + "}," + + "\"ExpiredTime\":2000000000," + + "\"RequestId\":\"mock-sts-request-id\"" + + "}}"; + } else if ("DescribeInstances".equals(action)) { + describeInstancesCalls.incrementAndGet(); + body = "{\"Response\":{" + + "\"TotalCount\":1," + + "\"InstanceSet\":[{" + + "\"InstanceId\":\"ins-mock-001\"," + + "\"InstanceName\":\"mock-instance\"," + + "\"InstanceType\":\"S5.MEDIUM4\"" + + "}]," + + "\"RequestId\":\"mock-cvm-request-id\"" + + "}}"; + } else { + body = "{\"Response\":{\"RequestId\":\"mock-unknown\",\"Error\":{" + + "\"Code\":\"UnknownAction\",\"Message\":\"unsupported action: " + action + "\"}}}"; + } + return new Response.Builder() + .request(req) + .protocol(okhttp3.Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(okhttp3.ResponseBody.create( + okhttp3.MediaType.parse("application/json; charset=utf-8"), + body)) + .build(); + } + }; + + OkHttpClient mocked = ((OkHttpClient) originalClient).newBuilder() + .addInterceptor(stub) + .build(); + putStatic(f, mocked); + } + + @After + public void restoreOriginalClient() throws Exception { + Field f = HttpConnection.class.getDeclaredField("clientSingleton"); + f.setAccessible(true); + putStatic(f, originalClient); + } + + /** + * Writes a value to a {@code private static final} field, working around the {@code final} + * restriction on Java 9+ via {@code sun.misc.Unsafe.putObject} on the field's base+offset. + */ + private static void putStatic(Field field, Object value) throws Exception { + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Object unsafe = unsafeField.get(null); + + Method objectFieldOffset = unsafeClass.getMethod("staticFieldOffset", Field.class); + Method staticFieldBase = unsafeClass.getMethod("staticFieldBase", Field.class); + Method putObject = unsafeClass.getMethod("putObject", Object.class, long.class, Object.class); + + Object base = staticFieldBase.invoke(unsafe, field); + long offset = (Long) objectFieldOffset.invoke(unsafe, field); + putObject.invoke(unsafe, base, offset, value); + } + + @Test + public void getSnapshotTriggersAssumeRoleAndReturnsEphemeralTriple() { + STSCredential cred = new STSCredential( + "permanent-secret-id", + "permanent-secret-key", + "qcs::cam::uin/10000:roleName/test-role", + "test-session", + "sts.tencentcloudapi.com"); + + Credential snap = cred.getSnapshot(); + + // The first getSnapshot() must have triggered exactly one AssumeRole refresh + // (UpdaterImpl.expiredTime starts at 0, so needRefresh() is true on the first call). + assertEquals(1, assumeRoleCalls.get()); + + assertNotNull(snap); + assertEquals("mock-tmp-secret-id", snap.getSecretId()); + assertEquals("mock-tmp-secret-key", snap.getSecretKey()); + assertEquals("mock-token", snap.getToken()); + } + + @Test + public void subsequentSnapshotDoesNotRefreshUntilNearExpiry() { + STSCredential cred = new STSCredential( + "permanent-secret-id", + "permanent-secret-key", + "qcs::cam::uin/10000:roleName/test-role", + "test-session", + "sts.tencentcloudapi.com"); + + // First read triggers a refresh. + Credential snap1 = cred.getSnapshot(); + assertEquals(1, assumeRoleCalls.get()); + assertEquals("mock-tmp-secret-id", snap1.getSecretId()); + + // The canned response sets ExpiredTime far in the future, so the next read should + // reuse the cached triple without another AssumeRole call. + Credential snap2 = cred.getSnapshot(); + assertEquals("expected no refresh while credentials are still valid", + 1, assumeRoleCalls.get()); + assertEquals("mock-tmp-secret-id", snap2.getSecretId()); + } + + @Test + public void cvmDescribeInstancesSignedWithStsCredential() throws Exception { + // 1) Build an STSCredential. On first read it will refresh via AssumeRole. + STSCredential stsCred = new STSCredential( + "permanent-secret-id", + "permanent-secret-key", + "qcs::cam::uin/10000:roleName/test-role", + "test-session", + "sts.tencentcloudapi.com"); + + // 2) Hand it to CvmClient exactly as a real caller would. CvmClient internally calls + // credential.getSnapshot() to sign each request — which triggers the STS refresh. + CvmClient cvm = new CvmClient(stsCred, "ap-guangzhou"); + + DescribeInstancesRequest req = new DescribeInstancesRequest(); + req.setLimit(1L); + + DescribeInstancesResponse resp = cvm.DescribeInstances(req); + + // 3) The STS refresh fired exactly once (cached afterwards), and the CVM call fired once. + assertEquals(1, assumeRoleCalls.get()); + assertEquals(1, describeInstancesCalls.get()); + + // 4) The business response was parsed into the typed model. + assertNotNull(resp); + assertEquals(Long.valueOf(1L), resp.getTotalCount()); + Instance[] instances = resp.getInstanceSet(); + assertNotNull(instances); + assertEquals(1, instances.length); + assertEquals("ins-mock-001", instances[0].getInstanceId()); + assertEquals("mock-instance", instances[0].getInstanceName()); + assertEquals("S5.MEDIUM4", instances[0].getInstanceType()); + assertEquals("mock-cvm-request-id", resp.getRequestId()); + } + + @Test + public void repeatedBusinessCallsReuseCachedStsCredential() throws Exception { + STSCredential stsCred = new STSCredential( + "permanent-secret-id", + "permanent-secret-key", + "qcs::cam::uin/10000:roleName/test-role", + "test-session", + "sts.tencentcloudapi.com"); + CvmClient cvm = new CvmClient(stsCred, "ap-guangzhou"); + + // First business call triggers the initial STS refresh. + DescribeInstancesResponse r1 = cvm.DescribeInstances(new DescribeInstancesRequest()); + assertEquals("ins-mock-001", r1.getInstanceSet()[0].getInstanceId()); + assertEquals(1, assumeRoleCalls.get()); + assertEquals(1, describeInstancesCalls.get()); + + // Second business call: credentials still valid → no second AssumeRole. + DescribeInstancesResponse r2 = cvm.DescribeInstances(new DescribeInstancesRequest()); + assertEquals("ins-mock-001", r2.getInstanceSet()[0].getInstanceId()); + assertEquals("expected STS refresh to be cached across calls", + 1, assumeRoleCalls.get()); + assertEquals(2, describeInstancesCalls.get()); + } +} diff --git a/src/test/java/com/tencentcloudapi/integration/common/provider/ProfileCredentialsProviderTest.java b/src/test/java/com/tencentcloudapi/integration/common/provider/ProfileCredentialsProviderTest.java index 0bb227fd08..3b6a7524a6 100644 --- a/src/test/java/com/tencentcloudapi/integration/common/provider/ProfileCredentialsProviderTest.java +++ b/src/test/java/com/tencentcloudapi/integration/common/provider/ProfileCredentialsProviderTest.java @@ -33,7 +33,7 @@ public void testGetCredentials() throws Exception { // 测试ProfileCredentialsProvider是否能正确读取 ProfileCredentialsProvider provider = new ProfileCredentialsProvider(); - Credential cred = provider.getCredentials(); + Credential cred = provider.getCredentials().getSnapshot(); // 验证读取的凭据是否正确 assertEquals("secret_id_test", cred.getSecretId());