| @@ -0,0 +1,206 @@ |
| 1 | package httpd |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "regexp" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "gitbay.org/gitbay/internal/config" |
| 10 | ) |
| 11 | |
| 12 | // aasaGlob matches one applinks pattern: * is any run of characters, |
| 13 | // slashes included, and ? is one character. |
| 14 | func aasaGlob(pat, s string) bool { |
| 15 | if pat == "" { |
| 16 | return s == "" |
| 17 | } |
| 18 | switch pat[0] { |
| 19 | case '*': |
| 20 | for i := 0; i <= len(s); i++ { |
| 21 | if aasaGlob(pat[1:], s[i:]) { |
| 22 | return true |
| 23 | } |
| 24 | } |
| 25 | return false |
| 26 | case '?': |
| 27 | return s != "" && aasaGlob(pat[1:], s[1:]) |
| 28 | } |
| 29 | return s != "" && s[0] == pat[0] && aasaGlob(pat[1:], s[1:]) |
| 30 | } |
| 31 | |
| 32 | // opensApp applies a component list the way iOS does: the first match |
| 33 | // decides, and a path nothing matches stays in the browser. |
| 34 | func opensApp(comps []aasaComponent, p string) bool { |
| 35 | for _, c := range comps { |
| 36 | if aasaGlob(c.Path, p) { |
| 37 | return !c.Exclude |
| 38 | } |
| 39 | } |
| 40 | return false |
| 41 | } |
| 42 | |
| 43 | func TestAppleAppSiteAssociation(t *testing.T) { |
| 44 | s := plainServer() |
| 45 | s.cfg.Web.AppleAppIDs = []string{"ZCNAX3VL9D.org.gitbay.gitbay"} |
| 46 | w := get(t, s.Handler(), "/.well-known/apple-app-site-association", nil) |
| 47 | if w.Code != 200 { |
| 48 | t.Fatalf("status %d", w.Code) |
| 49 | } |
| 50 | if ct := w.Header().Get("Content-Type"); ct != "application/json" { |
| 51 | t.Errorf("content type %q", ct) |
| 52 | } |
| 53 | var doc struct { |
| 54 | Applinks struct { |
| 55 | Details []struct { |
| 56 | AppIDs []string `json:"appIDs"` |
| 57 | Components []aasaComponent `json:"components"` |
| 58 | } `json:"details"` |
| 59 | } `json:"applinks"` |
| 60 | } |
| 61 | if err := json.Unmarshal(w.Body.Bytes(), &doc); err != nil { |
| 62 | t.Fatalf("body is not JSON: %v\n%s", err, w.Body.String()) |
| 63 | } |
| 64 | if len(doc.Applinks.Details) != 1 || len(doc.Applinks.Details[0].AppIDs) != 1 || |
| 65 | doc.Applinks.Details[0].AppIDs[0] != "ZCNAX3VL9D.org.gitbay.gitbay" { |
| 66 | t.Fatalf("details: %+v", doc.Applinks.Details) |
| 67 | } |
| 68 | comps := doc.Applinks.Details[0].Components |
| 69 | for _, p := range []string{ |
| 70 | "/krz", |
| 71 | "/krz/-/repositories", |
| 72 | "/krz/-/snippets", |
| 73 | "/krz/-/snippets/abc123", |
| 74 | "/krz/-/labels", |
| 75 | "/krz/-/milestones", |
| 76 | "/krz/gitbay", |
| 77 | "/krz/gitbay/issues", |
| 78 | "/krz/gitbay/issues/12", |
| 79 | "/krz/gitbay/mrs", |
| 80 | "/krz/gitbay/mrs/7", |
| 81 | "/krz/gitbay/builds", |
| 82 | "/krz/gitbay/builds/1510", |
| 83 | "/krz/gitbay/tree/main/internal/httpd", |
| 84 | "/krz/gitbay/blob/main/README.org", |
| 85 | "/krz/gitbay/blame/main/README.org", |
| 86 | "/krz/gitbay/commit/d0c63c4", |
| 87 | "/krz/gitbay/log", |
| 88 | "/krz/gitbay/log/main", |
| 89 | "/krz/gitbay/refs", |
| 90 | "/krz/gitbay/compare", |
| 91 | "/krz/gitbay/releases", |
| 92 | "/krz/gitbay/milestones", |
| 93 | "/krz/gitbay/labels", |
| 94 | "/krz/gitbay/wiki", |
| 95 | "/krz/gitbay/wiki/Architecture/00-Overview", |
| 96 | "/krz/gitbay/blob/main/feed.atom", |
| 97 | "/bookmarks", |
| 98 | "/notifications", |
| 99 | } { |
| 100 | if !opensApp(comps, p) { |
| 101 | t.Errorf("%s does not open the app", p) |
| 102 | } |
| 103 | } |
| 104 | for _, p := range []string{ |
| 105 | "/", |
| 106 | "/.well-known/apple-app-site-association", |
| 107 | "/static/style.css", |
| 108 | "/static/fonts/x.woff2", |
| 109 | "/favicon.svg", |
| 110 | "/api/v1/read", |
| 111 | "/admin", |
| 112 | "/admin/users", |
| 113 | "/settings", |
| 114 | "/settings/export", |
| 115 | "/login", |
| 116 | "/logout", |
| 117 | "/register", |
| 118 | "/new", |
| 119 | "/explore", |
| 120 | "/search", |
| 121 | "/healthz", |
| 122 | "/privacy", |
| 123 | "/krz/activity.atom", |
| 124 | "/krz/-/queries", |
| 125 | "/krz/-/snippets/new", |
| 126 | "/krz/-/snippets/abc123/raw/a.txt", |
| 127 | "/krz/gitbay/settings", |
| 128 | "/krz/gitbay/fork", |
| 129 | "/krz/gitbay/search", |
| 130 | "/krz/gitbay/symbols", |
| 131 | "/krz/gitbay/issues/new", |
| 132 | "/krz/gitbay/mrs/new", |
| 133 | "/krz/gitbay/mrs/7/range-diff", |
| 134 | "/krz/gitbay/edit/main/README.org", |
| 135 | "/krz/gitbay/raw/main/README.org", |
| 136 | "/krz/gitbay/archive/main.tar.gz", |
| 137 | "/krz/gitbay/releases/download/v1.0.0/gitbay.tar.gz", |
| 138 | "/krz/gitbay/releases.atom", |
| 139 | "/krz/gitbay/log.atom", |
| 140 | "/krz/gitbay/badge/build.svg", |
| 141 | "/krz/gitbay/wiki/_raw/diagram.svg", |
| 142 | "/krz/gitbay/compare/main...aasa", |
| 143 | "/krz/gitbay/info/refs", |
| 144 | "/krz/gitbay/info/lfs/objects/batch", |
| 145 | "/krz/gitbay/raw/main/docs/tree/a", |
| 146 | "/krz/gitbay/log.atom/main", |
| 147 | } { |
| 148 | if opensApp(comps, p) { |
| 149 | t.Errorf("%s opens the app", p) |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // With no app configured the file does not exist. |
| 155 | func TestAppleAppSiteAssociationOff(t *testing.T) { |
| 156 | for _, r := range plainServer().Routes() { |
| 157 | if r.Pattern == "/.well-known/apple-app-site-association" { |
| 158 | t.Fatal("route registered with no apple_app_ids") |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | // appRoutes are the GET patterns the iOS app has a screen for. Every |
| 164 | // other page stays in the browser, so a new route is classified here or |
| 165 | // this test fails. |
| 166 | var appRoutes = map[string]bool{ |
| 167 | "/bookmarks": true, "/notifications": true, |
| 168 | "/{owner}": true, "/{owner}/-/repositories": true, "/{owner}/-/bookmarks": true, |
| 169 | "/{owner}/-/people": true, "/{owner}/-/labels": true, "/{owner}/-/milestones": true, |
| 170 | "/{owner}/-/snippets": true, "/{owner}/-/snippets/{id}": true, |
| 171 | "/{owner}/{repo}": true, |
| 172 | "/{owner}/{repo}/issues": true, "/{owner}/{repo}/issues/{n}": true, |
| 173 | "/{owner}/{repo}/mrs": true, "/{owner}/{repo}/mrs/{n}": true, |
| 174 | "/{owner}/{repo}/builds": true, "/{owner}/{repo}/builds/{n}": true, |
| 175 | "/{owner}/{repo}/tree/{ref}/{path...}": true, "/{owner}/{repo}/blob/{ref}/{path...}": true, |
| 176 | "/{owner}/{repo}/blame/{ref}/{path...}": true, "/{owner}/{repo}/commit/{sha}": true, |
| 177 | "/{owner}/{repo}/log": true, "/{owner}/{repo}/log/{ref}": true, |
| 178 | "/{owner}/{repo}/refs": true, "/{owner}/{repo}/compare": true, |
| 179 | "/{owner}/{repo}/releases": true, "/{owner}/{repo}/milestones": true, |
| 180 | "/{owner}/{repo}/labels": true, "/{owner}/{repo}/wiki": true, |
| 181 | "/{owner}/{repo}/wiki/{page...}": true, |
| 182 | } |
| 183 | |
| 184 | func TestAppleAppSiteAssociationCoversEveryRoute(t *testing.T) { |
| 185 | cfg := config.Default() |
| 186 | cfg.Web.Mode = "accounts" |
| 187 | cfg.Registration.Mode = "open" |
| 188 | cfg.API.Enabled = true |
| 189 | s := New(cfg, nil, nil) |
| 190 | wild := regexp.MustCompile(`\{[a-z]+(\.\.\.)?\}`) |
| 191 | for _, r := range s.Routes() { |
| 192 | if r.Method != "GET" { |
| 193 | continue |
| 194 | } |
| 195 | pat := strings.TrimSuffix(r.Pattern, "{$}") |
| 196 | p := wild.ReplaceAllStringFunc(pat, func(w string) string { |
| 197 | if strings.HasSuffix(w, "...}") { |
| 198 | return "a/b" |
| 199 | } |
| 200 | return "x" |
| 201 | }) |
| 202 | if got := opensApp(aasaComponents, p); got != appRoutes[r.Pattern] { |
| 203 | t.Errorf("%s (%s): opens app = %v", r.Pattern, p, got) |
| 204 | } |
| 205 | } |
| 206 | } |