From 62cd7154ae095e4a3e777897a129451e95dbb5f0 Mon Sep 17 00:00:00 2001 From: dmakarau Date: Wed, 8 Oct 2025 15:00:41 +0200 Subject: [PATCH 1/2] Fixed color validation on the server side --- .../Controllers/HabitsController.swift | 11 +++++++---- Sources/GrowBitAppServer/Models/Category.swift | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Sources/GrowBitAppServer/Controllers/HabitsController.swift b/Sources/GrowBitAppServer/Controllers/HabitsController.swift index 1c5ece4..42ef9ad 100644 --- a/Sources/GrowBitAppServer/Controllers/HabitsController.swift +++ b/Sources/GrowBitAppServer/Controllers/HabitsController.swift @@ -39,12 +39,15 @@ struct HabitsController: RouteCollection { throw Abort(.badRequest, reason: "Category name cannot be empty") // HTTP 400 } - // Validate color code format (#RRGGBB) - let colorCodePattern = #"^#([A-Fa-f0-9]{6})$"# + // Validate color code format (RRGGBB or #RRGGBB) + let colorCodePattern = #"^#?([A-Fa-f0-9]{6})$"# guard colorCode.range(of: colorCodePattern, options: .regularExpression) != nil else { - throw Abort(.badRequest, reason: "Color code should be in format #RRGGBB") // HTTP 400 + throw Abort(.badRequest, reason: "Color code should be in format RRGGBB or #RRGGBB") // HTTP 400 } + // Normalize color code to always include # + let normalizedColorCode = colorCode.hasPrefix("#") ? colorCode : "#\(colorCode)" + // Check for duplicate category name for this user (case-insensitive) let existingCategories = try await Category.query(on: req.db) .filter(\.$user.$id, .equal, userId) @@ -58,7 +61,7 @@ struct HabitsController: RouteCollection { let habitCategory = Category( name: name, - colorCode: colorCode, + colorCode: normalizedColorCode, userId: userId ) try await habitCategory.save(on: req.db) diff --git a/Sources/GrowBitAppServer/Models/Category.swift b/Sources/GrowBitAppServer/Models/Category.swift index a4a7696..f5da379 100644 --- a/Sources/GrowBitAppServer/Models/Category.swift +++ b/Sources/GrowBitAppServer/Models/Category.swift @@ -34,8 +34,8 @@ final class Category: Model, Validatable, Content, @unchecked Sendable { static func validations(_ validations: inout Validations) { validations.add("name", as: String.self, is: !.empty, customFailureDescription: "Category name cannot be empty") - validations.add("colorCode", as: String.self, is: !.empty, customFailureDescription: "Color coe cannot be empty") - validations.add("color_code", as: String.self, is: .pattern(#"^#([A-Fa-f0-9]{6})$"#), customFailureDescription: "Color code should be in format #RRGGBB") + validations.add("colorCode", as: String.self, is: !.empty, customFailureDescription: "Color code cannot be empty") + validations.add("color_code", as: String.self, is: .pattern(#"^#?([A-Fa-f0-9]{6})$"#), customFailureDescription: "Color code should be in format RRGGBB or #RRGGBB") } From 59a04e7113ab8ed4c0e2a35835ca4b6eb06716be Mon Sep 17 00:00:00 2001 From: dmakarau Date: Wed, 8 Oct 2025 15:23:09 +0200 Subject: [PATCH 2/2] Added a test for color normalizer funtionlity --- .gitignore | 3 +- Package.resolved | 366 ------------------ .../GrowBitAppServerLoginTests.swift | 1 - .../GrowBitAppServerSavingCategoryTests.swift | 48 ++- 4 files changed, 48 insertions(+), 370 deletions(-) delete mode 100644 Package.resolved diff --git a/.gitignore b/.gitignore index e646ccc..e6f268d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ db.sqlite !.env.example default.profraw .vscode +Package.resolved # Development guidance for Claude Code -CLAUDE.md \ No newline at end of file +CLAUDE.md diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index b2af171..0000000 --- a/Package.resolved +++ /dev/null @@ -1,366 +0,0 @@ -{ - "originHash" : "790b17c32995ac1583b6f3ad5a89c48f9af9e412b7c71c692b3de104b7435fd5", - "pins" : [ - { - "identity" : "async-http-client", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/async-http-client.git", - "state" : { - "revision" : "7dc119c7edf3c23f52638faadb89182861dee853", - "version" : "1.28.0" - } - }, - { - "identity" : "async-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/async-kit.git", - "state" : { - "revision" : "6f3615ccf2ac3c2ae0c8087d527546e9544a43dd", - "version" : "1.21.0" - } - }, - { - "identity" : "console-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/console-kit.git", - "state" : { - "revision" : "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b", - "version" : "4.15.2" - } - }, - { - "identity" : "fluent", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/fluent.git", - "state" : { - "revision" : "2fe9e36daf4bdb5edcf193e0d0806ba2074d2864", - "version" : "4.13.0" - } - }, - { - "identity" : "fluent-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/fluent-kit.git", - "state" : { - "revision" : "8baacd7e8f7ebf68886c496b43bbe6cdcc5b57e0", - "version" : "1.52.2" - } - }, - { - "identity" : "fluent-postgres-driver", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/fluent-postgres-driver", - "state" : { - "revision" : "cd47a7042a529735e401bdfaa070823d151f7f94", - "version" : "2.11.0" - } - }, - { - "identity" : "fluent-sqlite-driver", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/fluent-sqlite-driver", - "state" : { - "revision" : "73529a63ab11c7fe87da17b5a67a1b1f58c020f8", - "version" : "4.8.1" - } - }, - { - "identity" : "growbitshareddto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/dmakarau/GrowBitSharedDTO", - "state" : { - "branch" : "main", - "revision" : "8d00b4d406269648f863d4e6cf02e33073445eb7" - } - }, - { - "identity" : "jwt", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/jwt.git", - "state" : { - "revision" : "af1c59762d70d1065ddbc0d7902ea9b3dacd1a26", - "version" : "5.1.2" - } - }, - { - "identity" : "jwt-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/jwt-kit.git", - "state" : { - "revision" : "2033b3e661238dda3d30e36a2d40987499d987de", - "version" : "5.2.0" - } - }, - { - "identity" : "multipart-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/multipart-kit.git", - "state" : { - "revision" : "3498e60218e6003894ff95192d756e238c01f44e", - "version" : "4.7.1" - } - }, - { - "identity" : "postgres-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/postgres-kit.git", - "state" : { - "revision" : "d2fd3172c2e318bd292a4c1297e4c65a418cf6f3", - "version" : "2.14.1" - } - }, - { - "identity" : "postgres-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/postgres-nio.git", - "state" : { - "revision" : "8ee6118c03501196be183b0938d2ec4478c18954", - "version" : "1.27.0" - } - }, - { - "identity" : "routing-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/routing-kit.git", - "state" : { - "revision" : "93f7222c8e195cbad39fafb5a0e4cc85a8def7ea", - "version" : "4.9.2" - } - }, - { - "identity" : "sql-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/sql-kit.git", - "state" : { - "revision" : "1a9ab0523fb742d9629558cede64290165c4285b", - "version" : "3.33.2" - } - }, - { - "identity" : "sqlite-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/sqlite-kit.git", - "state" : { - "revision" : "f35a863ecc2da5d563b836a9a696b148b0f4169f", - "version" : "4.5.2" - } - }, - { - "identity" : "sqlite-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/sqlite-nio.git", - "state" : { - "revision" : "a4c62fa1d99db69bf96d48f5b903eca1427f4a0e", - "version" : "1.12.0" - } - }, - { - "identity" : "swift-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-algorithms.git", - "state" : { - "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", - "version" : "1.2.1" - } - }, - { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", - "version" : "1.4.0" - } - }, - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms.git", - "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" - } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-certificates", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-certificates.git", - "state" : { - "revision" : "4b092f15164144c24554e0a75e080a960c5190a6", - "version" : "1.14.0" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version" : "3.15.1" - } - }, - { - "identity" : "swift-distributed-tracing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-distributed-tracing.git", - "state" : { - "revision" : "6600888f4cb5bbf1bcac51000f60b2cbd224c91b", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-http-structured-headers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-structured-headers.git", - "state" : { - "revision" : "1625f271afb04375bf48737a5572613248d0e7a0", - "version" : "1.4.0" - } - }, - { - "identity" : "swift-http-types", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-types.git", - "state" : { - "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", - "version" : "1.4.0" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" - } - }, - { - "identity" : "swift-metrics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-metrics.git", - "state" : { - "revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6", - "version" : "2.7.1" - } - }, - { - "identity" : "swift-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio.git", - "state" : { - "revision" : "a18bddb0acf7a40d982b2f128ce73ce4ee31f352", - "version" : "2.86.2" - } - }, - { - "identity" : "swift-nio-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-extras.git", - "state" : { - "revision" : "a55c3dd3a81d035af8a20ce5718889c0dcab073d", - "version" : "1.29.0" - } - }, - { - "identity" : "swift-nio-http2", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-http2.git", - "state" : { - "revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", - "version" : "1.38.0" - } - }, - { - "identity" : "swift-nio-ssl", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssl.git", - "state" : { - "revision" : "b2b043a8810ab6d51b3ff4df17f057d87ef1ec7c", - "version" : "2.34.1" - } - }, - { - "identity" : "swift-nio-transport-services", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-transport-services.git", - "state" : { - "revision" : "e645014baea2ec1c2db564410c51a656cf47c923", - "version" : "1.25.1" - } - }, - { - "identity" : "swift-numerics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics.git", - "state" : { - "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-service-context", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-service-context.git", - "state" : { - "revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6", - "version" : "1.2.1" - } - }, - { - "identity" : "swift-service-lifecycle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-service-lifecycle.git", - "state" : { - "revision" : "e7187309187695115033536e8fc9b2eb87fd956d", - "version" : "2.8.0" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", - "state" : { - "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", - "version" : "1.6.3" - } - }, - { - "identity" : "vapor", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/vapor.git", - "state" : { - "revision" : "773ea6a63595ae4f6bc46a366d78769d4cb8b08c", - "version" : "4.117.0" - } - }, - { - "identity" : "websocket-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/websocket-kit.git", - "state" : { - "revision" : "8666c92dbbb3c8eefc8008c9c8dcf50bfd302167", - "version" : "2.16.1" - } - } - ], - "version" : 3 -} diff --git a/Tests/GrowBitAppServerTests/GrowBitAppServerLoginTests.swift b/Tests/GrowBitAppServerTests/GrowBitAppServerLoginTests.swift index d7988fa..d66fdce 100644 --- a/Tests/GrowBitAppServerTests/GrowBitAppServerLoginTests.swift +++ b/Tests/GrowBitAppServerTests/GrowBitAppServerLoginTests.swift @@ -10,7 +10,6 @@ import Foundation import GrowBitSharedDTO import VaporTesting import Testing -import HabitTrackerAppSharedDTO @Suite("App Login Tests") struct GrowBitAppServerLoginTests { diff --git a/Tests/GrowBitAppServerTests/GrowBitAppServerSavingCategoryTests.swift b/Tests/GrowBitAppServerTests/GrowBitAppServerSavingCategoryTests.swift index 894389b..e647664 100644 --- a/Tests/GrowBitAppServerTests/GrowBitAppServerSavingCategoryTests.swift +++ b/Tests/GrowBitAppServerTests/GrowBitAppServerSavingCategoryTests.swift @@ -185,7 +185,7 @@ struct GrowBitAppServerSavingCategoryTests { // Request body with invalid color code (missing #) let requestBody = [ "name": "test category", - "colorCode": "FFFFFF" + "colorCode": "FF$FF§FF" ] try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in @@ -214,7 +214,7 @@ struct GrowBitAppServerSavingCategoryTests { throw TestError.userCreationFailed } - // Test various valid color codes + // Test various valid color codes with # let validColors = ["#000000", "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#abcdef", "#123456"] for (index, color) in validColors.enumerated() { @@ -233,6 +233,50 @@ struct GrowBitAppServerSavingCategoryTests { } } } + + @Test("Category creation - Success - Color normalization") + func categoryCreationSuccessColorNormalization() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "testuser7", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "testuser7") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Test that colors without # are normalized to include # + let testCases: [(input: String, expected: String)] = [ + ("FF0000", "#FF0000"), + ("00FF00", "#00FF00"), + ("0000FF", "#0000FF"), + ("abcdef", "#ABCDEF"), + ("123456", "#123456") + ] + + for (index, testCase) in testCases.enumerated() { + let requestBody = [ + "name": "normalized category \(index)", + "colorCode": testCase.input + ] + + try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in + try req.content.encode(requestBody) + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode(CategoryResponseDTO.self) + #expect(response.colorCode.uppercased() == testCase.expected.uppercased()) + } + } + } + } } enum TestError: Error {