770 lines
23 KiB
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!")
|
|
}
|