43962 vpp managed config migration (#44435)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #43962
Adds two tables: `vpp_app_configurations` and
`in_house_app_configurations`
`vpp_app_configurations` has `team_id` unsigned **not nullable**, rather
than `team_id` nullable + `global_or_team_id`. This is following the
pattern in `software_title_display_names` and `software_title_icons`,
since software installers are team only and cannot be global.
`android_app_configurations` uses team_id + global_or_team_id but that
seems to be unnecessary.
`in_house_app_configurations` keys on `in_house_app_id` only — the
parent `in_house_apps` row already pins the team and platform.
Both use MEDIUMTEXT to store the XML configuration.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.


## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Added database tables for storing VPP and in-house app configurations,
organized by team/platform with automatic cleanup when parent apps are
deleted.
* **Tests**
* Added migration tests to validate config storage fidelity, uniqueness
and platform-specific constraints, foreign-key enforcement, and
cascade-delete behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jonathan Katz
2026-04-29 17:47:08 -04:00
committed by GitHub
parent 39429c2e31
commit c158f912c6
3 changed files with 187 additions and 2 deletions
@@ -0,0 +1,58 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20260429180725, Down_20260429180725)
}
func Up_20260429180725(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE TABLE vpp_app_configurations (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
application_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
team_id INT UNSIGNED NOT NULL,
platform VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
configuration MEDIUMTEXT NOT NULL,
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY idx_vpp_app_config_team_app_platform (team_id, application_id, platform),
CONSTRAINT fk_vpp_app_configurations_app
FOREIGN KEY (application_id, platform)
REFERENCES vpp_apps (adam_id, platform)
ON DELETE CASCADE
) DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci`)
if err != nil {
return fmt.Errorf("failed to create table vpp_app_configurations: %w", err)
}
_, err = tx.Exec(`
CREATE TABLE in_house_app_configurations (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
in_house_app_id INT UNSIGNED NOT NULL,
configuration MEDIUMTEXT NOT NULL,
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY idx_in_house_app_config_app (in_house_app_id),
CONSTRAINT fk_in_house_app_configurations_app
FOREIGN KEY (in_house_app_id)
REFERENCES in_house_apps (id)
ON DELETE CASCADE
) DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci`)
if err != nil {
return fmt.Errorf("failed to create table in_house_app_configurations: %w", err)
}
return nil
}
func Down_20260429180725(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,98 @@
package tables
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUp_20260429180725(t *testing.T) {
db := applyUpToPrev(t)
// Seed parent rows: same VPP adam_id on both platforms, and a separate
// in_house_apps row per platform (in-house apps are platform-specific).
const adamID = "1234567890"
execNoErr(t, db, `INSERT INTO vpp_apps (adam_id, platform) VALUES (?, ?)`, adamID, "ios")
execNoErr(t, db, `INSERT INTO vpp_apps (adam_id, platform) VALUES (?, ?)`, adamID, "ipados")
iosAppID := execNoErrLastID(t, db, `INSERT INTO in_house_apps (filename, storage_id, platform) VALUES (?, ?, ?)`, "test.ipa", "abc123", "ios")
ipadosAppID := execNoErrLastID(t, db, `INSERT INTO in_house_apps (filename, storage_id, platform) VALUES (?, ?, ?)`, "test.ipa", "def456", "ipados")
// Apply current migration.
applyNext(t, db)
// Realistic plist managed-config payload (multi-line raw string).
const iosPlist = `<dict>
<key>ServerURL</key>
<string>https://fleetdm.com</string>
<key>EnableTelemetry</key>
<true/>
<key>MaxRetries</key>
<integer>5</integer>
</dict>`
// VPP: same adam_id can be configured for ios and ipados independently on the same team.
execNoErr(t, db, `INSERT INTO vpp_app_configurations (application_id, team_id, platform, configuration) VALUES (?, ?, ?, ?)`,
adamID, 1, "ios", iosPlist)
execNoErr(t, db, `INSERT INTO vpp_app_configurations (application_id, team_id, platform, configuration) VALUES (?, ?, ?, ?)`,
adamID, 1, "ipados", "<dict><key>platform</key><string>ipados</string></dict>")
// Round-trip the plist back out to make sure MEDIUMTEXT preserves it byte-for-byte
// (whitespace, newlines, and angle brackets all intact).
var got string
require.NoError(t, db.Get(&got, `SELECT configuration FROM vpp_app_configurations WHERE application_id = ? AND team_id = ? AND platform = ?`,
adamID, 1, "ios"))
assert.Equal(t, iosPlist, got)
// VPP: same adam_id+platform on a different team is allowed.
execNoErr(t, db, `INSERT INTO vpp_app_configurations (application_id, team_id, platform, configuration) VALUES (?, ?, ?, ?)`,
adamID, 2, "ios", "<dict/>")
// In-house: each in_house_apps row (one per platform, already team-scoped) gets its own config.
execNoErr(t, db, `INSERT INTO in_house_app_configurations (in_house_app_id, configuration) VALUES (?, ?)`,
iosAppID, "<dict><key>platform</key><string>ios</string></dict>")
execNoErr(t, db, `INSERT INTO in_house_app_configurations (in_house_app_id, configuration) VALUES (?, ?)`,
ipadosAppID, "<dict><key>platform</key><string>ipados</string></dict>")
// VPP duplicate (team_id, application_id, platform) — must fail.
_, err := db.Exec(`INSERT INTO vpp_app_configurations (application_id, team_id, platform, configuration) VALUES (?, ?, ?, ?)`,
adamID, 1, "ios", "<dict/>")
require.Error(t, err)
// In-house duplicate in_house_app_id — must fail.
_, err = db.Exec(`INSERT INTO in_house_app_configurations (in_house_app_id, configuration) VALUES (?, ?)`,
iosAppID, "<dict/>")
require.Error(t, err)
// VPP composite FK rejects an adam_id that exists only for the other platform.
execNoErr(t, db, `INSERT INTO vpp_apps (adam_id, platform) VALUES (?, ?)`, "iosonly", "ios")
_, err = db.Exec(`INSERT INTO vpp_app_configurations (application_id, team_id, platform, configuration) VALUES (?, ?, ?, ?)`,
"iosonly", 1, "ipados", "<dict/>")
require.Error(t, err)
// VPP composite FK rejects an unknown adam_id.
_, err = db.Exec(`INSERT INTO vpp_app_configurations (application_id, team_id, platform, configuration) VALUES (?, ?, ?, ?)`,
"9999999999", 1, "ios", "<dict/>")
require.Error(t, err)
// In-house FK rejects an unknown id.
_, err = db.Exec(`INSERT INTO in_house_app_configurations (in_house_app_id, configuration) VALUES (?, ?)`,
999999, "<dict/>")
require.Error(t, err)
// Cascade: deleting only the iOS row from vpp_apps drops its config but leaves the iPadOS config intact.
execNoErr(t, db, `DELETE FROM vpp_apps WHERE adam_id = ? AND platform = ?`, adamID, "ios")
var vppCount int
require.NoError(t, db.Get(&vppCount, `SELECT COUNT(*) FROM vpp_app_configurations WHERE application_id = ? AND platform = ?`, adamID, "ios"))
assert.Equal(t, 0, vppCount)
require.NoError(t, db.Get(&vppCount, `SELECT COUNT(*) FROM vpp_app_configurations WHERE application_id = ? AND platform = ?`, adamID, "ipados"))
assert.Equal(t, 1, vppCount)
// Cascade: deleting only the iOS in_house_apps row drops its config but leaves iPadOS intact.
execNoErr(t, db, `DELETE FROM in_house_apps WHERE id = ?`, iosAppID)
var inHouseCount int
require.NoError(t, db.Get(&inHouseCount, `SELECT COUNT(*) FROM in_house_app_configurations WHERE in_house_app_id = ?`, iosAppID))
assert.Equal(t, 0, inHouseCount)
require.NoError(t, db.Get(&inHouseCount, `SELECT COUNT(*) FROM in_house_app_configurations WHERE in_house_app_id = ?`, ipadosAppID))
assert.Equal(t, 1, inHouseCount)
}
File diff suppressed because one or more lines are too long