- Adds UI links for joining with PIN in Login and Register pages. - Normalizes event slugs to lowercase (preserving hyphens) and event PINs to strip all hyphens/spaces to handle raw inputs. - Implements a pre-check rate limit pattern (`isRateLimited`) to safely enforce a max of 5 failed attempts per IP window (60s) without rate-limiting successful authentications. - Achieves NAT-safe idempotency in `POST /api/join` by extracting and reusing active event guest sessions instead of blindly incrementing claimed seats on every request. - Integrates complete test suite coverage for these new constraints. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
542 lines
16 KiB
TypeScript
542 lines
16 KiB
TypeScript
import { assert, assertEquals, assertExists } from "jsr:@std/assert";
|
|
import { stub } from "jsr:@std/testing/mock";
|
|
import { app } from "../main.ts";
|
|
import { sqlWrapper } from "../db.ts";
|
|
import { valkey } from "../valkey.ts";
|
|
import { rateLimitWrapper } from "../ratelimit.ts";
|
|
|
|
rateLimitWrapper.checkRateLimit = () => Promise.resolve(true);
|
|
|
|
Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
|
await t.step("POST /api/events creates an event pass", async () => {
|
|
const valkeyStub = stub(valkey, "get", (key: any) => {
|
|
if (String(key) === "admin-session") {
|
|
return Promise.resolve(
|
|
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
|
|
);
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
|
|
const originalSql = sqlWrapper.sql;
|
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
|
const query = Array.isArray(strings)
|
|
? strings.join("?")
|
|
: String(strings);
|
|
if (query.includes("INSERT INTO event_passes")) {
|
|
return Promise.resolve([{
|
|
id: "event-uuid-1",
|
|
slug: "deno-lab",
|
|
pin_code: "749-123",
|
|
name: "Deno Workshop",
|
|
max_seats: 50,
|
|
seats_claimed: 0,
|
|
lifespan_hours: 3,
|
|
}]);
|
|
}
|
|
return Promise.resolve([]);
|
|
}) as any;
|
|
|
|
try {
|
|
const res = await app.request("/api/events", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: "Bearer admin-session",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
name: "Deno Workshop",
|
|
slug: "deno-lab",
|
|
pinCode: "749-123",
|
|
maxSeats: 50,
|
|
lifespanHours: 3,
|
|
}),
|
|
});
|
|
|
|
assertEquals(res.status, 200);
|
|
const json = await res.json();
|
|
assert(json.success === true);
|
|
assertEquals(json.event.slug, "deno-lab");
|
|
assertEquals(json.event.pin_code, "749-123");
|
|
} finally {
|
|
sqlWrapper.sql = originalSql;
|
|
valkeyStub.restore();
|
|
}
|
|
});
|
|
|
|
await t.step(
|
|
"POST /api/join normalizes PIN without hyphens and mints guest session",
|
|
async () => {
|
|
const originalSql = sqlWrapper.sql;
|
|
let updateCalled = false;
|
|
let selectCalled = false;
|
|
sqlWrapper.sql = ((strings: any, ...values: any[]) => {
|
|
const query = Array.isArray(strings)
|
|
? strings.join("?")
|
|
: String(strings);
|
|
if (query.includes("SELECT * FROM event_passes")) {
|
|
selectCalled = true;
|
|
// Verify pinNormalized parameter value
|
|
assert(values.includes("749123"));
|
|
return Promise.resolve([{
|
|
id: "event-uuid-1",
|
|
slug: "deno-lab",
|
|
pin_code: "749-123",
|
|
name: "Deno Workshop",
|
|
max_seats: 50,
|
|
seats_claimed: 0,
|
|
lifespan_hours: 3,
|
|
app_id: null,
|
|
}]);
|
|
}
|
|
if (query.includes("UPDATE event_passes")) {
|
|
updateCalled = true;
|
|
return Promise.resolve([{
|
|
id: "event-uuid-1",
|
|
slug: "deno-lab",
|
|
pin_code: "749-123",
|
|
name: "Deno Workshop",
|
|
max_seats: 50,
|
|
seats_claimed: 1,
|
|
lifespan_hours: 3,
|
|
app_id: null,
|
|
}]);
|
|
}
|
|
return Promise.resolve([]);
|
|
}) as any;
|
|
|
|
const valkeySetexStub = stub(
|
|
valkey,
|
|
"setex",
|
|
() => Promise.resolve("OK" as any),
|
|
);
|
|
|
|
const { auditWrapper } = await import("../audit.ts");
|
|
let auditCalled = false;
|
|
let auditPayload: any = null;
|
|
const auditStub = stub(
|
|
auditWrapper,
|
|
"auditLog",
|
|
(_userId, action, resource, metadata, _ip) => {
|
|
if (action === "event_seat_claimed") {
|
|
auditCalled = true;
|
|
auditPayload = { resource, metadata };
|
|
}
|
|
},
|
|
);
|
|
|
|
try {
|
|
const res = await app.request("/api/join", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Forwarded-For": "192.168.1.1",
|
|
},
|
|
body: JSON.stringify({ code: "749-123" }),
|
|
});
|
|
|
|
assertEquals(res.status, 200);
|
|
const json = await res.json();
|
|
assert(json.success === true);
|
|
assert(json.token.startsWith("ay_sess_"));
|
|
assertEquals(json.username, "guest_deno-lab_1");
|
|
|
|
const cookies = res.headers.get("set-cookie");
|
|
assertExists(cookies);
|
|
assert(cookies.includes(`session_id=${json.token};`));
|
|
|
|
assert(selectCalled);
|
|
assert(updateCalled);
|
|
assert(auditCalled);
|
|
assertEquals(auditPayload.resource, "event-uuid-1");
|
|
assertEquals(auditPayload.metadata.slug, "deno-lab");
|
|
assertEquals(auditPayload.metadata.method, "web");
|
|
} finally {
|
|
sqlWrapper.sql = originalSql;
|
|
valkeySetexStub.restore();
|
|
auditStub.restore();
|
|
}
|
|
},
|
|
);
|
|
|
|
await t.step(
|
|
"POST /api/join idempotent re-entry returns existing session without burning a seat",
|
|
async () => {
|
|
const valkeyGetStub = stub(valkey, "get", (key: any) => {
|
|
if (String(key) === "existing-guest-session") {
|
|
return Promise.resolve(
|
|
JSON.stringify({
|
|
uuid: "guest-uuid",
|
|
username: "guest_deno-lab_1",
|
|
sessionId: "existing-guest-session",
|
|
}),
|
|
);
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
|
|
const originalSql = sqlWrapper.sql;
|
|
let updateCalled = false;
|
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
|
const query = Array.isArray(strings)
|
|
? strings.join("?")
|
|
: String(strings);
|
|
if (query.includes("SELECT * FROM event_passes")) {
|
|
return Promise.resolve([{
|
|
id: "event-uuid-1",
|
|
slug: "deno-lab",
|
|
pin_code: "749-123",
|
|
name: "Deno Workshop",
|
|
max_seats: 50,
|
|
seats_claimed: 1,
|
|
lifespan_hours: 3,
|
|
app_id: null,
|
|
}]);
|
|
}
|
|
if (query.includes("UPDATE event_passes")) {
|
|
updateCalled = true;
|
|
return Promise.resolve([]);
|
|
}
|
|
return Promise.resolve([]);
|
|
}) as any;
|
|
|
|
const valkeyExpireStub = stub(
|
|
valkey,
|
|
"expire",
|
|
() => Promise.resolve(1 as any),
|
|
);
|
|
|
|
try {
|
|
const res = await app.request("/api/join", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Forwarded-For": "192.168.1.1",
|
|
"Cookie": "session_id=existing-guest-session",
|
|
},
|
|
body: JSON.stringify({ code: "deno-lab" }),
|
|
});
|
|
|
|
assertEquals(res.status, 200);
|
|
const json = await res.json();
|
|
assert(json.success === true);
|
|
assert(json.reused === true);
|
|
assertEquals(json.sessionId, "existing-guest-session");
|
|
assert(!updateCalled); // verify seat was not burned
|
|
} finally {
|
|
sqlWrapper.sql = originalSql;
|
|
valkeyGetStub.restore();
|
|
valkeyExpireStub.restore();
|
|
}
|
|
},
|
|
);
|
|
|
|
await t.step(
|
|
"POST /api/join enforces 5 failed attempts rate limit per IP",
|
|
async () => {
|
|
let isRateLimitedCalled = false;
|
|
let checkRateLimitCalled = false;
|
|
|
|
rateLimitWrapper.isRateLimited = (
|
|
key: string,
|
|
limit: number,
|
|
_window: number,
|
|
) => {
|
|
if (key === "ratelimit:join:fail:192.168.1.99" && limit === 5) {
|
|
isRateLimitedCalled = true;
|
|
return Promise.resolve(true);
|
|
}
|
|
return Promise.resolve(false);
|
|
};
|
|
|
|
rateLimitWrapper.checkRateLimit = () => {
|
|
checkRateLimitCalled = true;
|
|
return Promise.resolve(false);
|
|
};
|
|
|
|
try {
|
|
const res = await app.request("/api/join", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Forwarded-For": "192.168.1.99",
|
|
},
|
|
body: JSON.stringify({ code: "invalid-code" }),
|
|
});
|
|
|
|
assertEquals(res.status, 429);
|
|
const json = await res.json();
|
|
assertEquals(json.error, "Too Many Requests");
|
|
assert(isRateLimitedCalled);
|
|
assert(!checkRateLimitCalled); // didn't reach failure branch
|
|
} finally {
|
|
rateLimitWrapper.isRateLimited = () => Promise.resolve(false);
|
|
rateLimitWrapper.checkRateLimit = () => Promise.resolve(true);
|
|
}
|
|
},
|
|
);
|
|
|
|
await t.step(
|
|
"POST /api/join normalizes slug case-insensitively while preserving hyphens",
|
|
async () => {
|
|
const originalSql = sqlWrapper.sql;
|
|
sqlWrapper.sql = ((strings: any, ...values: any[]) => {
|
|
const query = Array.isArray(strings)
|
|
? strings.join("?")
|
|
: String(strings);
|
|
if (query.includes("SELECT * FROM event_passes")) {
|
|
assert(values.includes("deno-lab-workshop"));
|
|
return Promise.resolve([{
|
|
id: "event-uuid-2",
|
|
slug: "deno-lab-workshop",
|
|
pin_code: "111-222",
|
|
name: "Deno Workshop 2",
|
|
max_seats: 50,
|
|
seats_claimed: 0,
|
|
lifespan_hours: 3,
|
|
app_id: null,
|
|
}]);
|
|
}
|
|
if (query.includes("UPDATE event_passes")) {
|
|
return Promise.resolve([{
|
|
id: "event-uuid-2",
|
|
slug: "deno-lab-workshop",
|
|
pin_code: "111-222",
|
|
name: "Deno Workshop 2",
|
|
max_seats: 50,
|
|
seats_claimed: 1,
|
|
lifespan_hours: 3,
|
|
app_id: null,
|
|
}]);
|
|
}
|
|
return Promise.resolve([]);
|
|
}) as any;
|
|
|
|
const valkeySetexStub = stub(
|
|
valkey,
|
|
"setex",
|
|
() => Promise.resolve("OK" as any),
|
|
);
|
|
|
|
try {
|
|
const res = await app.request("/api/join", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Forwarded-For": "192.168.1.1",
|
|
},
|
|
body: JSON.stringify({ code: " Deno-Lab-Workshop " }),
|
|
});
|
|
|
|
assertEquals(res.status, 200);
|
|
const json = await res.json();
|
|
assert(json.success === true);
|
|
assertEquals(json.username, "guest_deno-lab-workshop_1");
|
|
} finally {
|
|
sqlWrapper.sql = originalSql;
|
|
valkeySetexStub.restore();
|
|
}
|
|
},
|
|
);
|
|
|
|
await t.step(
|
|
"GET /join/:slug?format=env returns CLI export string",
|
|
async () => {
|
|
const originalSql = sqlWrapper.sql;
|
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
|
const query = Array.isArray(strings)
|
|
? strings.join("?")
|
|
: String(strings);
|
|
if (query.includes("UPDATE event_passes")) {
|
|
return Promise.resolve([{
|
|
id: "event-uuid-1",
|
|
slug: "deno-lab",
|
|
pin_code: "749-123",
|
|
name: "Deno Workshop",
|
|
max_seats: 50,
|
|
seats_claimed: 2,
|
|
lifespan_hours: 3,
|
|
app_id: null,
|
|
}]);
|
|
}
|
|
return Promise.resolve([]);
|
|
}) as any;
|
|
|
|
const valkeySetexStub = stub(
|
|
valkey,
|
|
"setex",
|
|
() => Promise.resolve("OK" as any),
|
|
);
|
|
|
|
const { auditWrapper } = await import("../audit.ts");
|
|
let auditCalled = false;
|
|
let auditPayload: any = null;
|
|
const auditStub = stub(
|
|
auditWrapper,
|
|
"auditLog",
|
|
(_userId, action, resource, metadata, _ip) => {
|
|
if (action === "event_seat_claimed") {
|
|
auditCalled = true;
|
|
auditPayload = { resource, metadata };
|
|
}
|
|
},
|
|
);
|
|
|
|
try {
|
|
const res = await app.request("/join/deno-lab?format=env", {
|
|
method: "GET",
|
|
headers: { "X-Forwarded-For": "192.168.1.2" },
|
|
});
|
|
|
|
assertEquals(res.status, 200);
|
|
const text = await res.text();
|
|
assert(text.includes('export AUTH_YES_TOKEN="ay_sess_'));
|
|
assert(text.includes('export AUTH_YES_USER="guest_deno-lab_2"'));
|
|
|
|
assert(auditCalled);
|
|
assertEquals(auditPayload.resource, "event-uuid-1");
|
|
assertEquals(auditPayload.metadata.slug, "deno-lab");
|
|
assertEquals(auditPayload.metadata.method, "cli");
|
|
} finally {
|
|
sqlWrapper.sql = originalSql;
|
|
valkeySetexStub.restore();
|
|
auditStub.restore();
|
|
}
|
|
},
|
|
);
|
|
|
|
await t.step(
|
|
"POST /api/events/:id/end master kill switch revokes sessions",
|
|
async () => {
|
|
const valkeyGetStub = stub(valkey, "get", (key: any) => {
|
|
if (String(key) === "admin-session") {
|
|
return Promise.resolve(
|
|
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
|
|
);
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
|
|
const originalSql = sqlWrapper.sql;
|
|
let updateEventCalled = false;
|
|
let deleteSessionsCalled = false;
|
|
|
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
|
const query = Array.isArray(strings)
|
|
? strings.join("?")
|
|
: String(strings);
|
|
if (
|
|
query.includes("UPDATE event_passes") &&
|
|
query.includes("is_active = FALSE")
|
|
) {
|
|
updateEventCalled = true;
|
|
return Promise.resolve([{ slug: "deno-lab" }]);
|
|
}
|
|
if (query.includes("DELETE FROM sessions")) {
|
|
deleteSessionsCalled = true;
|
|
return Promise.resolve([{ id: "sess-1" }, { id: "sess-2" }]);
|
|
}
|
|
return Promise.resolve([]);
|
|
}) as any;
|
|
|
|
const valkeyDelStub = stub(
|
|
valkey,
|
|
"del",
|
|
() => Promise.resolve(2 as any),
|
|
);
|
|
|
|
try {
|
|
const res = await app.request("/api/events/evt-123/end", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: "Bearer admin-session",
|
|
},
|
|
});
|
|
|
|
assertEquals(res.status, 200);
|
|
const json = await res.json();
|
|
assert(json.success === true);
|
|
assertEquals(json.revokedCount, 2);
|
|
assert(updateEventCalled);
|
|
assert(deleteSessionsCalled);
|
|
} finally {
|
|
sqlWrapper.sql = originalSql;
|
|
valkeyGetStub.restore();
|
|
valkeyDelStub.restore();
|
|
}
|
|
},
|
|
);
|
|
|
|
await t.step(
|
|
"POST /api/events/:id/extend adds time to event and sessions",
|
|
async () => {
|
|
const valkeyGetStub = stub(valkey, "get", (key: any) => {
|
|
if (String(key) === "admin-session") {
|
|
return Promise.resolve(
|
|
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
|
|
);
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
|
|
const futureDate = new Date(Date.now() + 7200000).toISOString();
|
|
const originalSql = sqlWrapper.sql;
|
|
let updateEventCalled = false;
|
|
let updateSessionsCalled = false;
|
|
|
|
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
|
const query = Array.isArray(strings)
|
|
? strings.join("?")
|
|
: String(strings);
|
|
if (
|
|
query.includes("UPDATE event_passes") &&
|
|
query.includes("expires_at = expires_at + interval")
|
|
) {
|
|
updateEventCalled = true;
|
|
return Promise.resolve([{
|
|
slug: "deno-lab",
|
|
expires_at: futureDate,
|
|
}]);
|
|
}
|
|
if (
|
|
query.includes("UPDATE sessions") &&
|
|
query.includes("expires_at = expires_at + interval")
|
|
) {
|
|
updateSessionsCalled = true;
|
|
return Promise.resolve([{ id: "sess-1" }, { id: "sess-2" }]);
|
|
}
|
|
return Promise.resolve([]);
|
|
}) as any;
|
|
|
|
let expireCallCount = 0;
|
|
const valkeyExpireStub = stub(valkey, "expire", () => {
|
|
expireCallCount++;
|
|
return Promise.resolve(1 as any);
|
|
});
|
|
|
|
try {
|
|
const res = await app.request("/api/events/evt-123/extend", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: "Bearer admin-session",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ extendHours: 2 }),
|
|
});
|
|
|
|
assertEquals(res.status, 200);
|
|
const json = await res.json();
|
|
assert(json.success === true);
|
|
assertEquals(json.expiresAt, futureDate);
|
|
assert(updateEventCalled);
|
|
assert(updateSessionsCalled);
|
|
assertEquals(expireCallCount, 2);
|
|
} finally {
|
|
sqlWrapper.sql = originalSql;
|
|
valkeyGetStub.restore();
|
|
valkeyExpireStub.restore();
|
|
}
|
|
},
|
|
);
|
|
});
|