Support projects with anonymous HTTP access

Enforcing authentication in all cases is problematic for those who wish
to make their public Gerrit hosted Go projects easily installable via
the standard Go toolchain, either as standalone applications or as
libraries.

Anonymous repo URLs are now used as long as the anonymous user is
granted read access to the project's `refs/heads/*`.

Change-Id: I8b5bfbf3019dc7d1920bbfab048412f29962d36e
diff --git a/src/main/java/com/ericsson/gerrit/plugins/goimport/GoImportFilter.java b/src/main/java/com/ericsson/gerrit/plugins/goimport/GoImportFilter.java
index d297b26..3675d92 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/goimport/GoImportFilter.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/goimport/GoImportFilter.java
@@ -17,12 +17,18 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.httpd.AllRequestFilter;
 import com.google.gerrit.httpd.HtmlDomUtil;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.OutputStream;
@@ -67,13 +73,21 @@
           + "</body>\n"
           + "</html>";
 
+  private final Provider<AnonymousUser> anonProvider;
+  private final PermissionBackend permissions;
   private final ProjectCache projectCache;
   final String webUrl;
   final String projectPrefix;
 
   @Inject
-  GoImportFilter(ProjectCache projectCache, @CanonicalWebUrl String webUrl)
+  GoImportFilter(
+      Provider<AnonymousUser> anonProvider,
+      PermissionBackend permissions,
+      ProjectCache projectCache,
+      @CanonicalWebUrl String webUrl)
       throws URISyntaxException {
+    this.anonProvider = anonProvider;
+    this.permissions = permissions;
     this.projectCache = projectCache;
     this.webUrl = webUrl.replaceFirst("/?$", "/");
     this.projectPrefix = generateProjectPrefix();
@@ -146,7 +160,23 @@
   }
 
   private CharSequence getContent(String projectName) {
-    return projectPrefix + projectName + " git " + webUrl + "a/" + projectName;
+    return projectPrefix + projectName + " git " + getRepoRoot(projectName);
+  }
+
+  private String getRepoRoot(String projectName) {
+    if (allowsAnonymousAccess(projectName)) {
+      return webUrl + projectName;
+    }
+
+    return webUrl + "a/" + projectName;
+  }
+
+  private boolean allowsAnonymousAccess(String projectName) {
+    AnonymousUser anonymous = anonProvider.get();
+    Branch.NameKey heads =
+        new Branch.NameKey(new Project.NameKey(projectName), RefNames.REFS_HEADS);
+
+    return permissions.user(anonymous).ref(heads).testOrFalse(RefPermission.READ);
   }
 
   private boolean projectExists(String projectName) {
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index d2b86eb..35cfe61 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -47,3 +47,9 @@
 | `/bob/my-project/package1`         | `bob/my-project` |
 | `/bob/my-project/package1/folder2` | `bob/my-project` |
 
+### Access (Anonymous vs. Authenticated URLs)
+The exact git clone URL served to the client go command will depend on the
+project's configured ACLs. If the project is configured to allow anonymous
+read access to `refs/heads/*`, an anonymous URL will be served (e.g.
+`https://gerrit.example/bob/my-project`). Otherwise, a URL that requires
+authentication will be used (e.g. `https://gerrit.example/a/bob/my-project`).
diff --git a/src/test/java/com/ericsson/gerrit/plugins/goimport/GoImportFilterTest.java b/src/test/java/com/ericsson/gerrit/plugins/goimport/GoImportFilterTest.java
index d00e03e..3b24f18 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/goimport/GoImportFilterTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/goimport/GoImportFilterTest.java
@@ -23,8 +23,12 @@
 import static org.mockito.Mockito.when;
 
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.net.URISyntaxException;
 import javax.servlet.FilterChain;
@@ -42,13 +46,17 @@
 public class GoImportFilterTest {
 
   private static final String PROD_URL = "https://gerrit-review.googlesource.com";
+  private static final String PROJECT_URL_AUTH =
+      "https://gerrit-review.googlesource.com/a/projectName";
+  private static final String PROJECT_URL_ANON =
+      "https://gerrit-review.googlesource.com/projectName";
   private static final String PAGE_200 =
       "<!DOCTYPE html>\n"
           + "<html>\n"
           + "<head>\n"
           + "  <title>Gerrit-Go-Import</title>\n"
           + "  <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"/>\n"
-          + "  <meta name=\"go-import\" content=\"gerrit-review.googlesource.com/projectName git https://gerrit-review.googlesource.com/a/projectName\"/>\n"
+          + "  <meta name=\"go-import\" content=\"gerrit-review.googlesource.com/projectName git ${content}\"/>\n"
           + "</head>\n"
           + "<body>\n"
           + "<div>\n"
@@ -56,9 +64,16 @@
           + "</div>\n"
           + "</body>\n"
           + "</html>";
+  private static final String PAGE_200_AUTH = PAGE_200.replace("${content}", PROJECT_URL_AUTH);
+  private static final String PAGE_200_ANON = PAGE_200.replace("${content}", PROJECT_URL_ANON);
 
   private GoImportFilter unitUnderTest;
 
+  @Mock private Provider<AnonymousUser> mockAnonProvider;
+  @Mock private AnonymousUser mockAnon;
+  @Mock private PermissionBackend mockPerms;
+  @Mock private PermissionBackend.WithUser mockPermsWithUser;
+  @Mock private PermissionBackend.ForRef mockPermsForRef;
   @Mock private ProjectCache mockProjectCache;
   @Mock private HttpServletRequest mockRequest;
   @Mock private HttpServletResponse mockResponse;
@@ -68,23 +83,31 @@
 
   @Before
   public void setUp() throws Exception {
-    unitUnderTest = new GoImportFilter(mockProjectCache, PROD_URL);
+    unitUnderTest = new GoImportFilter(mockAnonProvider, mockPerms, mockProjectCache, PROD_URL);
     assertThat(unitUnderTest).isNotNull();
     when(mockResponse.getOutputStream()).thenReturn(mockOutputStream);
+
+    when(mockAnonProvider.get()).thenReturn(mockAnon);
+    when(mockPerms.user(mockAnon)).thenReturn(mockPermsWithUser);
+    when(mockPermsWithUser.ref(any())).thenReturn(mockPermsForRef);
   }
 
   @Test
   public void testConstructor() throws Exception {
     assertThat(unitUnderTest.webUrl.endsWith("/")).isTrue();
     unitUnderTest =
-        new GoImportFilter(mockProjectCache, "http://gerrit-review.googlesource.com:8080/");
+        new GoImportFilter(
+            mockAnonProvider,
+            mockPerms,
+            mockProjectCache,
+            "http://gerrit-review.googlesource.com:8080/");
     assertThat(unitUnderTest.webUrl.endsWith("/")).isTrue();
     assertThat(unitUnderTest.projectPrefix).isNotNull();
   }
 
   @Test(expected = URISyntaxException.class)
   public void testConstructorWithURISyntaxException() throws Exception {
-    unitUnderTest = new GoImportFilter(mockProjectCache, "\\\\");
+    unitUnderTest = new GoImportFilter(mockAnonProvider, mockPerms, mockProjectCache, "\\\\");
   }
 
   @Test
@@ -116,11 +139,13 @@
     when(mockRequest.getServletPath()).thenReturn("/projectName");
     when(mockRequest.getParameter("go-get")).thenReturn("1");
     when(mockProjectCache.get(new Project.NameKey("projectName"))).thenReturn(mockProjectState);
+    when(mockPermsForRef.testOrFalse(RefPermission.READ)).thenReturn(false);
     unitUnderTest.doFilter(mockRequest, mockResponse, mockChain);
-    verify(mockOutputStream, times(1)).write(PAGE_200.getBytes());
+    verify(mockOutputStream, times(1)).write(PAGE_200_AUTH.getBytes());
     verify(mockChain, times(0)).doFilter(mockRequest, mockResponse);
     verify(mockProjectCache, times(1)).get(any(Project.NameKey.class));
     verify(mockResponse, times(1)).setStatus(200);
+    verify(mockPermsForRef, times(1)).testOrFalse(RefPermission.READ);
   }
 
   @Test
@@ -128,11 +153,27 @@
     when(mockRequest.getServletPath()).thenReturn("/projectName/my/package");
     when(mockRequest.getParameter("go-get")).thenReturn("1");
     when(mockProjectCache.get(new Project.NameKey("projectName"))).thenReturn(mockProjectState);
+    when(mockPermsForRef.testOrFalse(RefPermission.READ)).thenReturn(false);
     unitUnderTest.doFilter(mockRequest, mockResponse, mockChain);
-    verify(mockOutputStream, times(1)).write(PAGE_200.getBytes());
+    verify(mockOutputStream, times(1)).write(PAGE_200_AUTH.getBytes());
     verify(mockChain, times(0)).doFilter(mockRequest, mockResponse);
     verify(mockProjectCache, times(3)).get(any(Project.NameKey.class));
     verify(mockResponse, times(1)).setStatus(200);
+    verify(mockPermsForRef, times(1)).testOrFalse(RefPermission.READ);
+  }
+
+  @Test
+  public void testDoFilterWithAnonymousAccessibleProject() throws Exception {
+    when(mockRequest.getServletPath()).thenReturn("/projectName");
+    when(mockRequest.getParameter("go-get")).thenReturn("1");
+    when(mockProjectCache.get(new Project.NameKey("projectName"))).thenReturn(mockProjectState);
+    when(mockPermsForRef.testOrFalse(RefPermission.READ)).thenReturn(true);
+    unitUnderTest.doFilter(mockRequest, mockResponse, mockChain);
+    verify(mockOutputStream, times(1)).write(PAGE_200_ANON.getBytes());
+    verify(mockChain, times(0)).doFilter(mockRequest, mockResponse);
+    verify(mockProjectCache, times(1)).get(any(Project.NameKey.class));
+    verify(mockResponse, times(1)).setStatus(200);
+    verify(mockPermsForRef, times(1)).testOrFalse(RefPermission.READ);
   }
 
   @Test
diff --git a/src/test/java/com/ericsson/gerrit/plugins/goimport/HttpModuleTest.java b/src/test/java/com/ericsson/gerrit/plugins/goimport/HttpModuleTest.java
index 547c159..a0a107d 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/goimport/HttpModuleTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/goimport/HttpModuleTest.java
@@ -16,7 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
@@ -32,6 +34,8 @@
 public class HttpModuleTest {
 
   private HttpModule unitUnderTest;
+  @Mock private AnonymousUser mockAnon;
+  @Mock private PermissionBackend mockPerms;
   @Mock private ProjectCache mockProjectCache;
 
   @Before
@@ -53,6 +57,8 @@
   public class TestModule extends AbstractModule {
     @Override
     protected void configure() {
+      bind(AnonymousUser.class).toInstance(mockAnon);
+      bind(PermissionBackend.class).toInstance(mockPerms);
       bind(ProjectCache.class).toInstance(mockProjectCache);
     }