Link measures to third parties
Add a many-to-many relationship between measures and third parties, surfaced as a measures tab on the third party detail page and a third parties tab on the measure detail page. Each side gets a paginated list with a link/unlink dialog. Also remove the right-hand drawer on the measure detail page and expose the state as a badge in the page header, mirroring how the compliance page surfaces its active flag. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -2239,3 +2239,240 @@ func TestMeasure_Ordering(t *testing.T) {
|
||||
testutil.AssertTimesOrderedDescending(t, times, "createdAt")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMeasure_ThirdPartyMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
const createMutation = `
|
||||
mutation($input: CreateMeasureThirdPartyMappingInput!) {
|
||||
createMeasureThirdPartyMapping(input: $input) {
|
||||
measureEdge { node { id } }
|
||||
thirdPartyEdge { node { id } }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const deleteMutation = `
|
||||
mutation($input: DeleteMeasureThirdPartyMappingInput!) {
|
||||
deleteMeasureThirdPartyMapping(input: $input) {
|
||||
deletedMeasureId
|
||||
deletedThirdPartyId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
t.Run("create mapping links measure to third party on both sides", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
measureID := factory.NewMeasure(owner).WithName("Mapping Measure").Create()
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("Mapping Third Party").Create()
|
||||
|
||||
_, err := owner.Do(createMutation, map[string]any{
|
||||
"input": map[string]any{
|
||||
"measureId": measureID,
|
||||
"thirdPartyId": thirdPartyID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
const measureQuery = `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Measure {
|
||||
thirdParties(first: 10) {
|
||||
edges { node { id } }
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var measureResult struct {
|
||||
Node struct {
|
||||
ThirdParties struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"thirdParties"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err = owner.Execute(measureQuery, map[string]any{"id": measureID}, &measureResult)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, measureResult.Node.ThirdParties.TotalCount)
|
||||
assert.Equal(t, thirdPartyID, measureResult.Node.ThirdParties.Edges[0].Node.ID)
|
||||
|
||||
const thirdPartyQuery = `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on ThirdParty {
|
||||
measures(first: 10) {
|
||||
edges { node { id } }
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var tpResult struct {
|
||||
Node struct {
|
||||
Measures struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"measures"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err = owner.Execute(thirdPartyQuery, map[string]any{"id": thirdPartyID}, &tpResult)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, tpResult.Node.Measures.TotalCount)
|
||||
assert.Equal(t, measureID, tpResult.Node.Measures.Edges[0].Node.ID)
|
||||
})
|
||||
|
||||
t.Run("create mapping is idempotent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
measureID := factory.NewMeasure(owner).WithName("Idempotent Measure").Create()
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("Idempotent Third Party").Create()
|
||||
|
||||
input := map[string]any{
|
||||
"measureId": measureID,
|
||||
"thirdPartyId": thirdPartyID,
|
||||
}
|
||||
|
||||
_, err := owner.Do(createMutation, map[string]any{"input": input})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = owner.Do(createMutation, map[string]any{"input": input})
|
||||
require.NoError(t, err, "second mapping creation should be idempotent")
|
||||
|
||||
const countQuery = `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Measure {
|
||||
thirdParties(first: 10) { totalCount }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
Node struct {
|
||||
ThirdParties struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"thirdParties"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err = owner.Execute(countQuery, map[string]any{"id": measureID}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, result.Node.ThirdParties.TotalCount, "duplicate create must not produce a second row")
|
||||
})
|
||||
|
||||
t.Run("delete mapping removes link from both sides", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
measureID := factory.NewMeasure(owner).WithName("Unlink Measure").Create()
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("Unlink Third Party").Create()
|
||||
|
||||
input := map[string]any{
|
||||
"measureId": measureID,
|
||||
"thirdPartyId": thirdPartyID,
|
||||
}
|
||||
|
||||
_, err := owner.Do(createMutation, map[string]any{"input": input})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = owner.Do(deleteMutation, map[string]any{"input": input})
|
||||
require.NoError(t, err)
|
||||
|
||||
const measureCountQuery = `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Measure {
|
||||
thirdParties(first: 10) { totalCount }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var measureResult struct {
|
||||
Node struct {
|
||||
ThirdParties struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"thirdParties"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err = owner.Execute(measureCountQuery, map[string]any{"id": measureID}, &measureResult)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, measureResult.Node.ThirdParties.TotalCount, "measure side should have no linked third parties")
|
||||
|
||||
const thirdPartyCountQuery = `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on ThirdParty {
|
||||
measures(first: 10) { totalCount }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var thirdPartyResult struct {
|
||||
Node struct {
|
||||
Measures struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"measures"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err = owner.Execute(thirdPartyCountQuery, map[string]any{"id": thirdPartyID}, &thirdPartyResult)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, thirdPartyResult.Node.Measures.TotalCount, "third-party side should have no linked measures")
|
||||
})
|
||||
|
||||
t.Run("viewer cannot create mapping", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||
|
||||
measureID := factory.NewMeasure(owner).WithName("RBAC Measure").Create()
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("RBAC Third Party").Create()
|
||||
|
||||
_, err := viewer.Do(createMutation, map[string]any{
|
||||
"input": map[string]any{
|
||||
"measureId": measureID,
|
||||
"thirdPartyId": thirdPartyID,
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("tenant isolation on mapping creation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
otherOwner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
measureID := factory.NewMeasure(owner).WithName("Tenant Measure").Create()
|
||||
otherThirdPartyID := factory.NewThirdParty(otherOwner).WithName("Other Tenant Third Party").Create()
|
||||
|
||||
_, err := owner.Do(createMutation, map[string]any{
|
||||
"input": map[string]any{
|
||||
"measureId": measureID,
|
||||
"thirdPartyId": otherThirdPartyID,
|
||||
},
|
||||
})
|
||||
require.Error(t, err, "should not link a third party from another tenant")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user