// SPDX-License-Identifier: AGPL-3.0-or-later package app import ( "context" "io" "net/http" "net/netip" "net/url" "strings" "testing" "time" "connectrpc.com/connect" "github.com/bnema/zerowrap" "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" pb "bnema.dev/ermite-api/gen/ermite" "bnema.dev/ermite/internal/domain" "bnema.dev/ermite/internal/ports" ) const ( publicOrigin = "http://example.test" // ERMITE_PUBLIC_URL of the harness sessionCookie = "__Host-ermite" ) // page is the answer of a request to the web. type page struct { status int hdr http.Header body string } func (p page) cookie(name string) *http.Cookie { for _, c := range (&http.Response{Header: p.hdr}).Cookies() { if c.Name == name { return c } } return nil } // browser is a bare HTTP client: it follows no redirect and keeps no cookie, // so that the tests say exactly what the browser sends. type browser struct { h *harness } func (b browser) do(method, path string, hdr map[string]string, body string) page { b.h.t.Helper() req, err := http.NewRequest(method, b.h.url(path), strings.NewReader(body)) require.NoError(b.h.t, err) for k, v := range hdr { req.Header.Set(k, v) } if body != "" { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req) require.NoError(b.h.t, err) defer func() { _ = resp.Body.Close() }() out, err := io.ReadAll(resp.Body) require.NoError(b.h.t, err) return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)} } func (b browser) get(path string, hdr map[string]string) page { return b.do("GET", path, hdr, "") } // as is the header map of a browser holding the session token. func as(token string) map[string]string { return map[string]string{"Cookie": sessionCookie + "=" + token} } func post(token string) map[string]string { h := as(token) h["Origin"] = publicOrigin return h } // loginPath turns the link the CLI printed into a path on the test server. func loginPath(t *testing.T, link string) string { t.Helper() u, err := url.Parse(strings.TrimSpace(link)) require.NoError(t, err) assert.Equal(t, publicOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL") assert.True(t, strings.HasPrefix(u.Path, "/-/login/"), u.Path) return u.Path } // signIn runs `ermite web login` and confirms the link: it returns the token // of the cookie the browser would hold. func (c *cliHarness) signIn(t *testing.T) string { t.Helper() res := c.cli(t, adminToken, "web", "login").ok(t) p := browser{c.harness}.do("POST", loginPath(t, res.stdout), map[string]string{"Origin": publicOrigin}, "") require.Equal(t, http.StatusSeeOther, p.status, p.body) ck := p.cookie(sessionCookie) require.NotNil(t, ck) return ck.Value } // AC1, AC2, AC7 (the session part): the whole path of the owner, from the CLI // link to the private page and back out. func TestWebLoginEndToEnd(t *testing.T) { c := newCLIHarness(t) c.seed("secret", domain.VisibilityPrivate, "notes.txt") c.seed("pub", domain.VisibilityPublic, "a.txt") b := browser{c.harness} // Anonymous: the private repository is invisible; public caches as before. home := b.get("/", nil) assert.Contains(t, home.body, "pub") assert.NotContains(t, home.body, "secret") assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control")) assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page") assert.NotContains(t, home.body, `action="/-/logout"`) assert.Equal(t, 404, b.get("/secret", nil).status) // The CLI asks for a link and prints it; stderr says when it ends. res := c.cli(t, adminToken, "web", "login").ok(t) assert.Contains(t, res.stderr, "works once") path := loginPath(t, res.stdout) // GET shows the confirmation and never consumes the link. for range 3 { p := b.get(path, nil) assert.Equal(t, 200, p.status) assert.Equal(t, "no-store", p.hdr.Get("Cache-Control")) // Only the origin leaves the page; no-referrer would make the browser // send Origin: null on the sign-in POST. assert.Equal(t, "strict-origin", p.hdr.Get("Referrer-Policy")) assert.Contains(t, p.body, `method="post"`) assert.Contains(t, p.body, `action="`+path+`"`) assert.Nil(t, p.cookie(sessionCookie), "a GET sets no session") assertNoInline(t, p.body) } // A POST that is not from this site is refused and does not consume it. assert.Equal(t, 403, b.do("POST", path, nil, "").status, "no Origin") assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "https://evil.example"}, "").status, "foreign Origin") assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "null"}, "").status, "opaque Origin") assert.Equal(t, 200, b.get(path, nil).status, "still pending") // The POST consumes it and sets the cookie. login := b.do("POST", path, map[string]string{"Origin": publicOrigin}, "") require.Equal(t, http.StatusSeeOther, login.status, login.body) assert.Equal(t, "/", login.hdr.Get("Location")) assert.Equal(t, "no-store", login.hdr.Get("Cache-Control")) ck := login.cookie(sessionCookie) require.NotNil(t, ck) assert.True(t, ck.Secure && ck.HttpOnly) assert.Equal(t, http.SameSiteStrictMode, ck.SameSite) assert.Equal(t, "/", ck.Path) assert.Empty(t, ck.Domain, "no Domain: host only") assert.Equal(t, 12*60*60, ck.MaxAge) token := ck.Value claims := jwt.MapClaims{} _, _, err := jwt.NewParser().ParseUnverified(token, claims) require.NoError(t, err) assert.Equal(t, "owner", claims["sub"]) assert.Equal(t, float64(0), claims["epoch"]) exp, err := claims.GetExpirationTime() require.NoError(t, err) assert.WithinDuration(t, time.Now().Add(12*time.Hour), exp.Time, time.Minute) assert.NotNil(t, claims["iat"]) // Replayed and unknown links fail with the same page. for _, p := range []string{path, "/-/login/" + strings.Repeat("a", 43), "/-/login/short"} { g := b.get(p, nil) assert.Equal(t, 404, g.status, p) assert.Equal(t, "no-store", g.hdr.Get("Cache-Control")) assert.Contains(t, g.body, "login link not valid") r := b.do("POST", p, map[string]string{"Origin": publicOrigin}, "") assert.Nil(t, r.cookie(sessionCookie)) if validNonceShape(p) { assert.Equal(t, 404, r.status, p) assert.Equal(t, g.body, r.body, "no detail tells why") } else { // Not even the shape of a link: the gate refuses it, as it does // any POST without a session. assert.Equal(t, 403, r.status, p) } } // Signed in: private repositories show on every page, headers say private. for _, p := range []string{"/", "/secret", "/secret/tree", "/secret/blob/main/notes.txt", "/secret/commits/main", "/secret/refs", "/nope"} { got := b.get(p, as(token)) if p == "/nope" { assert.Equal(t, 404, got.status) } else { assert.Equal(t, 200, got.status, p) } assert.Equal(t, "private, no-store", got.hdr.Get("Cache-Control"), p) assert.Contains(t, got.hdr.Values("Vary"), "Cookie", p) assertNoInline(t, got.body) } home = b.get("/", as(token)) assert.Contains(t, home.body, "secret") assert.Contains(t, home.body, `