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

612 lines
19 KiB
Go

package service
import (
"io"
"net/http"
"strconv"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/unicode/norm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestOidcService_updateClientLogoType(t *testing.T) {
// Create a test database
db := testutils.NewDatabaseForTest(t)
// Create database storage
dbStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
// Init the OidcService
s := &OidcService{
db: db,
fileStorage: dbStorage,
}
// Create a test client
client := model.OidcClient{
Name: "Test Client",
CallbackURLs: model.UrlList{"https://example.com/callback"},
}
err = db.Create(&client).Error
require.NoError(t, err)
// Helper function to check if a file exists in storage
fileExists := func(t *testing.T, path string) bool {
t.Helper()
_, _, err := dbStorage.Open(t.Context(), path)
return err == nil
}
// Helper function to create a dummy file in storage
createDummyFile := func(t *testing.T, path string) {
t.Helper()
err := dbStorage.Save(t.Context(), path, strings.NewReader("dummy content"))
require.NoError(t, err)
}
t.Run("Updates light logo type for client without previous logo", func(t *testing.T) {
// Update the logo type
err := s.updateClientLogoType(t.Context(), client.ID, "png", true)
require.NoError(t, err)
// Verify the client was updated
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.ImageType)
assert.Equal(t, "png", *updatedClient.ImageType)
})
t.Run("Updates dark logo type for client without previous dark logo", func(t *testing.T) {
// Update the dark logo type
err := s.updateClientLogoType(t.Context(), client.ID, "jpg", false)
require.NoError(t, err)
// Verify the client was updated
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.DarkImageType)
assert.Equal(t, "jpg", *updatedClient.DarkImageType)
})
t.Run("Updates light logo type and deletes old file when type changes", func(t *testing.T) {
// Create the old PNG file in storage
oldPath := "oidc-client-images/" + client.ID + ".png"
createDummyFile(t, oldPath)
require.True(t, fileExists(t, oldPath), "Old file should exist before update")
// Client currently has a PNG logo, update to WEBP
err := s.updateClientLogoType(t.Context(), client.ID, "webp", true)
require.NoError(t, err)
// Verify the client was updated
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.ImageType)
assert.Equal(t, "webp", *updatedClient.ImageType)
// Old PNG file should be deleted
assert.False(t, fileExists(t, oldPath), "Old PNG file should have been deleted")
})
t.Run("Updates dark logo type and deletes old file when type changes", func(t *testing.T) {
// Create the old JPG dark file in storage
oldPath := "oidc-client-images/" + client.ID + "-dark.jpg"
createDummyFile(t, oldPath)
require.True(t, fileExists(t, oldPath), "Old dark file should exist before update")
// Client currently has a JPG dark logo, update to WEBP
err := s.updateClientLogoType(t.Context(), client.ID, "webp", false)
require.NoError(t, err)
// Verify the client was updated
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.DarkImageType)
assert.Equal(t, "webp", *updatedClient.DarkImageType)
// Old JPG dark file should be deleted
assert.False(t, fileExists(t, oldPath), "Old JPG dark file should have been deleted")
})
t.Run("Does not delete file when type remains the same", func(t *testing.T) {
// Create the WEBP file in storage
webpPath := "oidc-client-images/" + client.ID + ".webp"
createDummyFile(t, webpPath)
require.True(t, fileExists(t, webpPath), "WEBP file should exist before update")
// Update to the same type (WEBP)
err := s.updateClientLogoType(t.Context(), client.ID, "webp", true)
require.NoError(t, err)
// Verify the client still has WEBP
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.ImageType)
assert.Equal(t, "webp", *updatedClient.ImageType)
// WEBP file should still exist since type didn't change
assert.True(t, fileExists(t, webpPath), "WEBP file should still exist")
})
t.Run("Returns error for non-existent client", func(t *testing.T) {
err := s.updateClientLogoType(t.Context(), "non-existent-client-id", "png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to look up client")
})
}
func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
const publicLogoHost = "https://8.8.8.8"
// Create a test database
db := testutils.NewDatabaseForTest(t)
// Create database storage
dbStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
// Create a test client
client := model.OidcClient{
Name: "Test Client",
CallbackURLs: model.UrlList{"https://example.com/callback"},
}
err = db.Create(&client).Error
require.NoError(t, err)
// Helper function to check if a file exists in storage
fileExists := func(t *testing.T, path string) bool {
t.Helper()
_, _, err := dbStorage.Open(t.Context(), path)
return err == nil
}
// Helper function to get file content from storage
getFileContent := func(t *testing.T, path string) []byte {
t.Helper()
reader, _, err := dbStorage.Open(t.Context(), path)
require.NoError(t, err)
defer reader.Close()
content, err := io.ReadAll(reader)
require.NoError(t, err)
return content
}
t.Run("Successfully downloads and saves PNG logo from URL", func(t *testing.T) {
// Create mock PNG content
pngContent := []byte("fake-png-content")
// Create a mock HTTP response with headers
//nolint:bodyclose
pngResponse := testutils.NewMockResponse(http.StatusOK, string(pngContent))
pngResponse.Header.Set("Content-Type", "image/png")
// Create a mock HTTP client with responses
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/logo.png": pngResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
// Init the OidcService with mock HTTP client
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
// Download and save the logo
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/logo.png", true)
require.NoError(t, err)
// Verify the file was saved
logoPath := "oidc-client-images/" + client.ID + ".png"
require.True(t, fileExists(t, logoPath), "Logo file should exist in storage")
// Verify the content
savedContent := getFileContent(t, logoPath)
assert.Equal(t, pngContent, savedContent)
// Verify the client was updated
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.ImageType)
assert.Equal(t, "png", *updatedClient.ImageType)
})
t.Run("Successfully downloads and saves dark logo", func(t *testing.T) {
// Create mock WEBP content
webpContent := []byte("fake-webp-content")
//nolint:bodyclose
webpResponse := testutils.NewMockResponse(http.StatusOK, string(webpContent))
webpResponse.Header.Set("Content-Type", "image/webp")
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/dark-logo.webp": webpResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
// Download and save the dark logo
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/dark-logo.webp", false)
require.NoError(t, err)
// Verify the dark logo file was saved
darkLogoPath := "oidc-client-images/" + client.ID + "-dark.webp"
require.True(t, fileExists(t, darkLogoPath), "Dark logo file should exist in storage")
// Verify the content
savedContent := getFileContent(t, darkLogoPath)
assert.Equal(t, webpContent, savedContent)
// Verify the client was updated
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.DarkImageType)
assert.Equal(t, "webp", *updatedClient.DarkImageType)
})
t.Run("Detects extension from URL path", func(t *testing.T) {
svgContent := []byte("<svg></svg>")
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/icon.svg": testutils.NewMockResponse(http.StatusOK, string(svgContent)),
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/icon.svg", true)
require.NoError(t, err)
// Verify SVG file was saved
logoPath := "oidc-client-images/" + client.ID + ".svg"
require.True(t, fileExists(t, logoPath), "SVG logo should exist")
})
t.Run("Detects extension from Content-Type when path has no extension", func(t *testing.T) {
jpgContent := []byte("fake-jpg-content")
//nolint:bodyclose
jpgResponse := testutils.NewMockResponse(http.StatusOK, string(jpgContent))
jpgResponse.Header.Set("Content-Type", "image/jpeg")
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/logo": jpgResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/logo", true)
require.NoError(t, err)
// Verify JPG file was saved (jpeg extension is normalized to jpg)
logoPath := "oidc-client-images/" + client.ID + ".jpg"
require.True(t, fileExists(t, logoPath), "JPG logo should exist")
})
t.Run("Returns error for invalid URL", func(t *testing.T) {
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: &http.Client{},
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "://invalid-url", true)
require.Error(t, err)
})
t.Run("Returns error for non-200 status code", func(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/not-found.png": testutils.NewMockResponse(http.StatusNotFound, "Not Found"),
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/not-found.png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to fetch logo")
})
t.Run("Returns error for too large content", func(t *testing.T) {
// Create content larger than 2MB (maxLogoSize)
largeContent := strings.Repeat("x", 2<<20+100) // 2.1MB
//nolint:bodyclose
largeResponse := testutils.NewMockResponse(http.StatusOK, largeContent)
largeResponse.Header.Set("Content-Type", "image/png")
largeResponse.Header.Set("Content-Length", strconv.Itoa(len(largeContent)))
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/large.png": largeResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/large.png", true)
require.Error(t, err)
require.ErrorIs(t, err, errLogoTooLarge)
})
t.Run("Returns error for unsupported file type", func(t *testing.T) {
//nolint:bodyclose
textResponse := testutils.NewMockResponse(http.StatusOK, "text content")
textResponse.Header.Set("Content-Type", "text/plain")
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/file.txt": textResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/file.txt", true)
require.Error(t, err)
var fileTypeErr *common.FileTypeNotSupportedError
require.ErrorAs(t, err, &fileTypeErr)
})
t.Run("Returns error for non-existent client", func(t *testing.T) {
//nolint:bodyclose
pngResponse := testutils.NewMockResponse(http.StatusOK, "content")
pngResponse.Header.Set("Content-Type", "image/png")
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/logo.png": pngResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), "non-existent-client-id", publicLogoHost+"/logo.png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to look up client")
})
}
func TestOidcService_CreateClient_withDescription(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
require.NoError(t, err)
name := norm.NFD.String("Tést Client")
description := norm.NFD.String("A café client description")
input := dto.OidcClientCreateDto{
OidcClientUpdateDto: dto.OidcClientUpdateDto{
Name: name,
Description: description,
CallbackURLs: []string{"https://example.com/callback"},
},
}
client, err := s.CreateClient(t.Context(), input, "user-id")
require.NoError(t, err)
var fetched model.OidcClient
err = db.First(&fetched, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotEmpty(t, fetched.Description)
assert.Equal(t, norm.NFC.String(name), fetched.Name)
assert.Equal(t, norm.NFC.String(description), fetched.Description)
}
func TestOidcService_CreateClient_withoutDescription(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
require.NoError(t, err)
input := dto.OidcClientCreateDto{
OidcClientUpdateDto: dto.OidcClientUpdateDto{
Name: "Test Client",
CallbackURLs: []string{"https://example.com/callback"},
},
}
client, err := s.CreateClient(t.Context(), input, "user-id")
require.NoError(t, err)
var fetched model.OidcClient
err = db.First(&fetched, "id = ?", client.ID).Error
require.NoError(t, err)
assert.Empty(t, fetched.Description)
}
func TestOidcService_CreateClientSecret_withCustomSecret(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
require.NoError(t, err)
client := model.OidcClient{Name: "Test Client"}
err = db.Create(&client).Error
require.NoError(t, err)
customSecret := "custom-client-secret-with-a-minimum-length"
input := dto.OidcClientSecretDto{Secret: customSecret}
secret, err := s.CreateClientSecret(t.Context(), client.ID, input)
require.NoError(t, err)
assert.Equal(t, customSecret, secret)
var fetched model.OidcClient
err = db.First(&fetched, "id = ?", client.ID).Error
require.NoError(t, err)
require.NoError(t, bcrypt.CompareHashAndPassword([]byte(fetched.Secret), []byte(customSecret)))
}
func TestOidcService_UpdateClient_description(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
require.NoError(t, err)
// Create a client without a description
client := model.OidcClient{
Name: "Test Client",
CallbackURLs: model.UrlList{"https://example.com/callback"},
}
err = db.Create(&client).Error
require.NoError(t, err)
// Update with a description
description := norm.NFD.String("Updated café description")
input := dto.OidcClientUpdateDto{
Name: "Test Client",
Description: description,
CallbackURLs: []string{"https://example.com/callback"},
}
_, err = s.UpdateClient(t.Context(), client.ID, input)
require.NoError(t, err)
var fetched model.OidcClient
err = db.First(&fetched, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotEmpty(t, fetched.Description)
assert.Equal(t, norm.NFC.String(description), fetched.Description)
// Update to clear the description
input.Description = ""
_, err = s.UpdateClient(t.Context(), client.ID, input)
require.NoError(t, err)
err = db.First(&fetched, "id = ?", client.ID).Error
require.NoError(t, err)
assert.Empty(t, fetched.Description)
}
func TestOidcService_ListAccessibleOidcClients_requiresExplicitGroupPermission(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
require.NoError(t, err)
allowedGroup := model.UserGroup{Name: "allowed", FriendlyName: "Allowed"}
otherGroup := model.UserGroup{Name: "other", FriendlyName: "Other"}
require.NoError(t, db.Create(&allowedGroup).Error)
require.NoError(t, db.Create(&otherGroup).Error)
userWithGroup := model.User{Username: "with-group", UserGroups: []model.UserGroup{allowedGroup}}
userWithoutGroup := model.User{Username: "without-group"}
require.NoError(t, db.Create(&userWithGroup).Error)
require.NoError(t, db.Create(&userWithoutGroup).Error)
clients := []model.OidcClient{
{Name: "Unrestricted", CallbackURLs: model.UrlList{"https://unrestricted.example.com/callback"}},
{Name: "Restricted without groups", CallbackURLs: model.UrlList{"https://empty.example.com/callback"}, IsGroupRestricted: true},
{Name: "Restricted to user group", CallbackURLs: model.UrlList{"https://allowed.example.com/callback"}, IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{allowedGroup}},
{Name: "Restricted to other group", CallbackURLs: model.UrlList{"https://other.example.com/callback"}, IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{otherGroup}},
}
for i := range clients {
require.NoError(t, db.Create(&clients[i]).Error)
}
groupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithGroup.ID, utils.ListRequestOptions{})
require.NoError(t, err)
assert.ElementsMatch(t, []string{"Unrestricted", "Restricted to user group"}, accessibleClientNames(groupClients))
noGroupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithoutGroup.ID, utils.ListRequestOptions{})
require.NoError(t, err)
assert.Equal(t, []string{"Unrestricted"}, accessibleClientNames(noGroupClients))
}
func accessibleClientNames(clients []dto.AccessibleOidcClientDto) []string {
names := make([]string, len(clients))
for i := range clients {
names[i] = clients[i].Name
}
return names
}