package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// The networked CLI is a forge door like the web: it can ask for a
// reset link to go to the owner, and it cannot set anybody's password.
func TestTheNetworkedCLIAsksForAResetAndSetsNoPassword(t *testing.T) {
var mu sync.Mutex
var hits []string
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
mu.Lock()
hits, bodies = append(hits, r.URL.Path), append(bodies, b)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"jo","emailed":true}`))
}))
defer srv.Close()
if err := accountAPICmd([]string{"passwd", "--server", srv.URL, "--as", "root", "--as-password", "pw",
"--user", "jo", "--password", "chosen-by-root"}); err == nil {
t.Error("FAIL: `quarry accounts passwd` still answers")
}
for _, h := range hits {
if strings.Contains(h, "passwd") {
t.Fatalf("FAIL: the CLI reached %s", h)
}
}
if err := accountAPICmd([]string{"reset", "--server", srv.URL, "--as", "root", "--as-password", "pw",
"--user", "jo"}); err != nil {
t.Fatal(err)
}
if len(hits) != 1 || hits[0] != "/api/v1/accounts/reset" {
t.Fatalf("FAIL: want one call to /api/v1/accounts/reset, got %v", hits)
}
if bodies[0]["name"] != "jo" || len(bodies[0]) != 1 {
t.Errorf("FAIL: reset body = %v", bodies[0])
}
t.Log("ok: the CLI asks for a reset link and has no passwd")
}
// Recovering an account on the box stays possible — it is the host
// operator's act, not the forge admin's — and it is journalled.
func TestTheHostOperatorsPasswdIsJournalled(t *testing.T) {
dir := t.TempDir()
if err := accountCmd([]string{"add", "--data", dir, "--user", "jo", "--password", "jos-password"}); err != nil {
t.Fatal(err)
}
if err := accountCmd([]string{"passwd", "--data", dir, "--user", "jo", "--password", "a-recovered-password"}); err != nil {
t.Fatal(err)
}
db, err := oltp.Open(filepath.Join(dir, "oltp.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
acts, err := db.AccountActs("jo", 10)
if err != nil {
t.Fatal(err)
}
if len(acts) != 1 || acts[0].Op != "account passwd" || acts[0].Owner != "jo" || !strings.HasPrefix(acts[0].Actor, "host:") {
t.Fatalf("FAIL: journal = %+v", acts)
}
if strings.Contains(acts[0].Actor+acts[0].Op+acts[0].Repo+acts[0].Owner, "a-recovered-password") {
t.Fatal("FAIL: the password is in the journal")
}
t.Log("ok: the host operator's recovery is journalled")
}
// An imported fossil user has no native password but signs in with the
// one their old server held. Through the real account store, an admin
// can neither re-invite it (a link that sets its password) nor point
// its confirmed address somewhere else (#319).
func TestAnAdminCannotTakeOverAnImportedFossilUser(t *testing.T) {
db, err := oltp.Open(filepath.Join(t.TempDir(), "oltp.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.AddAccount("root", "root-password", true); err != nil {
t.Fatal(err)
}
if _, _, err := db.ImportUsers("repo", "projectcode", []oltp.ImportedUser{
{Login: "alice", Caps: "ei", PWHash: "a-fossil-credential"},
}); err != nil {
t.Fatal(err)
}
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v", Accounts: accountStore{db: db},
Repos: api.NewRepos(map[string]*api.Repo{})}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "root")
// The op inputs are unexported; build each from JSON, as the API does.
invoke := func(op, body string) error {
d, ok := r.Lookup(op)
if !ok {
t.Fatalf("no op %s", op)
}
in := reflect.New(d.In)
if err := json.Unmarshal([]byte(body), in.Interface()); err != nil {
t.Fatal(err)
}
_, err := r.Invoke(ctx, op, in.Elem().Interface())
return err
}
if err := invoke("account.invite", `{"name":"alice","email":"[email protected]"}`); err == nil || !strings.Contains(err.Error(), "has a password") {
t.Error("FAIL: an imported user was re-invited, handing root a link to set its password")
}
if err := invoke("account.email.set", `{"name":"alice","email":"[email protected]"}`); err != nil {
t.Fatal(err)
}
a, _ := db.AccountByName("alice")
if a.Email == "[email protected]" {
t.Fatal("FAIL: root set an imported user's confirmed address")
}
if a.EmailPending != "[email protected]" {
t.Errorf("FAIL: the proposed address is not pending: %+v", a)
}
t.Log("ok: no re-invite, and the address is only proposed")
}