You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@manifoldcf.apache.org by sc...@apache.org on 2022/01/27 08:04:04 UTC

svn commit: r1897536 - in /manifoldcf/trunk: ./ connectors/solr/connector/src/main/java/org/apache/manifoldcf/agents/output/solr/ connectors/solr/connector/src/test/java/org/apache/manifoldcf/agents/output/solr/tests/

Author: schuch
Date: Thu Jan 27 08:04:03 2022
New Revision: 1897536

URL: http://svn.apache.org/viewvc?rev=1897536&view=rev
Log:
CONNECTORS-1694: Enforce preemptive basic auth for Solr output connector

closes apache/manifoldcf#113

Added:
    manifoldcf/trunk/connectors/solr/connector/src/main/java/org/apache/manifoldcf/agents/output/solr/PreemptiveBasicAuthInterceptor.java
    manifoldcf/trunk/connectors/solr/connector/src/test/java/org/apache/manifoldcf/agents/output/solr/tests/PreemptiveBasicAuthInterceptorTest.java
Modified:
    manifoldcf/trunk/CHANGES.txt
    manifoldcf/trunk/connectors/solr/connector/src/main/java/org/apache/manifoldcf/agents/output/solr/HttpPoster.java

Modified: manifoldcf/trunk/CHANGES.txt
URL: http://svn.apache.org/viewvc/manifoldcf/trunk/CHANGES.txt?rev=1897536&r1=1897535&r2=1897536&view=diff
==============================================================================
--- manifoldcf/trunk/CHANGES.txt (original)
+++ manifoldcf/trunk/CHANGES.txt Thu Jan 27 08:04:03 2022
@@ -3,6 +3,10 @@ $Id$
 
 ======================= 2.22-dev =====================
 
+CONNECTORS-1694: Enforce preemptive basic auth for Solr output connector.
+(Markus Günther, Markus Schuch)
+
+
 CONNECTORS-1693: Fix for the solr output connector to support
 basic authentication without realm scope.
 (Markus Günther, Markus Schuch)

Modified: manifoldcf/trunk/connectors/solr/connector/src/main/java/org/apache/manifoldcf/agents/output/solr/HttpPoster.java
URL: http://svn.apache.org/viewvc/manifoldcf/trunk/connectors/solr/connector/src/main/java/org/apache/manifoldcf/agents/output/solr/HttpPoster.java?rev=1897536&r1=1897535&r2=1897536&view=diff
==============================================================================
--- manifoldcf/trunk/connectors/solr/connector/src/main/java/org/apache/manifoldcf/agents/output/solr/HttpPoster.java (original)
+++ manifoldcf/trunk/connectors/solr/connector/src/main/java/org/apache/manifoldcf/agents/output/solr/HttpPoster.java Thu Jan 27 08:04:03 2022
@@ -94,12 +94,12 @@ public class HttpPoster
   // Solrj connection-associated objects
   protected PoolingHttpClientConnectionManager connectionManager = null;
   protected SolrClient solrServer = null;
-  
+
   // Action URI pieces
   private final String postUpdateAction;
   private final String postRemoveAction;
   private final String postStatusAction;
-  
+
   // Attribute names
   private final String allowAttributeName;
   private final String denyAttributeName;
@@ -111,17 +111,17 @@ public class HttpPoster
   private final String fileNameAttributeName;
   private final String mimeTypeAttributeName;
   private final String contentAttributeName;
-  
+
   // Whether we use extract/update handler or not
   private final boolean useExtractUpdateHandler;
-  
+
   // Document max length
   private final Long maxDocumentLength;
 
   // Included and excluded mime types
   private final Set<String> includedMimeTypes;
   private final Set<String>excludedMimeTypes;
-  
+
   // Commit-within flag
   private final String commitWithin;
 
@@ -130,7 +130,7 @@ public class HttpPoster
   private static final String NOTHING = "__NOTHING__";
   private static final String ID_METADATA = "lcf_metadata_id";
   private static final String COMMITWITHIN_METADATA = "commitWithin";
-  
+
   /** How long to wait before retrying a failed ingestion */
   private static final long interruptionRetryTime = 60000L;
 
@@ -152,9 +152,9 @@ public class HttpPoster
     this.postUpdateAction = updatePath;
     this.postRemoveAction = removePath;
     this.postStatusAction = statusPath;
-    
+
     this.commitWithin = commitWithin;
-    
+
     this.allowAttributeName = allowAttributeName;
     this.denyAttributeName = denyAttributeName;
     this.idAttributeName = idAttributeName;
@@ -168,11 +168,11 @@ public class HttpPoster
     this.useExtractUpdateHandler = useExtractUpdateHandler;
     this.includedMimeTypes = includedMimeTypes;
     this.excludedMimeTypes = excludedMimeTypes;
-    
+
     this.maxDocumentLength = maxDocumentLength;
-    
+
     initializeKerberos();
-    
+
     try
     {
       CloudSolrClient cloudSolrServer = new CloudSolrClient.Builder()
@@ -210,9 +210,9 @@ public class HttpPoster
     this.postUpdateAction = updatePath;
     this.postRemoveAction = removePath;
     this.postStatusAction = statusPath;
-    
+
     this.commitWithin = commitWithin;
-    
+
     this.allowAttributeName = allowAttributeName;
     this.denyAttributeName = denyAttributeName;
     this.idAttributeName = idAttributeName;
@@ -226,11 +226,11 @@ public class HttpPoster
     this.useExtractUpdateHandler = useExtractUpdateHandler;
     this.includedMimeTypes = includedMimeTypes;
     this.excludedMimeTypes = excludedMimeTypes;
-    
+
     this.maxDocumentLength = maxDocumentLength;
 
     initializeKerberos();
-    
+
     String location = "";
     if (webapp != null)
       location = "/" + webapp;
@@ -242,7 +242,7 @@ public class HttpPoster
     }
 
     // Initialize standard solr-j.
-    
+
     SSLConnectionSocketFactory myFactory;
     if (keystoreManager != null)
     {
@@ -265,7 +265,7 @@ public class HttpPoster
       .setTcpNoDelay(true)
       .setSoTimeout(socketTimeout)
       .build());
-    
+
     RequestConfig.Builder requestBuilder = RequestConfig.custom()
       .setCircularRedirectsAllowed(true)
       .setSocketTimeout(socketTimeout)
@@ -286,9 +286,16 @@ public class HttpPoster
       CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
       Credentials credentials = new UsernamePasswordCredentials(userID, password);
       if (realm != null && realm.trim().length() > 0)
-        credentialsProvider.setCredentials(new AuthScope(AuthScope.ANY_HOST, AuthScope.ANY_PORT, realm), credentials);
+      {
+        final AuthScope scope = new AuthScope(AuthScope.ANY_HOST, AuthScope.ANY_PORT, realm);
+        credentialsProvider.setCredentials(scope, credentials);
+        clientBuilder.addInterceptorFirst(new PreemptiveBasicAuthInterceptor(scope));
+      }
       else
+      {
         credentialsProvider.setCredentials(AuthScope.ANY, credentials);
+        clientBuilder.addInterceptorFirst(new PreemptiveBasicAuthInterceptor(AuthScope.ANY));
+      }
 
       clientBuilder.setDefaultCredentialsProvider(credentialsProvider);
     }
@@ -314,7 +321,7 @@ public class HttpPoster
     }
 
   }
-  
+
   /** Shut down the poster.
   */
   public void shutdown()
@@ -335,7 +342,7 @@ public class HttpPoster
       connectionManager.shutdown();
     connectionManager = null;
   }
-  
+
   /** Cause a commit to happen.
   */
   public void commitPost()
@@ -381,7 +388,7 @@ public class HttpPoster
       return;
     }
   }
-  
+
   /** Handle a RuntimeException.
   * Unfortunately, SolrCloud 4.6.x throws RuntimeExceptions whenever ZooKeeper is not happy.
   * We have to catch these too.  I've logged a ticket: SOLR-5678.
@@ -399,12 +406,12 @@ public class HttpPoster
         currentTime + 2L * 60L * 60000L,
         -1,
         true);
-    } 
+    }
     // Solr was not able to parse the request because it is malformed: skip the document
     Logging.ingest.warn("Solr was unable to parse request during "+context+": "+e.getMessage(),e);
     return;
   }
-  
+
   /** Handle a SolrServerException.
   * These exceptions seem to be catch-all exceptions having to do with misconfiguration or
   * underlying IO exceptions, or request parsing exceptions.
@@ -457,7 +464,7 @@ public class HttpPoster
         throw new ManifoldCFException("Unexpected error: "+e2.getMessage());
       }
     }
-      
+
     // Use the exception text to determine the proper result.
     if (code == 500 && e.getMessage().indexOf("org.apache.tika.exception.TikaException") != -1)
       // Can't process the document, so don't keep trying.
@@ -470,17 +477,17 @@ public class HttpPoster
       Logging.ingest.error(message);
       throw new ManifoldCFException(message);
     }
-    
+
     // If the code is in the 400 range, the document will never be accepted, so indicate that.
     if (code >= 400 && code < 500)
       return;
-    
+
     // The only other kind of return code we know how to handle is 50x.
     // For these, we should retry for a while.
     if (code == 500)
     {
       long currentTime = System.currentTimeMillis();
-      
+
       // Log the error
       String message = "Solr exception during "+context+" ("+e.code()+"): "+e.getMessage();
       Logging.ingest.warn(message,e);
@@ -491,11 +498,11 @@ public class HttpPoster
         -1,
         true);
     }
-    
+
     // Unknown code: end the job.
     throw new ManifoldCFException("Unhandled Solr exception during "+context+" ("+e.code()+"): "+e.getMessage());
   }
-  
+
   /** Handle an IOException.
   * I'm not actually sure where these exceptions come from in SolrJ, but we handle them
   * as real I/O errors, meaning they should be retried.
@@ -507,7 +514,7 @@ public class HttpPoster
       throw new ManifoldCFException(e.getMessage(), ManifoldCFException.INTERRUPTED);
 
     long currentTime = System.currentTimeMillis();
-    
+
     if (e instanceof java.net.ConnectException)
     {
       // Server isn't up at all.  Try for a brief time then give up.
@@ -520,7 +527,7 @@ public class HttpPoster
         3,
         true);
     }
-    
+
     if (e instanceof java.net.SocketTimeoutException)
     {
       String message2 = "Socket timeout exception during "+context+": "+e.getMessage();
@@ -532,7 +539,7 @@ public class HttpPoster
         -1,
         false);
     }
-      
+
     if (e.getClass().getName().equals("java.net.SocketException"))
     {
       // In the past we would have treated this as a straight document rejection, and
@@ -559,8 +566,8 @@ public class HttpPoster
           3,
           false);
       }
-      
-      // Other socket exceptions are service interruptions - but if we keep getting them, it means 
+
+      // Other socket exceptions are service interruptions - but if we keep getting them, it means
       // that a socket timeout is probably set too low to accept this particular document.  So
       // we retry for a while, then skip the document.
       String message2 = "Socket exception during "+context+": "+e.getMessage();
@@ -583,7 +590,7 @@ public class HttpPoster
       3,
       false);
   }
-  
+
   /**
   * Post the input stream to ingest
   *
@@ -616,7 +623,7 @@ public class HttpPoster
       activities.recordActivity(null,SolrConnector.INGEST_ACTIVITY,null,documentURI,activities.EXCLUDED_MIMETYPE,"Solr connector rejected document due to mime type restrictions: ("+document.getMimeType()+")");
       return false;
     }
-    
+
     // Convert the incoming acls that we know about to qualified forms, and reject the document if
     // we don't know how to deal with its acls
     Map<String,String[]> aclsMap = new HashMap<String,String[]>();
@@ -628,7 +635,7 @@ public class HttpPoster
       String aclType = aclTypes.next();
       aclsMap.put(aclType,convertACL(document.getSecurityACL(aclType),authorityNameString,activities));
       denyAclsMap.put(aclType,convertACL(document.getSecurityDenyACL(aclType),authorityNameString,activities));
-      
+
       // Reject documents that have security we don't know how to deal with in the Solr plugin!!  Only safe thing to do.
       if (!aclType.equals(RepositoryDocument.SECURITY_TYPE_DOCUMENT) &&
         !aclType.equals(RepositoryDocument.SECURITY_TYPE_SHARE) &&
@@ -769,7 +776,7 @@ public class HttpPoster
       {
         t.start();
         t.finishUp();
-        
+
         if (t.getActivityCode() != null)
           activities.recordActivity(t.getActivityStart(),SolrConnector.REMOVE_ACTIVITY,null,documentURI,t.getActivityCode(),t.getActivityDetails());
 
@@ -891,7 +898,7 @@ public class HttpPoster
   {
     out.add(fieldName, fieldValues);
   }
-  
+
   /** Write a field */
   protected static void writeField(ModifiableSolrParams out, String fieldName, List<String> fieldValues)
   {
@@ -902,7 +909,7 @@ public class HttpPoster
     }
     writeField(out, fieldName, values);
   }
-  
+
   /** Write a field */
   protected static void writeField(ModifiableSolrParams out, String fieldName, String fieldValue)
   {
@@ -923,7 +930,7 @@ public class HttpPoster
       writeField(out,metadataDenyACLName,denyAcl[i]);
     }
   }
-  
+
   /**
     * Output an acl level in a SolrInputDocument
     */
@@ -949,7 +956,7 @@ public class HttpPoster
     protected final Map<String,List<String>> arguments;
     protected final Map<String,String[]> aclsMap;
     protected final Map<String,String[]> denyAclsMap;
-    
+
     protected Long activityStart = null;
     protected Long activityBytes = null;
     protected String activityCode = null;
@@ -1043,7 +1050,7 @@ public class HttpPoster
             activityBytes = new Long(length);
             activityDetails = e.getMessage() +
               ((e.getCause() != null)?": "+e.getCause().getMessage():"");
-            
+
             // Broken pipe exceptions we log specially because they usually mean
             // Solr has rejected the document, and the user will want to know that.
             if (e.getCause() != null && e.getCause().getClass().getName().equals("java.net.SocketException") &&
@@ -1065,7 +1072,7 @@ public class HttpPoster
             activityCode = Integer.toString(e.code());
             activityDetails = e.getMessage() +
               ((e.getCause() != null)?": "+e.getCause().getMessage():"");
-            
+
             // Rethrow; we'll interpret at the next level
             throw e;
           }
@@ -1074,7 +1081,7 @@ public class HttpPoster
         {
           if ((ioe instanceof InterruptedIOException) && (!(ioe instanceof java.net.SocketTimeoutException)))
             return;
-          
+
           activityStart = new Long(fullStartTime);
           activityCode = ioe.getClass().getSimpleName().toUpperCase(Locale.ROOT);
           activityDetails = ioe.getMessage();
@@ -1098,7 +1105,7 @@ public class HttpPoster
 
       // Write the id field
       outputDoc.addField( idAttributeName, documentURI );
-      
+
       if (contentAttributeName != null)
       {
         // Copy the content into a string.  This is a bad thing to do, but we have no choice given SolrJ architecture at this time.
@@ -1115,7 +1122,7 @@ public class HttpPoster
         }
         outputDoc.addField( contentAttributeName, sb.toString() );
       }
-      
+
       // Write the rest of the attributes
       if ( originalSizeAttributeName != null )
       {
@@ -1187,7 +1194,7 @@ public class HttpPoster
     {
       ModifiableSolrParams out = new ModifiableSolrParams();
       Logging.ingest.debug("Solr: Writing document '"+documentURI);
-      
+
       // Write the id field
       writeField(out,LITERAL+idAttributeName,documentURI);
       // Write the rest of the attributes
@@ -1231,7 +1238,7 @@ public class HttpPoster
         if (!StringUtils.isBlank(mimeType))
           writeField(out,LITERAL+mimeTypeAttributeName,mimeType);
       }
-          
+
       // Write the access token information
       // Both maps have the same keys.
       Iterator<String> typeIterator = aclsMap.keySet().iterator();
@@ -1250,23 +1257,23 @@ public class HttpPoster
 
       // Write the metadata, each in a field by itself
       buildSolrParamsFromMetadata(out);
-             
+
       // These are unnecessary now in the case of non-solrcloud setups, because we overrode the SolrJ posting method to use multipart.
       //writeField(out,LITERAL+"stream_size",String.valueOf(length));
       //writeField(out,LITERAL+"stream_name",document.getFileName());
-          
+
       // General hint for Tika
       if (!StringUtils.isBlank(document.getFileName()))
         writeField(out,"resource.name",document.getFileName());
-          
+
       // Write the commitWithin parameter
       if (commitWithin != null)
         writeField(out,COMMITWITHIN_METADATA,commitWithin);
 
       contentStreamUpdateRequest.setParams(out);
-          
+
       contentStreamUpdateRequest.addContentStream(new RepositoryDocumentStream(is,length,contentType,contentName));
-      
+
       Logging.ingest.debug("Solr: Done writing '"+documentURI+"'");
     }
 
@@ -1405,7 +1412,7 @@ public class HttpPoster
         try
         {
           UpdateResponse response = new UpdateRequest(postRemoveAction).deleteById(documentURI).process(solrServer);
-            
+
           // Success
           activityStart = new Long(fullStartTime);
           activityCode = "OK";
@@ -1487,7 +1494,7 @@ public class HttpPoster
       return activityDetails;
     }
   }
-  
+
   /** Killable thread that does a commit.
   * Java 1.5 stopped permitting thread interruptions to abort socket waits.  As a result, it is impossible to get threads to shutdown cleanly that are doing
   * such waits.  So, the places where this happens are segregated in their own threads so that they can be just abandoned.
@@ -1630,7 +1637,7 @@ public class HttpPoster
     protected final long length;
     protected final String contentType;
     protected final String contentName;
-    
+
     public RepositoryDocumentStream(InputStream is, long length, String contentType, String contentName)
     {
       this.is = is;
@@ -1638,19 +1645,19 @@ public class HttpPoster
       this.contentType = contentType;
       this.contentName = contentName;
     }
-    
+
     @Override
     public Long getSize()
     {
       return new Long(length);
     }
-    
+
     @Override
     public InputStream getStream() throws IOException
     {
       return is;
     }
-    
+
     @Override
     public Reader getReader() throws IOException
     {
@@ -1676,7 +1683,7 @@ public class HttpPoster
   {
     /** Request parameters. */
     private ModifiableSolrParams params;
-    
+
     /**
      * Create a new SolrPing object.
      */
@@ -1684,7 +1691,7 @@ public class HttpPoster
       super(METHOD.GET, "/admin/ping");
       params = new ModifiableSolrParams();
     }
-    
+
     public SolrPing(String url)
     {
       super( METHOD.GET, url );
@@ -1705,45 +1712,45 @@ public class HttpPoster
     public ModifiableSolrParams getParams() {
       return params;
     }
-    
+
     /**
      * Remove the action parameter from this request. This will result in the same
      * behavior as {@code SolrPing#setActionPing()}. For Solr server version 4.0
      * and later.
-     * 
+     *
      * @return this
      */
     public SolrPing removeAction() {
       params.remove(CommonParams.ACTION);
       return this;
     }
-    
+
     /**
      * Set the action parameter on this request to enable. This will delete the
      * health-check file for the Solr core. For Solr server version 4.0 and later.
-     * 
+     *
      * @return this
      */
     public SolrPing setActionDisable() {
       params.set(CommonParams.ACTION, CommonParams.DISABLE);
       return this;
     }
-    
+
     /**
      * Set the action parameter on this request to enable. This will create the
      * health-check file for the Solr core. For Solr server version 4.0 and later.
-     * 
+     *
      * @return this
      */
     public SolrPing setActionEnable() {
       params.set(CommonParams.ACTION, CommonParams.ENABLE);
       return this;
     }
-    
+
     /**
      * Set the action parameter on this request to ping. This is the same as not
      * including the action at all. For Solr server version 4.0 and later.
-     * 
+     *
      * @return this
      */
     public SolrPing setActionPing() {
@@ -1783,6 +1790,6 @@ public class HttpPoster
     }
     return sb.toString();
   }
-  
+
 }
 

Added: manifoldcf/trunk/connectors/solr/connector/src/main/java/org/apache/manifoldcf/agents/output/solr/PreemptiveBasicAuthInterceptor.java
URL: http://svn.apache.org/viewvc/manifoldcf/trunk/connectors/solr/connector/src/main/java/org/apache/manifoldcf/agents/output/solr/PreemptiveBasicAuthInterceptor.java?rev=1897536&view=auto
==============================================================================
--- manifoldcf/trunk/connectors/solr/connector/src/main/java/org/apache/manifoldcf/agents/output/solr/PreemptiveBasicAuthInterceptor.java (added)
+++ manifoldcf/trunk/connectors/solr/connector/src/main/java/org/apache/manifoldcf/agents/output/solr/PreemptiveBasicAuthInterceptor.java Thu Jan 27 08:04:03 2022
@@ -0,0 +1,68 @@
+/* $Id$ */
+
+/**
+ * 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.manifoldcf.agents.output.solr;
+
+import org.apache.http.HttpException;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpRequestInterceptor;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.AuthState;
+import org.apache.http.auth.Credentials;
+import org.apache.http.client.CredentialsProvider;
+import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.impl.auth.BasicScheme;
+import org.apache.http.protocol.HttpContext;
+
+import java.io.IOException;
+
+public class PreemptiveBasicAuthInterceptor implements HttpRequestInterceptor {
+
+  private final AuthScope scope;
+
+  public PreemptiveBasicAuthInterceptor(final AuthScope scope) {
+    this.scope = scope;
+  }
+
+  @Override
+  public void process(final HttpRequest request,
+                      final HttpContext context) throws HttpException, IOException {
+    if (!alreadyAppliesAuthScheme(context)) {
+      final CredentialsProvider provider = getCredentialsProvider(context);
+      final Credentials credentials = provider.getCredentials(scope);
+      if (credentials == null) {
+        throw new HttpException("Missing credentials for preemptive basic authentication.");
+      }
+      final AuthState state = getAuthState(context);
+      state.update(new BasicScheme(), credentials);
+    }
+  }
+
+  private boolean alreadyAppliesAuthScheme(final HttpContext context) {
+    final AuthState authState = getAuthState(context);
+    return authState.getAuthScheme() != null;
+  }
+
+  private AuthState getAuthState(final HttpContext context) {
+    return (AuthState) context.getAttribute(HttpClientContext.TARGET_AUTH_STATE);
+  }
+
+  private CredentialsProvider getCredentialsProvider(final HttpContext context) {
+    return (CredentialsProvider) context.getAttribute(HttpClientContext.CREDS_PROVIDER);
+  }
+}

Added: manifoldcf/trunk/connectors/solr/connector/src/test/java/org/apache/manifoldcf/agents/output/solr/tests/PreemptiveBasicAuthInterceptorTest.java
URL: http://svn.apache.org/viewvc/manifoldcf/trunk/connectors/solr/connector/src/test/java/org/apache/manifoldcf/agents/output/solr/tests/PreemptiveBasicAuthInterceptorTest.java?rev=1897536&view=auto
==============================================================================
--- manifoldcf/trunk/connectors/solr/connector/src/test/java/org/apache/manifoldcf/agents/output/solr/tests/PreemptiveBasicAuthInterceptorTest.java (added)
+++ manifoldcf/trunk/connectors/solr/connector/src/test/java/org/apache/manifoldcf/agents/output/solr/tests/PreemptiveBasicAuthInterceptorTest.java Thu Jan 27 08:04:03 2022
@@ -0,0 +1,124 @@
+/* $Id$ */
+
+/**
+ * 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
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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.manifoldcf.agents.output.solr.tests;
+
+import org.apache.http.HttpException;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpRequestInterceptor;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.AuthState;
+import org.apache.http.auth.Credentials;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.CredentialsProvider;
+import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.impl.auth.BasicScheme;
+import org.apache.http.message.BasicHttpRequest;
+import org.apache.http.protocol.HttpContext;
+import org.apache.manifoldcf.agents.output.solr.PreemptiveBasicAuthInterceptor;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class PreemptiveBasicAuthInterceptorTest {
+
+  @Test
+  public void shouldAddBasicAuthenticationToRequestIfNotAlreadySet() throws Exception {
+    final HttpRequestInterceptor interceptor = new PreemptiveBasicAuthInterceptor(AuthScope.ANY);
+    final HttpContext context = contextWithoutBasicAuth(new UsernamePasswordCredentials("user", "secret"));
+    interceptor.process(get(), context);
+    final AuthState authState = (AuthState) context.getAttribute(HttpClientContext.TARGET_AUTH_STATE);
+    assertTrue(authState.getAuthScheme() instanceof BasicScheme);
+    assertEquals("user", authState.getCredentials().getUserPrincipal().getName());
+    assertEquals("secret", authState.getCredentials().getPassword());
+  }
+
+  @Test
+  public void shouldThrowHttpExceptionIfNoCredentialsWereProvided() {
+    final HttpRequestInterceptor interceptor = new PreemptiveBasicAuthInterceptor(AuthScope.ANY);
+    final HttpContext context = contextWithoutBasicAuth(null);
+    try {
+      interceptor.process(get(), context);
+      fail("Expected an HttpException, but none was raised.");
+    } catch (HttpException e) {
+      assertEquals("Missing credentials for preemptive basic authentication.", e.getMessage());
+    } catch (IOException e) {
+      fail("Expected an HttpException, but an IOException was raised instead.");
+    }
+  }
+
+  private HttpRequest get() {
+    return new BasicHttpRequest("GET", "https://manifoldcf.apache.org/");
+  }
+
+  private HttpContext contextWithoutBasicAuth(final Credentials credentials) {
+    final CredentialsProvider credentialsProvider = new FakeCredentialsProvider();
+    credentialsProvider.setCredentials(AuthScope.ANY, credentials);
+    final AuthState authState = new AuthState();
+    final HttpContext context = new FakeHttpContext();
+    context.setAttribute(HttpClientContext.CREDS_PROVIDER, credentialsProvider);
+    context.setAttribute(HttpClientContext.TARGET_AUTH_STATE, authState);
+    return context;
+  }
+
+  static class FakeHttpContext implements HttpContext {
+
+    private final Map<String, Object> context = new HashMap<>();
+
+    @Override
+    public Object getAttribute(final String id) {
+      return context.get(id);
+    }
+
+    @Override
+    public void setAttribute(final String id, final Object obj) {
+      context.put(id, obj);
+    }
+
+    @Override
+    public Object removeAttribute(final String id) {
+      return context.remove(id);
+    }
+  }
+
+  static class FakeCredentialsProvider implements CredentialsProvider {
+
+    private final Map<AuthScope, Credentials> credentialsByAuthScope = new HashMap<>();
+
+    @Override
+    public void setCredentials(final AuthScope authScope, final Credentials credentials) {
+      credentialsByAuthScope.put(authScope, credentials);
+    }
+
+    @Override
+    public Credentials getCredentials(final AuthScope authScope) {
+      return credentialsByAuthScope.get(authScope);
+    }
+
+    @Override
+    public void clear() {
+      credentialsByAuthScope.clear();
+    }
+  }
+}