internal/httpd/aasa_test.go

206 lines · 5950 bytes

6 symbols in this file
  1package httpd
  2
  3import (
  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.
 14func 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.
 34func 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
 43func 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.
155func 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.
166var 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
184func 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}