| @@ -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 | } |