MtomAttachmentStreamFailureTest.java

/**
 * 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.cxf.systest.jaxws;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.logging.Logger;

import jakarta.activation.DataHandler;
import jakarta.activation.DataSource;
import jakarta.xml.ws.BindingProvider;
import jakarta.xml.ws.Endpoint;
import jakarta.xml.ws.WebServiceException;
import jakarta.xml.ws.soap.SOAPBinding;
import org.apache.cxf.Download;
import org.apache.cxf.DownloadNextResponseType;
import org.apache.cxf.common.logging.LogUtils;
import org.apache.cxf.jaxws.JaxWsProxyFactoryBean;
import org.apache.cxf.testutil.common.AbstractBusClientServerTestBase;
import org.apache.cxf.testutil.common.AbstractBusTestServerBase;

import org.junit.BeforeClass;
import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

/**
 * CXF-8542: if the InputStream of an MTOM attachment's DataHandler throws an IOException while
 * the server is writing the response, the client must not receive the truncated data as if it
 * were complete.
 */
public class MtomAttachmentStreamFailureTest extends AbstractBusClientServerTestBase {
    static final String PORT = allocatePort(MtomAttachmentStreamFailureTest.class);
    private static final Logger LOG = LogUtils.getLogger(MtomAttachmentStreamFailureTest.class);

    /** Size of the attachment when no failure is simulated */
    private static final int FULL_SIZE = 4 * 1024 * 1024;

    public static class Server extends AbstractBusTestServerBase {
        protected void run() {
            String address = "http://localhost:" + PORT + "/SoapContext/SoapPort";
            Endpoint endpoint = Endpoint.publish(address, new FailingDownloadImpl());
            ((SOAPBinding)endpoint.getBinding()).setMTOMEnabled(true);
        }

        public static void main(String[] args) {
            try {
                Server s = new Server();
                s.start();
            } catch (Exception ex) {
                ex.printStackTrace();
                System.exit(-1);
            } finally {
                LOG.info("done!");
            }
        }
    }

    /**
     * Returns an attachment of FULL_SIZE bytes, or, if simulate is true, an attachment whose
     * InputStream throws an IOException after minNumberOfChunks KB have been read.
     */
    static final class FailingDownloadImpl implements Download {
        @Override
        public DownloadNextResponseType downloadNext(Integer minNumberOfChunks, Boolean simulate) {
            final long size = Boolean.TRUE.equals(simulate) ? minNumberOfChunks * 1024L : FULL_SIZE;
            final boolean fail = Boolean.TRUE.equals(simulate);
            DownloadNextResponseType response = new DownloadNextResponseType();
            response.setDataContent(new DataHandler(new DataSource() {
                @Override
                public InputStream getInputStream() {
                    return new GeneratingInputStream(size, fail);
                }

                @Override
                public OutputStream getOutputStream() {
                    return null;
                }

                @Override
                public String getContentType() {
                    return "application/octet-stream";
                }

                @Override
                public String getName() {
                    return "data";
                }
            }));
            return response;
        }
    }

    /**
     * Generates size bytes, then either reports the end of the stream or throws an IOException.
     */
    static final class GeneratingInputStream extends InputStream {
        private final long size;
        private final boolean fail;
        private long count;

        GeneratingInputStream(long size, boolean fail) {
            this.size = size;
            this.fail = fail;
        }

        @Override
        public int read() throws IOException {
            byte[] b = new byte[1];
            return read(b, 0, 1) == -1 ? -1 : b[0];
        }

        @Override
        public int read(byte[] b, int off, int len) throws IOException {
            if (count >= size) {
                if (fail) {
                    throw new IOException("simulated I/O error after " + count + " bytes");
                }
                return -1;
            }
            int n = (int)Math.min(len, size - count);
            for (int i = off; i < off + n; i++) {
                b[i] = (byte)'x';
            }
            count += n;
            return n;
        }
    }

    @BeforeClass
    public static void startServers() throws Exception {
        assertTrue("server did not launch correctly", launchServer(Server.class, true));
    }

    @Test
    public void testFullDownload() throws Exception {
        Download client = createClient(false);
        DownloadNextResponseType response = client.downloadNext(0, false);
        assertEquals(FULL_SIZE, readAll(response.getDataContent()));
    }

    @Test
    public void testFailureAfterResponseCommitted() throws Exception {
        // 1 MB is well beyond the servlet response buffer, so the response is committed
        assertFailureSignaled(createClient(false), 1024);
    }

    @Test
    public void testFailureBeforeResponseCommitted() throws Exception {
        // 1 KB still fits in the servlet response buffer
        assertFailureSignaled(createClient(false), 1);
    }

    @Test
    public void testFailureAfterResponseCommittedURLConnection() throws Exception {
        assertFailureSignaled(createClient(true), 1024);
    }

    @Test
    public void testFailureBeforeResponseCommittedURLConnection() throws Exception {
        assertFailureSignaled(createClient(true), 1);
    }

    private static void assertFailureSignaled(Download client, int failAfterKb) throws Exception {
        final DownloadNextResponseType response;
        try {
            response = client.downloadNext(failAfterKb, true);
        } catch (WebServiceException ex) {
            // the response was aborted before the attachment could be read
            LOG.fine("Failure signaled by the invocation: " + ex);
            return;
        }
        final long read;
        try {
            read = readAll(response.getDataContent());
        } catch (IOException ex) {
            // the response was aborted while the attachment was being read
            LOG.fine("Failure signaled while reading the attachment: " + ex);
            return;
        }
        fail("The server failed after " + failAfterKb * 1024L + " bytes, but the client read "
            + read + " bytes without any error");
    }

    private Download createClient(boolean urlConnection) {
        JaxWsProxyFactoryBean factory = new JaxWsProxyFactoryBean();
        factory.setServiceClass(Download.class);
        factory.setAddress("http://localhost:" + PORT + "/SoapContext/SoapPort/DownloadPort");
        Download client = (Download)factory.create();
        BindingProvider bp = (BindingProvider)client;
        ((SOAPBinding)bp.getBinding()).setMTOMEnabled(true);
        if (urlConnection) {
            bp.getRequestContext().put("force.urlconnection.http.conduit", Boolean.TRUE);
        }
        return client;
    }

    private static long readAll(DataHandler dh) throws IOException {
        long total = 0;
        byte[] buf = new byte[8192];
        try (InputStream in = dh.getInputStream()) {
            for (int n = in.read(buf); n != -1; n = in.read(buf)) {
                total += n;
            }
        }
        return total;
    }
}