dealspace/api/integration_test.go

770 lines
23 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/mish/dealspace/lib"
)
// setupTestUser creates a user directly in the DB and returns a config with backdoor enabled
func setupTestUser(t *testing.T, db *lib.DB, cfg *lib.Config, email, name string) {
t.Helper()
now := time.Now().UnixMilli()
user := &lib.User{
UserID: uuid.New().String(),
Email: email,
Name: name,
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := lib.UserCreate(db, user); err != nil {
t.Fatalf("create test user: %v", err)
}
}
// loginWithBackdoor uses the challenge/verify flow with backdoor code
func loginWithBackdoor(t *testing.T, client *http.Client, serverURL, email string) string {
t.Helper()
// Step 1: Send challenge
challengeBody, _ := json.Marshal(map[string]string{"email": email})
resp, err := client.Post(serverURL+"/api/auth/challenge", "application/json", bytes.NewReader(challengeBody))
if err != nil {
t.Fatalf("challenge request failed: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("challenge expected 200, got %d", resp.StatusCode)
}
// Step 2: Verify with backdoor code
verifyBody, _ := json.Marshal(map[string]string{"email": email, "code": "220402"})
resp, err = client.Post(serverURL+"/api/auth/verify", "application/json", bytes.NewReader(verifyBody))
if err != nil {
t.Fatalf("verify request failed: %v", err)
}
var verifyResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&verifyResp)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("verify expected 200, got %d: %v", resp.StatusCode, verifyResp)
}
token, ok := verifyResp["token"].(string)
if !ok || token == "" {
t.Fatal("verify response should contain token")
}
return token
}
func TestFullFlow(t *testing.T) {
tmpFile, err := os.CreateTemp("", "dealspace-integration-test-*.db")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
tmpFile.Close()
defer os.Remove(tmpFile.Name())
db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer db.Close()
masterKey := make([]byte, 32)
for i := range masterKey {
masterKey[i] = byte(i)
}
jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
cfg := &lib.Config{
MasterKey: masterKey,
JWTSecret: jwtSecret,
Env: "development",
BackdoorCode: "220402",
}
tmpDir, err := os.MkdirTemp("", "dealspace-store-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
store, _ := lib.NewLocalStore(tmpDir)
router := NewRouter(db, cfg, store, nil, nil, nil)
server := httptest.NewServer(router)
defer server.Close()
client := &http.Client{}
// Create test user directly in DB
setupTestUser(t, db, cfg, "admin@test.com", "Admin User")
// Step 1: Login with challenge/verify + backdoor
t.Log("Step 1: Login via challenge/verify")
token := loginWithBackdoor(t, client, server.URL, "admin@test.com")
t.Logf("Got token: %s...", token[:20])
// Wrong code should fail
wrongVerify, _ := json.Marshal(map[string]string{"email": "admin@test.com", "code": "000000"})
resp, _ := client.Post(server.URL+"/api/auth/verify", "application/json", bytes.NewReader(wrongVerify))
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("wrong code should return 401, got %d", resp.StatusCode)
}
resp.Body.Close()
// Step 2: GET /api/auth/me
t.Log("Step 2: Get current user")
req, _ := http.NewRequest("GET", server.URL+"/api/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
if err != nil {
t.Fatalf("me request failed: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("me expected 200, got %d", resp.StatusCode)
}
var meResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&meResp)
resp.Body.Close()
if meResp["email"] != "admin@test.com" {
t.Errorf("me response email mismatch: got %s", meResp["email"])
}
// Step 3: POST /api/projects
t.Log("Step 3: Create project")
projectBody, _ := json.Marshal(map[string]string{
"name": "Test Deal Project",
"deal_type": "M&A",
})
req, _ = http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err = client.Do(req)
if err != nil {
t.Fatalf("create project request failed: %v", err)
}
if resp.StatusCode != http.StatusCreated {
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
t.Fatalf("create project expected 201, got %d: %v", resp.StatusCode, errResp)
}
var projectResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&projectResp)
resp.Body.Close()
projectID := projectResp["project_id"].(string)
if projectID == "" {
t.Fatal("project response should contain project_id")
}
t.Logf("Created project: %s", projectID)
// Step 4: GET /api/projects
t.Log("Step 4: List projects")
req, _ = http.NewRequest("GET", server.URL+"/api/projects", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
if err != nil {
t.Fatalf("list projects request failed: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("list projects expected 200, got %d", resp.StatusCode)
}
var listResp []lib.Entry
json.NewDecoder(resp.Body).Decode(&listResp)
resp.Body.Close()
if len(listResp) < 1 {
t.Errorf("expected at least 1 project, got %d", len(listResp))
}
// Step 5: Logout
t.Log("Step 5: Logout")
req, _ = http.NewRequest("POST", server.URL+"/api/auth/logout", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
if err != nil {
t.Fatalf("logout request failed: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("logout expected 200, got %d", resp.StatusCode)
}
resp.Body.Close()
// Step 6: Token should be invalid after logout
t.Log("Step 6: Verify token invalidated")
req, _ = http.NewRequest("GET", server.URL+"/api/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
if err != nil {
t.Fatalf("me after logout request failed: %v", err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("me after logout expected 401, got %d", resp.StatusCode)
}
resp.Body.Close()
t.Log("Full flow test passed!")
}
func TestHealthEndpoint(t *testing.T) {
tmpFile, _ := os.CreateTemp("", "dealspace-health-test-*.db")
tmpFile.Close()
defer os.Remove(tmpFile.Name())
db, _ := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
defer db.Close()
cfg := &lib.Config{
MasterKey: make([]byte, 32),
JWTSecret: []byte("test-secret"),
}
router := NewRouter(db, cfg, nil, nil, nil, nil)
server := httptest.NewServer(router)
defer server.Close()
resp, err := http.Get(server.URL + "/health")
if err != nil {
t.Fatalf("health request failed: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("health expected 200, got %d", resp.StatusCode)
}
var healthResp map[string]string
json.NewDecoder(resp.Body).Decode(&healthResp)
resp.Body.Close()
if healthResp["status"] != "ok" {
t.Errorf("health status should be 'ok', got %s", healthResp["status"])
}
}
func TestUnauthenticatedAccess(t *testing.T) {
tmpFile, _ := os.CreateTemp("", "dealspace-unauth-test-*.db")
tmpFile.Close()
defer os.Remove(tmpFile.Name())
db, _ := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
defer db.Close()
cfg := &lib.Config{
MasterKey: make([]byte, 32),
JWTSecret: []byte("test-secret"),
}
router := NewRouter(db, cfg, nil, nil, nil, nil)
server := httptest.NewServer(router)
defer server.Close()
endpoints := []struct {
method string
path string
}{
{"GET", "/api/auth/me"},
{"POST", "/api/auth/logout"},
{"GET", "/api/projects"},
{"POST", "/api/projects"},
{"GET", "/api/projects/test/entries"},
}
for _, ep := range endpoints {
req, _ := http.NewRequest(ep.method, server.URL+ep.path, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Errorf("%s %s: request failed: %v", ep.method, ep.path, err)
continue
}
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s %s: expected 401, got %d", ep.method, ep.path, resp.StatusCode)
}
resp.Body.Close()
}
}
func TestEntryOperations(t *testing.T) {
tmpFile, _ := os.CreateTemp("", "dealspace-entry-test-*.db")
tmpFile.Close()
defer os.Remove(tmpFile.Name())
db, _ := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
defer db.Close()
masterKey := make([]byte, 32)
jwtSecret := []byte("test-secret-32-bytes!!")
cfg := &lib.Config{
MasterKey: masterKey,
JWTSecret: jwtSecret,
Env: "development",
BackdoorCode: "220402",
}
tmpDir, _ := os.MkdirTemp("", "dealspace-store-entry-test")
defer os.RemoveAll(tmpDir)
store, _ := lib.NewLocalStore(tmpDir)
router := NewRouter(db, cfg, store, nil, nil, nil)
server := httptest.NewServer(router)
defer server.Close()
client := &http.Client{}
// Create user and login via challenge/verify with backdoor
setupTestUser(t, db, cfg, "entry@test.com", "Entry Test")
token := loginWithBackdoor(t, client, server.URL, "entry@test.com")
// Create project
projectBody, _ := json.Marshal(map[string]string{"name": "Entry Test Project"})
req, _ := http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, _ := client.Do(req)
var projectResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&projectResp)
resp.Body.Close()
projectID := projectResp["project_id"].(string)
// Create entry
entryBody, _ := json.Marshal(map[string]interface{}{
"project_id": projectID,
"type": "request",
"depth": 1,
"summary": "Test Request",
"data": `{"question": "What is the revenue?"}`,
"stage": "pre_dataroom",
})
req, _ = http.NewRequest("POST", server.URL+"/api/projects/"+projectID+"/entries", bytes.NewReader(entryBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusCreated {
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
t.Fatalf("create entry expected 201, got %d: %v", resp.StatusCode, errResp)
}
var entryResp lib.Entry
json.NewDecoder(resp.Body).Decode(&entryResp)
resp.Body.Close()
entryID := entryResp.EntryID
if entryID == "" {
t.Fatal("entry should have ID")
}
// List entries
req, _ = http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/entries?type=request", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list entries expected 200, got %d", resp.StatusCode)
}
var entries []lib.Entry
json.NewDecoder(resp.Body).Decode(&entries)
resp.Body.Close()
if len(entries) != 1 {
t.Errorf("expected 1 entry, got %d", len(entries))
}
// Update entry
updateBody, _ := json.Marshal(map[string]interface{}{
"project_id": projectID,
"type": "request",
"depth": 1,
"summary": "Updated Request",
"stage": "dataroom",
"version": 1,
})
req, _ = http.NewRequest("PUT", server.URL+"/api/projects/"+projectID+"/entries/"+entryID, bytes.NewReader(updateBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusOK {
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
t.Fatalf("update entry expected 200, got %d: %v", resp.StatusCode, errResp)
}
resp.Body.Close()
// Delete entry
req, _ = http.NewRequest("DELETE", server.URL+"/api/projects/"+projectID+"/entries/"+entryID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete entry expected 200, got %d", resp.StatusCode)
}
resp.Body.Close()
// Verify deleted
req, _ = http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/entries?type=request", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ = client.Do(req)
json.NewDecoder(resp.Body).Decode(&entries)
resp.Body.Close()
if len(entries) != 0 {
t.Errorf("expected 0 entries after delete, got %d", len(entries))
}
}
func TestChallengeVerifyFlow(t *testing.T) {
tmpFile, _ := os.CreateTemp("", "dealspace-challenge-test-*.db")
tmpFile.Close()
defer os.Remove(tmpFile.Name())
db, _ := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
defer db.Close()
cfg := &lib.Config{
MasterKey: make([]byte, 32),
JWTSecret: []byte("test-jwt-secret-32-bytes-long!!"),
Env: "development",
BackdoorCode: "220402",
}
router := NewRouter(db, cfg, nil, nil, nil, nil)
server := httptest.NewServer(router)
defer server.Close()
client := &http.Client{}
// Challenge for non-existent user should still return 200 (no info leak)
body, _ := json.Marshal(map[string]string{"email": "nobody@test.com"})
resp, _ := client.Post(server.URL+"/api/auth/challenge", "application/json", bytes.NewReader(body))
if resp.StatusCode != http.StatusOK {
t.Errorf("challenge for unknown email should return 200, got %d", resp.StatusCode)
}
resp.Body.Close()
// Verify for non-existent user should fail
body, _ = json.Marshal(map[string]string{"email": "nobody@test.com", "code": "220402"})
resp, _ = client.Post(server.URL+"/api/auth/verify", "application/json", bytes.NewReader(body))
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("verify for unknown user should return 401, got %d", resp.StatusCode)
}
resp.Body.Close()
// Create user and test backdoor login
setupTestUser(t, db, cfg, "test@test.com", "Test User")
token := loginWithBackdoor(t, client, server.URL, "test@test.com")
if token == "" {
t.Fatal("backdoor login should return token")
}
// Verify the token works
req, _ := http.NewRequest("GET", server.URL+"/api/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusOK {
t.Errorf("me with valid token should return 200, got %d", resp.StatusCode)
}
resp.Body.Close()
}
// ---- Additional integration tests ----
func TestFullDealWorkflow(t *testing.T) {
tmpFile, err := os.CreateTemp("", "dealspace-deal-workflow-*.db")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
tmpFile.Close()
defer os.Remove(tmpFile.Name())
db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer db.Close()
masterKey := make([]byte, 32)
for i := range masterKey {
masterKey[i] = byte(i)
}
jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
cfg := &lib.Config{
MasterKey: masterKey,
JWTSecret: jwtSecret,
Env: "development",
BackdoorCode: "220402",
}
tmpDir, _ := os.MkdirTemp("", "dealspace-deal-workflow-store")
defer os.RemoveAll(tmpDir)
store, _ := lib.NewLocalStore(tmpDir)
router := NewRouter(db, cfg, store, nil, nil, nil)
server := httptest.NewServer(router)
defer server.Close()
client := &http.Client{}
// Step 1: Create IB admin user
t.Log("Step 1: Create IB admin user")
setupTestUser(t, db, cfg, "ibadmin@workflow.com", "IB Admin")
ibToken := loginWithBackdoor(t, client, server.URL, "ibadmin@workflow.com")
// Step 2: Create a project
t.Log("Step 2: Create project")
projectBody, _ := json.Marshal(map[string]string{
"name": "Acme Acquisition",
"deal_type": "M&A",
})
req, _ := http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectBody))
req.Header.Set("Authorization", "Bearer "+ibToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := client.Do(req)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create project expected 201, got %d", resp.StatusCode)
}
var projectResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&projectResp)
resp.Body.Close()
projectID := projectResp["project_id"].(string)
t.Logf("Created project: %s", projectID)
// Step 3: Create an organization
t.Log("Step 3: Create organization")
orgBody, _ := json.Marshal(map[string]interface{}{
"name": "Acme Corp",
"domains": []string{"acme.com", "acmecorp.com"},
"role": "seller",
})
req, _ = http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
req.Header.Set("Authorization", "Bearer "+ibToken)
req.Header.Set("Content-Type", "application/json")
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusCreated {
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
t.Fatalf("create org expected 201, got %d: %v", resp.StatusCode, errResp)
}
var orgResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&orgResp)
resp.Body.Close()
orgID := orgResp["entry_id"].(string)
t.Logf("Created org: %s", orgID)
// Step 4: Link org to project (deal_org)
t.Log("Step 4: Link org to project")
dealOrgBody, _ := json.Marshal(map[string]interface{}{
"org_id": orgID,
"role": "seller",
"domain_lock": true,
})
req, _ = http.NewRequest("POST", server.URL+"/api/projects/"+projectID+"/orgs", bytes.NewReader(dealOrgBody))
req.Header.Set("Authorization", "Bearer "+ibToken)
req.Header.Set("Content-Type", "application/json")
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusCreated {
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
t.Fatalf("create deal_org expected 201, got %d: %v", resp.StatusCode, errResp)
}
var dealOrgResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&dealOrgResp)
resp.Body.Close()
t.Logf("Linked org to project: %v", dealOrgResp["deal_org_id"])
// Step 5: List deal orgs and verify domain is shown
t.Log("Step 5: List deal orgs")
req, _ = http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/orgs", nil)
req.Header.Set("Authorization", "Bearer "+ibToken)
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list deal orgs expected 200, got %d", resp.StatusCode)
}
var dealOrgs []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&dealOrgs)
resp.Body.Close()
if len(dealOrgs) != 1 {
t.Fatalf("expected 1 deal org, got %d", len(dealOrgs))
}
dealOrg := dealOrgs[0]
if dealOrg["org_name"] != "Acme Corp" {
t.Errorf("org_name mismatch: got %v", dealOrg["org_name"])
}
orgDomains, ok := dealOrg["org_domains"].([]interface{})
if !ok || len(orgDomains) != 2 {
t.Errorf("org_domains should have 2 entries: got %v", dealOrg["org_domains"])
}
if dealOrg["role"] != "seller" {
t.Errorf("role mismatch: got %v", dealOrg["role"])
}
if dealOrg["domain_lock"] != true {
t.Errorf("domain_lock should be true: got %v", dealOrg["domain_lock"])
}
t.Log("Full deal workflow test passed!")
}
func TestSuperAdminSeeAllProjects(t *testing.T) {
tmpFile, err := os.CreateTemp("", "dealspace-superadmin-projects-*.db")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
tmpFile.Close()
defer os.Remove(tmpFile.Name())
db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer db.Close()
masterKey := make([]byte, 32)
for i := range masterKey {
masterKey[i] = byte(i)
}
jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
cfg := &lib.Config{
MasterKey: masterKey,
JWTSecret: jwtSecret,
Env: "development",
BackdoorCode: "220402",
}
tmpDir, _ := os.MkdirTemp("", "dealspace-superadmin-store")
defer os.RemoveAll(tmpDir)
store, _ := lib.NewLocalStore(tmpDir)
router := NewRouter(db, cfg, store, nil, nil, nil)
server := httptest.NewServer(router)
defer server.Close()
client := &http.Client{}
now := time.Now().UnixMilli()
// Create User A and their project
t.Log("Create User A and Project A")
setupTestUser(t, db, cfg, "usera@test.com", "User A")
userAToken := loginWithBackdoor(t, client, server.URL, "usera@test.com")
projectABody, _ := json.Marshal(map[string]string{"name": "Project A"})
req, _ := http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectABody))
req.Header.Set("Authorization", "Bearer "+userAToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := client.Do(req)
resp.Body.Close()
// Create User B and their project
t.Log("Create User B and Project B")
setupTestUser(t, db, cfg, "userb@test.com", "User B")
userBToken := loginWithBackdoor(t, client, server.URL, "userb@test.com")
projectBBody, _ := json.Marshal(map[string]string{"name": "Project B"})
req, _ = http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectBBody))
req.Header.Set("Authorization", "Bearer "+userBToken)
req.Header.Set("Content-Type", "application/json")
resp, _ = client.Do(req)
resp.Body.Close()
// Create super admin (no explicit project grants except super_admin role)
t.Log("Create Super Admin")
saID := uuid.New().String()
saUser := &lib.User{
UserID: saID,
Email: "superadmin@test.com",
Name: "Super Admin",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, saUser)
lib.AccessGrant(db, &lib.Access{
ID: uuid.New().String(),
ProjectID: "global",
UserID: saID,
Role: lib.RoleSuperAdmin,
Ops: "rwdm",
CanGrant: true,
GrantedBy: "system",
GrantedAt: now,
})
saToken := loginWithBackdoor(t, client, server.URL, "superadmin@test.com")
// User A should only see Project A
t.Log("Verify User A sees only Project A")
req, _ = http.NewRequest("GET", server.URL+"/api/projects", nil)
req.Header.Set("Authorization", "Bearer "+userAToken)
resp, _ = client.Do(req)
var userAProjects []lib.Entry
json.NewDecoder(resp.Body).Decode(&userAProjects)
resp.Body.Close()
if len(userAProjects) != 1 {
t.Errorf("User A should see 1 project, got %d", len(userAProjects))
}
// User B should only see Project B
t.Log("Verify User B sees only Project B")
req, _ = http.NewRequest("GET", server.URL+"/api/projects", nil)
req.Header.Set("Authorization", "Bearer "+userBToken)
resp, _ = client.Do(req)
var userBProjects []lib.Entry
json.NewDecoder(resp.Body).Decode(&userBProjects)
resp.Body.Close()
if len(userBProjects) != 1 {
t.Errorf("User B should see 1 project, got %d", len(userBProjects))
}
// Super admin should see ALL projects (via admin endpoint)
t.Log("Verify Super Admin sees all projects")
req, _ = http.NewRequest("GET", server.URL+"/api/admin/projects", nil)
req.Header.Set("Authorization", "Bearer "+saToken)
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin/projects expected 200, got %d", resp.StatusCode)
}
var saProjects []lib.Entry
json.NewDecoder(resp.Body).Decode(&saProjects)
resp.Body.Close()
if len(saProjects) < 2 {
t.Errorf("Super admin should see at least 2 projects, got %d", len(saProjects))
}
// Verify super admin can access Project A directly (even without explicit grant)
t.Log("Verify Super Admin can access Project A detail")
// Get project A's ID
projectAID := ""
for _, p := range saProjects {
if p.SummaryText == "Project A" {
projectAID = p.ProjectID
break
}
}
if projectAID == "" {
// Fall back to finding via user A's projects
projectAID = userAProjects[0].ProjectID
}
req, _ = http.NewRequest("GET", server.URL+"/api/projects/"+projectAID+"/detail", nil)
req.Header.Set("Authorization", "Bearer "+saToken)
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusOK {
t.Errorf("super admin should access Project A detail, got %d", resp.StatusCode)
}
resp.Body.Close()
t.Log("Super admin project visibility test passed!")
}