agentbbs/internal/ircpass/ircpass_test.go
Anthony Ettinger 54da317f4e
feat(passwd): self-service password reset across git, mail & chat (#59)
Add a key-gated `ssh passwd@host` route (alias `password@`) that sets ONE
member-chosen password across every service with its own credential:

  - git  (Forgejo)        new forgejo.SetPassword (PATCH /admin/users, clears
                          must_change; EnsureUser first so the account exists)
  - mail (Mailu webmail)  existing mailu.SetPassword
  - chat (IRC/Ergo + The Lounge)  new internal/ircpass package

Because the route authenticates by the member's registered SSH key, it also
serves as the forgot-password path — no old password required.

The BBS runs as a non-root service user, but the Ergo password store and The
Lounge user files are root-owned. internal/ircpass bridges this by shelling out
to scripts/set-irc-password.sh through a narrow sudoers rule (installed by
setup.sh). The new password travels on stdin (a new `set-irc-password.sh
<member> -` form), so it never appears in the process table or sudo's log.

UX: masked entry typed twice (readSecret); no-PTY reads stdin; empty input
generates a strong password and shows it once. Each service leg is independent
and best-effort with a per-service ✓/✗ summary, plus a confirmation email that
never contains the password.

Tests: ircpass (stdin contract + member/password rejection), forgejo.SetPassword,
auth IsPasswdName + reservation. Docs: credentials.md (passwd@ section) + irc.md.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:46:23 -07:00

75 lines
2.2 KiB
Go

package ircpass
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestConfiguredRequiresScript(t *testing.T) {
if (Config{}).Configured() {
t.Fatal("empty config should not be Configured")
}
if !(Config{Script: "/x/set-irc-password.sh"}).Configured() {
t.Fatal("config with a script path should be Configured")
}
}
func TestSetPasswordRunsHelperWithArgs(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "args.txt")
script := filepath.Join(dir, "set-irc-password.sh")
// A fake helper mirroring the real contract: member in $1, "-" in $2, and the
// password on stdin. Records member + stdin so the test can assert both.
body := "#!/bin/sh\nread pw\nprintf '%s\\n%s\\n%s\\n' \"$1\" \"$2\" \"$pw\" > " + out + "\n"
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
c := Config{Script: script, UseSudo: false}
if err := c.SetPassword("alice", "s3cret-pw"); err != nil {
t.Fatalf("SetPassword: %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
// member as argv[0], "-" sentinel as argv[1], password only on stdin.
want := "alice\n-\ns3cret-pw\n"
if string(got) != want {
t.Fatalf("helper got %q, want %q", got, want)
}
}
func TestSetPasswordRejectsBadMember(t *testing.T) {
c := Config{Script: "/bin/true", UseSudo: false}
for _, bad := range []string{"", "-rf", "a b", "alice;rm", "../etc", "Alice"} {
if err := c.SetPassword(bad, "pw"); err == nil {
t.Fatalf("expected error for member %q", bad)
}
}
}
func TestSetPasswordRejectsBadPassword(t *testing.T) {
for _, bad := range []string{"", "with\nnewline", "carriage\rreturn"} {
if err := (Config{Script: "/bin/true"}).SetPassword("alice", bad); err == nil {
t.Fatalf("expected error for password %q", bad)
}
}
}
func TestSetPasswordUnconfigured(t *testing.T) {
if err := (Config{}).SetPassword("alice", "pw"); err == nil ||
!strings.Contains(err.Error(), "not configured") {
t.Fatalf("want not-configured error, got %v", err)
}
}
func TestSetPasswordSurfacesHelperFailure(t *testing.T) {
c := Config{Script: "/bin/false", UseSudo: false}
if err := c.SetPassword("alice", "pw"); err == nil {
t.Fatal("expected error when helper exits non-zero")
}
}