001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.test;
018
019import java.util.*;
020import java.util.function.*;
021import java.util.regex.*;
022
023/**
024 * Test helper that checks every asset a rendered console page links (stylesheets, scripts, images, icons) actually
025 * resolves.
026 *
027 * <p>
028 * The console chrome links its assets at context-root URLs such as {@code /juneau-console/chrome.css}, so they only
029 * load if the host mounts something at those paths.  Page tests that assert the hrefs cannot see a missing mount;
030 * this helper fetches each URL through whatever client the caller supplies and reports every one that does not
031 * answer {@code 200}.
032 *
033 * <p>
034 * The fetcher is a plain {@link Function} from URL to HTTP status, so the class has no dependency on a test
035 * framework or on a particular client.  Failures throw {@link AssertionError}.  Only same-origin URLs are checked:
036 * absolute {@code http(s):}, protocol-relative, {@code data:} and fragment-only URLs are skipped.
037 *
038 * <h5 class='section'>Example:</h5>
039 * <p class='bjava'>
040 *    MockRestClient <jv>client</jv> = MockRestClient.<jsm>buildLax</jsm>(MyHost.<jk>class</jk>);
041 *    String <jv>html</jv> = <jv>client</jv>.get(<js>"/page"</js>).run().getContent().asString();
042 *    AssetResolutionAssert.<jsm>assertAssetsResolve</jsm>(<jv>html</jv>, <jv>url</jv> -&gt; <jv>client</jv>.get(<jv>url</jv>).run().getStatusCode());
043 * </p>
044 *
045 * @since 10.0.0
046 */
047public final class AssetResolutionAssert {
048
049   private static final Pattern TAG = Pattern.compile("<(script|img|link)\\b([^>]*)>", Pattern.CASE_INSENSITIVE);
050   private static final Pattern SRC = attr("src");
051   private static final Pattern HREF = attr("href");
052   private static final Pattern REL = attr("rel");
053   private static final Set<String> ASSET_RELS = Set.of("stylesheet", "icon", "shortcut", "apple-touch-icon", "preload", "modulepreload", "manifest");
054
055   private AssetResolutionAssert() {}
056
057   private static Pattern attr(String name) {
058      return Pattern.compile("(?<![\\w-])" + name + "\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')", Pattern.CASE_INSENSITIVE);
059   }
060
061   private static String value(Pattern p, String attrs) {
062      var m = p.matcher(attrs);
063      if (! m.find())
064         return null;
065      return m.group(m.group(1) != null ? 1 : 2);
066   }
067
068   private static boolean isAssetLink(String attrs) {
069      var rel = value(REL, attrs);
070      if (rel == null)
071         return false;
072      for (var token : rel.toLowerCase(Locale.ROOT).split("\\s+"))
073         if (ASSET_RELS.contains(token))
074            return true;
075      return false;
076   }
077
078   private static boolean isSameOrigin(String url) {
079      return ! (url.isEmpty() || url.startsWith("#") || url.startsWith("//") || url.matches("(?i)^[a-z][a-z0-9+.-]*:.*"));
080   }
081
082   /**
083    * Returns the same-origin asset URLs a page links, in document order and without duplicates.
084    *
085    * <p>
086    * Covers {@code <script src>}, {@code <img src>} and {@code <link href>} whose {@code rel} is a stylesheet,
087    * icon, preload or manifest.  {@code &amp;} is decoded and any fragment is dropped.
088    *
089    * @param html The rendered page.
090    * @return The asset URLs.  Never <jk>null</jk>.
091    */
092   public static List<String> assetUrls(String html) {
093      var urls = new LinkedHashSet<String>();
094      var m = TAG.matcher(html);
095      while (m.find()) {
096         var attrs = m.group(2);
097         String url;
098         if ("link".equalsIgnoreCase(m.group(1)))
099            url = isAssetLink(attrs) ? value(HREF, attrs) : null;
100         else
101            url = value(SRC, attrs);
102         if (url == null)
103            continue;
104         url = url.replace("&amp;", "&").trim();
105         var hash = url.indexOf('#');
106         if (hash > 0)
107            url = url.substring(0, hash);
108         if (isSameOrigin(url))
109            urls.add(url);
110      }
111      return List.copyOf(urls);
112   }
113
114   /**
115    * Returns a description of every linked asset that does not answer {@code 200}.
116    *
117    * @param html The rendered page.
118    * @param statusOf Fetches a URL and returns its HTTP status code.
119    * @return One {@code "<status> <url>"} entry per failing asset, sorted; empty when every asset resolves.
120    */
121   @SuppressWarnings({
122      "java:S4276" // A null status signals failure, so it cannot be a ToIntFunction
123   })
124   public static List<String> unresolved(String html, Function<String,Integer> statusOf) {
125      var bad = new TreeSet<String>();
126      for (var url : assetUrls(html)) {
127         var status = statusOf.apply(url);
128         if (status == null || status != 200)
129            bad.add(status + " " + url);
130      }
131      return List.copyOf(bad);
132   }
133
134   /**
135    * Asserts that every asset the page links answers {@code 200}.
136    *
137    * @param html The rendered page.
138    * @param statusOf Fetches a URL and returns its HTTP status code.
139    * @throws AssertionError If any linked asset does not resolve; the message lists each one.
140    */
141   @SuppressWarnings({
142      "java:S4276" // A null status signals failure, so it cannot be a ToIntFunction
143   })
144   public static void assertAssetsResolve(String html, Function<String,Integer> statusOf) {
145      assertAssetsResolve(null, html, statusOf);
146   }
147
148   /**
149    * Asserts that every asset the page links answers {@code 200}, naming the page in the failure message.
150    *
151    * @param page A label for the page (typically its path); can be <jk>null</jk>.
152    * @param html The rendered page.
153    * @param statusOf Fetches a URL and returns its HTTP status code.
154    * @throws AssertionError If any linked asset does not resolve; the message lists each one.
155    */
156   @SuppressWarnings({
157      "java:S4276" // A null status signals failure, so it cannot be a ToIntFunction
158   })
159   public static void assertAssetsResolve(String page, String html, Function<String,Integer> statusOf) {
160      var bad = unresolved(html, statusOf);
161      if (! bad.isEmpty())
162         throw new AssertionError((page == null ? "" : "Unresolved assets linked from " + page + ":\n") + String.join("\n", bad));
163   }
164}