gitmote / internal / webui / webui_test.go · history · branches
13047 bytes · raw
package webui
import (
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/atmin/gitmote/internal/auth"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
"github.com/atmin/gitmote/internal/secrets"
"github.com/atmin/gitmote/internal/store"
)
// harness wires a real meta DB + auth guard behind the UI handler, seeds one
// global admin with a token, and returns the admin's raw token for login.
type harness struct {
t *testing.T
h *Handler
md *meta.Metadata
store store.Store
mux *http.ServeMux
admin *meta.User
}
func newHarness(t *testing.T) *harness {
t.Helper()
ctx := context.Background()
md, err := meta.Open(ctx, meta.Config{LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3")})
if err != nil {
t.Fatalf("meta.Open: %v", err)
}
t.Cleanup(func() { _ = md.Close() })
objs := store.NewMem()
mz := repo.New(md, objs, t.TempDir())
// A keyring with one all-zero key: enough to exercise the secrets panel
// (Enabled() true, encrypt/decrypt round-trip) without a real key.
t.Setenv("GITMOTE_CI_SECRET_KEY_V1", base64.StdEncoding.EncodeToString(make([]byte, 32)))
kr, err := secrets.NewKeyringFromEnv()
if err != nil {
t.Fatalf("keyring: %v", err)
}
svc := secrets.NewService(kr, md)
h, err := New(md, mz, objs, nil, auth.NewGuard(md), svc, []byte("test-cookie-key"), nil)
if err != nil {
t.Fatalf("New: %v", err)
}
mux := http.NewServeMux()
h.Register(mux)
admin, err := md.CreateAdmin(ctx, "root")
if err != nil {
t.Fatalf("CreateAdmin: %v", err)
}
return &harness{t: t, h: h, md: md, store: objs, mux: mux, admin: admin}
}
// mintTokenFor mints a token for an existing user and returns the raw string.
func (x *harness) mintTokenFor(userID int64) string {
x.t.Helper()
raw, sel, ver, err := auth.Mint()
if err != nil {
x.t.Fatalf("Mint: %v", err)
}
if _, err := x.md.CreateToken(context.Background(), userID, sel, ver, "test"); err != nil {
x.t.Fatalf("CreateToken: %v", err)
}
return raw
}
func (x *harness) do(method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
x.t.Helper()
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
r := httptest.NewRequest(method, target, body)
if form != nil {
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if cookie != nil {
r.AddCookie(cookie)
}
rec := httptest.NewRecorder()
x.mux.ServeHTTP(rec, r)
return rec
}
// login posts the admin token and returns the resulting session cookie.
func (x *harness) login(raw string) *http.Cookie {
x.t.Helper()
rec := x.do(http.MethodPost, "/login", url.Values{"token": {raw}}, nil)
if rec.Code != http.StatusSeeOther {
x.t.Fatalf("login status = %d, want 303 (body: %s)", rec.Code, rec.Body)
}
for _, c := range rec.Result().Cookies() {
if c.Name == cookieName {
return c
}
}
x.t.Fatal("login set no session cookie")
return nil
}
// TestRootDashboard: the bare root renders the viewer-scoped dashboard (not a
// redirect, not a 404). It matches only the exact root, leaving repo paths to
// the git handler.
func TestRootDashboard(t *testing.T) {
x := newHarness(t)
rec := x.do(http.MethodGet, "/", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET / = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Repositories") {
t.Errorf("dashboard body missing heading:\n%s", rec.Body)
}
// Anonymous sees a sign-in link, not admin nav.
if !strings.Contains(rec.Body.String(), `href="/login"`) {
t.Errorf("anonymous dashboard missing sign-in link:\n%s", rec.Body)
}
}
// TestDashboardScoping: the dashboard lists exactly the repos a viewer may see —
// anonymous → public only; an ACL user → their repos + public; admin → all.
func TestDashboardScoping(t *testing.T) {
x := newHarness(t)
ctx := context.Background()
pub, _ := x.md.CreateRepo(ctx, "pub", "main")
if err := x.md.SetVisibility(ctx, pub.ID, meta.VisibilityPublic); err != nil {
t.Fatal(err)
}
if _, err := x.md.CreateRepo(ctx, "priv", "main"); err != nil { // admin-only
t.Fatal(err)
}
mine, _ := x.md.CreateRepo(ctx, "mine", "main") // granted to alice
alice, _ := x.md.CreateUser(ctx, "alice")
if err := x.md.SetACL(ctx, mine.ID, alice.ID, meta.PermRead); err != nil {
t.Fatal(err)
}
body := func(cookie *http.Cookie) string {
rec := x.do(http.MethodGet, "/", nil, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("dashboard = %d", rec.Code)
}
return rec.Body.String()
}
has := func(s, name string) bool { return strings.Contains(s, `/`+name+`"`) }
anon := body(nil)
if !has(anon, "pub") || has(anon, "priv") || has(anon, "mine") {
t.Errorf("anonymous dashboard scoping wrong:\n%s", anon)
}
as := body(x.sessionFor(alice.ID))
if !has(as, "pub") || !has(as, "mine") || has(as, "priv") {
t.Errorf("ACL-user dashboard scoping wrong:\n%s", as)
}
admin := body(x.login(x.mintTokenFor(x.admin.ID)))
if !has(admin, "pub") || !has(admin, "priv") || !has(admin, "mine") {
t.Errorf("admin dashboard should list all repos:\n%s", admin)
}
}
func TestGoldenPath(t *testing.T) {
x := newHarness(t)
ctx := context.Background()
session := x.login(x.mintTokenFor(x.admin.ID))
// Create a user to own a repo and receive grants/tokens.
if rec := x.do(http.MethodPost, "/users", url.Values{"handle": {"alice"}}, session); rec.Code != http.StatusOK {
t.Fatalf("create user: %d (%s)", rec.Code, rec.Body)
}
if _, err := x.md.GetUser(ctx, "alice"); err != nil {
t.Fatalf("alice not created: %v", err)
}
// Create a repo (POST to the dashboard) — no owner field; the creating admin
// gets admin on it.
rec := x.do(http.MethodPost, "/",
url.Values{"name": {"app"}, "default_branch": {"main"}}, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "created app") {
t.Fatalf("create repo: %d (%s)", rec.Code, rec.Body)
}
repo, err := x.md.GetRepo(ctx, "app")
if err != nil {
t.Fatalf("repo not created: %v", err)
}
// Set default branch via the repo's settings page.
rec = x.do(http.MethodPost, "/app/settings",
url.Values{"default_branch": {"trunk"}}, session)
if rec.Code != http.StatusOK {
t.Fatalf("set default branch: %d (%s)", rec.Code, rec.Body)
}
if got, _ := x.md.GetRepo(ctx, "app"); got.DefaultBranch != "trunk" {
t.Errorf("default branch = %q, want trunk", got.DefaultBranch)
}
// Toggle visibility to public via settings.
rec = x.do(http.MethodPost, "/app/settings", url.Values{"visibility": {"public"}}, session)
if rec.Code != http.StatusOK {
t.Fatalf("set visibility: %d (%s)", rec.Code, rec.Body)
}
if got, _ := x.md.GetRepo(ctx, "app"); got.Visibility != meta.VisibilityPublic {
t.Errorf("visibility = %q, want public", got.Visibility)
}
// Mint a token for alice — the raw token is shown exactly once in the body.
rec = x.do(http.MethodPost, "/tokens", url.Values{"user": {"alice"}, "label": {"laptop"}}, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "gmt_") {
t.Fatalf("mint token: %d (%s)", rec.Code, rec.Body)
}
alice, _ := x.md.GetUser(ctx, "alice")
toks, _ := x.md.ListTokens(ctx, alice.ID)
if len(toks) != 1 {
t.Fatalf("alice tokens = %d, want 1", len(toks))
}
// Grant write (collaborator) to alice on her repo via the access page.
rec = x.do(http.MethodPost, "/app/access",
url.Values{"handle": {"alice"}, "perm": {"write"}}, session)
if rec.Code != http.StatusOK {
t.Fatalf("grant acl: %d (%s)", rec.Code, rec.Body)
}
if perm, err := x.md.GetACL(ctx, repo.ID, alice.ID); err != nil || perm != meta.PermWrite {
t.Errorf("GetACL = %q, %v; want write", perm, err)
}
// Revoke it again.
rec = x.do(http.MethodPost, "/app/access/revoke",
url.Values{"user_id": {itoa(alice.ID)}}, session)
if rec.Code != http.StatusOK {
t.Fatalf("revoke acl: %d (%s)", rec.Code, rec.Body)
}
if _, err := x.md.GetACL(ctx, repo.ID, alice.ID); err == nil {
t.Error("acl still present after revoke")
}
}
// TestCreateRepoGrantsCreatorAdmin: creating a repo through the UI grants the
// creating admin admin on it, so it is immediately usable (clone/push) without a
// separate ACL step — the gap that left a freshly created repo 403-ing every push.
func TestCreateRepoGrantsCreatorAdmin(t *testing.T) {
x := newHarness(t)
ctx := context.Background()
session := x.login(x.mintTokenFor(x.admin.ID))
if rec := x.do(http.MethodPost, "/",
url.Values{"name": {"proj"}, "default_branch": {"main"}}, session); rec.Code != http.StatusOK {
t.Fatalf("create repo: %d (%s)", rec.Code, rec.Body)
}
repo, err := x.md.GetRepo(ctx, "proj")
if err != nil {
t.Fatalf("repo not created: %v", err)
}
if perm, err := x.md.GetACL(ctx, repo.ID, x.admin.ID); err != nil || perm != meta.PermAdmin {
t.Errorf("creator ACL after create = %q, %v; want admin", perm, err)
}
}
func TestUnauthenticatedDenied(t *testing.T) {
x := newHarness(t)
// GET a management page without a session → redirect to login.
rec := x.do(http.MethodGet, "/users", nil, nil)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
t.Errorf("GET /users unauth = %d loc %q, want 303 /login", rec.Code, rec.Header().Get("Location"))
}
// POST without a session → 401 (not a browser navigation).
rec = x.do(http.MethodPost, "/", url.Values{"name": {"x"}}, nil)
if rec.Code != http.StatusUnauthorized {
t.Errorf("POST / (create) unauth = %d, want 401", rec.Code)
}
}
func TestNonAdminDenied(t *testing.T) {
x := newHarness(t)
ctx := context.Background()
// A regular user with a valid token CAN sign in (to browse their repos)...
bob, _ := x.md.CreateUser(ctx, "bob")
bobToken := x.mintTokenFor(bob.ID)
if rec := x.do(http.MethodPost, "/login", url.Values{"token": {bobToken}}, nil); rec.Code != http.StatusSeeOther {
t.Errorf("non-admin login = %d, want 303 (allowed)", rec.Code)
}
// ...but a non-admin session cannot reach a management page (403), covering
// demotion mid-session too — the guard re-checks is_admin every request.
forged := x.sessionFor(bob.ID)
if rec := x.do(http.MethodGet, "/users", nil, forged); rec.Code != http.StatusForbidden {
t.Errorf("non-admin session on /users = %d, want 403", rec.Code)
}
}
func TestCreateRepoRejectsReservedName(t *testing.T) {
x := newHarness(t)
session := x.login(x.mintTokenFor(x.admin.ID))
// A reserved repo name (a global route) is refused by meta.CreateRepo.
rec := x.do(http.MethodPost, "/", url.Values{"name": {"login"}}, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "reserved") {
t.Errorf("reserved name: %d (%s)", rec.Code, rec.Body)
}
if _, err := x.md.GetRepo(context.Background(), "login"); err == nil {
t.Error("repo created despite a reserved name")
}
// A structurally invalid name is refused before it reaches CreateRepo.
rec = x.do(http.MethodPost, "/", url.Values{"name": {".hidden"}}, session)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "alphanumeric") {
t.Errorf("invalid name: %d (%s)", rec.Code, rec.Body)
}
if _, err := x.md.GetRepo(context.Background(), ".hidden"); err == nil {
t.Error("repo created despite an invalid name")
}
}
// TestSettingsVisibilityAffectsAnonBrowse: flipping a repo public via the
// settings page opens it to anonymous browsing; flipping it back closes it.
func TestSettingsVisibilityAffectsAnonBrowse(t *testing.T) {
x := newHarness(t)
x.seedBrowseRepo("app", "main") // private by default
session := x.login(x.mintTokenFor(x.admin.ID))
const target = "/app/tree/main"
if rec := x.do(http.MethodGet, target, nil, nil); rec.Code != http.StatusSeeOther {
t.Fatalf("anon browse private = %d, want 303 (login)", rec.Code)
}
if rec := x.do(http.MethodPost, "/app/settings", url.Values{"visibility": {"public"}}, session); rec.Code != http.StatusOK {
t.Fatalf("set public = %d (%s)", rec.Code, rec.Body)
}
if rec := x.do(http.MethodGet, target, nil, nil); rec.Code != http.StatusOK {
t.Fatalf("anon browse public = %d, want 200", rec.Code)
}
if rec := x.do(http.MethodPost, "/app/settings", url.Values{"visibility": {"private"}}, session); rec.Code != http.StatusOK {
t.Fatalf("set private = %d (%s)", rec.Code, rec.Body)
}
if rec := x.do(http.MethodGet, target, nil, nil); rec.Code != http.StatusSeeOther {
t.Fatalf("anon browse re-privatized = %d, want 303", rec.Code)
}
}
// sessionFor forges a valid signed session cookie for userID, used to test the
// middleware's admin re-check independent of the login form.
func (x *harness) sessionFor(userID int64) *http.Cookie {
x.t.Helper()
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
x.h.sess.issue(rec, r, userID, x.h.now())
for _, c := range rec.Result().Cookies() {
if c.Name == cookieName {
return c
}
}
x.t.Fatal("issue set no cookie")
return nil
}
func itoa(n int64) string {
return strconv.FormatInt(n, 10)
}