mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
173 lines
4.8 KiB
Go
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)
|
|
}
|
|
}
|