Skip to content

Commit f345baf

Browse files
committed
Enhance robustness of InternalOpenSearchSink tests through scenario-driven coverage, refactoring, and regression prevention:
- Add InternalOpenSearchSinkIntegrationTestConcreteIndex: covers the index creation path with a plain concrete date-based index (no alias) - Extract shared tests into AbstractInternalOpenSearchSinkIntegrationTest using Template Method pattern: testPersistsAuditEventsToTarget and testAuditDocumentContainsMandatoryFields now run against both concrete-index and alias variants without duplication - Refactor InternalOpenSearchSinkIntegrationTestAuditAlias: now extends the abstract base, removing 3 duplicate methods (countAuditDocs, generateAuditEvent, testAuditDocumentsViaAliasContainMandatoryFields); retains only the specialized test testRecognizesAuditTargetAsWriteAlias - Add InternalOpenSearchSinkTest unit tests: covers race condition (ResourceAlreadyExistsException), generic cluster failures, and acknowledged=false scenarios that cannot be tested reliably on single-node embedded cluster - Fix resource leak in testCreatesAuditIndexAutomatically Closes #6145 Signed-off-by: Pietro Paolo Castagna <PietroPaolo.Castagna@gmail.com>
1 parent 6bf3ea4 commit f345baf

4 files changed

Lines changed: 106 additions & 92 deletions

File tree

src/integrationTest/java/org/opensearch/security/auditlog/sink/AbstractInternalOpenSearchSinkIntegrationTest.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,17 @@
3434
* Base class for {@link InternalOpenSearchSink} integration tests.
3535
*
3636
* <p>Holds test methods that are valid regardless of whether the audit target
37-
* is a date-based index or a write alias. Each subclass provides its own
38-
* {@link org.junit.ClassRule}-annotated {@link LocalCluster} and overrides
37+
* is a plain concrete index or a write alias. Each subclass provides its own
38+
* {@code @ClassRule} {@link LocalCluster} and overrides
3939
* {@link #cluster()} and {@link #auditTarget()} to supply the correct
4040
* cluster and query pattern for that configuration variant.</p>
4141
*
4242
* <p>Using a shared base class avoids duplicating {@code testPersistsAuditEventsToTarget}
4343
* and {@code testAuditDocumentContainsMandatoryFields} across the two concrete
4444
* integration-test classes that differ only in cluster setup.</p>
4545
*
46-
* @see InternalOpenSearchSinkIntegrationTest default date-based index variant
47-
* @see InternalOpenSearchSinkIntegrationTestAuditAlias write-alias variant
46+
* @see InternalOpenSearchSinkIntegrationTestConcreteIndex concrete date-based index variant
47+
* @see InternalOpenSearchSinkIntegrationTestAuditAlias write-alias variant
4848
*/
4949
abstract class AbstractInternalOpenSearchSinkIntegrationTest {
5050

@@ -113,7 +113,7 @@ public void testPersistsAuditEventsToTarget() {
113113
generateAuditEvent("_cluster/health");
114114
generateAuditEvent("_cluster/stats");
115115

116-
await().atMost(10, SECONDS).pollInterval(100, MILLISECONDS).untilAsserted(() -> {
116+
await().atMost(10, SECONDS).pollInterval(200, MILLISECONDS).untilAsserted(() -> {
117117
refreshAuditTarget(client);
118118
assertThat("At least 2 new audit events must be persisted",
119119
countAuditDocs(client) - before, greaterThanOrEqualTo(2L));
@@ -130,7 +130,7 @@ public void testPersistsAuditEventsToTarget() {
130130
* <li>{@code audit_category} — event classification (e.g., {@code GRANTED_PRIVILEGES})</li>
131131
* <li>{@code audit_request_layer} — processing layer; expected value: {@code REST}</li>
132132
* <li>{@code audit_request_origin} — origin layer; expected value: {@code REST}</li>
133-
* <li>{@code @timestamp} — ISO-8601 event timestamp</li>
133+
* <li>{@code @timestamp} — event timestamp</li>
134134
* </ul>
135135
*
136136
* <p><b>REST-Specific Fields:</b></p>
@@ -151,7 +151,7 @@ public void testAuditDocumentContainsMandatoryFields() {
151151

152152
generateAuditEvent("_cluster/health");
153153

154-
await().atMost(10, SECONDS).pollInterval(100, MILLISECONDS).untilAsserted(() -> {
154+
await().atMost(10, SECONDS).pollInterval(200, MILLISECONDS).untilAsserted(() -> {
155155
refreshAuditTarget(client);
156156
assertThat("Test must generate at least one new event",
157157
countAuditDocs(client) - before, greaterThan(0L));
@@ -191,4 +191,4 @@ public void testAuditDocumentContainsMandatoryFields() {
191191
doc.containsKey("audit_transport_request_type"), is(false));
192192
}
193193
}
194-
}
194+
}

src/integrationTest/java/org/opensearch/security/auditlog/sink/InternalOpenSearchSinkIntegrationTestAuditAlias.java

Lines changed: 27 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
import static org.hamcrest.CoreMatchers.equalTo;
3030
import static org.hamcrest.CoreMatchers.not;
3131
import static org.hamcrest.MatcherAssert.assertThat;
32-
import static org.hamcrest.Matchers.greaterThan;
3332
import static org.hamcrest.Matchers.is;
3433
import static org.awaitility.Awaitility.await;
3534

@@ -42,8 +41,14 @@
4241
* Tests share a single cluster and use a before/after delta pattern so that
4342
* execution order does not matter.</p>
4443
*
44+
* <p>Shared tests ({@code testPersistsAuditEventsToTarget} and
45+
* {@code testAuditDocumentContainsMandatoryFields}) are inherited from
46+
* {@link AbstractInternalOpenSearchSinkIntegrationTest}.</p>
47+
*
48+
* @see InternalOpenSearchSinkIntegrationTestConcreteIndex for the concrete date-based index variant
49+
* @see InternalOpenSearchSinkTest for unit tests covering exception and race-condition branches
4550
*/
46-
public class InternalOpenSearchSinkIntegrationTestAuditAlias {
51+
public class InternalOpenSearchSinkIntegrationTestAuditAlias extends AbstractInternalOpenSearchSinkIntegrationTest {
4752

4853
private static final String AUDIT_ALIAS = "security-audit-write-alias";
4954
private static final String BACKING_INDEX = "security-audit-backend-000001";
@@ -52,27 +57,21 @@ public class InternalOpenSearchSinkIntegrationTestAuditAlias {
5257
static final TestAlias auditAlias = new TestAlias(AUDIT_ALIAS).on(backingIndex).writeIndex(backingIndex);
5358

5459
@ClassRule
55-
public static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE)
60+
public static final LocalCluster CLUSTER = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE)
5661
.nodeSettings(Map.of("plugins.security.audit.config.index", AUDIT_ALIAS))
5762
.internalAudit(new AuditConfiguration(true).filters(new AuditFilters().enabledRest(true).enabledTransport(false)))
5863
.indices(backingIndex)
5964
.aliases(auditAlias)
6065
.build();
6166

62-
/** Counts all audit documents reachable through the write alias. */
63-
private long countAuditDocs(TestRestClient client) {
64-
HttpResponse response = client.postJson(AUDIT_ALIAS + "/_search", """
65-
{"query": {"match_all": {}}, "size": 0}
66-
""");
67-
response.assertStatusCode(200);
68-
return response.getLongFromJsonBody("/hits/total/value");
67+
@Override
68+
LocalCluster cluster() {
69+
return CLUSTER;
6970
}
7071

71-
/** Issues an authenticated REST GET that triggers an {@code AUTHENTICATED} audit event. */
72-
private void generateAuditEvent(String path) {
73-
try (TestRestClient restClient = cluster.getRestClient(cluster.getAdminCertificate())) {
74-
restClient.get(path);
75-
}
72+
@Override
73+
String auditTarget() {
74+
return AUDIT_ALIAS;
7675
}
7776

7877
/**
@@ -81,13 +80,23 @@ private void generateAuditEvent(String path) {
8180
*
8281
* <p>Generates one event, then checks that the alias still resolves to the
8382
* backing index and no spurious concrete index was created.</p>
83+
*
84+
* <p><b>Tested Code Path:</b> {@code metadata.hasAlias(indexName)} returns
85+
* {@code true}, so the method logs a debug message and returns {@code true}
86+
* immediately without attempting index creation.</p>
8487
*/
8588
@Test
8689
public void testRecognizesAuditTargetAsWriteAlias() {
87-
try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) {
90+
try (TestRestClient client = CLUSTER.getRestClient(CLUSTER.getAdminCertificate())) {
8891
generateAuditEvent("_cluster/health");
8992

90-
await().until(() -> countAuditDocs(client) > 0);
93+
await().until(() -> {
94+
HttpResponse countResponse = client.postJson(AUDIT_ALIAS + "/_search", """
95+
{"query": {"match_all": {}}, "size": 0}
96+
""");
97+
countResponse.assertStatusCode(200);
98+
return countResponse.getLongFromJsonBody("/hits/total/value") > 0;
99+
});
91100

92101
HttpResponse aliasResponse = client.get("_alias/" + AUDIT_ALIAS);
93102
aliasResponse.assertStatusCode(200);
@@ -106,55 +115,4 @@ public void testRecognizesAuditTargetAsWriteAlias() {
106115
assertThat("Backing index must exist physically", indexExistsResponse.getStatusCode(), is(200));
107116
}
108117
}
109-
110-
/**
111-
* The alias branch is invoked on every {@code doStore} call.
112-
* Generates three distinct events and asserts all are persisted, confirming
113-
* that repeated writes through the alias succeed.
114-
*/
115-
@Test
116-
public void testWritesEventsToAliasSuccessfully() {
117-
try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) {
118-
long before = countAuditDocs(client);
119-
120-
generateAuditEvent("_cluster/health");
121-
generateAuditEvent("_cluster/stats");
122-
generateAuditEvent("_nodes/info");
123-
124-
await().untilAsserted(
125-
() -> assertThat("At least 3 events must be written through alias", countAuditDocs(client) - before, greaterThan(2L))
126-
);
127-
}
128-
}
129-
130-
/**
131-
* Documents written via the alias must contain the same mandatory audit fields
132-
* as those written to a concrete index (category, timestamp, REST method/path,
133-
* layer and origin). Transport-specific fields must be absent since transport
134-
* audit is disabled.
135-
*/
136-
@Test
137-
public void testAuditDocumentsViaAliasContainMandatoryFields() {
138-
try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) {
139-
long before = countAuditDocs(client);
140-
generateAuditEvent("_cluster/health");
141-
142-
await().until(() -> countAuditDocs(client) > before);
143-
144-
HttpResponse response = client.postJson(AUDIT_ALIAS + "/_search", """
145-
{"query": {"match_all": {}}, "size": 1, "sort": [{"@timestamp": "desc"}]}
146-
""");
147-
response.assertStatusCode(200);
148-
149-
JsonNode source = response.bodyAsJsonNode().get("hits").get("hits").get(0).get("_source");
150-
151-
assertThat(source.has("audit_category"), is(true));
152-
assertThat(source.has("@timestamp"), is(true));
153-
assertThat(source.has("audit_rest_request_method"), is(true));
154-
assertThat(source.has("audit_rest_request_path"), is(true));
155-
assertThat(source.get("audit_request_layer").asText(), is("REST"));
156-
assertThat(source.get("audit_request_origin").asText(), is("REST"));
157-
assertThat(source.has("audit_transport_request_type"), is(false));
158-
}
159-
}
160-
}
118+
}

src/integrationTest/java/org/opensearch/security/auditlog/sink/InternalOpenSearchSinkIntegrationTest.java renamed to src/integrationTest/java/org/opensearch/security/auditlog/sink/InternalOpenSearchSinkIntegrationTestConcreteIndex.java

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,19 @@
3030
import static org.hamcrest.Matchers.*;
3131

3232
/**
33-
* Integration tests for {@link InternalOpenSearchSink} with default date-based index naming.
33+
* Integration tests for {@link InternalOpenSearchSink} with a plain concrete index
34+
* (no alias configured).
3435
*
35-
* <p>Tests the regular index creation path (no alias configured). The default pattern
36-
* {@code 'security-auditlog-'YYYY.MM.dd} creates daily indices
37-
* (e.g., {@code security-auditlog-2025.01.11}).</p>
36+
* <p>Exercises the regular index creation path: the default pattern
37+
* {@code 'security-auditlog-'YYYY.MM.dd} produces daily indices
38+
* (e.g., {@code security-auditlog-2025.01.11}). No pre-existing index or alias is
39+
* present when the cluster starts, so the sink must create the index on first write.</p>
3840
*
3941
* <h5>Tested Code Paths in {@code createIndexIfAbsent()}:</h5>
4042
* <ul>
4143
* <li>First event: both {@code metadata.hasAlias()} and {@code metadata.hasIndex()} return
42-
* {@code false}, triggering a {@code CreateIndexRequest} with the date-based index name.</li>
44+
* {@code false}, triggering a {@code CreateIndexRequest} with the date-based index name
45+
* ({@link #testCreatesAuditIndexAutomatically()}).</li>
4346
* <li>Subsequent events: {@code metadata.hasIndex()} returns {@code true}; covered by
4447
* the inherited {@link AbstractInternalOpenSearchSinkIntegrationTest#testPersistsAuditEventsToTarget()}.</li>
4548
* </ul>
@@ -49,9 +52,9 @@
4952
* {@link AbstractInternalOpenSearchSinkIntegrationTest}.</p>
5053
*
5154
* @see InternalOpenSearchSinkIntegrationTestAuditAlias for the write-alias variant
52-
* @see InternalOpenSearchSinkTest for unit tests covering exception branches
55+
* @see InternalOpenSearchSinkTest for unit tests covering exception and race-condition branches
5356
*/
54-
public class InternalOpenSearchSinkIntegrationTest extends AbstractInternalOpenSearchSinkIntegrationTest {
57+
public class InternalOpenSearchSinkIntegrationTestConcreteIndex extends AbstractInternalOpenSearchSinkIntegrationTest {
5558

5659
private static final String AUDIT_INDEX_PREFIX = "security-auditlog-";
5760

@@ -90,14 +93,14 @@ public void testCreatesAuditIndexAutomatically() {
9093

9194
generateAuditEvent("_cluster/health");
9295

93-
await().atMost(10, SECONDS).pollInterval(100, MILLISECONDS).untilAsserted(() -> {
96+
await().atMost(10, SECONDS).pollInterval(200, MILLISECONDS).untilAsserted(() -> {
9497
refreshAuditTarget(client);
9598
assertThat("At least one new audit event must be generated",
9699
countAuditDocs(client), greaterThan(before));
97100
});
98101

99-
await().atMost(10, SECONDS).pollInterval(100, MILLISECONDS).untilAsserted(() -> {
100-
GetIndexResponse response = CLUSTER.getInternalNodeClient()
102+
await().atMost(10, SECONDS).pollInterval(200, MILLISECONDS).untilAsserted(() -> {
103+
GetIndexResponse response = client
101104
.admin()
102105
.indices()
103106
.getIndex(new GetIndexRequest().indices(AUDIT_INDEX_PREFIX + "*"))
@@ -112,4 +115,4 @@ public void testCreatesAuditIndexAutomatically() {
112115
});
113116
}
114117
}
115-
}
118+
}

src/test/java/org/opensearch/security/auditlog/sink/InternalOpenSearchSinkTest.java

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616

1717
import org.opensearch.ResourceAlreadyExistsException;
1818
import org.opensearch.action.admin.indices.create.CreateIndexRequest;
19+
import org.opensearch.action.admin.indices.create.CreateIndexResponse;
1920
import org.opensearch.cluster.ClusterState;
2021
import org.opensearch.cluster.metadata.Metadata;
2122
import org.opensearch.cluster.service.ClusterService;
23+
import org.opensearch.common.action.ActionFuture;
2224
import org.opensearch.common.settings.Settings;
2325
import org.opensearch.threadpool.ThreadPool;
2426
import org.opensearch.transport.client.AdminClient;
@@ -31,27 +33,32 @@
3133
import static org.hamcrest.MatcherAssert.assertThat;
3234
import static org.hamcrest.Matchers.is;
3335
import static org.mockito.ArgumentMatchers.any;
36+
import static org.mockito.Mockito.doReturn;
3437
import static org.mockito.Mockito.verify;
3538
import static org.mockito.Mockito.when;
3639

3740
/**
3841
* Unit tests for {@link InternalOpenSearchSink#createIndexIfAbsent(String)}.
3942
*
40-
* <p>Covers the two exception branches that cannot be reliably triggered in an
41-
* integration test against a single-node embedded cluster:</p>
43+
* <p>Covers three branches that cannot be reliably triggered in an integration
44+
* test against a single-node embedded cluster:</p>
4245
* <ul>
4346
* <li>{@code ResourceAlreadyExistsException} — thrown when two nodes race to
4447
* create the same index simultaneously. The sink must treat this as success
4548
* and return {@code true}.</li>
4649
* <li>Generic {@code Exception} — thrown on unexpected cluster failures (e.g.,
4750
* permission denied, cluster not available). The sink must absorb the error
4851
* and return {@code false} without propagating the exception.</li>
52+
* <li>{@code acknowledged=false} — the cluster manager accepted the request but
53+
* timed out waiting for all shard copies to confirm. The index likely exists
54+
* on the primary node but may not yet be visible cluster-wide. The sink must
55+
* return {@code false} so the current audit event is skipped gracefully.</li>
4956
* </ul>
5057
*
5158
* <p>The remaining branches ({@code hasAlias()}, {@code hasIndex()}, and
5259
* successful creation) are covered by the integration tests:</p>
5360
* <ul>
54-
* <li>{@link InternalOpenSearchSinkIntegrationTest}</li>
61+
* <li>{@link InternalOpenSearchSinkIntegrationTestConcreteIndex}</li>
5562
* <li>{@link InternalOpenSearchSinkIntegrationTestAuditAlias}</li>
5663
* </ul>
5764
*/
@@ -67,6 +74,7 @@ public class InternalOpenSearchSinkTest {
6774
@Mock private AdminClient adminClient;
6875
@Mock private IndicesAdminClient indicesAdminClient;
6976
@Mock private ThreadPool threadPool;
77+
@Mock private ActionFuture<CreateIndexResponse> createIndexFuture;
7078

7179
private InternalOpenSearchSink sink;
7280

@@ -148,4 +156,49 @@ public void createIndexIfAbsent_returnsFalse_onUnexpectedException() {
148156
result, is(false));
149157
verify(indicesAdminClient).create(any(CreateIndexRequest.class));
150158
}
151-
}
159+
160+
/**
161+
* Verifies that {@code createIndexIfAbsent()} returns {@code false} when the
162+
* cluster manager acknowledges the request but the response is not acknowledged.
163+
*
164+
* <p><b>Scenario:</b> The {@code CreateIndexRequest} completes without throwing,
165+
* but {@link CreateIndexResponse#isAcknowledged()} returns {@code false}. This
166+
* can happen when the cluster manager times out waiting for all shard copies to
167+
* confirm the mapping update. The index likely exists on the cluster manager node
168+
* but may not yet be visible to other nodes. The sink logs an error and returns
169+
* {@code false}, causing the current audit event to be dropped silently. The
170+
* next event will find the index present and succeed normally.</p>
171+
*
172+
* <p><b>Tested Code Path:</b></p>
173+
* <pre>{@code
174+
* final boolean acknowledged = clientProvider.admin().indices()
175+
* .create(createIndexRequest).actionGet().isAcknowledged();
176+
* if (acknowledged) { ... } else {
177+
* log.error("Failed to create audit log index '{}'. Index creation was not acknowledged.", indexName);
178+
* }
179+
* return acknowledged; // returns false
180+
* }</pre>
181+
*
182+
* <p><b>Implementation note:</b> {@code CreateIndexResponse} is instantiated directly
183+
* (not mocked) because {@link org.opensearch.action.support.clustermanager.AcknowledgedResponse#isAcknowledged()}
184+
* is declared {@code final} and cannot be stubbed by Mockito. {@code doReturn().when()}
185+
* is used for {@code actionGet()} to avoid calling the generic method on the mock during
186+
* stub setup (type erasure would confuse the standard {@code when().thenReturn()} form).</p>
187+
*/
188+
@Test
189+
public void createIndexIfAbsent_returnsFalse_whenCreationNotAcknowledged() {
190+
// CreateIndexResponse is instantiated directly because isAcknowledged() is final
191+
// in AcknowledgedResponse and cannot be stubbed by Mockito.
192+
// doReturn().when() is used instead of when().thenReturn() to avoid calling the
193+
// generic actionGet() on the mock during stub setup (prevents type-erasure issues).
194+
CreateIndexResponse notAcknowledged = new CreateIndexResponse(false, false, TEST_INDEX);
195+
doReturn(notAcknowledged).when(createIndexFuture).actionGet();
196+
when(indicesAdminClient.create(any(CreateIndexRequest.class))).thenReturn(createIndexFuture);
197+
198+
boolean result = sink.createIndexIfAbsent(TEST_INDEX);
199+
200+
assertThat("Must return false when index creation is not acknowledged by the cluster",
201+
result, is(false));
202+
verify(indicesAdminClient).create(any(CreateIndexRequest.class));
203+
}
204+
}

0 commit comments

Comments
 (0)