package org_test

import (
	"log/slog"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	. "github.com/tidbcloud/AgentTeam/internal/api/testkit"
	"github.com/tidbcloud/AgentTeam/internal/core/identifiers"
	"github.com/tidbcloud/AgentTeam/internal/core/secrets"
	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/mailer"
	"github.com/tidbcloud/AgentTeam/internal/store/organization"
	"github.com/tidbcloud/AgentTeam/internal/store/workspace"
)

func testFeishuCredentialEncryptionKey() []byte {
	return []byte("0123456789abcdef0123456789abcdef")
}

func startRuntimeWithOrganizationProxySigningKey(t *testing.T) Runtime {
	t.Helper()

	return StartRuntimeWithOrganizationProvider(t, false, 30, func(logger *slog.Logger) mailer.Mailer {
		return mailer.NewNoop(logger, "http://localhost:3001")
	}, func(pool coredb.DB, dataStore *Store) OrganizationProvider {
		return organization.NewWithDBOptions(pool, NewOrganizationCoreBridge(pool, dataStore), organization.ServiceOptions{
			FeishuCredentialEncryptionKey: testFeishuCredentialEncryptionKey(),
		})
	})
}

func putOrganizationFeishuCredentialsForTests(t *testing.T, client *http.Client, baseURL string, accessToken string, organizationID string, appID string, appSecret string) organization.OrganizationEnterpriseFeishuView {
	t.Helper()

	var enterprise organization.OrganizationEnterpriseFeishuView
	response := DoJSON(t, client, http.MethodPut, baseURL+"/api/v1/orgs/"+organizationID+"/enterprise/feishu/credentials", map[string]any{
		"appId":     appID,
		"appSecret": appSecret,
	}, authHeaders(accessToken), &enterprise)
	require.Equal(t, http.StatusOK, response.StatusCode)
	return enterprise
}

func insertOrganizationDirectorySyncRunForAppAPITests(t *testing.T, runtime Runtime, organizationID string, directoryRevision int64, externalProvider string, syncSource string, status string) string {
	t.Helper()

	syncRunID, err := identifiers.NewEntityID("odsr")
	require.NoError(t, err)

	now := time.Now().UTC()
	var completedAt any
	if status == "completed" {
		completedAt = now
	}
	_, err = runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO organization_directory_sync_runs (
    id,
    organization_id,
    directory_revision,
    external_provider,
    sync_source,
    status,
    started_at,
    completed_at,
    created_at,
    updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $7)
`, syncRunID, organizationID, directoryRevision, externalProvider, syncSource, status, now, completedAt)
	require.NoError(t, err)
	return syncRunID
}

func grantOrganizationSeatsForOrgTests(t *testing.T, runtime Runtime, organizationID string, seatDelta int64, updatedByUserID string) {
	t.Helper()

	grantID, err := identifiers.NewEntityID("oseg")
	require.NoError(t, err)
	now := time.Now().UTC()
	_, err = runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO organization_resource_quota_grants (
    id,
    organization_id,
    metric,
    quota_scope,
    limit_delta,
    expires_at,
    source_type,
    source_ref,
    request_id,
    reason,
    created_by_operator,
    created_at,
    updated_at
) VALUES ($1, $2, 'seats', 'organization', $3, NULL, 'operator_manual', '', $4, 'test fixture', $5, $6, $6)
`, grantID, organizationID, seatDelta, grantID+"_request", updatedByUserID, now)
	require.NoError(t, err)
}

func waitForOrganizationDirectorySyncRunCompletedForTests(t *testing.T, client *http.Client, baseURL string, organizationID string, headers map[string]string, syncRunID string) organization.OrganizationDirectorySyncRunDetailView {
	t.Helper()

	var detail organization.OrganizationDirectorySyncRunDetailView
	require.Eventually(t, func() bool {
		var latest organization.OrganizationDirectorySyncRunDetailView
		response := DoJSON(t, client, http.MethodGet, baseURL+"/api/v1/orgs/"+organizationID+"/directory-sync-runs/"+syncRunID, nil, headers, &latest)
		if response.StatusCode != http.StatusOK {
			return false
		}
		detail = latest
		return latest.Status != "running"
	}, 10*time.Second, 50*time.Millisecond)
	require.Equal(t, "completed", detail.Status)
	return detail
}

func TestOrganizationGrantAddsWorkspaceToTargetUser(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)
	require.Equal(t, "org_owner", organizationSummary.Role)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                targetAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, targetAuth.User.ID, account.UserID)
	require.Equal(t, targetAuth.User.ID, account.UserID)

	var createdWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Project One",
		"slug":           "project-one",
	}, authHeaders(ownerAuth.AccessToken), &createdWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var grant organization.OrganizationWorkspaceGrantView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+account.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), &grant)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, createdWorkspace.ID, grant.WorkspaceID)
	require.Equal(t, account.ID, grant.MemberID)
	require.NotEmpty(t, grant.MemberID)
	require.Equal(t, "manual_admin_grant", grant.Source)

	var targetWorkspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, authHeaders(targetAuth.AccessToken), &targetWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, workspaceIDsFromResponses(targetWorkspaces), createdWorkspace.ID)

	var targetOrganizations []organization.OrganizationSummary
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs", nil, authHeaders(targetAuth.AccessToken), &targetOrganizations)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, organizationIDsFromSummaries(targetOrganizations), organizationSummary.ID)

	var members workspace.WorkspaceMemberPage
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, hasWorkspaceMember(members, targetAuth.User.ID, "member"))
}

func TestOrganizationMembersListIncludesWorkspaceAccessSummary(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                targetAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var directWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Direct Workspace",
		"slug":           "direct-workspace",
	}, authHeaders(ownerAuth.AccessToken), &directWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+directWorkspace.ID+"/members/"+account.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), &organization.OrganizationWorkspaceGrantView{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	var teamWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Team Workspace",
		"slug":           "team-workspace",
	}, authHeaders(ownerAuth.AccessToken), &teamWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+teamWorkspace.ID+"/teams/"+organizationSummary.DefaultTeamID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), &organization.OrganizationWorkspaceTeamGrantView{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	var orgWorkspaces []organization.OrganizationWorkspaceView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces", nil, authHeaders(ownerAuth.AccessToken), &orgWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)

	defaultWorkspaceID := ""
	for _, orgWorkspace := range orgWorkspaces {
		if orgWorkspace.IsDefault {
			defaultWorkspaceID = orgWorkspace.ID
			break
		}
	}
	require.NotEmpty(t, defaultWorkspaceID)

	var members []organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)

	targetMember := requireOrganizationMemberByID(t, members, account.ID)
	require.Equal(t, 3, targetMember.WorkspaceAccessCount)
	require.Len(t, targetMember.WorkspaceAccesses, 3)

	defaultAccess := requireOrganizationWorkspaceAccessByID(t, targetMember.WorkspaceAccesses, defaultWorkspaceID)
	require.Equal(t, "member", defaultAccess.Role)
	require.Equal(t, []string{"org-team"}, defaultAccess.Sources)

	directAccess := requireOrganizationWorkspaceAccessByID(t, targetMember.WorkspaceAccesses, directWorkspace.ID)
	require.Equal(t, "member", directAccess.Role)
	require.Equal(t, []string{"org-direct"}, directAccess.Sources)

	teamAccess := requireOrganizationWorkspaceAccessByID(t, targetMember.WorkspaceAccesses, teamWorkspace.ID)
	require.Equal(t, "member", teamAccess.Role)
	require.Equal(t, []string{"org-team"}, teamAccess.Sources)
}

func TestOrganizationMemberRemovalPreviewAndDeleteReconcilesAccess(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                targetAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var createdWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Removal Workspace",
		"slug":           "removal-workspace",
	}, authHeaders(ownerAuth.AccessToken), &createdWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+account.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), &organization.OrganizationWorkspaceGrantView{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	machineID, agentID := seedOrganizationRemovalRuntimeForAPITests(t, runtime, createdWorkspace.ID, targetAuth.User.ID)

	var impact dto.OrganizationMemberRemovalImpactResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members/"+account.ID+"/removal-impact", nil, authHeaders(ownerAuth.AccessToken), &impact)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, account.ID, impact.Member.ID)
	require.Empty(t, impact.BlockingReasons)
	require.Contains(t, organizationRemovalWorkspaceIDs(impact.AffectedWorkspaces), createdWorkspace.ID)
	require.Len(t, impact.MachinesToRevoke, 1)
	require.Equal(t, machineID, impact.MachinesToRevoke[0].ID)
	require.Len(t, impact.AgentsToDisable, 1)
	require.Equal(t, agentID, impact.AgentsToDisable[0].ID)

	var result dto.OrganizationMemberRemovalResultResponse
	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members/"+account.ID, nil, authHeaders(ownerAuth.AccessToken), &result)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, account.ID, result.MemberID)
	require.Equal(t, "inactive", result.Status)
	require.NotNil(t, result.DisabledAt)

	var targetOrganizations []organization.OrganizationSummary
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs", nil, authHeaders(targetAuth.AccessToken), &targetOrganizations)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotContains(t, organizationIDsFromSummaries(targetOrganizations), organizationSummary.ID)

	var targetWorkspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, authHeaders(targetAuth.AccessToken), &targetWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotContains(t, workspaceIDsFromResponses(targetWorkspaces), createdWorkspace.ID)

	var workspaceMembers workspace.WorkspaceMemberPage
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &workspaceMembers)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, hasWorkspaceMember(workspaceMembers, targetAuth.User.ID, "member"))

	var machineStatus string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT status FROM machines WHERE id = $1`, machineID).Scan(&machineStatus))
	require.Equal(t, "revoked", machineStatus)
	var agentStatus string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT status FROM agents WHERE id = $1`, agentID).Scan(&agentStatus))
	require.Equal(t, "archived", agentStatus)

	var secondResult dto.OrganizationMemberRemovalResultResponse
	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members/"+account.ID, nil, authHeaders(ownerAuth.AccessToken), &secondResult)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, account.ID, secondResult.MemberID)
	require.Equal(t, "inactive", secondResult.Status)
}

func TestOrganizationMemberRemovalBlocksSelfRemove(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)

	var members []organization.OrganizationMemberView
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	ownerMember, ok := organizationMemberByUserID(members, ownerAuth.User.ID)
	require.True(t, ok)

	var impact dto.OrganizationMemberRemovalImpactResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members/"+ownerMember.ID+"/removal-impact", nil, authHeaders(ownerAuth.AccessToken), &impact)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, organizationRemovalBlockerCodes(impact.BlockingReasons), "self_remove_not_allowed")

	var apiError struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members/"+ownerMember.ID, nil, authHeaders(ownerAuth.AccessToken), &apiError)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	require.Equal(t, "self_remove_not_allowed", apiError.Error)
}

func TestOrganizationMembersListSupportsPagedDirectory(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	firstAuth := registerUserForOrganizationTests(t, client, server.URL, "first@example.com", "first")
	secondAuth := registerUserForOrganizationTests(t, client, server.URL, "second@example.com", "second")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)

	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                firstAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &organization.OrganizationMemberView{})
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                secondAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &organization.OrganizationMemberView{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	var firstPage dto.OrganizationMemberPageResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members?limit=1&includeWorkspaceAccesses=false", nil, authHeaders(ownerAuth.AccessToken), &firstPage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, firstPage.Members, 1)
	require.True(t, firstPage.HasMore)
	require.NotEmpty(t, firstPage.NextCursor)
	require.Empty(t, firstPage.Members[0].WorkspaceAccesses)
	require.Zero(t, firstPage.Members[0].WorkspaceAccessCount)

	var secondPage dto.OrganizationMemberPageResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members?limit=1&cursor="+firstPage.NextCursor+"&includeWorkspaceAccesses=false", nil, authHeaders(ownerAuth.AccessToken), &secondPage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, secondPage.Members, 1)
	require.NotEqual(t, firstPage.Members[0].ID, secondPage.Members[0].ID)
}

func TestOrganizationCreateBootstrapsDefaultWorkspaceAndBlocksUnbind(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var orgWorkspaces []organization.OrganizationWorkspaceView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces", nil, authHeaders(ownerAuth.AccessToken), &orgWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, orgWorkspaces, 1)
	require.True(t, orgWorkspaces[0].IsDefault)

	var ownerWorkspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, authHeaders(ownerAuth.AccessToken), &ownerWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, workspaceIDsFromResponses(ownerWorkspaces), orgWorkspaces[0].ID)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                targetAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var targetWorkspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, authHeaders(targetAuth.AccessToken), &targetWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, workspaceIDsFromResponses(targetWorkspaces), orgWorkspaces[0].ID)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+orgWorkspaces[0].ID, nil, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusNotFound, response.StatusCode)
}

func TestWorkspaceMembersIncludeWorkspaceAccessSourcesForBoundWorkspaces(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	teamAuth := registerUserForOrganizationTests(t, client, server.URL, "team@example.com", "team")
	directAuth := registerUserForOrganizationTests(t, client, server.URL, "direct@example.com", "direct")
	outsiderAuth := registerUserForOrganizationTests(t, client, server.URL, "outsider@example.com", "outsider")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	grantOrganizationSeatsForOrgTests(t, runtime, organizationSummary.ID, 10, ownerAuth.User.ID)
	response := (*http.Response)(nil)

	var opsTeam organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Ops",
		"slug": "ops",
	}, authHeaders(ownerAuth.AccessToken), &opsTeam)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var createdWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Bound Workspace",
		"slug":           "bound-workspace",
	}, authHeaders(ownerAuth.AccessToken), &createdWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	insertWorkspaceMemberForOrgTests(t, runtime, createdWorkspace.ID, teamAuth.User.ID, "Team Member", "member")
	insertWorkspaceMemberForOrgTests(t, runtime, createdWorkspace.ID, outsiderAuth.User.ID, "Outsider", "member")

	var teamAccount organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                teamAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &teamAccount)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var directAccount organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                directAuth.User.Email,
		"teamId":               opsTeam.ID,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &directAccount)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/teams/"+organizationSummary.DefaultTeamID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), &organization.OrganizationWorkspaceTeamGrantView{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+directAccount.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), &organization.OrganizationWorkspaceGrantView{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	var members workspace.WorkspaceMemberPage
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)

	require.Equal(t, []string{"org-team", "workspace-direct"}, requireWorkspaceMemberByUserID(t, members, teamAuth.User.ID).AccessSources)
	require.Equal(t, []string{"org-direct", "org-team"}, requireWorkspaceMemberByUserID(t, members, directAuth.User.ID).AccessSources)
	require.Equal(t, []string{"workspace-direct"}, requireWorkspaceMemberByUserID(t, members, outsiderAuth.User.ID).AccessSources)
	require.Equal(t, []string{"org-team", "workspace-direct"}, requireWorkspaceMemberByUserID(t, members, ownerAuth.User.ID).AccessSources)
}

func TestOrganizationTeamMembershipAPIListsAndSwitchesPrimary(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var productTeam organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Product",
		"slug": "product",
	}, authHeaders(ownerAuth.AccessToken), &productTeam)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email": targetAuth.User.Email,
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, organizationSummary.DefaultTeamID, account.PrimaryTeamID)

	var teamAccount organization.OrganizationTeamMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams/"+productTeam.ID+"/members", map[string]any{
		"memberId": account.ID,
	}, authHeaders(ownerAuth.AccessToken), &teamAccount)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, account.ID, teamAccount.MemberID)
	require.False(t, teamAccount.IsPrimary)

	var productAccounts []organization.OrganizationTeamMemberView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams/"+productTeam.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &productAccounts)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, productAccounts, 1)
	require.Equal(t, account.ID, productAccounts[0].MemberID)

	var updatedAccount organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members/"+account.ID+"/primary-team", map[string]any{
		"teamId": productTeam.ID,
	}, authHeaders(ownerAuth.AccessToken), &updatedAccount)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, productTeam.ID, updatedAccount.PrimaryTeamID)
	require.Equal(t, "Product", updatedAccount.PrimaryTeamName)
}

func TestOrganizationUnitAPIListsCreatesAndManagesMemberships(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var initialUnits []organization.OrganizationUnitView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units", nil, authHeaders(ownerAuth.AccessToken), &initialUnits)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, initialUnits, 1)
	require.True(t, initialUnits[0].IsDefault)
	require.Equal(t, "General", initialUnits[0].Name)
	defaultUnit := initialUnits[0]

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email": targetAuth.User.Email,
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var engineering organization.OrganizationUnitView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units", map[string]any{
		"name":            "Engineering",
		"slug":            "engineering",
		"parentUnitId":    defaultUnit.ID,
		"managerMemberId": account.ID,
	}, authHeaders(ownerAuth.AccessToken), &engineering)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, defaultUnit.ID, engineering.ParentUnitID)
	require.Equal(t, account.ID, engineering.ManagerMemberID)
	require.False(t, engineering.IsDefault)

	var listedUnits []organization.OrganizationUnitView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units", nil, authHeaders(ownerAuth.AccessToken), &listedUnits)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, listedUnits, 2)
	require.Equal(t, defaultUnit.ID, listedUnits[0].ID)
	require.Equal(t, 2, listedUnits[0].MemberCount)

	var engineeringMembership organization.OrganizationUnitMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units/"+engineering.ID+"/members", map[string]any{
		"memberId": account.ID,
	}, authHeaders(ownerAuth.AccessToken), &engineeringMembership)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, account.ID, engineeringMembership.MemberID)
	require.False(t, engineeringMembership.IsPrimary)
	require.Equal(t, defaultUnit.ID, engineeringMembership.PrimaryUnitID)

	var engineeringAccounts []organization.OrganizationUnitMemberView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units/"+engineering.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &engineeringAccounts)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, engineeringAccounts, 1)
	require.Equal(t, account.ID, engineeringAccounts[0].MemberID)

	var accountUnits []organization.OrganizationMemberUnitView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members/"+account.ID+"/units", nil, authHeaders(ownerAuth.AccessToken), &accountUnits)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, accountUnits, 2)
	require.Equal(t, defaultUnit.ID, accountUnits[0].UnitID)
	require.True(t, accountUnits[0].IsPrimary)
	require.Equal(t, engineering.ID, accountUnits[1].UnitID)
	require.False(t, accountUnits[1].IsPrimary)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units/"+engineering.ID+"/members", map[string]any{
		"memberId": account.ID,
		"primary":  true,
	}, authHeaders(ownerAuth.AccessToken), &engineeringMembership)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, engineeringMembership.IsPrimary)
	require.Equal(t, engineering.ID, engineeringMembership.PrimaryUnitID)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members/"+account.ID+"/units", nil, authHeaders(ownerAuth.AccessToken), &accountUnits)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, accountUnits, 2)
	require.Equal(t, engineering.ID, accountUnits[0].UnitID)
	require.True(t, accountUnits[0].IsPrimary)
	require.Equal(t, defaultUnit.ID, accountUnits[1].UnitID)
	require.False(t, accountUnits[1].IsPrimary)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units/"+defaultUnit.ID+"/members/"+account.ID, nil, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members/"+account.ID+"/units", nil, authHeaders(ownerAuth.AccessToken), &accountUnits)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, accountUnits, 1)
	require.Equal(t, engineering.ID, accountUnits[0].UnitID)
	require.True(t, accountUnits[0].IsPrimary)
}

func TestOrganizationSystemTeamMembershipAPIRejectsManualMutation(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email": targetAuth.User.Email,
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var apiError struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams/"+organizationSummary.DefaultTeamID+"/members", map[string]any{
		"memberId": account.ID,
	}, authHeaders(ownerAuth.AccessToken), &apiError)
	require.Equal(t, http.StatusBadRequest, response.StatusCode)
	require.Contains(t, apiError.Error, "managed by provider sync")
}

func TestOrganizationTeamGrantAddsWorkspaceToTeamMember(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var productTeam organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Product",
		"slug": "product",
	}, authHeaders(ownerAuth.AccessToken), &productTeam)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email": targetAuth.User.Email,
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams/"+productTeam.ID+"/members", map[string]any{
		"memberId": account.ID,
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var createdWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Project One",
		"slug":           "project-one",
	}, authHeaders(ownerAuth.AccessToken), &createdWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var teamGrant organization.OrganizationWorkspaceTeamGrantView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/teams/"+productTeam.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), &teamGrant)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, createdWorkspace.ID, teamGrant.WorkspaceID)
	require.Equal(t, productTeam.ID, teamGrant.TeamID)
	require.Equal(t, "team", teamGrant.Source)
	require.Equal(t, 1, teamGrant.MemberCount)

	var targetWorkspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, authHeaders(targetAuth.AccessToken), &targetWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, workspaceIDsFromResponses(targetWorkspaces), createdWorkspace.ID)

	var members workspace.WorkspaceMemberPage
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, hasWorkspaceMember(members, targetAuth.User.ID, "member"))
	require.Equal(t, "team", organizationProjectionSourceForAccount(t, runtime, organizationSummary.ID, createdWorkspace.ID, account.ID))

	var listedTeamGrants []organization.OrganizationWorkspaceTeamGrantView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/teams", nil, authHeaders(ownerAuth.AccessToken), &listedTeamGrants)
	require.Equal(t, http.StatusOK, response.StatusCode)
	productGrant := requireOrganizationWorkspaceTeamGrantByTeamID(t, listedTeamGrants, productTeam.ID)
	require.Equal(t, "team", productGrant.Source)
}

func TestOrganizationUnitBackedTeamGrantReportsUnitBackedSource(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email": targetAuth.User.Email,
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var engineering organization.OrganizationUnitView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units", map[string]any{
		"name": "Engineering",
		"slug": "engineering",
	}, authHeaders(ownerAuth.AccessToken), &engineering)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units/"+engineering.ID+"/members", map[string]any{
		"memberId": account.ID,
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	organizationService, ok := runtime.Organizations.(*organization.Service)
	require.True(t, ok)

	unitBackedTeam, err := organizationService.EnsureOrganizationUnitBackedTeamForUser(runtime.Ctx, ownerAuth.User.ID, organizationSummary.ID, engineering.ID)
	require.NoError(t, err)
	require.Equal(t, "unit_backed", unitBackedTeam.Source)

	var createdWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Project One",
		"slug":           "project-one",
	}, authHeaders(ownerAuth.AccessToken), &createdWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var teamGrant organization.OrganizationWorkspaceTeamGrantView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/teams/"+unitBackedTeam.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), &teamGrant)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, unitBackedTeam.ID, teamGrant.TeamID)
	require.Equal(t, "unit_backed_team", teamGrant.Source)
	require.Equal(t, 1, teamGrant.MemberCount)

	var listedTeamGrants []organization.OrganizationWorkspaceTeamGrantView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/teams", nil, authHeaders(ownerAuth.AccessToken), &listedTeamGrants)
	require.Equal(t, http.StatusOK, response.StatusCode)
	unitGrant := requireOrganizationWorkspaceTeamGrantByTeamIDAndSource(t, listedTeamGrants, unitBackedTeam.ID, "unit_backed_team")
	require.Equal(t, "unit_backed_team", unitGrant.Source)

	var targetWorkspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, authHeaders(targetAuth.AccessToken), &targetWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, workspaceIDsFromResponses(targetWorkspaces), createdWorkspace.ID)
	require.Equal(t, "unit_backed_team", organizationProjectionSourceForAccount(t, runtime, organizationSummary.ID, createdWorkspace.ID, account.ID))
}

func TestOrganizationRemovingTeamMembershipRevokesDerivedWorkspaceAccess(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var productTeam organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Product",
		"slug": "product",
	}, authHeaders(ownerAuth.AccessToken), &productTeam)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email": targetAuth.User.Email,
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams/"+productTeam.ID+"/members", map[string]any{
		"memberId": account.ID,
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var createdWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Project One",
		"slug":           "project-one",
	}, authHeaders(ownerAuth.AccessToken), &createdWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/teams/"+productTeam.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var targetWorkspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, authHeaders(targetAuth.AccessToken), &targetWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, workspaceIDsFromResponses(targetWorkspaces), createdWorkspace.ID)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams/"+productTeam.ID+"/members/"+account.ID, nil, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	targetWorkspaces = nil
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, authHeaders(targetAuth.AccessToken), &targetWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotContains(t, workspaceIDsFromResponses(targetWorkspaces), createdWorkspace.ID)

	var members workspace.WorkspaceMemberPage
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, hasWorkspaceMember(members, targetAuth.User.ID, "member"))
}

func TestOrganizationDirectGrantRevokeFallsBackToTeamGrant(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var productTeam organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Product",
		"slug": "product",
	}, authHeaders(ownerAuth.AccessToken), &productTeam)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email": targetAuth.User.Email,
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams/"+productTeam.ID+"/members", map[string]any{
		"memberId": account.ID,
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var createdWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Project One",
		"slug":           "project-one",
	}, authHeaders(ownerAuth.AccessToken), &createdWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/teams/"+productTeam.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var directGrant organization.OrganizationWorkspaceGrantView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+account.ID, map[string]any{
		"role": "admin",
	}, authHeaders(ownerAuth.AccessToken), &directGrant)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "admin", directGrant.Role)
	require.Equal(t, "manual_admin_grant", directGrant.Source)
	require.Equal(t, "manual_admin_grant", organizationProjectionSourceForAccount(t, runtime, organizationSummary.ID, createdWorkspace.ID, account.ID))

	var workspaceMembers workspace.WorkspaceMemberPage
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &workspaceMembers)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, hasWorkspaceMember(workspaceMembers, targetAuth.User.ID, "admin"))

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+account.ID, nil, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var targetWorkspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, authHeaders(targetAuth.AccessToken), &targetWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, workspaceIDsFromResponses(targetWorkspaces), createdWorkspace.ID)

	workspaceMembers = workspace.WorkspaceMemberPage{}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &workspaceMembers)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, hasWorkspaceMember(workspaceMembers, targetAuth.User.ID, "member"))
	require.Equal(t, "team", organizationProjectionSourceForAccount(t, runtime, organizationSummary.ID, createdWorkspace.ID, account.ID))
}

func TestOrganizationDirectGrantRevokeFallsBackToBaselineAndTeamRole(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var createdWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Project One",
		"slug":           "project-one",
	}, authHeaders(ownerAuth.AccessToken), &createdWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	inviteToken := "tok_org_direct_revoke_team_fallback"
	_, err := runtime.Store.CreateWorkspaceInvite(
		runtime.Ctx,
		ownerAuth.User.ID,
		createdWorkspace.ID,
		targetAuth.User.Email,
		secrets.HashToken(inviteToken),
		time.Now().UTC().Add(time.Hour),
	)
	require.NoError(t, err)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/accept-invite", map[string]any{
		"token": inviteToken,
	}, authHeaders(targetAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var productTeam organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Product",
		"slug": "product",
	}, authHeaders(ownerAuth.AccessToken), &productTeam)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var members []organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	account, ok := organizationMemberByUserID(members, targetAuth.User.ID)
	require.True(t, ok)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams/"+productTeam.ID+"/members", map[string]any{
		"memberId": account.ID,
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/teams/"+productTeam.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var directGrant organization.OrganizationWorkspaceGrantView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+account.ID, map[string]any{
		"role": "admin",
	}, authHeaders(ownerAuth.AccessToken), &directGrant)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "admin", directGrant.Role)
	require.Equal(t, "manual_admin_grant", directGrant.Source)

	var workspaceMembers workspace.WorkspaceMemberPage
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &workspaceMembers)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, hasWorkspaceMember(workspaceMembers, targetAuth.User.ID, "admin"))

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+account.ID, nil, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	workspaceMembers = workspace.WorkspaceMemberPage{}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &workspaceMembers)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, hasWorkspaceMember(workspaceMembers, targetAuth.User.ID, "member"))
	require.Equal(t, "team", organizationProjectionSourceForAccount(t, runtime, organizationSummary.ID, createdWorkspace.ID, account.ID))
}

func TestOrganizationDirectGrantRevokePreservesUpdatedBaselineAcrossTeamFallback(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var createdWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Project One",
		"slug":           "project-one",
	}, authHeaders(ownerAuth.AccessToken), &createdWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	inviteToken := "tok_org_baseline_drift_team_fallback"
	_, err := runtime.Store.CreateWorkspaceInvite(
		runtime.Ctx,
		ownerAuth.User.ID,
		createdWorkspace.ID,
		targetAuth.User.Email,
		secrets.HashToken(inviteToken),
		time.Now().UTC().Add(time.Hour),
	)
	require.NoError(t, err)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/accept-invite", map[string]any{
		"token": inviteToken,
	}, authHeaders(targetAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var productTeam organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Product",
		"slug": "product",
	}, authHeaders(ownerAuth.AccessToken), &productTeam)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var members []organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	account, ok := organizationMemberByUserID(members, targetAuth.User.ID)
	require.True(t, ok)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams/"+productTeam.ID+"/members", map[string]any{
		"memberId": account.ID,
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/teams/"+productTeam.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var directGrant organization.OrganizationWorkspaceGrantView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+account.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), &directGrant)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "member", directGrant.Role)

	targetMemberID := TaskWorkspaceMemberIDForUser(t, runtime, createdWorkspace.ID, targetAuth.User.ID)
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members/"+targetMemberID, map[string]any{
		"role": "admin",
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+account.ID, nil, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaceMembers workspace.WorkspaceMemberPage
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &workspaceMembers)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, hasWorkspaceMember(workspaceMembers, targetAuth.User.ID, "admin"))

	projection := organizationProjectionSnapshotForAccount(t, runtime, organizationSummary.ID, createdWorkspace.ID, account.ID)
	require.True(t, projection.HasBaseline)
	require.Equal(t, "admin", projection.BaselineRole)
	require.False(t, projection.MemberCreatedByOrg)
	require.Equal(t, "team", projection.AppliedSource)
}

func TestOrganizationRevokeLastGrantKeepsUpdatedLocalBaselineForOrgCreatedMember(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email": targetAuth.User.Email,
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var createdWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Project One",
		"slug":           "project-one",
	}, authHeaders(ownerAuth.AccessToken), &createdWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+account.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	targetMemberID := TaskWorkspaceMemberIDForUser(t, runtime, createdWorkspace.ID, targetAuth.User.ID)
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members/"+targetMemberID, map[string]any{
		"role": "admin",
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+account.ID, nil, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var targetWorkspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, authHeaders(targetAuth.AccessToken), &targetWorkspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, workspaceIDsFromResponses(targetWorkspaces), createdWorkspace.ID)

	var members workspace.WorkspaceMemberPage
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, hasWorkspaceMember(members, targetAuth.User.ID, "admin"))

	projection := organizationProjectionSnapshotForAccount(t, runtime, organizationSummary.ID, createdWorkspace.ID, account.ID)
	require.True(t, projection.HasBaseline)
	require.Equal(t, "admin", projection.BaselineRole)
	require.False(t, projection.MemberCreatedByOrg)
	require.Equal(t, "manual_admin_grant", projection.AppliedSource)
}

func organizationProjectionSourceForAccount(t *testing.T, runtime Runtime, organizationID string, workspaceID string, memberID string) string {
	t.Helper()

	return organizationProjectionSnapshotForAccount(t, runtime, organizationID, workspaceID, memberID).AppliedSource
}

func organizationProjectionExistsForAccount(t *testing.T, runtime Runtime, organizationID string, workspaceID string, memberID string) bool {
	t.Helper()

	var exists bool
	err := runtime.Pool.QueryRow(runtime.Ctx, `
SELECT EXISTS(
    SELECT 1
    FROM organization_workspace_member_projections
    WHERE organization_id = $1 AND workspace_id = $2 AND organization_member_id = $3
)
`, organizationID, workspaceID, memberID).Scan(&exists)
	require.NoError(t, err)
	return exists
}

type organizationProjectionSnapshot struct {
	BaselineRole       string
	HasBaseline        bool
	AppliedSource      string
	MemberCreatedByOrg bool
}

func organizationProjectionSnapshotForAccount(t *testing.T, runtime Runtime, organizationID string, workspaceID string, memberID string) organizationProjectionSnapshot {
	t.Helper()

	var snapshot organizationProjectionSnapshot
	err := runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COALESCE(baseline_workspace_role, ''),
       baseline_workspace_role IS NOT NULL,
       applied_source,
       member_created_by_org
FROM organization_workspace_member_projections
WHERE organization_id = $1 AND workspace_id = $2 AND organization_member_id = $3
`, organizationID, workspaceID, memberID).Scan(
		&snapshot.BaselineRole,
		&snapshot.HasBaseline,
		&snapshot.AppliedSource,
		&snapshot.MemberCreatedByOrg,
	)
	require.NoError(t, err)
	return snapshot
}

func registerUserForOrganizationTests(t *testing.T, client *http.Client, baseURL string, email string, name string) dto.AuthTokensResponse {
	t.Helper()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, baseURL+"/api/v1/auth/register", map[string]any{
		"email":    email,
		"password": TestUserPassword,
		"name":     name,
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, auth.AccessToken)
	return auth
}

func authHeaders(accessToken string) map[string]string {
	return map[string]string{
		"Authorization": "Bearer " + accessToken,
	}
}

func workspaceHeaders(accessToken string, workspaceID string) map[string]string {
	headers := authHeaders(accessToken)
	headers[HeaderWorkspaceID] = workspaceID
	return headers
}

func workspaceIDsFromResponses(workspaces []dto.WorkspaceResponse) []string {
	ids := make([]string, 0, len(workspaces))
	for _, workspace := range workspaces {
		ids = append(ids, workspace.ID)
	}
	return ids
}

func organizationWorkspaceIDs(workspaces []organization.OrganizationWorkspaceView) []string {
	ids := make([]string, 0, len(workspaces))
	for _, workspace := range workspaces {
		ids = append(ids, workspace.ID)
	}
	return ids
}

func organizationIDsFromSummaries(organizations []organization.OrganizationSummary) []string {
	ids := make([]string, 0, len(organizations))
	for _, organizationSummary := range organizations {
		ids = append(ids, organizationSummary.ID)
	}
	return ids
}

func organizationRemovalWorkspaceIDs(workspaces []dto.OrganizationMemberRemovalWorkspaceResponse) []string {
	ids := make([]string, 0, len(workspaces))
	for _, workspace := range workspaces {
		ids = append(ids, workspace.ID)
	}
	return ids
}

func organizationRemovalBlockerCodes(blockers []dto.OrganizationMemberRemovalBlockerResponse) []string {
	codes := make([]string, 0, len(blockers))
	for _, blocker := range blockers {
		codes = append(codes, blocker.Code)
	}
	return codes
}

func organizationMemberByUserID(members []organization.OrganizationMemberView, userID string) (organization.OrganizationMemberView, bool) {
	for _, member := range members {
		if member.UserID == userID {
			return member, true
		}
	}
	return organization.OrganizationMemberView{}, false
}

func organizationMemberByID(members []organization.OrganizationMemberView, memberID string) (organization.OrganizationMemberView, bool) {
	for _, member := range members {
		if member.ID == memberID {
			return member, true
		}
	}
	return organization.OrganizationMemberView{}, false
}

func organizationTeamByID(teams []organization.OrganizationTeamView, teamID string) (organization.OrganizationTeamView, bool) {
	for _, team := range teams {
		if team.ID == teamID {
			return team, true
		}
	}
	return organization.OrganizationTeamView{}, false
}

func organizationUnitByID(units []organization.OrganizationUnitView, unitID string) (organization.OrganizationUnitView, bool) {
	for _, unit := range units {
		if unit.ID == unitID {
			return unit, true
		}
	}
	return organization.OrganizationUnitView{}, false
}

func hasWorkspaceMember(members workspace.WorkspaceMemberPage, userID string, role string) bool {
	for _, member := range members.Members {
		if member.UserID == userID && member.Role == role {
			return true
		}
	}
	return false
}

func requireWorkspaceMemberByUserID(t *testing.T, members workspace.WorkspaceMemberPage, userID string) workspace.WorkspaceMember {
	t.Helper()

	for _, member := range members.Members {
		if member.UserID == userID {
			return member
		}
	}
	t.Fatalf("workspace member %s not found", userID)
	return workspace.WorkspaceMember{}
}

func requireOrganizationMemberByID(t *testing.T, members []organization.OrganizationMemberView, memberID string) organization.OrganizationMemberView {
	t.Helper()

	for _, account := range members {
		if account.ID == memberID {
			return account
		}
	}
	t.Fatalf("organization member %s not found", memberID)
	return organization.OrganizationMemberView{}
}

func requireOrganizationWorkspaceAccessByID(t *testing.T, accesses []organization.OrganizationMemberWorkspaceAccessView, workspaceID string) organization.OrganizationMemberWorkspaceAccessView {
	t.Helper()

	for _, access := range accesses {
		if access.WorkspaceID == workspaceID {
			return access
		}
	}
	t.Fatalf("organization workspace access %s not found", workspaceID)
	return organization.OrganizationMemberWorkspaceAccessView{}
}

func seedOrganizationRemovalRuntimeForAPITests(t *testing.T, runtime Runtime, workspaceID string, userID string) (string, string) {
	t.Helper()

	machineID, err := identifiers.NewEntityID("mac")
	require.NoError(t, err)
	agentMemberID, err := identifiers.NewEntityID("mem")
	require.NoError(t, err)
	agentID, err := identifiers.NewEntityID("agt")
	require.NoError(t, err)
	now := time.Now().UTC()

	_, err = runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO machines (id, workspace_id, created_by_user_id, name, api_key, status, created_at, updated_at)
VALUES ($1, $2, $3, 'Removal preview machine', 'removal-preview-machine-key', 'active', $4, $4)
`, machineID, workspaceID, userID, now)
	require.NoError(t, err)
	_, err = runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO members (id, workspace_id, type, display_name, created_at, updated_at)
VALUES ($1, $2, 'agent', 'Removal Preview Agent', $3, $3)
`, agentMemberID, workspaceID, now)
	require.NoError(t, err)
	_, err = runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO agents (
  id, workspace_id, machine_id, member_id, name, runtime, model,
  description, status, activity, activity_detail, created_at, updated_at
) VALUES (
  $1, $2, $3, $4, 'removal-preview-agent', 'claude', 'claude-sonnet-4',
  '', 'active', 'offline', '', $5, $5
)
`, agentID, workspaceID, machineID, agentMemberID, now)
	require.NoError(t, err)

	return machineID, agentID
}

func requireOrganizationWorkspaceTeamGrantByTeamID(t *testing.T, grants []organization.OrganizationWorkspaceTeamGrantView, teamID string) organization.OrganizationWorkspaceTeamGrantView {
	t.Helper()

	for _, grant := range grants {
		if grant.TeamID == teamID {
			return grant
		}
	}
	t.Fatalf("organization workspace team grant %s not found", teamID)
	return organization.OrganizationWorkspaceTeamGrantView{}
}

func requireOrganizationWorkspaceTeamGrantByTeamIDAndSource(t *testing.T, grants []organization.OrganizationWorkspaceTeamGrantView, teamID string, source string) organization.OrganizationWorkspaceTeamGrantView {
	t.Helper()

	for _, grant := range grants {
		if grant.TeamID == teamID && grant.Source == source {
			return grant
		}
	}
	t.Fatalf("organization workspace team grant %s source %s not found", teamID, source)
	return organization.OrganizationWorkspaceTeamGrantView{}
}

func insertWorkspaceMemberForOrgTests(t *testing.T, runtime Runtime, workspaceID string, userID string, displayName string, role string) {
	t.Helper()

	memberID := "mem_org_" + workspaceID + "_" + userID
	now := time.Now().UTC()
	_, err := runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, $5, $6, $6)
`, memberID, workspaceID, displayName, userID, role, now)
	require.NoError(t, err)
}

// TestOrganizationSuspendedOrgBlocksAccess verifies that suspending the
// organization's settings row causes all member API calls to return 403.
func TestOrganizationSuspendedOrgBlocksAccess(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	memberAuth := registerUserForOrganizationTests(t, client, server.URL, "member@example.com", "member")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	// Add member account to the org.
	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                memberAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	// Suspend the organization via direct DB mutation.
	_, err := runtime.Pool.Exec(runtime.Ctx,
		`UPDATE organization_settings SET status = 'suspended' WHERE organization_id = $1`,
		organizationSummary.ID,
	)
	require.NoError(t, err)

	// Owner can no longer list teams.
	var apiErr struct{ Error string }
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", nil, authHeaders(ownerAuth.AccessToken), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)

	// Owner can no longer list members.
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)

	// Org is no longer visible in the owner's list.
	var orgs []organization.OrganizationSummary
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs", nil, authHeaders(ownerAuth.AccessToken), &orgs)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotContains(t, organizationIDsFromSummaries(orgs), organizationSummary.ID)
}

// TestOrganizationInactiveTeamExcludedFromList verifies that an inactive team
// does not appear in the teams list returned to org members.
func TestOrganizationInactiveTeamExcludedFromList(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	// Create an active team.
	var activeTeam organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Active Team",
		"slug": "active-team",
	}, authHeaders(ownerAuth.AccessToken), &activeTeam)
	require.Equal(t, http.StatusOK, response.StatusCode)

	// Create a team that will be set to inactive.
	var inactiveTeam organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Inactive Team",
		"slug": "inactive-team",
	}, authHeaders(ownerAuth.AccessToken), &inactiveTeam)
	require.Equal(t, http.StatusOK, response.StatusCode)

	// Mark it inactive via direct DB mutation.
	_, err := runtime.Pool.Exec(runtime.Ctx,
		`UPDATE organization_teams SET status = 'inactive' WHERE id = $1`,
		inactiveTeam.ID,
	)
	require.NoError(t, err)

	// List teams — only the active team should be present.
	var teams []organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", nil, authHeaders(ownerAuth.AccessToken), &teams)
	require.Equal(t, http.StatusOK, response.StatusCode)

	teamIDs := make([]string, 0, len(teams))
	for _, team := range teams {
		teamIDs = append(teamIDs, team.ID)
	}
	require.Contains(t, teamIDs, activeTeam.ID)
	require.NotContains(t, teamIDs, inactiveTeam.ID)
}

// TestOrganizationInactiveAccountExcludedFromList verifies that an inactive
// account does not appear in the members list returned to org members.
func TestOrganizationInactiveAccountExcludedFromList(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	activeUserAuth := registerUserForOrganizationTests(t, client, server.URL, "active@example.com", "active")
	inactiveUserAuth := registerUserForOrganizationTests(t, client, server.URL, "inactive@example.com", "inactive")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	// Add both members to the org.
	var activeAccount organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                activeUserAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &activeAccount)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var inactiveAccount organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                inactiveUserAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &inactiveAccount)
	require.Equal(t, http.StatusOK, response.StatusCode)

	// Mark inactive account via direct DB mutation.
	_, err := runtime.Pool.Exec(runtime.Ctx,
		`UPDATE organization_members SET status = 'inactive' WHERE id = $1`,
		inactiveAccount.ID,
	)
	require.NoError(t, err)

	// List members — only the active account should appear.
	var members []organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)

	memberIDs := make([]string, 0, len(members))
	for _, acct := range members {
		memberIDs = append(memberIDs, acct.ID)
	}
	require.Contains(t, memberIDs, activeAccount.ID)
	require.NotContains(t, memberIDs, inactiveAccount.ID)
}

func TestOrganizationSettingsAPIRefreshesSyncMetadataAcrossBackendModes(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                targetAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var team organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Engineering",
		"slug": "engineering",
	}, authHeaders(ownerAuth.AccessToken), &team)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var unit organization.OrganizationUnitView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units", map[string]any{
		"name": "Engineering Unit",
		"slug": "engineering-unit",
	}, authHeaders(ownerAuth.AccessToken), &unit)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var settings organization.OrganizationSettingsView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", nil, authHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, organizationSummary.ID, settings.OrganizationID)
	require.Equal(t, "embedded", settings.DirectoryBackend)
	require.EqualValues(t, 1, settings.DirectoryRevision)

	var apiErr struct{ Error string }
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", map[string]any{
		"directoryBackend": "external_shadow",
	}, authHeaders(targetAuth.AccessToken), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", map[string]any{
		"directoryBackend": "external_shadow",
	}, authHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "external_shadow", settings.DirectoryBackend)
	require.EqualValues(t, 2, settings.DirectoryRevision)

	organizationService, ok := runtime.Organizations.(*organization.Service)
	require.True(t, ok)

	team, err := organizationService.MarkOrganizationTeamSynced(runtime.Ctx, organizationSummary.ID, team.ID, organization.MarkOrganizationSyncedParams{
		ExternalProvider: "okta",
		ExternalID:       "team-engineering",
		SyncSource:       "scim",
	})
	require.NoError(t, err)
	unit, err = organizationService.MarkOrganizationUnitSynced(runtime.Ctx, organizationSummary.ID, unit.ID, organization.MarkOrganizationSyncedParams{
		ExternalProvider: "okta",
		ExternalID:       "unit-engineering",
		SyncSource:       "scim",
	})
	require.NoError(t, err)
	account, err = organizationService.MarkOrganizationMemberSynced(runtime.Ctx, organizationSummary.ID, account.ID, organization.MarkOrganizationSyncedParams{
		ExternalProvider: "okta",
		ExternalID:       "user-target",
		SyncSource:       "scim",
	})
	require.NoError(t, err)

	var memberSettings organization.OrganizationSettingsView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", nil, authHeaders(targetAuth.AccessToken), &memberSettings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "external_shadow", memberSettings.DirectoryBackend)
	require.EqualValues(t, 2, memberSettings.DirectoryRevision)

	var teams []organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", nil, authHeaders(ownerAuth.AccessToken), &teams)
	require.Equal(t, http.StatusOK, response.StatusCode)
	listedTeam, ok := organizationTeamByID(teams, team.ID)
	require.True(t, ok)
	require.Equal(t, "shadow", listedTeam.SyncMode)
	require.Equal(t, "scim", listedTeam.SyncSource)
	require.Equal(t, "okta", listedTeam.ExternalProvider)
	require.NotEmpty(t, listedTeam.LastSyncRunID)
	require.EqualValues(t, 2, listedTeam.LastSyncDirectoryRevision)
	require.Equal(t, "completed", listedTeam.LastSyncRunStatus)
	require.True(t, listedTeam.ReadOnly)

	var units []organization.OrganizationUnitView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units", nil, authHeaders(ownerAuth.AccessToken), &units)
	require.Equal(t, http.StatusOK, response.StatusCode)
	listedUnit, ok := organizationUnitByID(units, unit.ID)
	require.True(t, ok)
	require.Equal(t, "shadow", listedUnit.SyncMode)
	require.Equal(t, "scim", listedUnit.SyncSource)
	require.Equal(t, "okta", listedUnit.ExternalProvider)
	require.NotEmpty(t, listedUnit.LastSyncRunID)
	require.EqualValues(t, 2, listedUnit.LastSyncDirectoryRevision)
	require.Equal(t, "completed", listedUnit.LastSyncRunStatus)
	require.True(t, listedUnit.ReadOnly)

	var members []organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	listedMember, ok := organizationMemberByID(members, account.ID)
	require.True(t, ok)
	require.Equal(t, "shadow", listedMember.SyncMode)
	require.Equal(t, "scim", listedMember.SyncSource)
	require.Equal(t, "okta", listedMember.ExternalProvider)
	require.NotEmpty(t, listedMember.LastSyncRunID)
	require.EqualValues(t, 2, listedMember.LastSyncDirectoryRevision)
	require.Equal(t, "completed", listedMember.LastSyncRunStatus)
	require.True(t, listedMember.ReadOnly)

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", map[string]any{
		"directoryBackend": "external_authority",
	}, authHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "external_authority", settings.DirectoryBackend)
	require.EqualValues(t, 3, settings.DirectoryRevision)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", nil, authHeaders(ownerAuth.AccessToken), &teams)
	require.Equal(t, http.StatusOK, response.StatusCode)
	listedTeam, ok = organizationTeamByID(teams, team.ID)
	require.True(t, ok)
	require.Equal(t, "authority", listedTeam.SyncMode)
	require.True(t, listedTeam.ReadOnly)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units", nil, authHeaders(ownerAuth.AccessToken), &units)
	require.Equal(t, http.StatusOK, response.StatusCode)
	listedUnit, ok = organizationUnitByID(units, unit.ID)
	require.True(t, ok)
	require.Equal(t, "authority", listedUnit.SyncMode)
	require.True(t, listedUnit.ReadOnly)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	listedMember, ok = organizationMemberByID(members, account.ID)
	require.True(t, ok)
	require.Equal(t, "authority", listedMember.SyncMode)
	require.True(t, listedMember.ReadOnly)

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", map[string]any{
		"directoryBackend": "embedded",
	}, authHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "embedded", settings.DirectoryBackend)
	require.EqualValues(t, 4, settings.DirectoryRevision)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", nil, authHeaders(ownerAuth.AccessToken), &teams)
	require.Equal(t, http.StatusOK, response.StatusCode)
	listedTeam, ok = organizationTeamByID(teams, team.ID)
	require.True(t, ok)
	require.Equal(t, "local", listedTeam.SyncMode)
	require.Equal(t, "okta", listedTeam.ExternalProvider)
	require.False(t, listedTeam.ReadOnly)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units", nil, authHeaders(ownerAuth.AccessToken), &units)
	require.Equal(t, http.StatusOK, response.StatusCode)
	listedUnit, ok = organizationUnitByID(units, unit.ID)
	require.True(t, ok)
	require.Equal(t, "local", listedUnit.SyncMode)
	require.Equal(t, "okta", listedUnit.ExternalProvider)
	require.False(t, listedUnit.ReadOnly)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	listedMember, ok = organizationMemberByID(members, account.ID)
	require.True(t, ok)
	require.Equal(t, "local", listedMember.SyncMode)
	require.Equal(t, "okta", listedMember.ExternalProvider)
	require.False(t, listedMember.ReadOnly)
}

func TestOrganizationDirectorySyncRunDetailAPIExplainsCurrentProvenanceCounts(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	memberAuth := registerUserForOrganizationTests(t, client, server.URL, "member@example.com", "member")
	outsiderAuth := registerUserForOrganizationTests(t, client, server.URL, "outsider@example.com", "outsider")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                memberAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var team organization.OrganizationTeamView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Engineering",
		"slug": "engineering",
	}, authHeaders(ownerAuth.AccessToken), &team)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var unit organization.OrganizationUnitView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units", map[string]any{
		"name": "Engineering Unit",
		"slug": "engineering-unit",
	}, authHeaders(ownerAuth.AccessToken), &unit)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var settings organization.OrganizationSettingsView
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", map[string]any{
		"directoryBackend": "external_shadow",
	}, authHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)

	organizationService, ok := runtime.Organizations.(*organization.Service)
	require.True(t, ok)

	syncRunID := insertOrganizationDirectorySyncRunForAppAPITests(t, runtime, organizationSummary.ID, settings.DirectoryRevision, "okta", "scim", "completed")

	_, err := organizationService.MarkOrganizationTeamSynced(runtime.Ctx, organizationSummary.ID, team.ID, organization.MarkOrganizationSyncedParams{
		ExternalProvider: "okta",
		ExternalID:       "team-engineering",
		SyncSource:       "scim",
		SyncRunID:        syncRunID,
	})
	require.NoError(t, err)

	_, err = organizationService.MarkOrganizationUnitSynced(runtime.Ctx, organizationSummary.ID, unit.ID, organization.MarkOrganizationSyncedParams{
		ExternalProvider: "okta",
		ExternalID:       "unit-engineering",
		SyncSource:       "scim",
		SyncRunID:        syncRunID,
	})
	require.NoError(t, err)

	_, err = organizationService.MarkOrganizationMemberSynced(runtime.Ctx, organizationSummary.ID, account.ID, organization.MarkOrganizationSyncedParams{
		ExternalProvider: "okta",
		ExternalID:       "user-member",
		SyncSource:       "scim",
		SyncRunID:        syncRunID,
	})
	require.NoError(t, err)

	var detail organization.OrganizationDirectorySyncRunDetailView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/directory-sync-runs/"+syncRunID, nil, authHeaders(ownerAuth.AccessToken), &detail)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, syncRunID, detail.SyncRunID)
	require.Equal(t, "okta", detail.ExternalProvider)
	require.Equal(t, "scim", detail.SyncSource)
	require.Equal(t, "completed", detail.Status)
	require.EqualValues(t, settings.DirectoryRevision, detail.DirectoryRevision)
	require.Equal(t, 1, detail.LinkedTeamCount)
	require.Equal(t, 1, detail.LinkedUnitCount)
	require.Equal(t, 1, detail.LinkedMemberCount)

	var memberDetail organization.OrganizationDirectorySyncRunDetailView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/directory-sync-runs/"+syncRunID, nil, authHeaders(memberAuth.AccessToken), &memberDetail)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, syncRunID, memberDetail.SyncRunID)

	var apiErr struct{ Error string }
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/directory-sync-runs/"+syncRunID, nil, authHeaders(outsiderAuth.AccessToken), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
}

func TestOrganizationSettingsAPIPersistsFeishuProviderConfig(t *testing.T) {
	t.Parallel()

	runtime := StartRuntimeWithOrganizationProvider(t, false, 30, func(logger *slog.Logger) mailer.Mailer {
		return mailer.NewNoop(logger, "http://localhost:3001")
	}, func(pool coredb.DB, dataStore *Store) OrganizationProvider {
		return organization.NewWithDBOptions(pool, NewOrganizationCoreBridge(pool, dataStore), organization.ServiceOptions{
			FeishuAppID:     "cli_app_123",
			FeishuAppSecret: "feishu-secret-1",
		})
	})

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	memberAuth := registerUserForOrganizationTests(t, client, server.URL, "member@example.com", "member")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                memberAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, account.ID)

	var settings organization.OrganizationSettingsView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", nil, authHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, organizationSummary.ID, settings.OrganizationID)
	require.Empty(t, settings.AuthProvider)
	require.Empty(t, settings.DirectoryProvider)
	require.Empty(t, settings.Feishu.TenantKey)
	require.Empty(t, settings.Feishu.RootDepartmentID)
	require.True(t, settings.Feishu.HasServerConfig)
	require.EqualValues(t, 1, settings.DirectoryRevision)

	var apiErr struct{ Error string }
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", map[string]any{
		"authProvider":      "feishu",
		"directoryProvider": "feishu",
	}, authHeaders(memberAuth.AccessToken), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", map[string]any{
		"directoryBackend":  "external_authority",
		"authProvider":      "feishu",
		"directoryProvider": "feishu",
		"feishu": map[string]any{
			"tenantKey":        "tenant-key-1",
			"rootDepartmentId": "od-root-1",
		},
	}, authHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "feishu", settings.AuthProvider)
	require.Equal(t, "feishu", settings.DirectoryProvider)
	require.Equal(t, "tenant-key-1", settings.Feishu.TenantKey)
	require.Equal(t, "od-root-1", settings.Feishu.RootDepartmentID)
	require.True(t, settings.Feishu.HasServerConfig)
	require.EqualValues(t, 2, settings.DirectoryRevision)

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", map[string]any{
		"feishu": map[string]any{
			"rootDepartmentId": "od-root-2",
		},
	}, authHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "feishu", settings.AuthProvider)
	require.Equal(t, "feishu", settings.DirectoryProvider)
	require.Equal(t, "tenant-key-1", settings.Feishu.TenantKey)
	require.Equal(t, "od-root-2", settings.Feishu.RootDepartmentID)
	require.EqualValues(t, 3, settings.DirectoryRevision)

	var memberSettings organization.OrganizationSettingsView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", nil, authHeaders(memberAuth.AccessToken), &memberSettings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "feishu", memberSettings.AuthProvider)
	require.Equal(t, "feishu", memberSettings.DirectoryProvider)
	require.Equal(t, "tenant-key-1", memberSettings.Feishu.TenantKey)
	require.Equal(t, "od-root-2", memberSettings.Feishu.RootDepartmentID)
	require.True(t, memberSettings.Feishu.HasServerConfig)
}

func TestOrganizationEnterpriseFeishuCredentialsAPIStoresConfigWithoutEcho(t *testing.T) {
	t.Parallel()

	feishu := NewFeishuAPITestServer(t)
	defer feishu.Close()

	runtime := StartRuntimeWithOrganizationProvider(t, false, 30, func(logger *slog.Logger) mailer.Mailer {
		return mailer.NewNoop(logger, "http://localhost:3001")
	}, func(pool coredb.DB, dataStore *Store) OrganizationProvider {
		return organization.NewWithDBOptions(pool, NewOrganizationCoreBridge(pool, dataStore), organization.ServiceOptions{
			FeishuBaseURL:                 feishu.URL(),
			FeishuHTTPClient:              feishu.Client(),
			FeishuCredentialEncryptionKey: testFeishuCredentialEncryptionKey(),
		})
	})

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	memberAuth := registerUserForOrganizationTests(t, client, server.URL, "member@example.com", "member")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var memberAccount organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                memberAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &memberAccount)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, memberAccount.ID)

	var enterprise organization.OrganizationEnterpriseFeishuView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/enterprise/feishu", nil, authHeaders(ownerAuth.AccessToken), &enterprise)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, enterprise.StorageReady)
	require.False(t, enterprise.CredentialsConfigured)
	require.Empty(t, enterprise.CredentialSource)
	require.Nil(t, enterprise.UpdatedAt)
	require.Nil(t, enterprise.LastValidatedAt)

	var apiErr struct{ Error string }
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/enterprise/feishu", nil, authHeaders(memberAuth.AccessToken), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)

	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/enterprise/feishu/credentials", map[string]any{
		"appId":     feishu.AppID(),
		"appSecret": feishu.AppSecret(),
	}, authHeaders(memberAuth.AccessToken), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)

	var enterprisePayload map[string]any
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/enterprise/feishu/credentials", map[string]any{
		"appId":     feishu.AppID(),
		"appSecret": feishu.AppSecret(),
	}, authHeaders(ownerAuth.AccessToken), &enterprisePayload)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, organizationSummary.ID, enterprisePayload["organizationId"])
	require.Equal(t, true, enterprisePayload["storageReady"])
	require.Equal(t, true, enterprisePayload["credentialsConfigured"])
	require.Equal(t, "organization", enterprisePayload["credentialSource"])
	require.NotContains(t, enterprisePayload, "appId")
	require.NotContains(t, enterprisePayload, "appSecret")

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/enterprise/feishu", nil, authHeaders(ownerAuth.AccessToken), &enterprise)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, enterprise.CredentialsConfigured)
	require.Equal(t, "organization", enterprise.CredentialSource)
	require.NotNil(t, enterprise.UpdatedAt)
	require.NotNil(t, enterprise.LastValidatedAt)
	require.Equal(t, []organization.OrganizationFeishuDepartmentOptionView{
		{DepartmentID: "od-eng", Name: "Engineering"},
		{DepartmentID: "od-people", Name: "People Ops"},
	}, enterprise.AvailableRootDepartments)
	require.Empty(t, enterprise.AvailableRootDepartmentsError)

	var settings organization.OrganizationSettingsView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", nil, authHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, settings.Feishu.HasServerConfig)

	var storedAppIDCiphertext string
	var storedAppSecretCiphertext string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT app_id_ciphertext, app_secret_ciphertext
FROM organization_feishu_credentials
WHERE organization_id = $1
`, organizationSummary.ID).Scan(&storedAppIDCiphertext, &storedAppSecretCiphertext))
	require.NotEmpty(t, storedAppIDCiphertext)
	require.NotEmpty(t, storedAppSecretCiphertext)
	require.NotEqual(t, feishu.AppID(), storedAppIDCiphertext)
	require.NotEqual(t, feishu.AppSecret(), storedAppSecretCiphertext)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/enterprise/feishu/credentials", nil, authHeaders(ownerAuth.AccessToken), &enterprise)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, enterprise.CredentialsConfigured)
	require.Empty(t, enterprise.CredentialSource)
}

func TestOrganizationFeishuSyncAPIImportsDirectory(t *testing.T) {
	t.Parallel()

	feishu := NewFeishuAPITestServer(t)
	defer feishu.Close()

	runtime := StartRuntimeWithOrganizationProvider(t, false, 30, func(logger *slog.Logger) mailer.Mailer {
		return mailer.NewNoop(logger, "http://localhost:3001")
	}, func(pool coredb.DB, dataStore *Store) OrganizationProvider {
		return organization.NewWithDBOptions(pool, NewOrganizationCoreBridge(pool, dataStore), organization.ServiceOptions{
			FeishuBaseURL:                 feishu.URL(),
			FeishuHTTPClient:              feishu.Client(),
			FeishuCredentialEncryptionKey: testFeishuCredentialEncryptionKey(),
		})
	})

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	grantOrganizationSeatsForOrgTests(t, runtime, organizationSummary.ID, 10, ownerAuth.User.ID)
	response := (*http.Response)(nil)

	memberAuth := registerUserForOrganizationTests(t, client, server.URL, "member@example.com", "member")
	var memberAccount organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                memberAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &memberAccount)
	require.Equal(t, http.StatusOK, response.StatusCode)

	enterprise := putOrganizationFeishuCredentialsForTests(t, client, server.URL, ownerAuth.AccessToken, organizationSummary.ID, feishu.AppID(), feishu.AppSecret())
	require.True(t, enterprise.CredentialsConfigured)
	require.Equal(t, "organization", enterprise.CredentialSource)

	var settings organization.OrganizationSettingsView
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/settings", map[string]any{
		"directoryBackend":  "external_authority",
		"authProvider":      "feishu",
		"directoryProvider": "feishu",
	}, authHeaders(ownerAuth.AccessToken), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, settings.Feishu.TenantKey)
	require.Empty(t, settings.Feishu.RootDepartmentID)

	var result organization.OrganizationDirectorySyncRunSummaryView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/feishu/sync", nil, authHeaders(ownerAuth.AccessToken), &result)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, result.SyncRunID)
	require.Equal(t, "feishu", result.ExternalProvider)
	require.Equal(t, "directory", result.SyncSource)
	require.Equal(t, "running", result.Status)
	require.NotNil(t, result.StartedAt)
	require.Nil(t, result.CompletedAt)

	syncDetail := waitForOrganizationDirectorySyncRunCompletedForTests(t, client, server.URL, organizationSummary.ID, authHeaders(ownerAuth.AccessToken), result.SyncRunID)
	require.Equal(t, 3, syncDetail.LinkedUnitCount)
	require.Equal(t, 2, syncDetail.LinkedMemberCount)

	var units []organization.OrganizationUnitView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/units", nil, authHeaders(ownerAuth.AccessToken), &units)
	require.Equal(t, http.StatusOK, response.StatusCode)
	importedUnits := make([]organization.OrganizationUnitView, 0, len(units))
	for _, unit := range units {
		if unit.ExternalProvider == "feishu" {
			importedUnits = append(importedUnits, unit)
		}
	}
	require.Len(t, importedUnits, 3)

	var members []organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	importedAccounts := make([]organization.OrganizationMemberView, 0, len(members))
	for _, account := range members {
		if account.ExternalProvider == "feishu" {
			importedAccounts = append(importedAccounts, account)
		}
	}
	require.Len(t, importedAccounts, 2)

	var apiErr struct{ Error string }
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/feishu/sync", nil, authHeaders(memberAuth.AccessToken), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
}

// TestOrganizationInactiveMemberLosesWorkspaceAccessOnReconcile verifies that
// when an organization member's status is set to inactive, its next reconcile removes
// workspace access and org-installed agents without disturbing the local
// workspace member baseline or native agents.
func TestOrganizationInactiveMemberLosesWorkspaceAccessOnReconcile(t *testing.T) {
	t.Parallel()

	runtime := startRuntimeWithOrganizationProxySigningKey(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	targetAuth := registerUserForOrganizationTests(t, client, server.URL, "target@example.com", "target")

	// Create org and workspace.
	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var createdWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Project Alpha",
		"slug":           "project-alpha",
	}, authHeaders(ownerAuth.AccessToken), &createdWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	// Bind workspace to org.

	// Add member to org.
	var account organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                targetAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &account)
	require.Equal(t, http.StatusOK, response.StatusCode)

	// Grant member direct access to the workspace.
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+account.ID, map[string]any{
		"role": "member",
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	// Confirm target is now a workspace member.
	var members workspace.WorkspaceMemberPage
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, hasWorkspaceMember(members, targetAuth.User.ID, "member"))

	// Now mark the organization member as inactive via direct DB mutation.
	_, err := runtime.Pool.Exec(runtime.Ctx,
		`UPDATE organization_members SET status = 'inactive' WHERE id = $1`,
		account.ID,
	)
	require.NoError(t, err)

	// Trigger reconcile by revoking the direct grant (this calls reconcile internally).
	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/workspaces/"+createdWorkspace.ID+"/members/"+account.ID, nil, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	// Target user should have lost workspace access.
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+createdWorkspace.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, hasWorkspaceMember(members, targetAuth.User.ID, "member"))

}

// TestOrganizationInactiveMemberExcludedFromAccountsList verifies that an
// account whose membership row is inactive is not returned in the members list.
func TestOrganizationInactiveMemberExcludedFromAccountsList(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	inactiveMemberAuth := registerUserForOrganizationTests(t, client, server.URL, "inactive-member@example.com", "inactive-member")
	activeMemberAuth := registerUserForOrganizationTests(t, client, server.URL, "active-member@example.com", "active-member")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var inactiveAccount organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                inactiveMemberAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &inactiveAccount)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var activeAccount organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", map[string]any{
		"email":                activeMemberAuth.User.Email,
		"defaultWorkspaceRole": "member",
	}, authHeaders(ownerAuth.AccessToken), &activeAccount)
	require.Equal(t, http.StatusOK, response.StatusCode)

	// Set membership row for inactiveAccount to inactive.
	_, err := runtime.Pool.Exec(runtime.Ctx,
		`UPDATE organization_members SET status = 'inactive' WHERE id = $1`,
		inactiveAccount.ID,
	)
	require.NoError(t, err)

	// List members — inactive-member account must not appear.
	var members []organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)

	memberIDs := make([]string, 0, len(members))
	for _, acct := range members {
		memberIDs = append(memberIDs, acct.ID)
	}
	require.NotContains(t, memberIDs, inactiveAccount.ID)
	require.Contains(t, memberIDs, activeAccount.ID)
}

func TestOrganizationInviteAcceptCreatesMembershipWithoutWorkspaceAccess(t *testing.T) {
	t.Parallel()

	controlledMailer := &TestControlledMailer{}
	runtime := StartRuntimeWithMailer(t, false, 30, func(_ *slog.Logger) mailer.Mailer {
		return controlledMailer
	})

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	inviteeAuth := registerUserForOrganizationTests(t, client, server.URL, "invitee@example.com", "invitee")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var createdWorkspace dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces", map[string]any{
		"organizationId": organizationSummary.ID,
		"name":           "Bound Workspace",
		"slug":           "bound-workspace",
	}, authHeaders(ownerAuth.AccessToken), &createdWorkspace)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var invite organization.OrganizationInviteView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/invites", map[string]any{
		"email": inviteeAuth.User.Email,
		"role":  "org_member",
	}, authHeaders(ownerAuth.AccessToken), &invite)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, inviteeAuth.User.Email, controlledMailer.OrganizationInviteEmail)
	require.Equal(t, organizationSummary.Name, controlledMailer.OrganizationInviteName)
	require.NotEmpty(t, controlledMailer.OrganizationInviteToken)

	var inviteInfo struct {
		Kind             string `json:"kind"`
		OrganizationID   string `json:"organizationId"`
		OrganizationName string `json:"organizationName"`
		InviterName      string `json:"inviterName"`
		InvitedEmail     string `json:"invitedEmail"`
		Role             string `json:"role"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/auth/invite-info?token="+controlledMailer.OrganizationInviteToken, nil, nil, &inviteInfo)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "organization", inviteInfo.Kind)
	require.Equal(t, organizationSummary.ID, inviteInfo.OrganizationID)
	require.Equal(t, organizationSummary.Name, inviteInfo.OrganizationName)
	require.Equal(t, inviteeAuth.User.Email, inviteInfo.InvitedEmail)
	require.Equal(t, "org_member", inviteInfo.Role)

	var accepted struct {
		Kind             string `json:"kind"`
		OrganizationID   string `json:"organizationId"`
		OrganizationName string `json:"organizationName"`
		Role             string `json:"role"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/accept-invite", map[string]any{
		"token": controlledMailer.OrganizationInviteToken,
	}, authHeaders(inviteeAuth.AccessToken), &accepted)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "organization", accepted.Kind)
	require.Equal(t, organizationSummary.ID, accepted.OrganizationID)
	require.Equal(t, organizationSummary.Name, accepted.OrganizationName)
	require.Equal(t, "org_member", accepted.Role)

	var organizations []organization.OrganizationSummary
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs", nil, authHeaders(inviteeAuth.AccessToken), &organizations)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Contains(t, organizationIDsFromSummaries(organizations), organizationSummary.ID)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, authHeaders(inviteeAuth.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotContains(t, workspaceIDsFromResponses(workspaces), createdWorkspace.ID)

	var members []organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)
	account, ok := organizationMemberByUserID(members, inviteeAuth.User.ID)
	require.True(t, ok)
	require.Equal(t, "org_member", account.Role)
}

func TestOrganizationInviteRejectsWrongEmail(t *testing.T) {
	t.Parallel()

	controlledMailer := &TestControlledMailer{}
	runtime := StartRuntimeWithMailer(t, false, 30, func(_ *slog.Logger) mailer.Mailer {
		return controlledMailer
	})

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	inviteeAuth := registerUserForOrganizationTests(t, client, server.URL, "invitee@example.com", "invitee")
	otherAuth := registerUserForOrganizationTests(t, client, server.URL, "other@example.com", "other")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/invites", map[string]any{
		"email": inviteeAuth.User.Email,
	}, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, controlledMailer.OrganizationInviteToken)

	var apiErr struct{ Error string }
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/accept-invite", map[string]any{
		"token": controlledMailer.OrganizationInviteToken,
	}, authHeaders(otherAuth.AccessToken), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)

	var organizations []organization.OrganizationSummary
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs", nil, authHeaders(otherAuth.AccessToken), &organizations)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotContains(t, organizationIDsFromSummaries(organizations), organizationSummary.ID)
}

func TestOrganizationInviteListRevokeAndRoleUpdate(t *testing.T) {
	t.Parallel()

	controlledMailer := &TestControlledMailer{}
	runtime := StartRuntimeWithMailer(t, false, 30, func(_ *slog.Logger) mailer.Mailer {
		return controlledMailer
	})

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	ownerAuth := registerUserForOrganizationTests(t, client, server.URL, "owner@example.com", "owner")
	adminAuth := registerUserForOrganizationTests(t, client, server.URL, "admin@example.com", "admin")
	memberAuth := registerUserForOrganizationTests(t, client, server.URL, "member@example.com", "member")

	organizationSummary := FirstOrganizationForTests(t, client, server.URL, ownerAuth.AccessToken)
	response := (*http.Response)(nil)

	var revokedInvite organization.OrganizationInviteView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/invites", map[string]any{
		"email": memberAuth.User.Email,
	}, authHeaders(ownerAuth.AccessToken), &revokedInvite)
	require.Equal(t, http.StatusOK, response.StatusCode)
	revokedToken := controlledMailer.OrganizationInviteToken
	require.NotEmpty(t, revokedToken)

	var invites []organization.OrganizationInviteView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/invites", nil, authHeaders(ownerAuth.AccessToken), &invites)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, invites, 1)
	require.Equal(t, revokedInvite.ID, invites[0].ID)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/invites/"+revokedInvite.ID, nil, authHeaders(ownerAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/invites", nil, authHeaders(ownerAuth.AccessToken), &invites)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, invites)

	var apiErr struct{ Error string }
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/auth/invite-info?token="+revokedToken, nil, nil, &apiErr)
	require.Equal(t, http.StatusBadRequest, response.StatusCode)

	var adminInvite organization.OrganizationInviteView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/invites", map[string]any{
		"email": adminAuth.User.Email,
		"role":  "org_admin",
	}, authHeaders(ownerAuth.AccessToken), &adminInvite)
	require.Equal(t, http.StatusOK, response.StatusCode)
	adminToken := controlledMailer.OrganizationInviteToken
	require.NotEmpty(t, adminToken)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/accept-invite", map[string]any{
		"token": adminToken,
	}, authHeaders(adminAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Ops",
		"slug": "ops",
	}, authHeaders(adminAuth.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var members []organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members", nil, authHeaders(ownerAuth.AccessToken), &members)
	require.Equal(t, http.StatusOK, response.StatusCode)

	adminMember, ok := organizationMemberByUserID(members, adminAuth.User.ID)
	require.True(t, ok)
	require.Equal(t, "org_admin", adminMember.Role)

	var updatedAccount organization.OrganizationMemberView
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/members/"+adminMember.ID+"/role", map[string]any{
		"role": "org_member",
	}, authHeaders(ownerAuth.AccessToken), &updatedAccount)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "org_member", updatedAccount.Role)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/orgs/"+organizationSummary.ID+"/teams", map[string]any{
		"name": "Support",
		"slug": "support",
	}, authHeaders(adminAuth.AccessToken), &apiErr)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
}
