Skip to content
Open
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
84 changes: 84 additions & 0 deletions app/api/share-links/[id]/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof shareLinkManagementService>;
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);
});
});
28 changes: 28 additions & 0 deletions app/api/share-links/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
116 changes: 116 additions & 0 deletions app/api/share-links/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof shareLinkManagementService>;
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);
});
});
21 changes: 21 additions & 0 deletions app/api/share-links/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -60,13 +71,23 @@ 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") || "";

if (!snippetId) {
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,
Expand Down
Loading