Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ struct UsernameLookupScreen: View {
@Environment(Session.self) private var session
@Environment(AppRouter.self) private var router

private static let validator = UsernameValidator()
// Any handle the server can hold, including assigned ones past the claim cap.
private static let validator = UsernameValidator(maximumLength: Username.maximumLength)

@FocusState private var isFocused: Bool
@State private var input: String = ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,5 @@ public enum MentionDetector {

// Swift Regex has no lookbehind, so the preceding character is matched (and excluded from
// the mention's range) instead. Case folds after the match; `Username` stores lowercase.
private nonisolated(unsafe) static let pattern = /(?:^|[^A-Za-z0-9_.@])(@([A-Za-z0-9_]{2,15}))(?![A-Za-z0-9_@])/
private nonisolated(unsafe) static let pattern = /(?:^|[^A-Za-z0-9_.@])(@([A-Za-z0-9_]{2,30}))(?![A-Za-z0-9_@])/
}
7 changes: 5 additions & 2 deletions FlipcashCore/Sources/FlipcashCore/Models/Username.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import FlipcashAPI
/// not just the caller.
public struct Username: Codable, Equatable, Hashable, Sendable {

/// The handle as the server stores it: lowercase, 2–15 characters.
/// The handle as the server stores it: lowercase, 2–30 characters.
public let value: String

/// Returns the handle `string` names, or `nil` when it isn't well formed.
Expand All @@ -39,9 +39,12 @@ public struct Username: Codable, Equatable, Hashable, Sendable {
URL(string: "https://flipcash.com/\(value)")!
}

/// The longest handle the contract allows; `pattern`'s upper bound.
public static let maximumLength = 30

// Mirrors the `validate.rules.string.pattern` on `common.v1.Username`;
// keep the two in step when the contract moves.
private nonisolated(unsafe) static let pattern = /^[a-z0-9_]{2,15}$/
private nonisolated(unsafe) static let pattern = /^[a-z0-9_]{2,30}$/
}

// MARK: - Codable -
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,39 +21,52 @@ public struct UsernameValidator: Validator {
case invalidCharacters
}

/// Mirrors `Username`'s own bounds, which come from the `validate.rules`
/// on `common.v1.Username`. Public because the rejection copy names them.
/// `UsernameValidatorTests` pins them to what `Username` actually accepts,
/// so the two can't drift apart unnoticed.
/// The bounds a claimed handle must fit. The minimum is `Username`'s own; the
/// maximum is a product cap below the 30 that `common.v1.Username` allows, so
/// handles the server assigns can be longer than any a user may claim.
/// Public because the rejection copy names them.
public static let minimumLength = 2
public static let maximumLength = 15

public init() {}
private let lengthCap: Int

/// Holds input to `maximumLength`, or to `Username.maximumLength` for a field that
/// looks up an existing handle rather than claiming one.
public init(maximumLength: Int = Self.maximumLength) {
self.lengthCap = maximumLength
}

/// The handle `input` names once normalised, or `nil` when it isn't well
/// formed. This exact value is what gets submitted; the raw input stays on
/// screen.
public func validate(_ input: String) -> Username? {
Username(normalized(input))
claimable(normalized(input))
}

/// Which rule `input` breaks, or `nil` when it breaks none.
///
/// Asks ``validate(_:)`` first, so a `nil` here and a non-nil there can
/// Runs the same check ``validate(_:)`` does first, so a `nil` here and a non-nil there can
/// never disagree. Past that, length is reported ahead of the character
/// set: "too short" explains a one-letter input better than "invalid
/// characters" would, and an over-long input is over-long whatever it is
/// made of.
public func failure(for input: String) -> Failure? {
let candidate = normalized(input)

guard Username(candidate) == nil else { return nil }
guard claimable(candidate) == nil else { return nil }

if candidate.count < Self.minimumLength { return .tooShort }
if candidate.count > Self.maximumLength { return .tooLong }
if candidate.count > lengthCap { return .tooLong }
return .invalidCharacters
}

/// The handle `candidate` names within this validator's cap. `Username` also accepts the
/// contract's longer server-assigned handles, so a claim's lower cap is held here.
private func claimable(_ candidate: String) -> Username? {
guard candidate.count <= lengthCap else { return nil }
return Username(candidate)
}

/// Lowercased, stripped of surrounding whitespace, and of one leading `@`.
/// A paste carries all three — a handle copied out of a profile arrives as
/// `@taylor` — and none is worth rejecting someone over. Only one `@` goes:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,14 @@ struct ConversationMessageContentEncodingTests {
#expect(Flipcash_Messaging_V1_WidgetContent().shareProfile == nil)
}

@Test("A share-profile widget keeps a handle longer than the claim cap")
func shareProfileWidgetKeepsLongHandle() throws {
let proto = Flipcash_Messaging_V1_WidgetContent.with {
$0.shareProfile = .with { $0.username = .with { $0.value = "ted_livingston_2" } }
}
#expect(ConversationMessage.Widget(proto) == .shareProfile(ShareProfileWidget(username: try #require(Username("ted_livingston_2")))))
}

@Test("A share-profile widget message is kept as a widget")
func widgetMessageIsKept() throws {
let proto = Flipcash_Messaging_V1_Message.with {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,12 @@ struct MentionDetectorTests {
#expect(handles("@jeff@") == [])
}

@Test("Handles outside 2–15 characters are not mentions")
@Test("Handles outside 2–30 characters are not mentions")
func lengthBounds() {
#expect(handles("@a") == [])
#expect(handles("@abcdefghijklmnop") == [])
#expect(handles("@abcdefghijklmno") == ["abcdefghijklmno"])
#expect(handles("@abcdefghijklmnopqrstuvwxyz12345") == [])
#expect(handles("@abcdefghijklmnopqrstuvwxyz1234") == ["abcdefghijklmnopqrstuvwxyz1234"])
#expect(handles("@ted_livingston_2") == ["ted_livingston_2"])
}

@Test("Characters a handle cannot hold end it rather than extend it")
Expand Down
5 changes: 3 additions & 2 deletions FlipcashCore/Tests/FlipcashCoreTests/UsernameTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ struct UsernameTests {
"ted",
"ted_1",
"t_9",
"abcdefghijklmno", // longest allowed, 15 chars
"ted_livingston_2", // 16 chars, past the claim cap but a valid server handle
"abcdefghijklmnopqrstuvwxyz1234", // longest allowed, 30 chars
])
func acceptsWellFormedHandles(input: String) {
#expect(Username(input)?.value == input)
Expand All @@ -27,7 +28,7 @@ struct UsernameTests {
arguments: [
"", // unclaimed, carried as an empty proto value
"a", // too short
"abcdefghijklmnop", // 16 chars, too long
"abcdefghijklmnopqrstuvwxyz12345", // 31 chars, too long
"Ted", // uppercase — the server stores lowercase only
"ted bart", // space
"ted-bart", // hyphen is outside the X character set
Expand Down
17 changes: 15 additions & 2 deletions FlipcashCore/Tests/FlipcashCoreTests/UsernameValidatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,25 @@ struct UsernameValidatorTests {
#expect(Username(belowMinimum) == nil)
}

@Test("The maximum length agrees with what Username itself accepts")
func maximumLength_matchesUsername_boundary() {
@Test("The claim cap sits within what Username accepts, and the validator enforces it")
func maximumLength_withinUsername_boundary() {
let atMaximum = String(repeating: "a", count: UsernameValidator.maximumLength)
let aboveMaximum = String(repeating: "a", count: UsernameValidator.maximumLength + 1)

#expect(Username(atMaximum) != nil)
#expect(Username(aboveMaximum) != nil)
#expect(UsernameValidator().failure(for: aboveMaximum) == .tooLong)
}

@Test("A lookup validator accepts a handle past the claim cap, up to Username's own maximum")
func lookupValidator_acceptsServerLengthHandles() {
let lookup = UsernameValidator(maximumLength: Username.maximumLength)
let atMaximum = String(repeating: "a", count: Username.maximumLength)
let aboveMaximum = String(repeating: "a", count: Username.maximumLength + 1)

#expect(lookup.validate("@Ted_Livingston_2")?.value == "ted_livingston_2")
#expect(lookup.validate(atMaximum) != nil)
#expect(lookup.failure(for: aboveMaximum) == .tooLong)
#expect(Username(aboveMaximum) == nil)
}

Expand Down
7 changes: 4 additions & 3 deletions FlipcashTests/RouteTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,8 @@ struct RouteTests {

@Test("The handle bounds hold at both ends", arguments: [
("https://app.flipcash.com/te", "te"),
("https://app.flipcash.com/abcdefghijklmno", "abcdefghijklmno"),
("https://app.flipcash.com/ted_livingston_2", "ted_livingston_2"),
("https://app.flipcash.com/abcdefghijklmnopqrstuvwxyz1234", "abcdefghijklmnopqrstuvwxyz1234"),
("https://app.flipcash.com/a_1", "a_1"),
])
func usernameRoute_bounds(urlString: String, handle: String) throws {
Expand All @@ -451,10 +452,10 @@ struct RouteTests {
}

@Test("A segment that can't be a handle stays unknown", arguments: [
// One character, sixteen characters, and a hyphen — the three ways the
// One character, thirty-one characters, and a hyphen — the three ways the
// handle pattern is missed.
"https://app.flipcash.com/t",
"https://app.flipcash.com/abcdefghijklmnop",
"https://app.flipcash.com/abcdefghijklmnopqrstuvwxyz12345",
"https://app.flipcash.com/not-a-handle",
// A handle is the whole path or it isn't a handle.
"https://app.flipcash.com/taylor/photos",
Expand Down
Loading