Files
pocket-id/backend/internal/service/oidc_client_secrets_migration_test.go

89 lines
3.8 KiB
Go

package service
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// versionBeforeMultipleClientSecrets is the migration version right before client secrets moved into the credentials document
const versionBeforeMultipleClientSecrets = 20260802120000
// TestMigrateClientSecretsToCredentials checks that the secret of every existing client is preserved as an entry of the credentials document
func TestMigrateClientSecretsToCredentials(t *testing.T) {
const legacySecret = "legacy-client-secret"
legacyHash, err := bcrypt.GenerateFromPassword([]byte(legacySecret), bcrypt.MinCost)
require.NoError(t, err)
createdAt := time.Now().Add(-72 * time.Hour).Truncate(time.Second)
db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMultipleClientSecrets, func(t *testing.T, db *gorm.DB) {
// A client with a secret and no other credentials
err := db.Exec(
`INSERT INTO oidc_clients (id, created_at, name, secret, callback_urls, is_public, pkce_enabled) VALUES (?, ?, ?, ?, ?, ?, ?)`,
"client-with-secret", createdAt.Unix(), "With Secret", string(legacyHash), `["https://example.com/callback"]`, false, false,
).Error
require.NoError(t, err)
// A client whose credentials document already holds a federated identity, which the migration must preserve
err = db.Exec(
`INSERT INTO oidc_clients (id, created_at, name, secret, callback_urls, is_public, pkce_enabled, credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
"client-with-federated-identity", createdAt.Unix(), "With Federated Identity", string(legacyHash), `["https://example.com/callback"]`, false, false,
`{"federatedIdentities":[{"issuer":"https://issuer.example.com"}]}`,
).Error
require.NoError(t, err)
// A public client, which never had a secret
err = db.Exec(
`INSERT INTO oidc_clients (id, created_at, name, secret, callback_urls, is_public, pkce_enabled) VALUES (?, ?, ?, ?, ?, ?, ?)`,
"public-client", createdAt.Unix(), "Public", "", `["https://example.com/callback"]`, true, true,
).Error
require.NoError(t, err)
})
// The legacy column is gone once the migration has run
ok := db.Migrator().HasColumn(&model.OidcClient{}, "secret")
assert.False(t, ok)
var clients []model.OidcClient
err = db.Find(&clients).Error
require.NoError(t, err)
byID := make(map[string]model.OidcClient, len(clients))
for _, client := range clients {
byID[client.ID] = client
}
require.Len(t, byID, 3)
// The migrated secret keeps its bcrypt hash, has no expiration, and carries no prefix because the value was never stored
migrated := byID["client-with-secret"].Credentials.Secrets
require.Len(t, migrated, 1)
assert.NotEmpty(t, migrated[0].ID)
assert.Equal(t, model.OidcClientSecretHashBcrypt, migrated[0].Algorithm)
assert.Equal(t, string(legacyHash), migrated[0].Hash)
assert.Empty(t, migrated[0].Prefix)
assert.Nil(t, migrated[0].ExpiresAt)
assert.True(t, migrated[0].IsActive())
assert.Equal(t, createdAt.UTC(), migrated[0].CreatedAt.UTC())
// Existing federated identities survive the migration alongside the new secret
withFederated := byID["client-with-federated-identity"].Credentials
require.Len(t, withFederated.Secrets, 1)
assert.Equal(t, string(legacyHash), withFederated.Secrets[0].Hash)
require.Len(t, withFederated.FederatedIdentities, 1)
assert.Equal(t, "https://issuer.example.com", withFederated.FederatedIdentities[0].Issuer)
// Clients that never had a secret do not get an empty one
assert.Empty(t, byID["public-client"].Credentials.Secrets)
// Every migrated secret gets its own identifier
assert.NotEqual(t, migrated[0].ID, withFederated.Secrets[0].ID)
}