Skip to content

Commit

Permalink
Feature: Add loop detection for patch updates
Browse files Browse the repository at this point in the history
  • Loading branch information
jakemingolla committed Dec 29, 2024
1 parent 69cd3e0 commit 0dffdde
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 6 deletions.
18 changes: 18 additions & 0 deletions db/seeds/2024-12-29-14-41-51-loop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Kysely } from "kysely";
import type { DB } from "../../src/db/types";

export async function seed(db: Kysely<DB>): Promise<void> {
await db
.insertInto("redirects")
.values([
{
id: "loop",
destination: "http://localhost:3000/r/loop2",
},
{
id: "loop2",
destination: "http://localhost:3000/r/loop",
},
])
.execute();
}
35 changes: 35 additions & 0 deletions src/datasources/redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import type { Insertable, Kysely, Selectable } from "kysely";
import { sql } from "kysely";
import crypto from "crypto";

const loopDetectionLimit = 100;
const extractIdRegex = /.*\/r\/([a-z0-9-]+)$/;

export class RedirectsDatasource {
constructor(private readonly db: Kysely<DB>) {}

Expand Down Expand Up @@ -58,4 +61,36 @@ export class RedirectsDatasource {

return id;
}

async detectLoop(id: string, destination: string) {
const seen: string[] = [id];
let match: RegExpExecArray | null = null;
let count = 0;
while ((match = extractIdRegex.exec(destination))) {
if (!match) {
return false;
}

if (count > loopDetectionLimit) {
return true; // or error?
}

id = match[1];

if (seen.includes(id)) {
return true;
} else {
seen.push(id);
}

const redirect = await this.getRedirect(id);
if (!redirect) {
return false;
}

destination = redirect.destination;
count++;
}
return false;
}
}
6 changes: 5 additions & 1 deletion src/services/redirects.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Elysia, t } from "elysia";
import { Elysia, error, t } from "elysia";
import { RedirectsDatasource } from "@/datasources/redirects";
import { db } from "@/db";

Expand Down Expand Up @@ -35,6 +35,10 @@ export const redirects = new Elysia()
.patch(
"/redirects/:id",
async ({ params: { id }, body: { destination }, datasource }) => {
if (await datasource.detectLoop(id, destination)) {
return error(400, "Redirect loop detected.");
}

await datasource.updateDestination(id, destination);
return { id };
},
Expand Down
38 changes: 38 additions & 0 deletions test/integration/redirects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,41 @@ test("can delete a missing redirect", async () => {

expect(res.status).toBe(200);
});

test("cannot create a redirect loop", async () => {
const first = await fetch("http://localhost:3000/api/v1/redirects", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ destination: "will-update-to-loop" }),
});

const { id: firstId } = await first.json();
createdRedirects.push(firstId);

const second = await fetch(`http://localhost:3000/api/v1/redirects`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ destination: `http://localhost:3000/r/${firstId}` }),
});
const { id: secondId } = await second.json();
createdRedirects.push(secondId);

const patch = await fetch(
`http://localhost:3000/api/v1/redirects/${firstId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
destination: `http://localhost:3000/r/${secondId}`,
}),
},
);
expect(patch.status).toBe(400);
expect(patch.text()).resolves.toEqual("Redirect loop detected.");
});
50 changes: 50 additions & 0 deletions test/unit/datasources/redirects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { RedirectsDatasource } from "@/datasources/redirects";
import type { DB } from "@/db/types";
import { expect, test, afterEach, mock, beforeEach } from "bun:test";
import type { Mock } from "bun:test";
import type { Kysely, Selectable } from "kysely";
import type { Redirect } from "@/db/types";

const mockedRedirects: Record<string, Selectable<Redirect>> = {};
const mockDb = mock();

const redirects = new RedirectsDatasource(mockDb as unknown as Kysely<DB>);
redirects.getRedirect = mock().mockImplementation((id) => {
return mockedRedirects[id];
});

beforeEach(() => {
for (let i = 0; i < 102; i++) {
const id = i.toString();
mockedRedirects[id] = {
id,
destination: `http://localhost:3000/r/${i + 1}`,
createdAt: new Date(),
updatedAt: new Date(),
hits: 0,
pk: i,
deletedAt: null,
};
}
});

afterEach(() => {
Object.keys(mockedRedirects).forEach((key) => {
delete mockedRedirects[key];
});
});

test("detects loop", async () => {
expect(redirects.detectLoop("5", "http://localhost:3000/r/0")).resolves.toBe(
true,
);
});

test.only("loop limit exceeded", async () => {
// Because the beforeEach already created a (non-looping) chain of 101
// redirects (over the loop detection limit), any attempt to modify the
// existing chain will be detected as a loop.
expect(redirects.detectLoop("0", "http://localhost:3000/r/1")).resolves.toBe(
true,
);
});
5 changes: 0 additions & 5 deletions test/unit/example.test.ts

This file was deleted.

0 comments on commit 0dffdde

Please sign in to comment.