Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions cmd/api/src/database/migration/goose_migration_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"testing"

"github.com/peterldowns/pgtestdb"
"github.com/pressly/goose/v3"
"github.com/specterops/bloodhound/cmd/api/src/config"
"github.com/specterops/bloodhound/cmd/api/src/database"
"github.com/specterops/bloodhound/cmd/api/src/database/migration"
Expand Down Expand Up @@ -264,6 +265,23 @@ func assertColumnExists(t *testing.T, db *gorm.DB, tableName string, columnName
assert.Truef(t, columnExists, "column %s.%s should exist", tableName, columnName)
}

func assertColumnDoesNotExist(t *testing.T, db *gorm.DB, tableName string, columnName string) {
t.Helper()

var columnExists bool
err := db.Raw(`
SELECT EXISTS(
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = ?
AND column_name = ?
)
`, tableName, columnName).Scan(&columnExists).Error
require.NoError(t, err)
assert.Falsef(t, columnExists, "column %s.%s should not exist", tableName, columnName)
}

func assertConstraintExists(t *testing.T, db *gorm.DB, constraintName string) {
t.Helper()

Expand Down Expand Up @@ -450,3 +468,81 @@ func TestMigrator_ExecuteGooseMigrations(t *testing.T) {
})
}
}

// TestMigration_UpsertKindFromCustomNodeKind verifies the logic of migration 20260526065858
func TestMigration_UpsertKindFromCustomNodeKind(t *testing.T) {
const (
// Version 1 is the baseline init migration; it creates kind and custom_node_kinds.
previousMigrationVersion int64 = 1
targetMigrationVersion int64 = 20260526065858
)

testContext := setupGooseTestContext(t)

provider, err := goose.NewProvider(
goose.DialectPostgres,
testContext.migrator.SqlDB,
testContext.migrator.GooseFS,
goose.WithAllowOutofOrder(true),
)
require.NoError(t, err)

// Run the baseline init so that kind and custom_node_kinds both exist.
_, err = provider.UpTo(testContext.ctx, previousMigrationVersion)
require.NoError(t, err)

// create a pre existing kind to verify the on conflict logic
require.NoError(t, testContext.gormDB.Exec(`INSERT INTO kind (name) VALUES ('PreExistingKind') ON CONFLICT (name) DO NOTHING`).Error)

// create three custom_node_kinds rows to verify the data transformation and filtering logic
require.NoError(t, testContext.gormDB.Exec(`
INSERT INTO custom_node_kinds (kind_name, config) VALUES
('PreExistingKind', '{}'),
('BrandNewKind', '{}'),
('Tag_ShouldDelete', '{}')
`).Error)

// read the kind_id sequence before the migration so we can assert it only
// advanced by the number of genuinely new kinds.
var kindSeqBefore int64
require.NoError(t, testContext.gormDB.Raw(`SELECT last_value FROM kind_id_seq`).Scan(&kindSeqBefore).Error)

// Execute the target migration.
_, err = provider.UpTo(testContext.ctx, targetMigrationVersion)
require.NoError(t, err)

// kind_id must be present, kind_name must be gone.
assertColumnExists(t, testContext.gormDB, "custom_node_kinds", "kind_id")
assertColumnDoesNotExist(t, testContext.gormDB, "custom_node_kinds", "kind_name")
assertConstraintExists(t, testContext.gormDB, "fk_custom_node_kinds_kind_id")
assertConstraintExists(t, testContext.gormDB, "custom_node_kinds_kind_id_key")

// Tag_-prefixed rows must have been deleted.
var tagCount int
require.NoError(t, testContext.gormDB.Raw(`
SELECT COUNT(*) FROM custom_node_kinds cnk
JOIN kind k ON k.id = cnk.kind_id
WHERE k.name LIKE 'Tag_%'
`).Scan(&tagCount).Error)
assert.Equal(t, 0, tagCount, "Tag_-prefixed kinds should have been deleted")

// two rows should survive (PreExistingKind and BrandNewKind).
var rowCount int
require.NoError(t, testContext.gormDB.Raw(`SELECT COUNT(*) FROM custom_node_kinds`).Scan(&rowCount).Error)
assert.Equal(t, 2, rowCount, "expected exactly two surviving custom_node_kinds rows")

// BrandNewKind should exist in the kind table
require.NoError(t, testContext.gormDB.Raw(`SELECT COUNT(*) FROM kind WHERE name = 'BrandNewKind'`).Scan(&rowCount).Error)
assert.Equal(t, 1, rowCount, "BrandNewKind should have been upserted into the kind table")

// The kind sequence must have advanced by exactly 1 (BrandNewKind only).
// PreExistingKind was already in kind and Tag_ShouldDelete was deleted before
// the insert, so neither should have consumed a sequence value.
var kindSeqAfter int64
require.NoError(t, testContext.gormDB.Raw(`SELECT last_value FROM kind_id_seq`).Scan(&kindSeqAfter).Error)
assert.Equal(t, kindSeqBefore+1, kindSeqAfter, "kind sequence should only advance for genuinely new kinds")

// Every surviving row should have a non-null kind_id.
require.NoError(t, testContext.gormDB.Raw(`SELECT COUNT(*) FROM custom_node_kinds WHERE kind_id IS NULL`).Scan(&rowCount).Error)
assert.Equal(t, 0, rowCount, "every custom_node_kinds row should have a kind_id after the migration")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
-- Copyright 2026 Specter Ops, Inc.
--
-- Licensed under the Apache License, Version 2.0
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
-- SPDX-License-Identifier: Apache-2.0
-- +goose Up

-- Delete any custom_node_kinds records whose kind_name is prefixed with 'Tag_'.
-- These names are reserved by the PZ system and shouldn't have slipped in here, but this is just in case.
DELETE FROM custom_node_kinds WHERE kind_name LIKE 'Tag_%';

-- Insert any kind_name values from custom_node_kinds that are not yet present in the
-- kind table. The WHERE NOT EXISTS filter avoids using up kind IDs (a SMALLSERIAL sequence)
-- as would happen with ON CONFLICT DO NOTHING.
INSERT INTO kind (name)
SELECT kind_name FROM custom_node_kinds cnk
WHERE NOT EXISTS (
SELECT 1 FROM kind WHERE kind.name = cnk.kind_name
);

-- Add the new kind_id FK column (SMALLINT matches kind.id SMALLSERIAL).
ALTER TABLE custom_node_kinds ADD COLUMN IF NOT EXISTS kind_id SMALLINT;

-- Back-fill the new kind_id column
UPDATE custom_node_kinds
SET kind_id = kind.id
FROM kind
WHERE kind.name = custom_node_kinds.kind_name;

-- remove any rows that could not be resolved to a kind_id. This should never happen but we'll kill them just in case so the following steps don't fail due to nulls in kind_id.
DELETE FROM custom_node_kinds WHERE kind_id IS NULL;

-- Enforce NOT NULL and add the FK constraint
ALTER TABLE custom_node_kinds ALTER COLUMN kind_id SET NOT NULL;
ALTER TABLE custom_node_kinds
ADD CONSTRAINT fk_custom_node_kinds_kind_id FOREIGN KEY (kind_id) REFERENCES kind (id);

-- kind_name previously enforced one unique constraint per row, but now that we've switched to kind_id we need to enforce the same uniqueness on kind_id instead
ALTER TABLE custom_node_kinds
ADD CONSTRAINT custom_node_kinds_kind_id_key UNIQUE (kind_id);

-- drop the now-superseded kind_name column.
-- dropping the column implicitly removes the custom_node_kinds_kind_name_key unique constraint.
ALTER TABLE custom_node_kinds DROP COLUMN kind_name;

-- +goose Down

-- Re-add the kind_name column (nullable initially so it can be populated).
ALTER TABLE custom_node_kinds ADD COLUMN IF NOT EXISTS kind_name VARCHAR(256);

-- Populate kind_name from the kind table via kind_id.
UPDATE custom_node_kinds
SET kind_name = kind.name
FROM kind
WHERE kind.id = custom_node_kinds.kind_id;

-- Enforce NOT NULL and restore the original unique constraint name.
ALTER TABLE custom_node_kinds ALTER COLUMN kind_name SET NOT NULL;
ALTER TABLE custom_node_kinds
ADD CONSTRAINT custom_node_kinds_kind_name_key UNIQUE (kind_name);

-- Drop the FK and unique constraints on kind_id, then drop the column.
ALTER TABLE custom_node_kinds DROP CONSTRAINT IF EXISTS fk_custom_node_kinds_kind_id;
ALTER TABLE custom_node_kinds DROP CONSTRAINT IF EXISTS custom_node_kinds_kind_id_key;
ALTER TABLE custom_node_kinds DROP COLUMN kind_id;

Loading