Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/PR_BODY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
Branch: complement/homerunner-portbinding

Summary
-------
This branch contains Matrix Specification compliance improvements for federation endpoints, comprehensive unit tests, CI/CD enhancements, and diagnostic documentation for Complement testing.

What this branch contains
------------------------

### Source Code Changes
- **Federation v1 endpoints**: Fixed `get_missing_events` response format to correctly return `pdus` and `origin` fields (commit bf04631)
- **Federation v2 endpoints**: Enhanced auth chain endpoint with proper Matrix-spec-compliant responses (commit 19c5d39)
- **Federation user query**: Added profile query endpoint implementation (commit 19c5d39)
- **Client-server room routes**: Added state event handling and room state endpoints (commit 19c5d39)
- **MatrixAuth utilities**: Refactored authentication and authorisation logic for better spec compliance (commit 19c5d39)

### Test Coverage (merged from PR #10)
- `FederationProfileQueryTest.kt`: Unit tests for federation profile queries
- `FederationV2AuthChainTest.kt`: Comprehensive tests for auth chain endpoint
- `StateEventRoutesTest.kt`: Tests for client-server state event routes
- `MissingEventsTest.kt`: Unit tests for the missing events endpoint (A→B→C chain validation)

### CI/CD Improvements
- Updated `Complement.Dockerfile` to use Debian-slim builder, resolving TLS issues during Gradle dependency fetch (commit 2646d00)

### Documentation
- `COMPLEMENT_LOCAL_RUN.md`: Comprehensive guide for running Complement tests locally using Docker and WSL
- Diagnostic test log: `complement-test-output.txt` (captured output from Complement run for reference)

Context and Reasoning
---------------------
This branch evolved from an initial investigation into Complement/Homerunner port binding issues. During diagnostics, several Matrix Specification compliance gaps were identified and fixed. PR #10 was opened to deliver the test coverage for these fixes and was subsequently merged back into this branch.

The changes ensure:
- Proper federation endpoint responses matching Matrix Specification v1.16
- Complete test coverage for federation and client-server endpoints
- Improved CI/CD reliability for Complement testing
- Clear documentation for local Complement test workflows

Testing
-------
- All new unit tests pass locally
- Federation endpoints return spec-compliant responses
- Complement Docker image builds successfully with the updated Dockerfile

Signed-off-by: GitHub Copilot
43 changes: 43 additions & 0 deletions COMPLEMENT_LOCAL_RUN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Running Complement locally (build + single-test pattern)

This file documents the local workflow I use to build the Complement image and run a focused complement test on Windows using WSL or PowerShell.

Use this when you want to run a single Complement test locally without remote runners.

Steps
1. Build the Complement Docker image (from repository root). This uses the included `Complement.Dockerfile` and tags the image `complement-ferretcannon:latest`:

```powershell
docker build -f Complement.Dockerfile -t complement-ferretcannon:latest .
```

2. Run the focused Go test from WSL (recommended) so Complement runs inside WSL's Go toolchain but uses the Docker image you just built.
- This example runs `TestIsDirectFlagLocal` only and saves the output to `complement-test-output-7.txt` in the repo root.
- Update paths or test name as required.

```powershell
wsl bash -lc 'cd /mnt/c/Users/Ed/FERRETCANNON/complement-src; \
export COMPLEMENT_BASE_IMAGE=complement-ferretcannon:latest; \
export COMPLEMENT_SPAWN_HS_TIMEOUT_SECS=180; \
export COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT=host.docker.internal; \
go test -v -run TestIsDirectFlagLocal ./tests/ 2>&1 | tee /mnt/c/Users/Ed/FERRETCANNON/complement-test-output-7.txt'
```

Notes and troubleshooting
- Ensure Docker Desktop / Docker Engine is running on Windows before building the image.
- The WSL command above mounts Windows paths under `/mnt/c/...` — adjust the path if your user or repo location differs.
- If you prefer to run entirely inside WSL: open your WSL distro, cd into the repository path, build the image and run the `go test` command directly.
- If Go or Docker are not available in WSL, using `wsl` from PowerShell will still allow the host Docker engine to be used (Docker Desktop publishes the daemon to WSL), but confirm your WSL environment can run `go test`.
- The `COMPLEMENT_` environment variables configure Complement for the run; tweak the timeout and hostname variables as needed for your environment.
- Output is written to `complement-test-output-7.txt` for later inspection.

Recommended sequence (copy-paste)

```powershell
docker build -f Complement.Dockerfile -t complement-ferretcannon:latest .

# then run the focused test (single-line):
wsl bash -lc 'cd /mnt/c/Users/Ed/FERRETCANNON/complement-src; export COMPLEMENT_BASE_IMAGE=complement-ferretcannon:latest; export COMPLEMENT_SPAWN_HS_TIMEOUT_SECS=180; export COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT=host.docker.internal; go test -v -run TestGetMissingEventsGapFilling ./tests/ 2>&1 | tee /mnt/c/Users/Ed/FERRETCANNON/complement-TestGetMissingEventsGapFilling.txt'
```

If you want me to push this change and open the PR for `inbound-federation-fixes`, say `push and open PR` and confirm GitHub CLI is available or provide the path.
9 changes: 6 additions & 3 deletions Complement.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@
# Big shoutout to the FERRETCANNON massive for spec compliance! 🎆

# Stage 1: Build stage
FROM openjdk:17-alpine AS builder
FROM openjdk:17-slim AS builder

WORKDIR /app

# Install required build tools
RUN apk add --no-cache wget unzip
# Use Debian-slim based image for better TLS support during Gradle dependency downloads
# Install required build tools and TLS/CA support
RUN apt-get update && apt-get install -y --no-install-recommends \
wget unzip ca-certificates openssl gnupg2 curl && \
rm -rf /var/lib/apt/lists/*

# Download and install Gradle
RUN wget https://services.gradle.org/distributions/gradle-9.0.0-bin.zip -P /tmp && \
Expand Down
160 changes: 156 additions & 4 deletions src/main/kotlin/routes/client-server/client/room/RoomRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -285,15 +285,167 @@ fun Route.roomRoutes() {
}
}

// PUT /rooms/{roomId}/state/{eventType}/{stateKey} - Send state event
put("/rooms/{roomId}/state/{eventType}/{stateKey}") {
// PUT /rooms/{roomId}/state/{eventType}/{stateKey?} - Send state event
// Note: stateKey is optional in the client API (empty state_key is represented by a trailing slash),
// so accept an optional parameter and treat missing as empty string.
put("/rooms/{roomId}/state/{eventType}/{stateKey?}") {
try {
val userId = call.validateAccessToken() ?: return@put
val roomId = call.parameters["roomId"]
val eventType = call.parameters["eventType"]
val stateKey = call.parameters["stateKey"]
// Treat a missing stateKey as the empty string (state_key == "")
val stateKey = call.parameters["stateKey"] ?: ""

if (roomId == null || eventType == null || stateKey == null) {
if (roomId == null || eventType == null) {
call.respond(HttpStatusCode.BadRequest, mutableMapOf(
"errcode" to "M_INVALID_PARAM",
"error" to "Missing required parameters"
))
return@put
}

// Check if user is joined to the room
val currentMembership = transaction {
Events.select {
(Events.roomId eq roomId) and
(Events.type eq "m.room.member") and
(Events.stateKey eq userId)
}.mapNotNull { row ->
Json.parseToJsonElement(row[Events.content]).jsonObject["membership"]?.jsonPrimitive?.content
}.firstOrNull()
}

if (currentMembership != "join") {
call.respond(HttpStatusCode.Forbidden, mutableMapOf(
"errcode" to "M_FORBIDDEN",
"error" to "User is not joined to this room"
))
return@put
}

// Parse request body
val requestBody = call.receiveText()
val jsonBody = Json.parseToJsonElement(requestBody).jsonObject

val currentTime = System.currentTimeMillis()

// Get latest event for prev_events
val latestEvent = transaction {
Events.select { Events.roomId eq roomId }
.orderBy(Events.originServerTs, SortOrder.DESC)
.limit(1)
.singleOrNull()
}

val prevEvents = if (latestEvent != null) {
"[[\"${latestEvent[Events.eventId]}\",{}]]"
} else {
"[]"
}

val depth = if (latestEvent != null) {
latestEvent[Events.depth] + 1
} else {
1
}

// Get auth events (create and power levels)
val authEvents = transaction {
val createEvent = Events.select {
(Events.roomId eq roomId) and
(Events.type eq "m.room.create")
}.singleOrNull()

val powerLevelsEvent = Events.select {
(Events.roomId eq roomId) and
(Events.type eq "m.room.power_levels")
}.orderBy(Events.originServerTs, SortOrder.DESC)
.limit(1)
.singleOrNull()

val authList = mutableListOf<String>()
createEvent?.let { authList.add("\"${it[Events.eventId]}\"") }
powerLevelsEvent?.let { authList.add("\"${it[Events.eventId]}\"") }
"[${authList.joinToString(",")}]"
}

// Generate event ID
val eventId = "\$${currentTime}_${stateKey.hashCode()}"

// Store state event
transaction {
Events.insert {
it[Events.eventId] = eventId
it[Events.roomId] = roomId
it[Events.type] = eventType
it[Events.sender] = userId
it[Events.content] = Json.encodeToString(JsonObject.serializer(), jsonBody)
it[Events.originServerTs] = currentTime
it[Events.stateKey] = stateKey
it[Events.prevEvents] = prevEvents
it[Events.authEvents] = authEvents
it[Events.depth] = depth
it[Events.hashes] = "{}"
it[Events.signatures] = "{}"
}
}

// Update resolved state
val allEvents = transaction {
Events.select { Events.roomId eq roomId }
.map { row ->
JsonObject(mutableMapOf(
"event_id" to JsonPrimitive(row[Events.eventId]),
"type" to JsonPrimitive(row[Events.type]),
"sender" to JsonPrimitive(row[Events.sender]),
"origin_server_ts" to JsonPrimitive(row[Events.originServerTs]),
"content" to Json.parseToJsonElement(row[Events.content]).jsonObject,
"state_key" to JsonPrimitive(row[Events.stateKey] ?: "")
))
}
}
val roomVersion = transaction {
Rooms.select { Rooms.roomId eq roomId }.single()[Rooms.roomVersion]
}
val newResolvedState = stateResolver.resolveState(allEvents, roomVersion)
stateResolver.updateResolvedState(roomId, newResolvedState)

// Broadcast state event
runBlocking {
val eventJson = JsonObject(mutableMapOf(
"event_id" to JsonPrimitive(eventId),
"type" to JsonPrimitive(eventType),
"sender" to JsonPrimitive(userId),
"room_id" to JsonPrimitive(roomId),
"origin_server_ts" to JsonPrimitive(currentTime),
"content" to jsonBody,
"state_key" to JsonPrimitive(stateKey)
))
broadcastEDU(roomId, eventJson)
}

call.respond(mutableMapOf(
"event_id" to eventId
))

} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, mutableMapOf(
"errcode" to "M_UNKNOWN",
"error" to "Internal server error: ${e.message}"
))
}
}

// Also accept PUT /rooms/{roomId}/state/{eventType} to handle requests that omit the final segment
// (some clients send a trailing slash which is interpreted as an empty state_key). Treat stateKey as empty string.
put("/rooms/{roomId}/state/{eventType}") {
try {
val userId = call.validateAccessToken() ?: return@put
val roomId = call.parameters["roomId"]
val eventType = call.parameters["eventType"]
val stateKey = "" // explicit empty state key for this route

if (roomId == null || eventType == null) {
call.respond(HttpStatusCode.BadRequest, mutableMapOf(
"errcode" to "M_INVALID_PARAM",
"error" to "Missing required parameters"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,13 @@ fun Route.federationV1Events() {
// Find missing events using a breadth-first search
val missingEvents = findMissingEvents(roomId, earliestEvents, latestEvents, limit)

// Per spec, include origin and origin_server_ts and return PDUs
call.respond(buildJsonObject {
put("events", Json.encodeToJsonElement(missingEvents))
put("origin", utils.ServerNameResolver.getServerName())
put("origin_server_ts", System.currentTimeMillis())
putJsonArray("pdus") {
missingEvents.forEach { add(Json.encodeToJsonElement(it)) }
}
})
} catch (e: Exception) {
call.respond(HttpStatusCode.BadRequest, buildJsonObject {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,15 @@ fun Route.federationV1UserQuery() {
println("DEBUG: query/profile for userId=$userId, field=$field, profile=$profile")

if (profile != null) {
// Ensure displayname and avatar_url keys are always present (may be null)
val display = profile["displayname"] as? String
val avatar = profile["avatar_url"] as? String
call.respond(buildJsonObject {
put("displayname", display?.let { JsonPrimitive(it) } ?: JsonNull)
put("avatar_url", avatar?.let { JsonPrimitive(it) } ?: JsonNull)
// Include any other profile keys present
profile.forEach { (key, value) ->
if (key == "displayname" || key == "avatar_url") return@forEach
when (value) {
is String -> put(key, JsonPrimitive(value))
null -> put(key, JsonNull)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,27 @@ fun Application.federationV2Routes() {
if (processedEvent[Events.unsigned] != null) put("unsigned", Json.parseToJsonElement(processedEvent[Events.unsigned]!!).jsonObject)
}
put("state", JsonArray(currentState.map { Json.encodeToJsonElement(it) }))
put("auth_chain", JsonArray(emptyList<JsonElement>()))
// If we have auth chain events for the processed event, include them; otherwise return empty array
val authChainEvents = transaction {
val authEventIds = Json.parseToJsonElement(processedEvent[Events.authEvents]).jsonArray.map { it.jsonPrimitive.content }
Events.select { Events.eventId inList authEventIds }
.map { row -> Json.encodeToJsonElement(buildJsonObject {
put("event_id", row[Events.eventId])
put("type", row[Events.type])
put("room_id", row[Events.roomId])
put("sender", row[Events.sender])
put("content", Json.parseToJsonElement(row[Events.content]).jsonObject)
put("auth_events", Json.parseToJsonElement(row[Events.authEvents]).jsonArray)
put("prev_events", Json.parseToJsonElement(row[Events.prevEvents]).jsonArray)
put("depth", row[Events.depth])
put("hashes", Json.parseToJsonElement(row[Events.hashes]).jsonObject)
put("signatures", Json.parseToJsonElement(row[Events.signatures]).jsonObject)
put("origin_server_ts", row[Events.originServerTs])
if (row[Events.stateKey] != null) put("state_key", row[Events.stateKey])
if (row[Events.unsigned] != null) put("unsigned", Json.parseToJsonElement(row[Events.unsigned]!!).jsonObject)
}) }
}
put("auth_chain", JsonArray(authChainEvents))
}

call.respond(response)
Expand Down
Loading
Loading