Files
pad/internal/server/handlers_auth_test.go
T
xarmian 7cda0d7896 feat: rebrand to Perpetual Software + new tagline (IDEA-832) (#273)
Migrates from xarmian/pad to PerpetualSoftware/pad across the entire
repo and updates the product subtitle to "Collaborate with your AI
agents".

Go module rename
- go.mod: github.com/xarmian/pad → github.com/PerpetualSoftware/pad
- All Go imports updated across cmd/pad, internal/{cli,server,store,
  models,collections,items,events,metrics,webhooks} (~130 files)
- Test fixtures with the literal repo slug ("xarmian/pad" in JSON
  shapes, SSH/HTTPS git URL strings, workspace_context fixtures)
  also updated, including the secondary repo entry
  (xarmian/pad-web → PerpetualSoftware/pad-web — pad-web was also
  moved to the org per branch context)

Docs / config
- README badges, install instructions, brew tap, Docker image, source
  build path, sponsor link (sponsor link kept as personal @xarmian)
- Subtitle: "Project management for developers and AI agents." →
  "Collaborate with your AI agents." (README, manifests, web layout
  meta, .goreleaser homebrew description)
- CONTRIBUTING.md, SECURITY.md, skills/INSTALL.md
- .goreleaser.yaml: homebrew_casks owner, GHCR image, release github
  owner, cosign cert-identity regex, comments
- .github/workflows/release.yml: tap/release comments
- deploy/k8s/deployment.yaml: container image
- docs/deployment.md: clone URL
- web/static/{site.webmanifest,manifest.json}: description
- web/src/routes/+layout.svelte: meta description + og:description

Brew tap path is PerpetualSoftware/tap/pad (CamelCase, matches
GitHub user case). GHCR image is ghcr.io/perpetualsoftware/pad
(lowercased per GHCR's URL normalization). CODEOWNERS @xarmian and
FUNDING.yml github: xarmian intentionally retained — those are the
personal maintainer / sponsor account, separate from the org repo.

Verification: go build ./..., go test ./... (all pkgs pass), web
build, and make install all clean (TASK-844, TASK-845).
2026-04-28 12:26:39 -04:00

359 lines
11 KiB
Go

package server
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
func bootstrapFirstUser(t *testing.T, srv *Server, email, name string) string {
t.Helper()
rr := doLoopbackRequest(srv, "POST", "/api/v1/auth/bootstrap", map[string]string{
"email": email,
"name": name,
"password": "correct-horse-battery-staple",
})
if rr.Code != http.StatusCreated {
t.Fatalf("bootstrap: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]interface{}
parseJSON(t, rr, &resp)
token, _ := resp["token"].(string)
if token == "" {
t.Fatal("expected bootstrap to return a session token")
}
return token
}
func TestAuthBootstrapFlow(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/auth/session", nil)
if rr.Code != http.StatusOK {
t.Fatalf("session check: expected 200, got %d", rr.Code)
}
var session map[string]interface{}
parseJSON(t, rr, &session)
if session["setup_required"] != true {
t.Error("expected setup_required=true when no users exist")
}
if session["setup_method"] != "local_cli" {
t.Errorf("expected setup_method=local_cli, got %v", session["setup_method"])
}
if _, ok := session["needs_setup"]; ok {
t.Error("did not expect deprecated needs_setup field")
}
token := bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
rr = doRequestWithCookie(srv, "GET", "/api/v1/auth/session", nil, token)
parseJSON(t, rr, &session)
if session["authenticated"] != true {
t.Error("expected authenticated=true after bootstrap")
}
authUser, ok := session["user"].(map[string]interface{})
if !ok || authUser == nil {
t.Error("expected authenticated session user payload")
} else {
if authUser["email"] != "admin@test.com" {
t.Errorf("expected email admin@test.com, got %v", authUser["email"])
}
if authUser["role"] != "admin" {
t.Errorf("expected admin role after bootstrap, got %v", authUser["role"])
}
}
if session["setup_required"] != false {
t.Errorf("expected setup_required=false after bootstrap, got %v", session["setup_required"])
}
}
func TestAuthBootstrapRequiresLoopback(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "POST", "/api/v1/auth/bootstrap", map[string]string{
"email": "admin@test.com",
"name": "Admin",
"password": "correct-horse-battery-staple",
})
if rr.Code != http.StatusForbidden {
t.Fatalf("remote bootstrap: expected 403, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestAuthLoginFlow(t *testing.T) {
srv := testServer(t)
bootstrapFirstUser(t, srv, "user@test.com", "Test User")
rr := doRequest(srv, "POST", "/api/v1/auth/login", map[string]string{
"email": "user@test.com",
"password": "correct-horse-battery-staple",
})
if rr.Code != http.StatusOK {
t.Fatalf("login: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var loginResp map[string]interface{}
parseJSON(t, rr, &loginResp)
user := loginResp["user"].(map[string]interface{})
if user["name"] != "Test User" {
t.Errorf("expected name 'Test User', got %v", user["name"])
}
rr = doRequest(srv, "POST", "/api/v1/auth/login", map[string]string{
"email": "user@test.com",
"password": "wrongpassword",
})
if rr.Code != http.StatusUnauthorized {
t.Errorf("wrong password: expected 401, got %d", rr.Code)
}
rr = doRequest(srv, "POST", "/api/v1/auth/login", map[string]string{
"email": "nobody@test.com",
"password": "anything",
})
if rr.Code != http.StatusUnauthorized {
t.Errorf("non-existent email: expected 401, got %d", rr.Code)
}
}
func TestAuthLoginRequiresSetupWhenNoUsers(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "POST", "/api/v1/auth/login", map[string]string{
"email": "nobody@test.com",
"password": "correct-horse-battery-staple",
})
if rr.Code != http.StatusConflict {
t.Fatalf("login without users: expected 409, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestFreshInstanceRegistrationIsForbidden(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "POST", "/api/v1/auth/register", map[string]string{
"email": "admin@test.com",
"name": "Admin",
"password": "correct-horse-battery-staple",
})
if rr.Code != http.StatusForbidden {
t.Fatalf("fresh registration: expected 403, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestInvitationRegistrationFlow(t *testing.T) {
srv := testServer(t)
adminToken := bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Test"})
if err != nil {
t.Fatalf("create workspace: %v", err)
}
admin, err := srv.store.GetUserByEmail("admin@test.com")
if err != nil || admin == nil {
t.Fatalf("load admin user: %v", err)
}
if err := srv.store.AddWorkspaceMember(ws.ID, admin.ID, "owner"); err != nil {
t.Fatalf("add admin workspace membership: %v", err)
}
inv, err := srv.store.CreateInvitation(ws.ID, "invitee@test.com", "viewer", admin.ID)
if err != nil {
t.Fatalf("create invitation: %v", err)
}
rr := doRequest(srv, "POST", "/api/v1/auth/register", map[string]string{
"email": "invitee@test.com",
"name": "Invitee",
"password": "correct-horse-battery-staple",
"invitation_code": inv.Code,
})
if rr.Code != http.StatusCreated {
t.Fatalf("invitation registration: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]interface{}
parseJSON(t, rr, &resp)
user := resp["user"].(map[string]interface{})
if user["role"] != "member" {
t.Errorf("expected invitation signup to create member role, got %v", user["role"])
}
invitee, err := srv.store.GetUserByEmail("invitee@test.com")
if err != nil || invitee == nil {
t.Fatalf("load invitee: %v", err)
}
member, err := srv.store.GetWorkspaceMember(ws.ID, invitee.ID)
if err != nil {
t.Fatalf("get workspace member: %v", err)
}
if member == nil || member.Role != "viewer" {
t.Fatalf("expected invitee to be added to workspace as viewer, got %#v", member)
}
rr = doRequestWithCookie(srv, "POST", "/api/v1/auth/logout", nil, adminToken)
if rr.Code != http.StatusOK {
t.Fatalf("logout admin: expected 200, got %d", rr.Code)
}
}
func TestAuthRegistrationValidation(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "POST", "/api/v1/auth/register", map[string]string{
"name": "Test",
"password": "correct-horse-battery-staple",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("missing email: expected 400, got %d", rr.Code)
}
rr = doRequest(srv, "POST", "/api/v1/auth/register", map[string]string{
"email": "not-an-email",
"name": "Test",
"password": "correct-horse-battery-staple",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("invalid email: expected 400, got %d", rr.Code)
}
rr = doRequest(srv, "POST", "/api/v1/auth/register", map[string]string{
"email": "test@test.com",
"name": "Test",
"password": "short",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("short password: expected 400, got %d", rr.Code)
}
rr = doRequest(srv, "POST", "/api/v1/auth/register", map[string]string{
"email": "test@test.com",
"password": "correct-horse-battery-staple",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("missing name: expected 400, got %d", rr.Code)
}
}
func TestAuthLogout(t *testing.T) {
srv := testServer(t)
token := bootstrapFirstUser(t, srv, "user@test.com", "Test")
rr := doRequestWithCookie(srv, "POST", "/api/v1/auth/logout", nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("logout: expected 200, got %d", rr.Code)
}
rr = doRequestWithCookie(srv, "GET", "/api/v1/auth/session", nil, token)
var session map[string]interface{}
parseJSON(t, rr, &session)
if session["authenticated"] != false {
t.Error("expected authenticated=false after logout")
}
}
func TestAuthRequiredWhenUsersExist(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/workspaces", nil)
if rr.Code != http.StatusOK {
t.Errorf("no users: expected 200, got %d", rr.Code)
}
bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
rr = doRequest(srv, "GET", "/api/v1/workspaces", nil)
if rr.Code != http.StatusUnauthorized {
t.Errorf("with users: expected 401, got %d: %s", rr.Code, rr.Body.String())
}
rr = doRequest(srv, "GET", "/api/v1/auth/session", nil)
if rr.Code != http.StatusOK {
t.Errorf("auth/session should be exempt: expected 200, got %d", rr.Code)
}
rr = doRequest(srv, "GET", "/api/v1/health", nil)
if rr.Code != http.StatusOK {
t.Errorf("health should be exempt: expected 200, got %d", rr.Code)
}
}
func TestAuthMeEndpoint(t *testing.T) {
srv := testServer(t)
token := bootstrapFirstUser(t, srv, "me@test.com", "Me Test")
rr := doRequestWithCookie(srv, "GET", "/api/v1/auth/me", nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("me: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var user map[string]interface{}
parseJSON(t, rr, &user)
if user["name"] != "Me Test" {
t.Errorf("expected name 'Me Test', got %v", user["name"])
}
if user["email"] != "me@test.com" {
t.Errorf("expected email 'me@test.com', got %v", user["email"])
}
}
func TestDuplicateRegistration(t *testing.T) {
srv := testServer(t)
adminToken := bootstrapFirstUser(t, srv, "admin@test.com", "Admin")
rr := doRequestWithCookie(srv, "POST", "/api/v1/auth/register", map[string]string{
"email": "dup@test.com",
"name": "First",
"password": "correct-horse-battery-staple",
}, adminToken)
if rr.Code != http.StatusCreated {
t.Fatalf("admin register: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
rr = doRequestWithCookie(srv, "POST", "/api/v1/auth/register", map[string]string{
"email": "dup@test.com",
"name": "Second",
"password": "password456",
}, adminToken)
if rr.Code == http.StatusCreated {
t.Error("duplicate registration should not succeed")
}
}
// doRequestWithCookie is like doRequest but adds a session cookie.
func doRequestWithCookie(srv *Server, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
var bodyReader io.Reader
if body != nil {
data, _ := json.Marshal(body)
bodyReader = bytes.NewReader(data)
}
req := httptest.NewRequest(method, path, bodyReader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.RemoteAddr = "192.0.2.1:1234"
req.AddCookie(&http.Cookie{
Name: "pad_session",
Value: token,
})
// Include CSRF token for the double-submit cookie pattern
// Must be csrfTokenLen*2 hex chars to pass the length check added
// in TASK-659. Any fixed 64-char hex string works for the test.
const testCSRF = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
req.AddCookie(&http.Cookie{
Name: "pad_csrf",
Value: testCSRF,
})
req.Header.Set("X-CSRF-Token", testCSRF)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
}