Files
zitadel/internal/migration/trigger_test.go
Livio Spring 0753ed2d6b feat(service ping): add additional resource counts (#10621)
# Which Problems Are Solved

Using the service ping, we want to have some additional insights to how
zitadel is configured. The current resource count report contains
already some amount of configured policies, such as the login_policy.
But we do not know if for example MFA is enforced.

# How the Problems Are Solved

- Added the following counts to the report:
  - service users per organization
  - MFA enforcements (though login policy)
  - Notification policies with password change option enabled
  - SCIM provisioned users (using user metadata)
- Since all of the above are conditional based on at least a column
inside a projection, a new `migration.CountTriggerConditional` has been
added, where a condition (column values) and an option to track updates
on that column should be considered for the count.
- For this to be possible, the following changes had to be made to the
existing sql resources:
- the `resource_name` has been added to unique constraint on the
`projection.resource_counts` table
- triggers have been added / changed to individually track `INSERT`,
`UPDATE`(s) and `DELETE` and be able to handle conditions
- an optional argument has been added to the
`projections.count_resource()` function to allow providing the
information to `UP` or `DOWN` count the resource on an update.

# Additional Changes

None

# Additional Context

- partially solves #10244 (reporting audit log retention limit will be
handled in #10245 directly)
- backport to v4.x

(cherry picked from commit 2dbe21fb30)
2025-09-15 08:30:02 +02:00

532 lines
13 KiB
Go

package migration
import (
"context"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/database"
)
const (
expCountTriggerQuery = `-- In case the old trigger exists, drop it to prevent duplicated counts.
DROP TRIGGER IF EXISTS count_resource ON table;
CREATE OR REPLACE TRIGGER count_resource_insert
AFTER INSERT
ON table
FOR EACH ROW
-- Only count if the conditions are met in the newly added row.
EXECUTE FUNCTION projections.count_resource(
'instance',
'instance_id',
'parent_id',
'resource'
);
CREATE OR REPLACE TRIGGER count_resource_delete
AFTER DELETE
ON table
FOR EACH ROW
-- Only count down if the conditions were met in the old / deleted row.
EXECUTE FUNCTION projections.count_resource(
'instance',
'instance_id',
'parent_id',
'resource'
);
CREATE OR REPLACE TRIGGER truncate_resource_counts
AFTER TRUNCATE
ON table
FOR EACH STATEMENT
EXECUTE FUNCTION projections.delete_table_counts();
-- Prevent inserts and deletes while we populate the counts.
LOCK TABLE table IN SHARE MODE;
-- Populate the resource counts for the existing data in the table.
INSERT INTO projections.resource_counts(
instance_id,
table_name,
parent_type,
parent_id,
resource_name,
amount
)
SELECT
instance_id,
'table',
'instance',
parent_id,
'resource',
COUNT(*) AS amount
FROM table
GROUP BY (instance_id, parent_id)
ON CONFLICT (instance_id, table_name, parent_type, parent_id, resource_name) DO
UPDATE SET updated_at = now(), amount = EXCLUDED.amount;`
expCountTriggerConditionalQuery = `-- In case the old trigger exists, drop it to prevent duplicated counts.
DROP TRIGGER IF EXISTS count_resource ON table;
CREATE OR REPLACE TRIGGER count_resource_insert
AFTER INSERT
ON table
FOR EACH ROW
-- Only count if the conditions are met in the newly added row.
WHEN ((NEW.col1 = 'value1' OR NEW.col2 = 'value1'))
EXECUTE FUNCTION projections.count_resource(
'instance',
'instance_id',
'parent_id',
'resource'
);
CREATE OR REPLACE TRIGGER count_resource_delete
AFTER DELETE
ON table
FOR EACH ROW
-- Only count down if the conditions were met in the old / deleted row.
WHEN ((OLD.col1 = 'value1' OR OLD.col2 = 'value1'))
EXECUTE FUNCTION projections.count_resource(
'instance',
'instance_id',
'parent_id',
'resource'
);
CREATE OR REPLACE TRIGGER count_resource_update_up
AFTER UPDATE
ON table
FOR EACH ROW
-- Only count up if the conditions are met in the new state, but were not in the old.
WHEN ((NEW.col1 = 'value1' OR NEW.col2 = 'value1') AND (OLD.col1 <> 'value1' AND OLD.col2 <> 'value1'))
EXECUTE FUNCTION projections.count_resource(
'instance',
'instance_id',
'parent_id',
'resource',
'UP'
);
CREATE OR REPLACE TRIGGER count_resource_update_down
AFTER UPDATE
ON table
FOR EACH ROW
-- Only count down if the conditions are not met in the new state, but were in the old.
WHEN ((NEW.col1 <> 'value1' AND NEW.col2 <> 'value1') AND (OLD.col1 = 'value1' OR OLD.col2 = 'value1'))
EXECUTE FUNCTION projections.count_resource(
'instance',
'instance_id',
'parent_id',
'resource',
'DOWN'
);
CREATE OR REPLACE TRIGGER truncate_resource_counts
AFTER TRUNCATE
ON table
FOR EACH STATEMENT
EXECUTE FUNCTION projections.delete_table_counts();
-- Prevent inserts and deletes while we populate the counts.
LOCK TABLE table IN SHARE MODE;
-- Populate the resource counts for the existing data in the table.
INSERT INTO projections.resource_counts(
instance_id,
table_name,
parent_type,
parent_id,
resource_name,
amount
)
SELECT
instance_id,
'table',
'instance',
parent_id,
'resource',
COUNT(*) AS amount
FROM table
WHERE (table.col1 = 'value1' OR table.col2 = 'value1')
GROUP BY (instance_id, parent_id)
ON CONFLICT (instance_id, table_name, parent_type, parent_id, resource_name) DO
UPDATE SET updated_at = now(), amount = EXCLUDED.amount;`
expDeleteParentCountsQuery = `CREATE OR REPLACE TRIGGER delete_parent_counts_trigger
AFTER DELETE
ON table
FOR EACH ROW
EXECUTE FUNCTION projections.delete_parent_counts(
'instance',
'instance_id',
'parent_id'
);`
)
func Test_triggerMigration_Execute(t *testing.T) {
type fields struct {
triggerConfig triggerConfig
templateName string
}
tests := []struct {
name string
fields fields
expects func(sqlmock.Sqlmock)
wantErr bool
}{
{
name: "template error",
fields: fields{
triggerConfig: triggerConfig{
Table: "table",
ParentType: "instance",
InstanceIDColumn: "instance_id",
ParentIDColumn: "parent_id",
Resource: "resource",
},
templateName: "foo",
},
expects: func(_ sqlmock.Sqlmock) {},
wantErr: true,
},
{
name: "db error",
fields: fields{
triggerConfig: triggerConfig{
Table: "table",
ParentType: "instance",
InstanceIDColumn: "instance_id",
ParentIDColumn: "parent_id",
Resource: "resource",
},
templateName: countTriggerTmpl,
},
expects: func(mock sqlmock.Sqlmock) {
mock.ExpectExec(regexp.QuoteMeta(expCountTriggerQuery)).
WillReturnError(assert.AnError)
},
wantErr: true,
},
{
name: "count trigger",
fields: fields{
triggerConfig: triggerConfig{
Table: "table",
ParentType: "instance",
InstanceIDColumn: "instance_id",
ParentIDColumn: "parent_id",
Resource: "resource",
},
templateName: countTriggerTmpl,
},
expects: func(mock sqlmock.Sqlmock) {
mock.ExpectExec(regexp.QuoteMeta(expCountTriggerQuery)).
WithoutArgs().
WillReturnResult(
sqlmock.NewResult(1, 1),
)
},
},
{
name: "count trigger conditionally",
fields: fields{
triggerConfig: triggerConfig{
Table: "table",
ParentType: "instance",
InstanceIDColumn: "instance_id",
ParentIDColumn: "parent_id",
Resource: "resource",
Conditions: OrCondition{
Conditions: []TriggerCondition{
{Column: "col1", Value: "value1"},
{Column: "col2", Value: "value1"},
},
},
TrackChange: true,
},
templateName: countTriggerTmpl,
},
expects: func(mock sqlmock.Sqlmock) {
mock.ExpectExec(regexp.QuoteMeta(expCountTriggerConditionalQuery)).
WithoutArgs().
WillReturnResult(
sqlmock.NewResult(1, 1),
)
},
},
{
name: "count trigger",
fields: fields{
triggerConfig: triggerConfig{
Table: "table",
ParentType: "instance",
InstanceIDColumn: "instance_id",
ParentIDColumn: "parent_id",
Resource: "resource",
},
templateName: deleteParentCountsTmpl,
},
expects: func(mock sqlmock.Sqlmock) {
mock.ExpectExec(regexp.QuoteMeta(expDeleteParentCountsQuery)).
WithoutArgs().
WillReturnResult(
sqlmock.NewResult(1, 1),
)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer func() {
err := mock.ExpectationsWereMet()
require.NoError(t, err)
}()
defer db.Close()
tt.expects(mock)
mock.ExpectClose()
m := &triggerMigration{
db: &database.DB{
DB: db,
},
triggerConfig: tt.fields.triggerConfig,
templateName: tt.fields.templateName,
}
err = m.Execute(context.Background(), nil)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func Test_triggerConfig_Check(t *testing.T) {
type fields struct {
Table string
ParentType string
InstanceIDColumn string
ParentIDColumn string
Resource string
TrackChange bool
Conditions TriggerConditions
}
type args struct {
lastRun map[string]any
}
tests := []struct {
name string
fields fields
args args
want bool
}{
{
name: "should",
fields: fields{
Table: "users2",
ParentType: "instance",
InstanceIDColumn: "instance_id",
ParentIDColumn: "parent_id",
Resource: "user",
TrackChange: true,
Conditions: &OrCondition{
Conditions: []TriggerCondition{
{Column: "col1", Value: "value1"},
{Column: "col2", Value: "value1"},
},
},
},
args: args{
lastRun: map[string]any{
"table": "users1",
"parent_type": "instance",
"instance_id_column": "instance_id",
"parent_id_column": "parent_id",
"resource": "user",
"track_change": true,
"conditions": map[string]any{
"orConditions": []any{
map[string]any{"column": "col1", "value": "value1"},
map[string]any{"column": "col2", "value": "value1"},
},
},
},
},
want: true,
},
{
name: "should not",
fields: fields{
Table: "users1",
ParentType: "instance",
InstanceIDColumn: "instance_id",
ParentIDColumn: "parent_id",
Resource: "user",
TrackChange: true,
Conditions: &AndCondition{
Conditions: []TriggerCondition{
{Column: "col1", Value: "value1"},
{Column: "col2", Value: "value1"},
},
},
},
args: args{
lastRun: map[string]any{
"table": "users1",
"parent_type": "instance",
"instance_id_column": "instance_id",
"parent_id_column": "parent_id",
"resource": "user",
"track_change": true,
"conditions": map[string]any{
"andConditions": []any{
map[string]any{"column": "col1", "value": "value1"},
map[string]any{"column": "col2", "value": "value1"},
},
},
},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &triggerConfig{
Table: tt.fields.Table,
ParentType: tt.fields.ParentType,
InstanceIDColumn: tt.fields.InstanceIDColumn,
ParentIDColumn: tt.fields.ParentIDColumn,
Resource: tt.fields.Resource,
TrackChange: tt.fields.TrackChange,
Conditions: tt.fields.Conditions,
}
got := c.Check(tt.args.lastRun)
assert.Equal(t, tt.want, got)
})
}
}
func Test_TriggerConditions_ToSQL(t *testing.T) {
type fields struct {
conditions TriggerConditions
}
type args struct {
table string
conditionsMet bool
}
tests := []struct {
name string
fields fields
args args
want string
}{
{
name: "single condition",
fields: fields{
conditions: TriggerCondition{
Column: "col1",
Value: "value1",
},
},
args: args{
table: "table",
conditionsMet: true,
},
want: "table.col1 = 'value1'",
},
{
name: "single condition not met",
fields: fields{
conditions: TriggerCondition{
Column: "col1",
Value: "value1",
},
},
args: args{
table: "table",
conditionsMet: false,
},
want: "table.col1 <> 'value1'",
},
{
name: "or condition",
fields: fields{
conditions: OrCondition{
Conditions: []TriggerCondition{
{Column: "col1", Value: "value1"},
{Column: "col2", Value: "value1"},
},
},
},
args: args{
table: "table",
conditionsMet: true,
},
want: "(table.col1 = 'value1' OR table.col2 = 'value1')",
},
{
name: "or condition not met",
fields: fields{
conditions: OrCondition{
Conditions: []TriggerCondition{
{Column: "col1", Value: "value1"},
{Column: "col2", Value: "value1"},
},
},
},
args: args{
table: "table",
conditionsMet: false,
},
want: "(table.col1 <> 'value1' AND table.col2 <> 'value1')",
},
{
name: "and condition",
fields: fields{
conditions: AndCondition{
Conditions: []TriggerCondition{
{Column: "col1", Value: "value1"},
{Column: "col2", Value: "value1"},
},
},
},
args: args{
table: "table",
conditionsMet: true,
},
want: "(table.col1 = 'value1' AND table.col2 = 'value1')",
},
{
name: "and condition not met",
fields: fields{
conditions: AndCondition{
Conditions: []TriggerCondition{
{Column: "col1", Value: "value1"},
{Column: "col2", Value: "value1"},
},
},
},
args: args{
table: "table",
conditionsMet: false,
},
want: "(table.col1 <> 'value1' OR table.col2 <> 'value1')",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.fields.conditions.ToSQL(tt.args.table, tt.args.conditionsMet)
assert.Equal(t, tt.want, got)
})
}
}