Pulse/internal/api/api_token_org_routes_integration_test.go
2026-03-18 16:06:30 +00:00

173 lines
4.8 KiB
Go

package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
func TestOrgScopedAPITokenCannotReadOtherOrgMembersWithSessionCookiePresent(t *testing.T) {
defer SetMultiTenantEnabled(false)
SetMultiTenantEnabled(true)
t.Setenv("PULSE_DEV", "true")
dataDir := t.TempDir()
hashedPass, err := internalauth.HashPassword("super-secure-pass")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg := &config.Config{
DataPath: dataDir,
ConfigPath: dataDir,
AuthUser: "admin",
AuthPass: hashedPass,
}
mtp := config.NewMultiTenantPersistence(dataDir)
for _, orgID := range []string{"org-a", "org-b"} {
if err := mtp.SaveOrganization(&models.Organization{
ID: orgID,
DisplayName: strings.ToUpper(orgID),
OwnerUserID: "admin",
Members: []models.OrganizationMember{
{UserID: "admin", Role: models.OrgRoleOwner, AddedAt: time.Now().UTC()},
},
}); err != nil {
t.Fatalf("save organization %s: %v", orgID, err)
}
}
router := newMultiTenantRouter(t, cfg)
server := httptest.NewServer(router.Handler())
t.Cleanup(server.Close)
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("create cookie jar: %v", err)
}
client := &http.Client{Jar: jar}
loginBody, err := json.Marshal(map[string]string{
"username": "admin",
"password": "super-secure-pass",
})
if err != nil {
t.Fatalf("marshal login payload: %v", err)
}
loginReq, err := http.NewRequest(http.MethodPost, server.URL+"/api/login", bytes.NewReader(loginBody))
if err != nil {
t.Fatalf("create login request: %v", err)
}
loginReq.Header.Set("Content-Type", "application/json")
loginResp, err := client.Do(loginReq)
if err != nil {
t.Fatalf("login request failed: %v", err)
}
loginResp.Body.Close()
if loginResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 on login, got %d", loginResp.StatusCode)
}
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server url: %v", err)
}
csrfToken := ""
for _, cookie := range jar.Cookies(serverURL) {
if cookie.Name == CookieNameCSRF {
csrfToken = cookie.Value
break
}
}
if csrfToken == "" {
t.Fatalf("expected CSRF cookie after login")
}
createReq, err := http.NewRequest(http.MethodPost, server.URL+"/api/security/tokens", strings.NewReader(`{"name":"org-a-token","scopes":["settings:read"]}`))
if err != nil {
t.Fatalf("create token request: %v", err)
}
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("X-CSRF-Token", csrfToken)
createReq.Header.Set("X-Pulse-Org-ID", "org-a")
createReq.Header.Set("X-Org-ID", "org-a")
createResp, err := client.Do(createReq)
if err != nil {
t.Fatalf("token creation failed: %v", err)
}
defer createResp.Body.Close()
createBody, err := io.ReadAll(createResp.Body)
if err != nil {
t.Fatalf("read token create response: %v", err)
}
if createResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 creating token, got %d: %s", createResp.StatusCode, string(createBody))
}
var created struct {
Token string `json:"token"`
}
if err := json.Unmarshal(createBody, &created); err != nil {
t.Fatalf("decode token create response: %v", err)
}
if strings.TrimSpace(created.Token) == "" {
t.Fatalf("expected raw token in create response")
}
if len(cfg.APITokens) != 1 {
t.Fatalf("expected one persisted token, got %d", len(cfg.APITokens))
}
if got := cfg.APITokens[0].OrgID; got != "org-a" {
t.Fatalf("expected created token to bind to org-a, got %q", got)
}
assertMembersStatus := func(path string, headers map[string]string, wantStatus int) string {
t.Helper()
req, err := http.NewRequest(http.MethodGet, server.URL+path, nil)
if err != nil {
t.Fatalf("create members request: %v", err)
}
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("members request failed: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read members response: %v", err)
}
if resp.StatusCode != wantStatus {
t.Fatalf("expected %d for %s, got %d: %s", wantStatus, path, resp.StatusCode, string(body))
}
return string(body)
}
headers := map[string]string{
"Authorization": "Bearer " + created.Token,
"X-Pulse-Org-ID": "org-a",
"X-Org-ID": "org-a",
}
assertMembersStatus("/api/orgs/org-a/members", headers, http.StatusOK)
body := assertMembersStatus("/api/orgs/org-b/members", headers, http.StatusForbidden)
if !strings.Contains(body, "access_denied") {
t.Fatalf("expected cross-org denial payload, got %q", body)
}
}