Skip to content

Commit

Permalink
Support App diagnostics endpoint features (#76)
Browse files Browse the repository at this point in the history
* Implement `isInngestEventKeySet` helper function

* Implement `hasSigningKey` helper function

I also added junit-pioneer as a test dependency to be able to
set environment variables in certain tests.

I followed this guide for now but we can look into mocking the
environment ourselves later on:

https://www.baeldung.com/java-unit-testing-environment-variables#setting-environment-variables-with-junit-pioneer

* Add the Introspection classes

I also left a few TODOs for unsupported fields:
- capabilities
- signing key fallback

The `extra` field was also left out, the spec says it's optional and I
think only the JS SDK uses it:
https://github.com/inngest/inngest/blob/main/docs/SDK_SPEC.md#45-introspection-requests

* Repurpose `introspect` to return the correct introspection payload

* Adapt both adapters according to the new `introspect` signature

* Add tests for cloud mode introspection

It's a pain to mock the environment variables in the tests. I introduced
a different `system-stubs-jupiter` library to help with that because
`org.junitpioneer.jupiter` did not work as expected in a Spring Boot
environment.

Ideally I think we should have a mockable custom `Environment` interface
that we use throughout the application, instead of reaching for
`System.getenv()` directly.

The method for mocking that worked is described in this guide:
https://www.baeldung.com/java-system-stubs#environment-and-property-overrides-for-junit-5-spring-tests

* Use the same signing key that was used by other tests

* Change order of introspection fields to alphabetical order

* Address PR feedback
  • Loading branch information
KiKoS0 authored Sep 13, 2024
1 parent 5faeb64 commit e2e6151
Show file tree
Hide file tree
Showing 13 changed files with 325 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

Expand All @@ -27,22 +28,15 @@ private HttpHeaders getHeaders() {
@Value("${inngest.serveOrigin:}")
private String serveOrigin;

@GetMapping()
@GetMapping
public ResponseEntity<String> index(
@RequestHeader(HttpHeaders.HOST) String hostHeader,
HttpServletRequest request
@RequestHeader(name = "X-Inngest-Signature", required = false) String signature,
@RequestHeader(name = "X-Inngest-Server-Kind", required = false) String serverKind
) {
if (commHandler.getClient().getEnv() != InngestEnv.Dev) {
// TODO: Return an UnauthenticatedIntrospection instead when app diagnostics are implemented
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Introspect endpoint is only available in development mode");
}
String origin = String.format("%s://%s", request.getScheme(), hostHeader);
if (this.serveOrigin != null && !this.serveOrigin.isEmpty()) {
origin = this.serveOrigin;
}
String response = commHandler.introspect(origin);
return ResponseEntity.ok().headers(getHeaders()).body(response);
String requestBody = "";
String response = commHandler.introspect(signature, requestBody, serverKind);
return ResponseEntity.ok().headers(getHeaders()).contentType(MediaType.APPLICATION_JSON).body(response);
}

@PutMapping()
Expand Down
11 changes: 11 additions & 0 deletions inngest-spring-boot-demo/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")

testImplementation("org.springframework.boot:spring-boot-starter-test")

if (JavaVersion.current().isJava11Compatible) {
testImplementation("uk.org.webcompere:system-stubs-jupiter:2.1.6")
} else {
testImplementation("uk.org.webcompere:system-stubs-jupiter:1.2.1")
}
}

dependencyManagement {
Expand All @@ -39,6 +45,11 @@ dependencyManagement {
tasks.withType<Test> {
useJUnitPlatform()
systemProperty("junit.jupiter.execution.parallel.enabled", true)
systemProperty("test-group", "unit-test")

// Required by `system-stubs-jupiter` for JDK 21+ compatibility
// https://github.com/raphw/byte-buddy/issues/1396
jvmArgs = listOf("-Dnet.bytebuddy.experimental=true")
testLogging {
events =
setOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package com.inngest.springbootdemo;

import com.inngest.*;
import com.inngest.signingkey.BearerTokenKt;
import com.inngest.signingkey.SignatureVerificationKt;
import com.inngest.springboot.InngestConfiguration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import uk.org.webcompere.systemstubs.environment.EnvironmentVariables;
import uk.org.webcompere.systemstubs.jupiter.SystemStub;
import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension;

import java.util.HashMap;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

class ProductionConfiguration extends InngestConfiguration {

public static final String INNGEST_APP_ID = "spring_test_prod_demo";

@Override
protected HashMap<String, InngestFunction> functions() {
return new HashMap<>();
}

@Override
protected Inngest inngestClient() {
return new Inngest(INNGEST_APP_ID);
}

@Override
protected ServeConfig serve(Inngest client) {
return new ServeConfig(client);
}

@Bean
protected CommHandler commHandler(@Autowired Inngest inngestClient) {
ServeConfig serveConfig = new ServeConfig(inngestClient);
return new CommHandler(functions(), inngestClient, serveConfig, SupportedFrameworkName.SpringBoot);
}
}

@ExtendWith(SystemStubsExtension.class)
public class CloudModeIntrospectionTest {

private static final String productionSigningKey = "signkey-prod-b2ed992186a5cb19f6668aade821f502c1d00970dfd0e35128d51bac4649916c";
private static final String productionEventKey = "test";
@SystemStub
private static EnvironmentVariables environmentVariables;

@BeforeAll
static void beforeAll() {
environmentVariables.set("INNGEST_DEV", "0");
environmentVariables.set("INNGEST_SIGNING_KEY", productionSigningKey);
environmentVariables.set("INNGEST_EVENT_KEY", productionEventKey);
}

// The nested class is useful for setting the environment variables before the configuration class (Beans) runs.
// https://www.baeldung.com/java-system-stubs#environment-and-property-overrides-for-junit-5-spring-tests
@Import(ProductionConfiguration.class)
@WebMvcTest(DemoController.class)
@Nested
@EnabledIfSystemProperty(named = "test-group", matches = "unit-test")
class InnerSpringTest {
@Autowired
private MockMvc mockMvc;

@Test
public void shouldReturnInsecureIntrospectionWhenSignatureIsMissing() throws Exception {
mockMvc.perform(get("/api/inngest").header("Host", "localhost:8080"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"))
.andExpect(header().string(InngestHeaderKey.Framework.getValue(), "springboot"))
.andExpect(jsonPath("$.authentication_succeeded").value(false))
.andExpect(jsonPath("$.function_count").isNumber())
.andExpect(jsonPath("$.has_event_key").value(true))
.andExpect(jsonPath("$.has_signing_key").value(true))
.andExpect(jsonPath("$.mode").value("cloud"))
.andExpect(jsonPath("$.schema_version").value("2024-05-24"));
}

@Test
public void shouldReturnInsecureIntrospectionWhenSignatureIsInvalid() throws Exception {
mockMvc.perform(get("/api/inngest")
.header("Host", "localhost:8080")
.header(InngestHeaderKey.Signature.getValue(), "invalid-signature"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"))
.andExpect(header().string(InngestHeaderKey.Framework.getValue(), "springboot"))
.andExpect(jsonPath("$.authentication_succeeded").value(false))
.andExpect(jsonPath("$.function_count").isNumber())
.andExpect(jsonPath("$.has_event_key").value(true))
.andExpect(jsonPath("$.has_signing_key").value(true))
.andExpect(jsonPath("$.mode").value("cloud"))
.andExpect(jsonPath("$.schema_version").value("2024-05-24"));
}

@Test
public void shouldReturnSecureIntrospectionWhenSignatureIsValid() throws Exception {
long currentTimestamp = System.currentTimeMillis() / 1000;

String signature = SignatureVerificationKt.signRequest("", currentTimestamp, productionSigningKey);
String formattedSignature = String.format("s=%s&t=%d", signature, currentTimestamp);

String expectedSigningKeyHash = BearerTokenKt.hashedSigningKey(productionSigningKey);

mockMvc.perform(get("/api/inngest")
.header("Host", "localhost:8080")
.header(InngestHeaderKey.Signature.getValue(), formattedSignature))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"))
.andExpect(header().string(InngestHeaderKey.Framework.getValue(), "springboot"))
.andExpect(jsonPath("$.authentication_succeeded").value(true))
.andExpect(jsonPath("$.function_count").isNumber())
.andExpect(jsonPath("$.has_event_key").value(true))
.andExpect(jsonPath("$.has_signing_key").value(true))
.andExpect(jsonPath("$.mode").value("cloud"))
.andExpect(jsonPath("$.schema_version").value("2024-05-24"))
.andExpect(jsonPath("$.api_origin").value("https://api.inngest.com/"))
.andExpect(jsonPath("$.app_id").value(ProductionConfiguration.INNGEST_APP_ID))
.andExpect(jsonPath("$.env").value("prod"))
.andExpect(jsonPath("$.event_api_origin").value("https://inn.gs/"))
.andExpect(jsonPath("$.framework").value("springboot"))
.andExpect(jsonPath("$.sdk_language").value("java"))
.andExpect(jsonPath("$.event_key_hash").value("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"))
.andExpect(jsonPath("$.sdk_version").value(Version.Companion.getVersion()))
.andExpect(jsonPath("$.signing_key_hash").value(expectedSigningKeyHash));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.inngest.InngestHeaderKey;
import com.inngest.Version;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
Expand All @@ -13,21 +14,23 @@

@Import(DemoTestConfiguration.class)
@WebMvcTest(DemoController.class)
public class DemoControllerTest {
public class DevModeIntrospectionTest {

@Autowired
private MockMvc mockMvc;

@Test
public void shouldReturnSyncPayload() throws Exception {
@EnabledIfSystemProperty(named = "test-group", matches = "unit-test")
public void shouldReturnInsecureIntrospectPayload() throws Exception {
mockMvc.perform(get("/api/inngest").header("Host", "localhost:8080"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"))
.andExpect(header().string(InngestHeaderKey.Framework.getValue(), "springboot"))
.andExpect(jsonPath("$.appName").value("spring_test_demo"))
.andExpect(jsonPath("$.framework").value("springboot"))
.andExpect(jsonPath("$.v").value("0.1"))
.andExpect(jsonPath("$.url").value("http://localhost:8080/api/inngest"))
.andExpect(jsonPath("$.sdk").value(String.format("java:v%s", Version.Companion.getVersion())));
.andExpect(jsonPath("$.authentication_succeeded").isEmpty())
.andExpect(jsonPath("$.function_count").isNumber())
.andExpect(jsonPath("$.has_event_key").value(false))
.andExpect(jsonPath("$.has_signing_key").value(false))
.andExpect(jsonPath("$.mode").value("dev"))
.andExpect(jsonPath("$.schema_version").value("2024-05-24"));
}
}
1 change: 1 addition & 0 deletions inngest/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies {
implementation("io.ktor:ktor-server-core:2.3.5")

testImplementation(kotlin("test"))
testImplementation("org.junit-pioneer:junit-pioneer:1.9.1")
}

publishing {
Expand Down
59 changes: 57 additions & 2 deletions inngest/src/main/kotlin/com/inngest/Comm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import com.beust.klaxon.Json
import com.beust.klaxon.Klaxon
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.inngest.signingkey.checkHeadersAndValidateSignature
import com.inngest.signingkey.getAuthorizationHeader
import com.inngest.signingkey.hashedSigningKey
import java.io.IOException
import java.security.MessageDigest

data class ExecutionRequestPayload(
val ctx: ExecutionContext,
Expand Down Expand Up @@ -177,8 +180,50 @@ class CommHandler(
// TODO
// fun sync(): Result<InngestSyncResult> = Result.success(InngestSyncResult.None)

fun introspect(origin: String): String {
val requestPayload = getRegistrationRequestPayload(origin)
fun introspect(
signature: String?,
requestBody: String,
serverKind: String?,
): String {
val insecureIntrospection =
InsecureIntrospection(
functionCount = functions.size,
hasEventKey = Environment.isInngestEventKeySet(client.eventKey),
hasSigningKey = config.hasSigningKey(),
mode = if (client.env == InngestEnv.Dev) "dev" else "cloud",
)

val requestPayload =
when (client.env) {
InngestEnv.Dev -> insecureIntrospection

else ->
runCatching {
checkHeadersAndValidateSignature(signature, requestBody, serverKind, config)

SecureIntrospection(
functionCount = functions.size,
hasEventKey = Environment.isInngestEventKeySet(client.eventKey),
hasSigningKey = config.hasSigningKey(),
authenticationSucceeded = true,
mode = "cloud",
env = client.env.value,
appId = config.appId(),
apiOrigin = "${config.baseUrl()}/",
framework = framework.value,
sdkVersion = Version.getVersion(),
sdkLanguage = "java",
servePath = config.servePath(),
serveOrigin = config.serveOrigin(),
signingKeyHash = hashedSigningKey(config.signingKey()),
eventApiOrigin = "${Environment.inngestEventApiBaseUrl(client.env)}/",
eventKeyHash = if (config.hasSigningKey()) hashedEventKey(client.eventKey) else null,
)
}.getOrElse {
insecureIntrospection.apply { authenticationSucceeded = false }
}
}

return serializePayload(requestPayload)
}

Expand All @@ -198,4 +243,14 @@ class CommHandler(
val servePath = config.servePath() ?: "/api/inngest"
return "$serveOrigin$servePath"
}

private fun hashedEventKey(eventKey: String): String? =
eventKey
.takeIf { Environment.isInngestEventKeySet(it) }
?.let {
MessageDigest
.getInstance("SHA-256")
.digest(it.toByteArray())
.joinToString("") { byte -> "%02x".format(byte) }
}
}
11 changes: 10 additions & 1 deletion inngest/src/main/kotlin/com/inngest/Environment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,20 @@ object Environment {
).filterValues { (it is String) }.entries.associate { (k, v) -> k to v!! }
}

private const val DUMMY_KEY_EVENT = "NO_EVENT_KEY_SET"

fun inngestEventKey(key: String? = null): String {
if (key != null) return key
return System.getenv(InngestSystem.EventKey.value) ?: "NO_EVENT_KEY_SET"
return System.getenv(InngestSystem.EventKey.value) ?: DUMMY_KEY_EVENT
}

fun isInngestEventKeySet(value: String?) =
when {
value.isNullOrEmpty() -> false
value == DUMMY_KEY_EVENT -> false
else -> true
}

fun inngestEventApiBaseUrl(
env: InngestEnv,
url: String? = null,
Expand Down
43 changes: 43 additions & 0 deletions inngest/src/main/kotlin/com/inngest/Introspection.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.inngest

import com.beust.klaxon.Json

abstract class Introspection(
@Json("authentication_succeeded") open val authenticationSucceeded: Boolean?,
open val functionCount: Int,
open val hasEventKey: Boolean,
open val hasSigningKey: Boolean,
open val mode: String,
@Json("schema_version") val schemaVersion: String = "2024-05-24",
)

internal data class InsecureIntrospection(
@Json("authentication_succeeded") override var authenticationSucceeded: Boolean? = null,
@Json("function_count") override val functionCount: Int,
@Json("has_event_key") override val hasEventKey: Boolean,
@Json("has_signing_key") override val hasSigningKey: Boolean,
override val mode: String,
) : Introspection(authenticationSucceeded, functionCount, hasEventKey, hasSigningKey, mode)

internal data class SecureIntrospection(
@Json("api_origin") val apiOrigin: String,
@Json("app_id") val appId: String,
@Json("authentication_succeeded") override val authenticationSucceeded: Boolean?,
// TODO: Add capabilities when adding the trust probe
// @Json("capabilities") val capabilities: Capabilities,
@Json("event_api_origin") val eventApiOrigin: String,
@Json("event_key_hash") val eventKeyHash: String?,
val env: String?,
val framework: String,
@Json("function_count") override val functionCount: Int,
@Json("has_event_key") override val hasEventKey: Boolean,
@Json("has_signing_key") override val hasSigningKey: Boolean,
@Json("has_signing_key_fallback") val hasSigningKeyFallback: Boolean = false,
override val mode: String,
@Json("sdk_language") val sdkLanguage: String,
@Json("sdk_version") val sdkVersion: String,
@Json("serve_origin") val serveOrigin: String?,
@Json("serve_path") val servePath: String?,
@Json("signing_key_fallback_hash") val signingKeyFallbackHash: String? = null,
@Json("signing_key_hash") val signingKeyHash: String?,
) : Introspection(authenticationSucceeded, functionCount, hasEventKey, hasSigningKey, mode)
Loading

0 comments on commit e2e6151

Please sign in to comment.