From e46f0f175c1af1bc4b2e70ab716002ae3f3138d3 Mon Sep 17 00:00:00 2001 From: Favour Date: Tue, 29 Sep 2026 12:15:33 +0100 Subject: [PATCH] feat: add snippet sharing modal --- app/api/share-links/[id]/route.test.ts | 84 +++++ app/api/share-links/[id]/route.ts | 28 ++ app/api/share-links/route.test.ts | 116 ++++++ app/api/share-links/route.ts | 21 ++ components/ShareSnippetModal.tsx | 330 ++++++++++++++++++ components/SnippetDetailModal.tsx | 43 ++- .../__tests__/ShareSnippetModal.test.tsx | 159 +++++++++ .../__tests__/SnippetDetailSharing.test.tsx | 83 +++++ components/ui/button.tsx | 4 +- lib/share-link-management.service.test.ts | 15 + lib/share-link-management.service.ts | 9 + 11 files changed, 884 insertions(+), 8 deletions(-) create mode 100644 app/api/share-links/[id]/route.test.ts create mode 100644 app/api/share-links/route.test.ts create mode 100644 components/ShareSnippetModal.tsx create mode 100644 components/__tests__/ShareSnippetModal.test.tsx create mode 100644 components/__tests__/SnippetDetailSharing.test.tsx diff --git a/app/api/share-links/[id]/route.test.ts b/app/api/share-links/[id]/route.test.ts new file mode 100644 index 0000000..179c688 --- /dev/null +++ b/app/api/share-links/[id]/route.test.ts @@ -0,0 +1,84 @@ +jest.mock("next/server", () => ({ + NextRequest: class MockNextRequest { + public headers: Headers; + constructor(_input: string, init?: RequestInit) { + this.headers = new Headers(init?.headers); + } + }, + NextResponse: { + json: (body: unknown, init?: { status?: number }) => ({ + status: init?.status ?? 200, + json: async () => body, + }), + }, +})); + +jest.mock("@/app/api/snippets/ownership.middleware", () => { + const mocks = { verifyOwnership: jest.fn() }; + const OwnershipMiddleware = class { + static extractWalletAddress = jest.fn(); + verifyOwnership = mocks.verifyOwnership; + }; + (OwnershipMiddleware as any).__verifyOwnership = mocks.verifyOwnership; + return { OwnershipMiddleware }; +}); + +jest.mock("@/lib/share-link-management.service", () => ({ + shareLinkManagementService: { + getShareLinkById: jest.fn(), + revokeShareLink: jest.fn(), + }, +})); + +import { NextRequest } from "next/server"; +import { OwnershipMiddleware } from "@/app/api/snippets/ownership.middleware"; +import { shareLinkManagementService } from "@/lib/share-link-management.service"; +import { DELETE } from "./route"; + +const WALLET = "GCRKPWEEZPKBMQ7L3FAKKZL7TPJBKEHIWUBMN554ASGZKDJXJ7FCXRRU"; +const service = shareLinkManagementService as jest.Mocked; +const verifyOwnership = (OwnershipMiddleware as any).__verifyOwnership as jest.Mock; + +function makeRequest(): NextRequest { + return new (NextRequest as any)("http://localhost:3000/api/share-links/link-1", { + method: "DELETE", + headers: { "x-wallet-address": WALLET }, + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + (OwnershipMiddleware.extractWalletAddress as jest.Mock).mockResolvedValue(WALLET); + verifyOwnership.mockResolvedValue({ isOwner: true }); + service.getShareLinkById.mockResolvedValue({ id: "link-1", snippetId: "snippet-1" } as any); +}); + +describe("DELETE /api/share-links/[id]", () => { + it("rejects revocation without an authenticated wallet", async () => { + (OwnershipMiddleware.extractWalletAddress as jest.Mock).mockResolvedValue(null); + + const result = await DELETE(makeRequest(), { params: Promise.resolve({ id: "link-1" }) }); + + expect(result.status).toBe(401); + expect(service.revokeShareLink).not.toHaveBeenCalled(); + }); + + it("rejects revocation by a non-owner", async () => { + verifyOwnership.mockResolvedValue({ isOwner: false }); + + const result = await DELETE(makeRequest(), { params: Promise.resolve({ id: "link-1" }) }); + + expect(result.status).toBe(403); + expect(service.revokeShareLink).not.toHaveBeenCalled(); + }); + + it("revokes a link after verifying ownership of its snippet", async () => { + service.revokeShareLink.mockResolvedValue({ id: "link-1", status: "revoked" } as any); + + const result = await DELETE(makeRequest(), { params: Promise.resolve({ id: "link-1" }) }); + + expect(result.status).toBe(200); + expect(verifyOwnership).toHaveBeenCalledWith("snippet-1", WALLET); + expect(service.revokeShareLink).toHaveBeenCalledWith("link-1", WALLET); + }); +}); diff --git a/app/api/share-links/[id]/route.ts b/app/api/share-links/[id]/route.ts index b47c7e9..93943fb 100644 --- a/app/api/share-links/[id]/route.ts +++ b/app/api/share-links/[id]/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { OwnershipMiddleware } from "@/app/api/snippets/ownership.middleware"; import { shareLinkManagementService } from "@/lib/share-link-management.service"; +const ownershipMiddleware = new OwnershipMiddleware(); + /** * DELETE /api/share-links/:id * Immediately invalidates a share link. @@ -14,6 +16,32 @@ export async function DELETE( const { id } = await params; const walletAddress = await OwnershipMiddleware.extractWalletAddress(req); + if (!walletAddress) { + return NextResponse.json( + { success: false, error: "Wallet address is required" }, + { status: 401 }, + ); + } + + const existing = await shareLinkManagementService.getShareLinkById(id); + if (!existing) { + return NextResponse.json( + { success: false, error: "Share link not found" }, + { status: 404 }, + ); + } + + const ownership = await ownershipMiddleware.verifyOwnership( + existing.snippetId, + walletAddress, + ); + if (!ownership.isOwner) { + return ownership.error ?? NextResponse.json( + { success: false, error: "Only the snippet owner can revoke share links" }, + { status: 403 }, + ); + } + const link = await shareLinkManagementService.revokeShareLink( id, walletAddress, diff --git a/app/api/share-links/route.test.ts b/app/api/share-links/route.test.ts new file mode 100644 index 0000000..40630f4 --- /dev/null +++ b/app/api/share-links/route.test.ts @@ -0,0 +1,116 @@ +jest.mock("next/server", () => ({ + NextRequest: class MockNextRequest { + public headers: Headers; + public url: string; + private body: string | null; + + constructor(input: string, init?: RequestInit) { + this.url = input; + this.headers = new Headers(init?.headers); + this.body = (init?.body as string | null) ?? null; + } + + async json() { + return this.body ? JSON.parse(this.body) : {}; + } + }, + NextResponse: { + json: (body: unknown, init?: { status?: number }) => ({ + status: init?.status ?? 200, + json: async () => body, + }), + }, +})); + +jest.mock("@/app/api/snippets/ownership.middleware", () => { + const mocks = { verifyOwnership: jest.fn() }; + const OwnershipMiddleware = class { + static extractWalletAddress = jest.fn(); + verifyOwnership = mocks.verifyOwnership; + }; + (OwnershipMiddleware as any).__verifyOwnership = mocks.verifyOwnership; + return { OwnershipMiddleware }; +}); + +jest.mock("@/lib/share-link-management.service", () => ({ + shareLinkManagementService: { + createShareLink: jest.fn(), + listActiveShareLinks: jest.fn(), + }, +})); + +import { NextRequest } from "next/server"; +import { OwnershipMiddleware } from "@/app/api/snippets/ownership.middleware"; +import { shareLinkManagementService } from "@/lib/share-link-management.service"; +import { GET, POST } from "./route"; + +const WALLET = "GCRKPWEEZPKBMQ7L3FAKKZL7TPJBKEHIWUBMN554ASGZKDJXJ7FCXRRU"; +const service = shareLinkManagementService as jest.Mocked; +const verifyOwnership = (OwnershipMiddleware as any).__verifyOwnership as jest.Mock; + +function makeRequest(method: string, body?: unknown): NextRequest { + return new (NextRequest as any)( + `http://localhost:3000/api/share-links${method === "GET" ? "?snippetId=snippet-1" : ""}`, + { + method, + headers: { "Content-Type": "application/json", "x-wallet-address": WALLET }, + body: body === undefined ? undefined : JSON.stringify(body), + }, + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + (OwnershipMiddleware.extractWalletAddress as jest.Mock).mockResolvedValue(WALLET); + verifyOwnership.mockResolvedValue({ isOwner: true }); +}); + +describe("share-link collection routes", () => { + it("rejects link creation without an authenticated wallet", async () => { + (OwnershipMiddleware.extractWalletAddress as jest.Mock).mockResolvedValue(null); + + const result = await POST(makeRequest("POST", { snippetId: "snippet-1" })); + + expect(result.status).toBe(401); + expect(service.createShareLink).not.toHaveBeenCalled(); + }); + + it("rejects link creation by a non-owner", async () => { + verifyOwnership.mockResolvedValue({ isOwner: false }); + + const result = await POST(makeRequest("POST", { snippetId: "snippet-1" })); + + expect(result.status).toBe(403); + expect(service.createShareLink).not.toHaveBeenCalled(); + }); + + it("creates a link after verifying snippet ownership", async () => { + const link = { id: "link-1", snippetId: "snippet-1", visibility: "read-only" }; + service.createShareLink.mockResolvedValue(link as any); + + const result = await POST(makeRequest("POST", { + snippetId: "snippet-1", + visibility: "read-only", + })); + + expect(result.status).toBe(201); + expect(verifyOwnership).toHaveBeenCalledWith("snippet-1", WALLET); + expect(service.createShareLink).toHaveBeenCalledWith({ + snippetId: "snippet-1", + visibility: "read-only", + expiresAt: null, + createdBy: WALLET, + }); + }); + + it("only lists active links for the snippet owner", async () => { + service.listActiveShareLinks.mockResolvedValue([]); + + const result = await GET(makeRequest("GET")); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body).toEqual({ success: true, data: [], count: 0 }); + expect(verifyOwnership).toHaveBeenCalledWith("snippet-1", WALLET); + }); +}); diff --git a/app/api/share-links/route.ts b/app/api/share-links/route.ts index 40b4065..1b5fb8e 100644 --- a/app/api/share-links/route.ts +++ b/app/api/share-links/route.ts @@ -5,6 +5,8 @@ import { ShareLinkVisibility, } from "@/lib/share-link-management.service"; +const ownershipMiddleware = new OwnershipMiddleware(); + function jsonError(message: string, status: number) { return NextResponse.json( { success: false, error: message }, @@ -32,6 +34,15 @@ export async function POST(req: NextRequest) { return jsonError("visibility must be 'read-only' or 'read-write'", 400); } + if (!walletAddress) { + return jsonError("Wallet address is required", 401); + } + + const ownership = await ownershipMiddleware.verifyOwnership(snippetId, walletAddress); + if (!ownership.isOwner) { + return ownership.error ?? jsonError("Only the snippet owner can create share links", 403); + } + const link = await shareLinkManagementService.createShareLink({ snippetId, visibility, @@ -60,6 +71,7 @@ export async function POST(req: NextRequest) { */ export async function GET(req: NextRequest) { try { + const walletAddress = await OwnershipMiddleware.extractWalletAddress(req); const { searchParams } = new URL(req.url); const snippetId = searchParams.get("snippetId") || ""; @@ -67,6 +79,15 @@ export async function GET(req: NextRequest) { return jsonError("snippetId query parameter is required", 400); } + if (!walletAddress) { + return jsonError("Wallet address is required", 401); + } + + const ownership = await ownershipMiddleware.verifyOwnership(snippetId, walletAddress); + if (!ownership.isOwner) { + return ownership.error ?? jsonError("Only the snippet owner can list share links", 403); + } + const links = await shareLinkManagementService.listActiveShareLinks(snippetId); return NextResponse.json({ success: true, diff --git a/components/ShareSnippetModal.tsx b/components/ShareSnippetModal.tsx new file mode 100644 index 0000000..3e1f66f --- /dev/null +++ b/components/ShareSnippetModal.tsx @@ -0,0 +1,330 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Check, Copy, Link2, Loader2, Shield, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { useWallet } from "./WalletConnect"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; + +export type ShareVisibility = "read-only" | "read-write"; + +export interface ShareLink { + id: string; + snippetId: string; + shareUrl: string; + visibility: ShareVisibility; + createdAt: string; + status: "active" | "expired" | "revoked"; +} + +interface ShareSnippetModalProps { + snippetId: string; + snippetTitle?: string; + isOpen: boolean; + onClose: () => void; +} + +interface ApiResponse { + success: boolean; + data?: T; + error?: string; +} + +async function copyToClipboard(value: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return; + } catch { + // Fall through for browsers that expose Clipboard API but deny access. + } + } + + const input = document.createElement("textarea"); + input.value = value; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + const copied = document.execCommand?.("copy") ?? false; + document.body.removeChild(input); + if (!copied) throw new Error("Clipboard access is unavailable"); +} + +export function ShareSnippetModal({ + snippetId, + snippetTitle, + isOpen, + onClose, +}: ShareSnippetModalProps) { + const wallet = useWallet(); + const [visibility, setVisibility] = useState("read-only"); + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [revokingId, setRevokingId] = useState(null); + const [copiedId, setCopiedId] = useState(null); + const [feedback, setFeedback] = useState(""); + const [error, setError] = useState(""); + + const requestHeaders = useCallback((): HeadersInit => ({ + "Content-Type": "application/json", + ...(wallet.publicKey ? { "x-wallet-address": wallet.publicKey } : {}), + ...(wallet.token ? { Authorization: `Bearer ${wallet.token}` } : {}), + }), [wallet.publicKey, wallet.token]); + + const loadLinks = useCallback(async () => { + if (!wallet.publicKey) { + setError("Connect the owner wallet to manage share links."); + setLinks([]); + return; + } + + setLoading(true); + setError(""); + try { + const response = await fetch( + `/api/share-links?snippetId=${encodeURIComponent(snippetId)}`, + { headers: requestHeaders() }, + ); + const body = await response.json() as ApiResponse; + if (!response.ok || !body.success) { + throw new Error(body.error || "Failed to load share links"); + } + setLinks(body.data ?? []); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Failed to load share links"); + } finally { + setLoading(false); + } + }, [requestHeaders, snippetId, wallet.publicKey]); + + useEffect(() => { + if (!isOpen) { + setFeedback(""); + setError(""); + setCopiedId(null); + return; + } + void loadLinks(); + }, [isOpen, loadLinks]); + + const createLink = async () => { + if (!wallet.publicKey) { + setError("Connect the owner wallet to create a share link."); + return; + } + + setCreating(true); + setError(""); + setFeedback(""); + try { + const response = await fetch("/api/share-links", { + method: "POST", + headers: requestHeaders(), + body: JSON.stringify({ snippetId, visibility }), + }); + const body = await response.json() as ApiResponse; + if (!response.ok || !body.success || !body.data) { + throw new Error(body.error || "Failed to generate share link"); + } + setLinks((current) => [body.data!, ...current]); + setFeedback("Share link created."); + toast.success("Share link created"); + } catch (reason) { + const message = reason instanceof Error ? reason.message : "Failed to generate share link"; + setError(message); + toast.error(message); + } finally { + setCreating(false); + } + }; + + const copyLink = async (link: ShareLink) => { + try { + await copyToClipboard(link.shareUrl); + setCopiedId(link.id); + setFeedback("Link copied!"); + toast.success("Link copied to clipboard"); + window.setTimeout(() => setCopiedId((id) => id === link.id ? null : id), 2000); + } catch { + setError("Could not copy the link. Select and copy it manually."); + toast.error("Failed to copy link"); + } + }; + + const revokeLink = async (link: ShareLink) => { + setRevokingId(link.id); + setError(""); + setFeedback(""); + try { + const response = await fetch(`/api/share-links/${encodeURIComponent(link.id)}`, { + method: "DELETE", + headers: requestHeaders(), + }); + const body = await response.json() as ApiResponse; + if (!response.ok || !body.success) { + throw new Error(body.error || "Failed to revoke share link"); + } + setLinks((current) => current.filter((item) => item.id !== link.id)); + setFeedback("Share link revoked."); + toast.success("Share link revoked"); + } catch (reason) { + const message = reason instanceof Error ? reason.message : "Failed to revoke share link"; + setError(message); + toast.error(message); + } finally { + setRevokingId(null); + } + }; + + return ( + !open && onClose()}> + + + + + Share snippet + + + Generate and manage secure links{snippetTitle ? ` for “${snippetTitle}”` : ""}. + + + +
+
+ Link visibility +
+ + +
+
+ + +
+ +
+ {feedback} +
+ {error && ( +
+ {error} +
+ )} + +
+
+

Active links

+ {links.length} active +
+ + {loading ? ( +
+ Loading links… +
+ ) : links.length === 0 ? ( +
+ No active links yet. +
+ ) : ( +
    + {links.map((link) => ( +
  • +
    +
    +
    + + + {link.visibility === "read-only" ? "Read-only" : "Public collaboration"} + + + {new Date(link.createdAt).toLocaleDateString()} + +
    + event.currentTarget.select()} + className="w-full truncate rounded border border-slate-700 bg-slate-950 px-2 py-1.5 font-mono text-xs text-slate-300 outline-none focus:border-purple-400" + /> +
    +
    + + +
    +
    +
  • + ))} +
+ )} +
+
+
+ ); +} + +export default ShareSnippetModal; diff --git a/components/SnippetDetailModal.tsx b/components/SnippetDetailModal.tsx index 5d1e325..c9a3a64 100644 --- a/components/SnippetDetailModal.tsx +++ b/components/SnippetDetailModal.tsx @@ -17,13 +17,14 @@ import { Calendar, User, Shield, - ExternalLink, + Share2, } from "lucide-react"; import { DerivationBadge } from "./DerivationBadge"; import { useWallet } from "./WalletConnect"; import { toast } from "sonner"; import { SnippetSummary } from "@/types/type"; import Loader from "./ui/loader"; +import { ShareSnippetModal } from "./ShareSnippetModal"; interface SnippetDetailModalProps { snippetId: string | null; @@ -51,7 +52,6 @@ export function SnippetDetailModal({ onClose, onDuplicate, onFork, - onDeleted, }: SnippetDetailModalProps) { const wallet = useWallet(); const [snippet, setSnippet] = useState(null); @@ -59,11 +59,13 @@ export function SnippetDetailModal({ const [loading, setLoading] = useState(false); const [copied, setCopied] = useState(false); const [duplicating, setDuplicating] = useState(false); + const [sharing, setSharing] = useState(false); useEffect(() => { if (!isOpen || !snippetId) { setSnippet(null); setOriginSnippet(null); + setSharing(false); return; } @@ -155,9 +157,16 @@ export function SnippetDetailModal({ } }; + const canShare = Boolean( + snippet && + wallet?.publicKey && + (!snippet.owner_wallet_address || snippet.owner_wallet_address === wallet.publicKey), + ); + return ( - !open && onClose()}> - + <> + !open && onClose()}> + {loading ? (
@@ -275,6 +284,18 @@ export function SnippetDetailModal({ {/* Action Buttons: Duplicate, Fork, Copy */}
+ +
)} - -
+
+
+ + {snippet && ( + setSharing(false)} + /> + )} + ); } diff --git a/components/__tests__/ShareSnippetModal.test.tsx b/components/__tests__/ShareSnippetModal.test.tsx new file mode 100644 index 0000000..3d14f29 --- /dev/null +++ b/components/__tests__/ShareSnippetModal.test.tsx @@ -0,0 +1,159 @@ +/** + * @jest-environment jsdom + */ + +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { toast } from "sonner"; +import { ShareSnippetModal } from "../ShareSnippetModal"; + +const wallet = { + publicKey: "GCRKPWEEZPKBMQ7L3FAKKZL7TPJBKEHIWUBMN554ASGZKDJXJ7FCXRRU", + token: "test-token", +}; + +jest.mock("../WalletConnect", () => ({ + useWallet: () => wallet, +})); + +jest.mock("sonner", () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const existingLink = { + id: "link-1", + snippetId: "snippet-1", + shareUrl: "https://codely.example/api/share-links/secret-token/validate", + visibility: "read-only" as const, + createdAt: "2026-09-29T00:00:00.000Z", + status: "active" as const, +}; + +function response(data: unknown, ok = true) { + return Promise.resolve({ + ok, + json: async () => data, + }); +} + +describe("ShareSnippetModal", () => { + const clipboardWrite = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + jest.clearAllMocks(); + wallet.publicKey = "GCRKPWEEZPKBMQ7L3FAKKZL7TPJBKEHIWUBMN554ASGZKDJXJ7FCXRRU"; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: clipboardWrite }, + }); + }); + + it("loads active links and copies one with visible feedback", async () => { + global.fetch = jest.fn(() => response({ success: true, data: [existingLink] })) as jest.Mock; + + render( + , + ); + + expect(await screen.findByDisplayValue(existingLink.shareUrl)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Copy share link" })); + + await waitFor(() => { + expect(clipboardWrite).toHaveBeenCalledWith(existingLink.shareUrl); + expect(screen.getByRole("status")).toHaveTextContent("Link copied!"); + expect(toast.success).toHaveBeenCalledWith("Link copied to clipboard"); + }); + }); + + it("generates a public collaboration link through the backend API", async () => { + const createdLink = { ...existingLink, id: "link-2", visibility: "read-write" as const }; + global.fetch = jest.fn() + .mockImplementationOnce(() => response({ success: true, data: [] })) + .mockImplementationOnce(() => response({ success: true, data: createdLink })) as jest.Mock; + + render( + , + ); + + await screen.findByText("No active links yet."); + fireEvent.click(screen.getByRole("radio", { name: /Public collaboration/i })); + fireEvent.click(screen.getByRole("button", { name: "Generate link" })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenLastCalledWith( + "/api/share-links", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "x-wallet-address": wallet.publicKey, + Authorization: "Bearer test-token", + }), + body: JSON.stringify({ snippetId: "snippet-1", visibility: "read-write" }), + }), + ); + expect(screen.getByRole("status")).toHaveTextContent("Share link created."); + expect(screen.getAllByText("Public collaboration")).toHaveLength(2); + }); + }); + + it("revokes an active link and removes it from the list", async () => { + global.fetch = jest.fn() + .mockImplementationOnce(() => response({ success: true, data: [existingLink] })) + .mockImplementationOnce(() => response({ + success: true, + data: { ...existingLink, status: "revoked" }, + })) as jest.Mock; + + render( + , + ); + + await screen.findByDisplayValue(existingLink.shareUrl); + fireEvent.click(screen.getByRole("button", { name: "Revoke share link" })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenLastCalledWith( + "/api/share-links/link-1", + expect.objectContaining({ method: "DELETE" }), + ); + expect(screen.queryByDisplayValue(existingLink.shareUrl)).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Share link revoked."); + }); + }); + + it("prevents link creation when no owner wallet is connected", async () => { + wallet.publicKey = ""; + global.fetch = jest.fn() as jest.Mock; + + render( + , + ); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Connect the owner wallet to manage share links.", + ); + expect(screen.getByRole("button", { name: "Generate link" })).toBeDisabled(); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/components/__tests__/SnippetDetailSharing.test.tsx b/components/__tests__/SnippetDetailSharing.test.tsx new file mode 100644 index 0000000..5d91da4 --- /dev/null +++ b/components/__tests__/SnippetDetailSharing.test.tsx @@ -0,0 +1,83 @@ +/** + * @jest-environment jsdom + */ + +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { SnippetDetailModal } from "../SnippetDetailModal"; + +const wallet = { + publicKey: "GCRKPWEEZPKBMQ7L3FAKKZL7TPJBKEHIWUBMN554ASGZKDJXJ7FCXRRU", +}; + +jest.mock("../WalletConnect", () => ({ + useWallet: () => wallet, +})); + +jest.mock("../ShareSnippetModal", () => ({ + ShareSnippetModal: ({ isOpen }: { isOpen: boolean }) => + isOpen ?
Share modal opened
: null, +})); + +jest.mock("sonner", () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +describe("SnippetDetailModal sharing", () => { + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: "snippet-1", + title: "Shareable snippet", + description: "Example", + code: "const answer = 42;", + language: "typescript", + owner_wallet_address: wallet.publicKey, + }), + }); + }); + + it("opens the sharing modal from the snippet detail Share button", async () => { + render( + , + ); + + const shareButton = await screen.findByRole("button", { name: "Share" }); + expect(shareButton).toBeEnabled(); + fireEvent.click(shareButton); + + expect(screen.getByText("Share modal opened")).toBeInTheDocument(); + }); + + it("disables sharing for a wallet that does not own the snippet", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "snippet-1", + title: "Someone else's snippet", + code: "return true;", + language: "typescript", + owner_wallet_address: "GOTHERWALLETXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + }), + }); + + render( + , + ); + + expect(await screen.findByRole("button", { name: "Share" })).toBeDisabled(); + }); +}); diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 2d49001..1bdcc91 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Slot } from '@radix-ui/react-slot' import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from '@lib/utils' +import { cn } from '@/lib/utils' const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg[:not([class*='size-')]:size-4 srink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ing/50 focus-visible:ring-[3] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -36,7 +36,7 @@ function Button({ size, asChild = false, ...props -}: React.ComponentProps<'button> & +}: React.ComponentProps<'button'> & VariantProps & { asChild?: boolean }) { diff --git a/lib/share-link-management.service.test.ts b/lib/share-link-management.service.test.ts index 71a38cf..d4d5399 100644 --- a/lib/share-link-management.service.test.ts +++ b/lib/share-link-management.service.test.ts @@ -122,6 +122,21 @@ describe("ShareLinkManagementService", () => { }); }); + describe("getShareLinkById", () => { + it("returns metadata for authorization checks without private fields", async () => { + const link = await service.createShareLink({ + snippetId: "snip", + createdBy: "Gowner", + }); + + const found = await service.getShareLinkById(link.id); + + expect(found).toEqual(link); + expect(found as any).not.toHaveProperty("createdBy"); + expect(await service.getShareLinkById("missing")).toBeNull(); + }); + }); + describe("validateShareLink", () => { it("returns valid metadata without snippet content", async () => { const link = await service.createShareLink({ diff --git a/lib/share-link-management.service.ts b/lib/share-link-management.service.ts index 26c8e6b..a1ec808 100644 --- a/lib/share-link-management.service.ts +++ b/lib/share-link-management.service.ts @@ -213,6 +213,15 @@ export class ShareLinkManagementService { .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } + /** Looks up link metadata for authorization checks without exposing audit data. */ + async getShareLinkById(linkId: string): Promise { + const id = (linkId || "").trim(); + if (!id) return null; + + const link = await this.store.findById(id); + return link ? toMetadata(link) : null; + } + async revokeShareLink( linkId: string, revokedBy: string | null = null,