From a4fdcacedd8603b75a21e20ea06ee8e58c5b49a2 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 9 Sep 2026 09:43:06 -0700 Subject: [PATCH] fix(domain,app): share the public permalink, and add Get embed code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Link action shipped in PR #32 handed out `/messages/`, which is an authenticated route: an anonymous visitor is redirected to `/login`, so every link the app produced — including the one pasted into GitHub issues — landed strangers on a sign-in page. Verified live on 2026-09-09 against a public message: GET /messages/d05faf17-… -> 200, final URL /login GET /user/hubcity/status/d05faf17-… -> 200, serves the message `MessagePermalink` now builds `/user//status/`. The author handle becomes a required input and both segments are percent-encoded with the path-segment set (no `/`), so neither an id nor a handle can forge extra path segments. A blank id or handle yields nil, and the UI hides the affordance rather than offering a link that 404s. Adds `embedHTML(forMessageID:authorUsername:base:)`, transcribed verbatim from the web's own share component rather than invented: the `blockquote.il-embed` carrying the raw id, the no-script fallback anchor, and the async `/embed/widgets.js` loader (confirmed live, 200 application/javascript). Every interpolated value is HTML-escaped in the web's exact order, `&` first. `Message.permalink()` / `Message.embedHTML()` also return nil for a private message. Only public messages resolve for a signed-out reader, so a copy action on a private post would produce a link nobody else can open. Every consumer inherits the gate, including the GitHub-issue body, which now carries the attribution line with no URL rather than an unopenable one. App-side, the row's single Link button becomes a Share menu with the web's two options — "Copy link" and "Get embed code" — built from one `shareItems` helper so the action bar and the context menu cannot drift. Both use `SwiftUI.ShareLink`, whose share sheet includes Copy: no NSPasteboard, no AppKit. Closes #38. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Tr46TLA4EzViEZMuvUfzJt --- App/Features/Timeline/MessageRowView.swift | 59 +++-- ...CreateIssueFromMessageViewModelTests.swift | 35 ++- .../Models/MessagePermalink.swift | 148 ++++++++++-- .../MessagePermalinkTests.swift | 226 ++++++++++++++---- 4 files changed, 378 insertions(+), 90 deletions(-) diff --git a/App/Features/Timeline/MessageRowView.swift b/App/Features/Timeline/MessageRowView.swift index b253604..561734d 100644 --- a/App/Features/Timeline/MessageRowView.swift +++ b/App/Features/Timeline/MessageRowView.swift @@ -213,20 +213,20 @@ struct MessageRowView: View { } /// The row's action bar. Order matches the web's message actions: - /// Reply, I Dig!, Push, Push & Comment, Link. + /// Reply, I Dig!, Push, Push & Comment, Share. /// /// Reply / Dig / Push degrade to a plain count label when the host wired /// no handler (search results, previews), so a read-only row still shows - /// the numbers without offering a control that would do nothing. Link is - /// unconditional — it is a pure client-side permalink and needs no host - /// wiring, so it works everywhere the message has an id. + /// the numbers without offering a control that would do nothing. Share + /// needs no host wiring — it is a pure client-side projection — so it + /// appears wherever the message is public and carries an id and a handle. private var footer: some View { HStack(spacing: 16) { replyButton digButton pushButton pushAndCommentButton - linkButton + shareButton if message.visibility == .private { Label("Private", systemImage: "lock") @@ -312,20 +312,45 @@ struct MessageRowView: View { } } - /// Link to this specific post. `SwiftUI.ShareLink` (disambiguated from - /// `InterlinedDomain.ShareLink`) opens the system share sheet, which - /// includes Copy \u{2014} no `NSPasteboard`, no AppKit in the App target. + /// Share this post. Mirrors the web's Share dropdown, which offers exactly + /// two actions (GitHub #38): **Copy link** \u{2014} the public permalink at + /// `/user//status/` \u{2014} and **Get embed code**, the HTML + /// snippet for pasting into a blog post. + /// + /// Both use `SwiftUI.ShareLink` (disambiguated from + /// `InterlinedDomain.ShareLink`), whose system share sheet includes Copy + /// \u{2014} no `NSPasteboard`, no AppKit in the App target. + /// + /// The whole menu disappears for a **private** post: `permalink()` and + /// `embedHTML()` both return nil there, because only public messages + /// resolve for the person on the other end of the link. @ViewBuilder - private var linkButton: some View { - if let url = message.permalink() { - SwiftUI.ShareLink(item: url) { + private var shareButton: some View { + if let url = message.permalink(), let embed = message.embedHTML() { + Menu { + shareItems(url: url, embed: embed) + } label: { Image(systemName: "link") .font(.ilMono(10)) .foregroundStyle(.secondary) } - .buttonStyle(.plain) - .accessibilityLabel("Link to this post") - .help("Share or copy a link to this post") + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .accessibilityLabel("Share this post") + .help("Copy link or get embed code for this post") + } + } + + /// The two share actions, shared verbatim by the action-bar menu and the + /// row's context menu so the two discovery paths can never drift. + @ViewBuilder + private func shareItems(url: URL, embed: String) -> some View { + SwiftUI.ShareLink(item: url) { + Label("Copy link", systemImage: "link") + } + SwiftUI.ShareLink(item: embed) { + Label("Get embed code", systemImage: "chevron.left.forwardslash.chevron.right") } } @@ -388,10 +413,8 @@ struct MessageRowView: View { } } - if let url = message.permalink() { - SwiftUI.ShareLink(item: url) { - Label("Link", systemImage: "link") - } + if let url = message.permalink(), let embed = message.embedHTML() { + shareItems(url: url, embed: embed) } if actions.onReply != nil || actions.onPush != nil || actions.onPushAndComment != nil { diff --git a/AppTests/CreateIssueFromMessageViewModelTests.swift b/AppTests/CreateIssueFromMessageViewModelTests.swift index ed1bc5b..51aa64c 100644 --- a/AppTests/CreateIssueFromMessageViewModelTests.swift +++ b/AppTests/CreateIssueFromMessageViewModelTests.swift @@ -7,8 +7,18 @@ import InterlinedKit @MainActor final class CreateIssueFromMessageViewModelTests: XCTestCase { - private func message(text: String = "First line\nSecond line", username: String = "ada", id: String = "msg-1") -> Message { - MessageFixtures.message(id: id, author: MessageFixtures.author(username: username), text: text) + private func message( + text: String = "First line\nSecond line", + username: String = "ada", + id: String = "msg-1", + visibility: Visibility = .public + ) -> Message { + MessageFixtures.message( + id: id, + author: MessageFixtures.author(username: username), + text: text, + visibility: visibility + ) } private func makeVM(_ stub: StubGitHubService, message: Message? = nil) -> CreateIssueFromMessageViewModel { @@ -26,10 +36,26 @@ final class CreateIssueFromMessageViewModelTests: XCTestCase { XCTAssertEqual(vm.title, "Fix the parser") XCTAssertTrue(vm.body.contains("more detail")) - XCTAssertTrue(vm.body.contains("https://example.test/messages/abc")) + // GitHub #38: the body must carry the **public** permalink. The old + // /messages/ form bounced anyone reading the issue to /login. + XCTAssertTrue(vm.body.contains("https://example.test/user/ada/status/abc")) + XCTAssertFalse(vm.body.contains("/messages/abc")) XCTAssertTrue(vm.body.contains("@ada")) } + func test_givenPrivateMessage_whenInit_thenBodyCarriesAttributionButNoLink() { + // Given a private message turned into an issue. + let vm = makeVM( + StubGitHubService(), + message: message(text: "Fix the parser", username: "ada", id: "abc", visibility: .private) + ) + + // Then the attribution line survives but no URL is pasted — a private + // post resolves for nobody reading the issue (GitHub #38). + XCTAssertTrue(vm.body.contains("From @ada on InterlinedList")) + XCTAssertFalse(vm.body.contains("https://example.test")) + } + func test_givenEmptyMessage_whenInit_thenTitleFallsBackToAuthor() { let vm = makeVM(StubGitHubService(), message: message(text: " ", username: "grace")) XCTAssertEqual(vm.title, "Post by @grace") @@ -102,7 +128,8 @@ final class CreateIssueFromMessageViewModelTests: XCTestCase { XCTAssertEqual(vm.createdIssue?.number, 55) XCTAssertEqual(stub.createdDrafts.first?.title, "Fix the parser") - XCTAssertTrue(stub.createdDrafts.first?.body?.contains("example.test/messages") ?? false) + // GitHub #38: what actually reaches GitHub must be the public permalink. + XCTAssertTrue(stub.createdDrafts.first?.body?.contains("example.test/user/ada/status/msg-1") ?? false) XCTAssertNil(vm.error) } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/MessagePermalink.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/MessagePermalink.swift index fb8d7ae..779eaff 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/MessagePermalink.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/MessagePermalink.swift @@ -1,15 +1,26 @@ import Foundation -/// Canonical web permalink for a message ("Link" in the web message actions). +/// Canonical public web permalink for a message, plus the embed snippet that +/// sits beside it in the web's Share menu ("Copy link" / "Get embed code"). /// -/// The link is a pure client-side projection — there is no API route that -/// hands back a message URL — so the shape lives here in the domain rather -/// than being re-derived by each feature that needs it. Three App-layer -/// surfaces consume it: the message row's Link action, the "Push & Comment" -/// body, and `CreateIssueFromMessageViewModel`'s issue body, which previously -/// owned a private copy of this logic. +/// Both are pure client-side projections — there is no API route that hands +/// back a message URL or an embed blob — so the shapes live here in the domain +/// rather than being re-derived by each feature that needs them. Three +/// App-layer surfaces consume them: the message row's Share menu, the same +/// menu mirrored into the row's context menu, and +/// `CreateIssueFromMessageViewModel`'s issue body. /// -/// Shape: `/messages/` — matching the web app's own route. +/// Shape: `/user//status/`. +/// +/// GitHub #38 — this used to build `/messages/`, which is an +/// **authenticated** route: anonymous visitors are redirected to `/login`, so +/// every link the app handed out (including the one pasted into GitHub issues) +/// landed strangers on a sign-in page. Verified live on 2026-09-09: +/// +/// GET /messages/d05faf17-… -> 200, final URL /login +/// GET /user/hubcity/status/d05faf17-… -> 200, serves the message +/// +/// The shapes below mirror the web's own share component verbatim. public enum MessagePermalink { /// Production web front-end. Overridable at every call site so tests and @@ -17,36 +28,127 @@ public enum MessagePermalink { public static let defaultWebBaseURL = URL(string: "https://interlinedlist.com")! /// Path-segment-safe character set: `urlPathAllowed` still permits `/`, - /// which would let an id containing a slash silently forge extra path - /// segments. Removing it forces such an id to percent-encode instead. + /// which would let an id or username containing a slash silently forge + /// extra path segments. Removing it forces such a value to percent-encode. private static let pathSegmentAllowed: CharacterSet = { var set = CharacterSet.urlPathAllowed set.remove("/") return set }() - /// Builds the permalink for `id`, or `nil` when the id is empty / blank - /// or cannot be encoded. Returning `nil` rather than a half-formed URL - /// lets the UI hide the affordance instead of offering a broken link. - public static func url(forMessageID id: String, base: URL = defaultWebBaseURL) -> URL? { - let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) else { + /// Builds the public permalink for `id` authored by `authorUsername`, or + /// `nil` when either component is empty / blank or cannot be encoded. + /// + /// The author handle is a **required** input rather than an optional with a + /// fallback: without it there is no valid public route at all, and + /// returning `nil` lets the UI hide the affordance instead of offering a + /// link that 404s. + /// + /// Note: the web encodes only the username and interpolates the id raw. We + /// encode both — for every server-issued id (a UUID) the result is + /// identical, and encoding closes the path-forging hole for a malformed one. + public static func url( + forMessageID id: String, + authorUsername: String, + base: URL = defaultWebBaseURL + ) -> URL? { + guard + let encodedID = encodedSegment(id), + let encodedUsername = encodedSegment(authorUsername) + else { return nil } + return URL(string: "\(normalizedOrigin(base))/user/\(encodedUsername)/status/\(encodedID)") + } + + /// Builds the HTML snippet the web's "Get embed code" action copies, or + /// `nil` when the permalink itself cannot be formed. + /// + /// The markup is **transcribed from the web's own share component**, not + /// invented: a `blockquote.il-embed` carrying the raw message id, a + /// fallback anchor for readers whose page never runs the script, and the + /// async `/embed/widgets.js` loader that upgrades the blockquote into the + /// rendered card. `/embed/widgets.js` was confirmed live (200, + /// `application/javascript`) on 2026-09-09. + /// + /// Every interpolated value is HTML-escaped exactly as the web escapes it + /// (`&` first, so an already-escaped entity can't be produced), because the + /// id, the URL and the origin all land inside double-quoted attributes. + public static func embedHTML( + forMessageID id: String, + authorUsername: String, + base: URL = defaultWebBaseURL + ) -> String? { + guard let canonical = url(forMessageID: id, authorUsername: authorUsername, base: base) else { return nil } - // Normalise the base so a caller-supplied trailing slash can't produce - // a double slash in the middle of the path. + // The web writes the *raw* (trimmed) id into `data-message-id`, not the + // percent-encoded path segment — the widget matches on the id it was + // given, so keep them identical. + let escapedID = htmlEscaped(id.trimmingCharacters(in: .whitespacesAndNewlines)) + let escapedURL = htmlEscaped(canonical.absoluteString) + let escapedOrigin = htmlEscaped(normalizedOrigin(base)) + return """ +
+ View this message on InterlinedList +
+ + """ + } + + // MARK: - Helpers + + /// Trims, rejects blank, then percent-encodes one path segment. + private static func encodedSegment(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) + } + + /// Strips trailing slashes from the base so a caller-supplied one can't + /// produce a double slash in the middle of the path. Mirrors the web's + /// own `origin.replace(/\/+$/, "")`. + private static func normalizedOrigin(_ base: URL) -> String { var stem = base.absoluteString while stem.hasSuffix("/") { stem.removeLast() } - return URL(string: "\(stem)/messages/\(encoded)") + return stem + } + + /// The web's escape function, character-for-character and in the same + /// order — `&` must be replaced first or the later entities get mangled. + private static func htmlEscaped(_ raw: String) -> String { + raw + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") } } public extension Message { - /// This message's canonical web permalink, or `nil` when the id can't - /// form one. See `MessagePermalink`. + /// This message's **shareable** public permalink, or `nil` when it cannot + /// be handed to anyone else. + /// + /// `nil` in two cases, and both are deliberate (GitHub #38): + /// + /// - the author handle or the id is missing / blank, so no valid route + /// exists; + /// - the message is **private**. Only public messages resolve for a + /// signed-out visitor, so a copy action on a private post would produce a + /// link nobody else can open — worse than offering no action at all. + /// + /// Every caller therefore gets the visibility gate for free, including the + /// GitHub-issue body, which must not embed a link that 404s for a reader. func permalink(base: URL = MessagePermalink.defaultWebBaseURL) -> URL? { - MessagePermalink.url(forMessageID: id, base: base) + guard visibility == .public else { return nil } + return MessagePermalink.url(forMessageID: id, authorUsername: author.username, base: base) + } + + /// The HTML snippet for embedding this message in a page, or `nil` under + /// exactly the same conditions as `permalink(base:)`. See + /// `MessagePermalink.embedHTML(forMessageID:authorUsername:base:)`. + func embedHTML(base: URL = MessagePermalink.defaultWebBaseURL) -> String? { + guard visibility == .public else { return nil } + return MessagePermalink.embedHTML(forMessageID: id, authorUsername: author.username, base: base) } } diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagePermalinkTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagePermalinkTests.swift index 88565db..d0ec4d9 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagePermalinkTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagePermalinkTests.swift @@ -1,33 +1,77 @@ import XCTest @testable import InterlinedDomain -/// BDD coverage for `MessagePermalink` (GitHub #27 — the "Link" message -/// action). The builder is pure and synchronous, so the quartet is -/// happy / invalid / upstream-shape / boundary with no service doubles. +/// BDD coverage for `MessagePermalink` (GitHub #38 — the public permalink and +/// the "Get embed code" snippet behind the row's Share menu; originally added +/// for #27 against the wrong `/messages/` route). +/// +/// The builder is pure and synchronous, so the quartet is +/// happy / invalid / no-public-surface / boundary with no service doubles. The +/// "upstream failure" slot has no meaning for a projection with no I/O; per the +/// issue it is spent instead on the case that actually bites — a private +/// message must offer no link and no embed at all. final class MessagePermalinkTests: XCTestCase { // MARK: - Happy path - func test_givenWellFormedID_whenBuildingPermalink_thenUsesMessagesRoute() { - // Given a normal server-issued id. - let id = "cmg1a2b3c4d5" - + func test_givenPublicMessage_whenBuildingPermalink_thenUsesPublicUserStatusRoute() { + // Given a normal server-issued id and author handle. // When we build the permalink against the production base. - let url = MessagePermalink.url(forMessageID: id) + let url = MessagePermalink.url(forMessageID: "cmg1a2b3c4d5", authorUsername: "hubcity") - // Then it matches the web app's own /messages/ route. - XCTAssertEqual(url?.absoluteString, "https://interlinedlist.com/messages/cmg1a2b3c4d5") + // Then it is the route that serves a signed-out visitor, verified live: + // /user//status/ answers 200 while /messages/ redirects + // to /login. + XCTAssertEqual( + url?.absoluteString, + "https://interlinedlist.com/user/hubcity/status/cmg1a2b3c4d5" + ) } - func test_givenMessage_whenAskedForPermalink_thenMatchesBuilder() { - // Given a domain message. - let message = Self.makeMessage(id: "abc123") + func test_givenPublicMessage_whenAskedForPermalink_thenMatchesBuilder() { + // Given a public domain message. + let message = Self.makeMessage(id: "abc123", username: "ada") // When we ask the message itself. let fromMessage = message.permalink() // Then it agrees with the standalone builder — one definition, not two. - XCTAssertEqual(fromMessage, MessagePermalink.url(forMessageID: "abc123")) + XCTAssertEqual( + fromMessage, + MessagePermalink.url(forMessageID: "abc123", authorUsername: "ada") + ) + XCTAssertEqual( + fromMessage?.absoluteString, + "https://interlinedlist.com/user/ada/status/abc123" + ) + } + + func test_givenPublicMessage_whenBuildingEmbedHTML_thenMatchesTheWebSnippetVerbatim() { + // Given the same message the web would embed. + // When we build the embed code. + let html = MessagePermalink.embedHTML(forMessageID: "abc123", authorUsername: "ada") + + // Then it is the web's own snippet, character for character: the + // il-embed blockquote carrying the raw id, the no-script fallback + // anchor, and the async widgets loader. + XCTAssertEqual(html, """ +
+ View this message on InterlinedList +
+ + """) + } + + func test_givenPublicMessage_whenAskedForEmbedHTML_thenMatchesBuilder() { + // Given a public domain message. + let message = Self.makeMessage(id: "abc123", username: "ada") + + // When / Then — the convenience mirrors the builder rather than + // re-deriving the markup. + XCTAssertEqual( + message.embedHTML(), + MessagePermalink.embedHTML(forMessageID: "abc123", authorUsername: "ada") + ) } // MARK: - Invalid input @@ -35,26 +79,82 @@ final class MessagePermalinkTests: XCTestCase { func test_givenEmptyID_whenBuildingPermalink_thenReturnsNil() { // Given an empty id (a message that never round-tripped the server). // When / Then — no half-formed URL is produced; the UI hides the action. - XCTAssertNil(MessagePermalink.url(forMessageID: "")) + XCTAssertNil(MessagePermalink.url(forMessageID: "", authorUsername: "ada")) + XCTAssertNil(MessagePermalink.embedHTML(forMessageID: "", authorUsername: "ada")) } func test_givenWhitespaceOnlyID_whenBuildingPermalink_thenReturnsNil() { // Given an id that is only whitespace. - // When / Then — treated the same as empty rather than linking to /messages/%20. - XCTAssertNil(MessagePermalink.url(forMessageID: " \n ")) + // When / Then — treated the same as empty rather than linking to a + // /status/%20 route that 404s. + XCTAssertNil(MessagePermalink.url(forMessageID: " \n ", authorUsername: "ada")) + } + + func test_givenMissingAuthorHandle_whenBuildingPermalink_thenReturnsNil() { + // Given a message projection that dropped the author handle. + // When / Then — the public route needs the handle, so there is no link + // to offer and the affordance is hidden rather than broken. + XCTAssertNil(MessagePermalink.url(forMessageID: "abc123", authorUsername: "")) + XCTAssertNil(MessagePermalink.embedHTML(forMessageID: "abc123", authorUsername: "")) + } + + func test_givenBlankAuthorHandle_whenBuildingPermalink_thenReturnsNil() { + // Given a whitespace-only handle. + // When / Then — same as missing. + XCTAssertNil(MessagePermalink.url(forMessageID: "abc123", authorUsername: " \t ")) } - // MARK: - Upstream shape (caller-supplied base) + // MARK: - No public surface (the "upstream failure" slot for a pure projection) + + func test_givenPrivateMessage_whenAskedForPermalink_thenOffersNoLink() { + // Given a private message. + let message = Self.makeMessage(id: "abc123", username: "ada", visibility: .private) + + // When / Then — only public messages resolve for a signed-out reader, + // so the row offers no Link action at all rather than copying a URL + // that shows the recipient nothing. + XCTAssertNil(message.permalink()) + } + + func test_givenPrivateMessage_whenAskedForEmbedHTML_thenOffersNoEmbed() { + // Given the same private message. + let message = Self.makeMessage(id: "abc123", username: "ada", visibility: .private) + + // When / Then — an embed of a private post renders nothing for a + // visitor, so the action is withheld too. + XCTAssertNil(message.embedHTML()) + } + + func test_givenPrivateMessage_whenBuildingViaRawBuilder_thenStillBuilds() { + // Given the raw builder, which knows ids and handles but not privacy. + // When / Then — the visibility rule lives on `Message`, keeping the + // builder a pure string projection the owner's own surfaces can reuse. + XCTAssertNotNil(MessagePermalink.url(forMessageID: "abc123", authorUsername: "ada")) + } + + // MARK: - Caller-supplied base func test_givenBaseWithTrailingSlash_whenBuildingPermalink_thenNoDoubleSlash() { // Given a base URL a caller wrote with a trailing slash. let base = URL(string: "https://staging.interlinedlist.com/")! // When we build against it. - let url = MessagePermalink.url(forMessageID: "xyz", base: base) + let url = MessagePermalink.url(forMessageID: "xyz", authorUsername: "ada", base: base) + + // Then the path is normalised rather than containing "//user". + XCTAssertEqual(url?.absoluteString, "https://staging.interlinedlist.com/user/ada/status/xyz") + } + + func test_givenBaseWithTrailingSlash_whenBuildingEmbedHTML_thenScriptSrcIsNormalised() { + // Given the same trailing-slash base. + let base = URL(string: "https://staging.interlinedlist.com/")! + + // When we build the embed code. + let html = MessagePermalink.embedHTML(forMessageID: "xyz", authorUsername: "ada", base: base) - // Then the path is normalised rather than containing "//messages". - XCTAssertEqual(url?.absoluteString, "https://staging.interlinedlist.com/messages/xyz") + // Then the widgets loader is not requested from a doubled slash — the + // web strips trailing slashes from the origin the same way. + XCTAssertEqual(html?.contains(#"src="https://staging.interlinedlist.com/embed/widgets.js""#), true) } func test_givenBaseWithSubpath_whenBuildingPermalink_thenSubpathIsPreserved() { @@ -62,56 +162,92 @@ final class MessagePermalinkTests: XCTestCase { let base = URL(string: "https://example.test/app")! // When we build against it. - let url = MessagePermalink.url(forMessageID: "xyz", base: base) + let url = MessagePermalink.url(forMessageID: "xyz", authorUsername: "ada", base: base) // Then the prefix survives — the builder appends, it does not replace. - XCTAssertEqual(url?.absoluteString, "https://example.test/app/messages/xyz") + XCTAssertEqual(url?.absoluteString, "https://example.test/app/user/ada/status/xyz") } // MARK: - Boundary - func test_givenIDNeedingPercentEncoding_whenBuildingPermalink_thenEncoded() { - // Given an id carrying characters that are illegal in a path segment. - let id = "a b#c" - + func test_givenHandleAndIDNeedingPercentEncoding_whenBuildingPermalink_thenBothEncoded() { + // Given a handle and an id carrying characters illegal in a path segment. // When we build the permalink. - let url = MessagePermalink.url(forMessageID: id) + let url = MessagePermalink.url(forMessageID: "a b#c", authorUsername: "adá x") - // Then they are percent-encoded rather than truncating the URL at the "#". - XCTAssertEqual(url?.absoluteString, "https://interlinedlist.com/messages/a%20b%23c") + // Then both segments are percent-encoded rather than truncating the URL + // at the "#" or breaking on the space. + XCTAssertEqual( + url?.absoluteString, + "https://interlinedlist.com/user/ad%C3%A1%20x/status/a%20b%23c" + ) } - func test_givenIDContainingSlash_whenBuildingPermalink_thenSlashIsEncodedNotForged() { - // Given a hostile / malformed id containing a path separator. - let id = "abc/../admin" - + func test_givenSlashInIDOrHandle_whenBuildingPermalink_thenSlashIsEncodedNotForged() { + // Given hostile / malformed values containing a path separator. // When we build the permalink. - let url = MessagePermalink.url(forMessageID: id) + let url = MessagePermalink.url(forMessageID: "abc/../admin", authorUsername: "ada/../root") - // Then the slashes are encoded — the id can never forge extra path - // segments, so the link always points inside /messages/. - XCTAssertEqual(url?.absoluteString, "https://interlinedlist.com/messages/abc%2F..%2Fadmin") + // Then the slashes are encoded — neither value can forge extra path + // segments, so the link always points inside /user/…/status/. + XCTAssertEqual( + url?.absoluteString, + "https://interlinedlist.com/user/ada%2F..%2Froot/status/abc%2F..%2Fadmin" + ) } - func test_givenIDWithSurroundingWhitespace_whenBuildingPermalink_thenTrimmed() { - // Given an id padded by whitespace. + func test_givenSurroundingWhitespace_whenBuildingPermalink_thenTrimmed() { + // Given an id and handle padded by whitespace. // When / Then — trimmed, not encoded as %20 padding. XCTAssertEqual( - MessagePermalink.url(forMessageID: " abc ")?.absoluteString, - "https://interlinedlist.com/messages/abc" + MessagePermalink.url(forMessageID: " abc ", authorUsername: " ada ")?.absoluteString, + "https://interlinedlist.com/user/ada/status/abc" + ) + } + + func test_givenHTMLSensitiveValues_whenBuildingEmbedHTML_thenEveryAttributeIsEscaped() { + // Given an id that would otherwise break out of its attribute. + let html = MessagePermalink.embedHTML( + forMessageID: #"a">&"#, + authorUsername: "ada" + ) + + // Then the raw id is HTML-escaped inside data-message-id — no quote + // closes the attribute and no tag is injected — and "&" is escaped + // first so the other entities are not double-mangled. + XCTAssertEqual( + html?.contains(#"data-message-id="a"><script>x</script>&""#), + true ) + XCTAssertEqual(html?.contains(""), false) + } + + func test_givenBaseWithQuerySensitiveCharacters_whenBuildingEmbedHTML_thenHrefIsEscaped() { + // Given a base whose ampersand would split the href attribute. + let base = URL(string: "https://example.test/app?a=1&b=2")! + + // When we build the embed code. + let html = MessagePermalink.embedHTML(forMessageID: "id1", authorUsername: "ada", base: base) + + // Then the ampersand is entity-escaped in both the href and the script src. + XCTAssertEqual(html?.contains("&b=2"), true) + XCTAssertEqual(html?.contains("?a=1&b=2"), false) } // MARK: - Helpers - private static func makeMessage(id: String) -> Message { + private static func makeMessage( + id: String, + username: String, + visibility: Visibility = .public + ) -> Message { Message( id: id, - author: UserSummary(id: "u1", username: "adron", displayName: "Adron"), + author: UserSummary(id: "u1", username: username, displayName: "Ada"), text: "hello", createdAt: Date(timeIntervalSince1970: 0), updatedAt: Date(timeIntervalSince1970: 0), - visibility: .public, + visibility: visibility, digCount: 0, didDig: false, repostCount: 0