/** * Copyright 2010 Google Inc. * * 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.waveprotocol.wave.federation.xmpp; import static org.mockito.Matchers.any; import static org.mockito.Matchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyZeroInteractions; import junit.framework.TestCase; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; import org.xmpp.packet.IQ; import org.xmpp.packet.Message; import org.xmpp.packet.Packet; import org.xmpp.packet.PacketError; import org.waveprotocol.wave.federation.FederationErrorProto.FederationError; import org.waveprotocol.wave.federation.FederationErrors; import org.waveprotocol.wave.federation.xmpp.MockOutgoingPacketTransport.Router; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; /** * Test round-trips between two XmppManager instances pointed at each other. * * This class is not intended to test specific calls; it is primary to test * reliable calls made by the manager along with error handling. Any specific * call coverage is purely a side-effect of wanting real test data. * * @author thorogood@google.com (Sam Thorogood) */ public class RoundTripTest extends TestCase { private static final String SERVER1_DOMAIN = "google.com"; private static final String SERVER2_DOMAIN = "acmewave.com"; private static final int PACKET_TIMEOUT = 10; private static final int DISCO_FAIL_EXPIRY_SECS = 5 * 60; private static final int DISCO_SUCCESS_EXPIRY_SECS = 2 * 60 * 60; private static class ServerInstances { final String jid; final XmppManager manager; final XmppFederationHost host; final XmppFederationRemote remote; final XmppDisco disco; final MockOutgoingPacketTransport transport; ServerInstances(String domain, MockOutgoingPacketTransport.Router router) { // Mocks. host = mock(XmppFederationHost.class); remote = mock(XmppFederationRemote.class); disco = mock(XmppDisco.class); // 'Real' instantiated classes! jid = "wave." + domain; transport = new MockOutgoingPacketTransport(router); manager = new XmppManager(host, remote, disco, transport, jid); // Verify manager callback. verify(host).setManager(eq(manager)); verify(remote).setManager(eq(manager)); verify(disco).setManager(eq(manager)); } } private ServerInstances server1; private ServerInstances server2; @Override public void setUp() throws Exception { super.setUp(); server1 = new ServerInstances(SERVER1_DOMAIN, new Router() { @Override public void route(Packet packet) { server2.manager.receivePacket(packet); } }); server2 = new ServerInstances(SERVER2_DOMAIN, new Router() { @Override public void route(Packet packet) { server1.manager.receivePacket(packet); } }); } /** * Test the simple case of packet send/receive by sending a malformed request. */ public void testPacketSendMalformedFailure() { Packet packet = new IQ(); packet.setFrom(server1.jid); packet.setID("irrelevant"); packet.setTo(server2.jid); PacketCallback callback = mock(PacketCallback.class); // Send an outgoing packet from server1 -> server2 server1.manager.send(packet, callback, PACKET_TIMEOUT); assertEquals("First transport should have a single packet pending", 1, server1.transport.packets.size()); assertEquals("First transport should have unmodified outgoing packet", packet, server1.transport.packets.peek()); // Confirm that server2 sent back an error assertEquals("Second transport should have a single packet pending", 1, server2.transport.packets.size()); assertNotNull("Second transport should be an error packet", server2.transport.packets.peek().getError()); // Ensure the error is interpreted correctly and returned to the callback ArgumentCaptor<FederationError> errorCaptor = ArgumentCaptor.forClass(FederationError.class); verify(callback).error(errorCaptor.capture()); verify(callback, never()).run(any(Packet.class)); assertEquals("Invalid packet was sent, error should be BAD_REQUEST", FederationError.Code.BAD_REQUEST, errorCaptor.getValue().getErrorCode()); } /** * Test the simple case of having a response invoked based entirely on the * timeout case. */ public void testPacketTimeout() throws Exception { int TIMEOUT_DELAY = 0; int TIMEOUT_WAIT = 5; // Send a valid packet, so it is received by the remote Disco mock, but not processed. IQ packet = new IQ(); packet.setFrom(server1.jid); packet.setID("disco"); packet.setTo(server2.jid); packet.setType(IQ.Type.get); packet.setChildElement("query", XmppNamespace.NAMESPACE_DISCO_ITEMS); PacketCallback callback = mock(PacketCallback.class); final CountDownLatch finished = new CountDownLatch(1); Mockito.doAnswer(new Answer<Void>() { @Override public Void answer(InvocationOnMock invocation) throws Throwable { FederationError error = (FederationError) invocation.getArguments()[0]; assertEquals(FederationError.Code.REMOTE_SERVER_TIMEOUT, error.getErrorCode()); finished.countDown(); return null; } }).when(callback).error(any(FederationError.class)); server1.manager.send(packet, callback, TIMEOUT_DELAY); assertTrue(finished.await(TIMEOUT_WAIT, TimeUnit.SECONDS)); verify(callback, never()).run(any(Packet.class)); // Mockito says never to reset a mock, but we have to as the callback is // already used deep in XmppManager. Mockito.reset(callback); // For fun, process the request by the remote disco and return a response // that will never be processed. ArgumentCaptor<PacketCallback> server2Callback = ArgumentCaptor.forClass(PacketCallback.class); verify(server2.disco).processDiscoItemsGet(eq(packet), server2Callback.capture()); XmppDisco realDisco = new XmppDisco("Some Unchecked Wave Server", DISCO_FAIL_EXPIRY_SECS, DISCO_SUCCESS_EXPIRY_SECS); realDisco.setManager(server2.manager); realDisco.processDiscoItemsGet(packet, server2Callback.getValue()); // Confirm disco on server2 has replied with a packet. assertEquals(1, server2.transport.packets.size()); assertEquals(null, server2.transport.packets.peek().getError()); // Confirm, however, that the packet is dropped by the first manager (no pending call!). verifyZeroInteractions(callback); } /** * Test that an arbitrary error response is properly returned when generated * by the second server. Also ensure that the second server can't invoke its * callback twice. */ public void testErrorResponse() { FederationError.Code TEST_CODE = FederationError.Code.NOT_AUTHORIZED; PacketError.Condition TEST_CONDITION = PacketError.Condition.not_authorized; // Send a valid packet, so it is received by the remote Disco mock, but not // explicitly processed. IQ packet = new IQ(); packet.setFrom(server1.jid); packet.setID("disco"); packet.setTo(server2.jid); packet.setType(IQ.Type.get); packet.setChildElement("query", XmppNamespace.NAMESPACE_DISCO_ITEMS); PacketCallback callback = mock(PacketCallback.class); server1.manager.send(packet, callback, PACKET_TIMEOUT); // Accept the disco request and return TEST_CODE error. ArgumentCaptor<PacketCallback> server2Callback = ArgumentCaptor.forClass(PacketCallback.class); verify(server2.disco).processDiscoItemsGet(eq(packet), server2Callback.capture()); server2Callback.getValue().error(FederationErrors.newFederationError(TEST_CODE)); // Try to then complete the message, but cause an IllegalStateException. IQ fakeResponse = IQ.createResultIQ(packet); try { server2Callback.getValue().run(fakeResponse); fail("Should not be able to invoke callback twice"); } catch (IllegalStateException e) { // pass } // Check the outgoing packet log. assertEquals(1, server2.transport.packets.size()); Packet errorResponse = server2.transport.packets.peek(); PacketError error = errorResponse.getError(); assertNotNull(error); assertEquals(TEST_CONDITION, error.getCondition()); // Assert that the error response does *not* include the original packet. assertTrue(errorResponse instanceof IQ); IQ errorIQ = (IQ) errorResponse; assertEquals(null, errorIQ.getChildElement()); // Confirm that the error is received properly on the first server. ArgumentCaptor<FederationError> returnedError = ArgumentCaptor.forClass(FederationError.class); verify(callback).error(returnedError.capture()); verify(callback, never()).run(any(Packet.class)); assertEquals(TEST_CODE, returnedError.getValue().getErrorCode()); // If we push the error again, it should be dropped. Note that resetting the // callback here is the simplest way to test this, since it is already // registered inside the manager. reset(callback); server1.manager.receivePacket(errorResponse); verifyZeroInteractions(callback); } /** * Test that an unhandled error (e.g. <forbidden>) is translated to * UNDEFINED_CONDITION before being returned to the mocked callback. */ public void testUnhandledErrorResponse() { IQ packet = new IQ(); packet.setFrom(server1.jid); packet.setID("foo"); packet.setTo(server2.jid); // Disable routing so we can intercept the packet. server1.transport.router = null; PacketCallback callback = mock(PacketCallback.class); server1.manager.send(packet, callback, PACKET_TIMEOUT); // Generate an explicit error <forbidden>. IQ errorPacket = IQ.createResultIQ(packet); errorPacket.setError(PacketError.Condition.forbidden); server1.manager.receivePacket(errorPacket); // Confirm that <forbidden> is transformed to UNDEFINED_CONDITION. ArgumentCaptor<FederationError> returnedError = ArgumentCaptor.forClass(FederationError.class); verify(callback).error(returnedError.capture()); verify(callback, never()).run(any(Packet.class)); assertEquals(FederationError.Code.UNDEFINED_CONDITION, returnedError.getValue().getErrorCode()); } /** * Test that packet IDs cannot be re-used while in-flight, and also that may * be re-used later. */ public void testReusePacketId() throws Exception { int REUSE_FAIL_WAIT = 5; IQ packet = new IQ(); packet.setFrom(server1.jid); packet.setID("foo-packet"); packet.setTo(server2.jid); // Disable routing so we can intercept the packet. server1.transport.router = null; PacketCallback callback = mock(PacketCallback.class); server1.manager.send(packet, callback, PACKET_TIMEOUT); assertEquals(1, server1.transport.packets.size()); assertEquals(packet, server1.transport.packets.poll()); // Try sending another packet with the same ID - must fail (called back in // another thread)! PacketCallback invalidCallback = mock(PacketCallback.class); final CountDownLatch finished = new CountDownLatch(1); Mockito.doAnswer(new Answer<Void>() { @Override public Void answer(InvocationOnMock invocation) throws Throwable { FederationError error = (FederationError) invocation.getArguments()[0]; assertEquals(FederationError.Code.UNDEFINED_CONDITION, error.getErrorCode()); finished.countDown(); return null; } }).when(invalidCallback).error(any(FederationError.class)); server1.manager.send(packet, invalidCallback, PACKET_TIMEOUT); assertTrue(finished.await(REUSE_FAIL_WAIT, TimeUnit.SECONDS)); verify(invalidCallback, never()).run(any(Packet.class)); // Generate an explicit success response. IQ successPacket = IQ.createResultIQ(packet); server1.manager.receivePacket(successPacket); verify(callback).run(eq(successPacket)); verify(callback, never()).error(any(FederationError.class)); // Again, re-use the ID: should succeed since it is cleared from callbacks. PacketCallback zeroCallback = mock(PacketCallback.class); server1.manager.send(packet, zeroCallback, PACKET_TIMEOUT); assertEquals(1, server1.transport.packets.size()); assertEquals(packet, server1.transport.packets.poll()); verifyZeroInteractions(zeroCallback); } /** * Test that if (e.g.) an IQ is sent, then an IQ must be returned as a * response. If a Message is returned instead, this should invoke an error * callback. */ public void testDropInvalidResponseType() throws Exception { IQ packet = server1.manager.createRequestIQ(server2.jid); // Disable routing so we can intercept the packet. server1.transport.router = null; PacketCallback callback = mock(PacketCallback.class); server1.manager.send(packet, callback, PACKET_TIMEOUT); // Generate an explicit Message receipt. Message response = new Message(); response.setTo(packet.getFrom()); response.setID(packet.getID()); response.setFrom(packet.getTo()); response.addChildElement("received", XmppNamespace.NAMESPACE_XMPP_RECEIPTS); server1.manager.receivePacket(response); // Confirm that an error callback is invoked. ArgumentCaptor<FederationError> returnedError = ArgumentCaptor.forClass(FederationError.class); verify(callback).error(returnedError.capture()); verify(callback, never()).run(any(Packet.class)); assertEquals(FederationError.Code.UNDEFINED_CONDITION, returnedError.getValue().getErrorCode()); // Confirm that sending a correct response now does nothing. reset(callback); IQ correctResponse = IQ.createResultIQ(packet); server1.manager.receivePacket(correctResponse); verifyZeroInteractions(callback); } }