diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/nio/ChannelActiveDoubleFireTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/nio/ChannelActiveDoubleFireTest.java new file mode 100644 index 0000000000..cd8dc535a6 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/nio/ChannelActiveDoubleFireTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved. + * + * Licensed 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.jivesoftware.openfire.nio; + +import io.netty.channel.*; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.ssl.SslHandshakeCompletionEvent; +import org.jivesoftware.openfire.net.ServerStanzaHandler; +import org.jivesoftware.openfire.spi.ConnectionConfiguration; +import org.junit.jupiter.api.Test; +import java.util.concurrent.atomic.AtomicInteger; +import static org.jivesoftware.openfire.nio.NettyConnectionHandler.CONNECTION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +/** + * Verifies that {@code channelActive()} is not replayed through handlers that precede {@link NettyConnectionHandler} in + * the pipeline after a successful TLS handshake. + * + * When a connection is accepted, Netty emits a single {@code channelActive()} event that traverses the pipeline from + * the head. After the TLS handshake completes, {@link NettyConnectionHandler} must notify downstream business handlers + * that the connection is ready for use without restarting {@code channelActive()} from the pipeline head. Restarting + * the event from the head causes every upstream handler to observe {@code channelActive()} a second time, implicitly + * requiring those handlers to be idempotent. + * + * @author Guus der Kinderen, guus@goodbytes.nl + * @see OF-3330: Stop replaying channelActive through head-side handlers after TLS handshake + */ +public class ChannelActiveDoubleFireTest +{ + /** + * Verifies that a head-sided handler is activated only once per connection. + */ + @Test + void headSideHandlersReceiveChannelActiveOnlyOnce() + { + // Setup test fixture. + final CountingHandler head = new CountingHandler(); + + // Real connection handler at the tail, exactly as in production. + final NettyConnectionHandler real = spy(new NettyServerConnectionHandler(mock(ConnectionConfiguration.class))); + + final EmbeddedChannel ch = new EmbeddedChannel(); + try { + // Satisfy userEventTriggered's precondition: setEncrypted() dereferences the CONNECTION attr. + ch.attr(CONNECTION).set(mock(NettyConnection.class)); + ch.pipeline().addLast(head).addLast(real); + + // Execute system under test. + ch.pipeline().fireChannelActive(); // accept-time + ch.pipeline().fireUserEventTriggered(SslHandshakeCompletionEvent.SUCCESS); // handshake + + assertEquals(1, head.count.get(), "Head-side handler must be activated once per connection; the post-handshake head restart re-fires it a second time."); + } finally { + // Tear down test fixture. + ch.finishAndReleaseAll(); + } + } + + private static class CountingHandler extends ChannelInboundHandlerAdapter { + final AtomicInteger count = new AtomicInteger(); + @Override public void channelActive(ChannelHandlerContext ctx) { + count.incrementAndGet(); + ctx.fireChannelActive(); + } + } +}