davsclaus commented on code in PR #26677: URL: https://github.com/apache/camel/pull/26677#discussion_r4061811163
########## components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmIT.java: ########## @@ -0,0 +1,243 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.opa; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.apache.camel.Exchange; +import org.apache.camel.test.infra.opa.services.OpaWasmBundleBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * In-process evaluation of a bundle compiled from the very {@code authz.rego} that {@link OpaIT} uploads to a real OPA + * server. + * <p/> + * Sharing one policy between the two classes is the point rather than a convenience: the component promises that a + * route sees the same decision whichever engine evaluated it, and that promise is only tested if both engines are asked + * about the same rules. It also closes the way that promise was broken before - a compiled bundle committed beside the + * Rego drifted from it, and the two suites asserted opposite things about {@code authz/decision} while both stayed + * green (CAMEL-24741). Nothing is committed now; the bundle is built from the policy under test. + */ +public class OpaWasmIT extends CamelTestSupport { + + @TempDir + static Path bundles; + + private static String authz; + private static String module; + private static String roles; + + private static String resource(String name) throws Exception { + try (InputStream in = OpaWasmIT.class.getResourceAsStream(name)) { + if (in == null) { + throw new IllegalStateException("Test resource not found on the classpath: " + name); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + @BeforeAll + static void compileBundles() throws Exception { Review Comment: Every bundle compiled here is a `bundle.tar.gz`, so the bare `.wasm` branch of `OpaWasmEvaluator.loadPolicy` (what `classpath:authz.wasm` used to cover) is no longer exercised by any test. Since the tarball is already in hand, one test could untar `policy.wasm` from `authzBundle` here and load it via `file:.../authz.wasm` so both accepted formats stay covered. ########## test-infra/camel-test-infra-opa/src/test/java/org/apache/camel/test/infra/opa/OpaWasmBundleBuilderTest.java: ########## @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.test.infra.opa; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.camel.test.infra.opa.services.OpaWasmBundleBuilder; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisabledIfSystemProperty(named = "skipITs", matches = "true") +public class OpaWasmBundleBuilderTest { Review Comment: This test needs Docker but runs under surefire during a plain `mvn install` of `camel-test-infra-opa`. The `skipITs` guard only fires when someone explicitly passes `-DskipITs`, so a developer without Docker gets a failing build rather than a skipped test. Convention in this tree is to make such tests `*IT` under failsafe - see `camel-test-infra-jaeger` (`JaegerServiceIT` plus the failsafe execution in its pom). Suggest renaming to `OpaWasmBundleBuilderIT` and adding the same failsafe config to `camel-test-infra-opa/pom.xml`. Also a nit on line 105: `assertEquals(true, x)` reads better as `assertTrue(x, message)`. ########## components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmIT.java: ########## @@ -0,0 +1,243 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.opa; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.apache.camel.Exchange; +import org.apache.camel.test.infra.opa.services.OpaWasmBundleBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * In-process evaluation of a bundle compiled from the very {@code authz.rego} that {@link OpaIT} uploads to a real OPA + * server. + * <p/> + * Sharing one policy between the two classes is the point rather than a convenience: the component promises that a + * route sees the same decision whichever engine evaluated it, and that promise is only tested if both engines are asked + * about the same rules. It also closes the way that promise was broken before - a compiled bundle committed beside the + * Rego drifted from it, and the two suites asserted opposite things about {@code authz/decision} while both stayed + * green (CAMEL-24741). Nothing is committed now; the bundle is built from the policy under test. + */ +public class OpaWasmIT extends CamelTestSupport { + + @TempDir + static Path bundles; + + private static String authz; + private static String module; + private static String roles; + + private static String resource(String name) throws Exception { + try (InputStream in = OpaWasmIT.class.getResourceAsStream(name)) { + if (in == null) { + throw new IllegalStateException("Test resource not found on the classpath: " + name); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + @BeforeAll + static void compileBundles() throws Exception { + byte[] authzBundle = OpaWasmBundleBuilder.build( + "authz.rego", resource("/authz.rego"), + "authz/allow", "authz/decision", "authz/strict_allow"); + authz = write("authz-bundle.tar.gz", authzBundle); + module = extractModule(authzBundle); + + // roles.rego decides from data.admins, which opa build packs beside it as data.json + byte[] rolesBundle = OpaWasmBundleBuilder.build( + Map.of("roles.rego", resource("/wasm-data/roles.rego").getBytes(StandardCharsets.UTF_8), + "data.json", resource("/wasm-data/data.json").getBytes(StandardCharsets.UTF_8)), + "roles/allow"); + roles = write("roles-bundle.tar.gz", rolesBundle); + } + + /** The /policy.wasm inside a bundle, so the bare-module branch of loadPolicy keeps its coverage. */ + private static String extractModule(byte[] bundle) throws Exception { + try (TarArchiveInputStream tar + = new TarArchiveInputStream(new GzipCompressorInputStream(new ByteArrayInputStream(bundle)))) { + TarArchiveEntry entry; + while ((entry = tar.getNextEntry()) != null) { + if (!entry.isDirectory() && entry.getName().endsWith("policy.wasm")) { + return write("authz.wasm", tar.readAllBytes()); + } + } + } + throw new IllegalStateException("opa build emitted no policy.wasm"); + } + + private static String write(String name, byte[] bundle) throws Exception { + Path path = bundles.resolve(name); + Files.write(path, bundle); + return "file:" + path.toAbsolutePath(); + } + + private String wasm(String policyPath) { + return "opa:" + policyPath + "?evaluationMode=wasm&policyBundle=" + authz; + } + + @Test + void allowsWhenThePolicyMatches() { + Exchange out = template.request(wasm("authz/allow"), e -> e.getMessage().setHeader("user", "alice")); + + assertThat(out.getException()).isNull(); + assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true); + } + + @Test + void acceptsABareModuleAsWellAsTheBundleTarball() { + // every other test here loads the tarball opa build emits, so without this the other half of loadPolicy - + // a bare .wasm, which is what an operator extracting the module by hand would have - goes untested + Exchange out = template.request( + "opa:authz/allow?evaluationMode=wasm&policyBundle=" + module, + e -> e.getMessage().setHeader("user", "alice")); + + assertThat(out.getException()).isNull(); + assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true); + } + + @Test + void deniesWhenThePolicyDoesNotMatch() { + Exchange out = template.request(wasm("authz/allow"), e -> e.getMessage().setHeader("user", "mallory")); + + assertThat(out.getException()).isNull(); + assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(false); + } + + @Test + void readsAVerdictOutOfADecisionObject() { + Exchange out = template.request(wasm("authz/decision"), e -> e.getMessage().setHeader("user", "alice")); + + assertThat(out.getException()).isNull(); + assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true); + assertThat(out.getMessage().getHeader(OpaConstants.DECISION, Map.class)).containsEntry("allow", true); + } + + @Test + void keepsTheDenyReasonsJustLikeTheRestEngine() { + // OpaIT.keepsTheDenyReasonsFromADecisionObject asserts exactly this against the server + Exchange out = template.request(wasm("authz/decision"), e -> e.getMessage().setHeader("user", "mallory")); + + assertThat(out.getException()).isNull(); + assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(false); + assertThat(out.getMessage().getHeader(OpaConstants.DECISION, Map.class)) + .containsEntry("reasons", List.of("not the owner")); + } + + @Test + void failsClosedOnAnUndefinedDecisionJustLikeTheRestEngine() { + // authz/strict_allow has no default, so it is undefined for mallory. The WASM ABI reports that as an empty + // result array where the REST client raises an error; OpaIT.failsClosedOnAnUndefinedDecision is the twin + Exchange out = template.request(wasm("authz/strict_allow"), e -> e.getMessage().setHeader("user", "mallory")); + + assertThat(out.getException()).isInstanceOf(OpaPolicyEvaluationException.class); + assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isNull(); + } + + @Test + void keepsTheEntrypointAcrossPooledReuse() { + // returning a borrowed instance resets it, putting the entrypoint back to 0 - so an instance configured + // only where it was built answers the first exchange from authz/decision and every later one from + // whatever rule is entrypoint 0. A single-instance pool and several messages is what shows it + String decision = wasm("authz/decision") + "&poolSize=1"; + + for (int i = 0; i < 5; i++) { + Exchange out = template.request(decision, e -> e.getMessage().setHeader("user", "alice")); + + assertThat(out.getException()).as("exchange %d", i).isNull(); + assertThat(out.getMessage().getHeader(OpaConstants.DECISION)) + .as("message %d was still decided by authz/decision", i) + .isInstanceOf(Map.class); + // the type alone would pass for any rule returning an object; the content is what pins the entrypoint + assertThat(out.getMessage().getHeader(OpaConstants.DECISION, Map.class)).containsEntry("allow", true); + } + } + + @Test + void appliesTheDataDocumentPackedInTheBundle() { + String policy = "opa:roles/allow?evaluationMode=wasm&policyBundle=" + roles + "&poolSize=1"; + + for (int i = 0; i < 3; i++) { + Exchange allowed = template.request(policy, e -> e.getMessage().setHeader("user", "carol")); + Exchange denied = template.request(policy, e -> e.getMessage().setHeader("user", "alice")); + + assertThat(allowed.getException()).as("exchange %d", i).isNull(); + assertThat(allowed.getMessage().getHeader(OpaConstants.DECISION_ALLOW)) + .as("data.admins was still visible on message %d", i) + .isEqualTo(true); + assertThat(denied.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(false); + } + } + + @Test + @Timeout(60) + void keepsThePoolUsableAfterRepeatedEvaluationFailures() { Review Comment: The `@Timeout(60)` that guarded this test is gone. Its whole point is to detect a wedged single-instance pool - without the timeout a regression hangs the build until surefire's own fork timeout instead of failing this test. Was dropping it intentional? -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
