Compare commits
3 Commits
455d130f74
...
49c071945d
| Author | SHA1 | Date | |
|---|---|---|---|
| 49c071945d | |||
| f0c10a313d | |||
|
|
edbceae0fb |
@ -57,6 +57,41 @@ export let checkRateLimit = async function checkRateLimit(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a sliding window rate limit is exceeded without incrementing.
|
||||||
|
*
|
||||||
|
* @param key - The unique identifier for the limit.
|
||||||
|
* @param limit - Maximum requests allowed in the window.
|
||||||
|
* @param windowMs - Size of the window in milliseconds.
|
||||||
|
* @returns boolean - true if limit is exceeded (rate limited), false if allowed.
|
||||||
|
*/
|
||||||
|
export let isRateLimited = async function isRateLimited(
|
||||||
|
key: string,
|
||||||
|
limit: number,
|
||||||
|
windowMs: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - windowMs;
|
||||||
|
|
||||||
|
const multi = valkey.multi();
|
||||||
|
multi.zremrangebyscore(key, 0, windowStart);
|
||||||
|
multi.zcount(key, "-inf", "+inf");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await multi.exec();
|
||||||
|
if (!results) return false; // fail open or closed depending on preference, returning false here allows it
|
||||||
|
|
||||||
|
const countResult = results[1];
|
||||||
|
if (countResult[0]) throw countResult[0];
|
||||||
|
|
||||||
|
const currentCount = countResult[1] as number;
|
||||||
|
return currentCount >= limit;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[RateLimit] Error checking isRateLimited:", error);
|
||||||
|
return false; // fail open or closed, returning false here
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const rateLimitWrapper = {
|
export const rateLimitWrapper = {
|
||||||
get checkRateLimit() {
|
get checkRateLimit() {
|
||||||
return checkRateLimit;
|
return checkRateLimit;
|
||||||
@ -64,4 +99,10 @@ export const rateLimitWrapper = {
|
|||||||
set checkRateLimit(val: any) {
|
set checkRateLimit(val: any) {
|
||||||
checkRateLimit = val;
|
checkRateLimit = val;
|
||||||
},
|
},
|
||||||
|
get isRateLimited() {
|
||||||
|
return isRateLimited;
|
||||||
|
},
|
||||||
|
set isRateLimited(val: any) {
|
||||||
|
isRateLimited = val;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
} from "../auth-session.ts";
|
} from "../auth-session.ts";
|
||||||
import { getClientIp } from "../middleware.ts";
|
import { getClientIp } from "../middleware.ts";
|
||||||
import { auditWrapper } from "../audit.ts";
|
import { auditWrapper } from "../audit.ts";
|
||||||
|
import { rateLimitWrapper } from "../ratelimit.ts";
|
||||||
import { EventJoinPage } from "../../ui/components/EventJoinPage.tsx";
|
import { EventJoinPage } from "../../ui/components/EventJoinPage.tsx";
|
||||||
import { EventSplashPage } from "../../ui/components/EventSplashPage.tsx";
|
import { EventSplashPage } from "../../ui/components/EventSplashPage.tsx";
|
||||||
|
|
||||||
@ -180,49 +181,122 @@ eventRoutes.post("/api/join", async (c) => {
|
|||||||
if (!code || typeof code !== "string") {
|
if (!code || typeof code !== "string") {
|
||||||
return c.json({ error: "Event code or PIN is required" }, 400);
|
return c.json({ error: "Event code or PIN is required" }, 400);
|
||||||
}
|
}
|
||||||
code = code.trim();
|
|
||||||
|
const rawNormalized = code.trim().toLowerCase();
|
||||||
|
const pinNormalized = code.trim().replace(/[-\s]/g, "");
|
||||||
|
|
||||||
|
const clientIp = getClientIp(c);
|
||||||
|
const rateLimitKey = `ratelimit:join:fail:${clientIp}`;
|
||||||
|
|
||||||
|
if (await rateLimitWrapper.isRateLimited(rateLimitKey, 5, 60000)) {
|
||||||
|
return c.json({ error: "Too Many Requests" }, 429);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sqlWrapper.sql`
|
const eventLookup = await sqlWrapper.sql`
|
||||||
UPDATE event_passes
|
SELECT * FROM event_passes
|
||||||
SET seats_claimed = seats_claimed + 1
|
WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized})
|
||||||
WHERE (slug = ${code} OR pin_code = ${code})
|
|
||||||
AND is_active = TRUE
|
AND is_active = TRUE
|
||||||
AND (expires_at IS NULL OR expires_at > NOW())
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
AND (max_seats = 0 OR seats_claimed < max_seats)
|
|
||||||
RETURNING *
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!result || result.length === 0) {
|
if (!eventLookup || eventLookup.length === 0) {
|
||||||
|
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "Invalid event code or workshop capacity reached" },
|
{ error: "Invalid event code or workshop capacity reached" },
|
||||||
404,
|
404,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = result[0];
|
const event = eventLookup[0];
|
||||||
|
const user = await getAuthenticatedUser(c);
|
||||||
|
|
||||||
|
if (user && user.username?.startsWith(`guest_${event.slug}_`)) {
|
||||||
|
const ttl = (Number(event.lifespan_hours) || 3) * 3600;
|
||||||
|
await valkey.expire(user.sessionId, ttl);
|
||||||
|
|
||||||
|
deleteCookie(c, "session_id", { path: "/" });
|
||||||
|
const rpID = Deno.env.get("RP_ID");
|
||||||
|
const cookieDomain = getCookieDomain(rpID);
|
||||||
|
|
||||||
|
setCookie(c, "session_id", user.sessionId, {
|
||||||
|
domain: cookieDomain,
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "Lax",
|
||||||
|
maxAge: ttl,
|
||||||
|
});
|
||||||
|
|
||||||
|
let appDomain = "";
|
||||||
|
if (event.app_id) {
|
||||||
|
const apps = await sqlWrapper
|
||||||
|
.sql`SELECT domain FROM apps WHERE id = ${event.app_id}`;
|
||||||
|
if (apps.length > 0) {
|
||||||
|
appDomain = apps[0].domain || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard";
|
||||||
|
|
||||||
|
if (
|
||||||
|
c.req.header("accept")?.includes("text/html") &&
|
||||||
|
!c.req.header("accept")?.includes("application/json")
|
||||||
|
) {
|
||||||
|
return c.redirect(redirectUrl, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
sessionId: user.sessionId,
|
||||||
|
token: user.sessionId,
|
||||||
|
guestUuid: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
redirectUrl,
|
||||||
|
reused: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateResult = await sqlWrapper.sql`
|
||||||
|
UPDATE event_passes
|
||||||
|
SET seats_claimed = seats_claimed + 1
|
||||||
|
WHERE id = ${event.id}
|
||||||
|
AND (max_seats = 0 OR seats_claimed < max_seats)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!updateResult || updateResult.length === 0) {
|
||||||
|
await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 60000);
|
||||||
|
return c.json(
|
||||||
|
{ error: "Workshop capacity reached" },
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedEvent = updateResult[0];
|
||||||
const guestUuid = crypto.randomUUID();
|
const guestUuid = crypto.randomUUID();
|
||||||
const username = `guest_${event.slug}_${event.seats_claimed}`;
|
const username = `guest_${updatedEvent.slug}_${updatedEvent.seats_claimed}`;
|
||||||
|
|
||||||
await sqlWrapper.sql`
|
await sqlWrapper.sql`
|
||||||
INSERT INTO users (id, username, display_name, account_status)
|
INSERT INTO users (id, username, display_name, account_status)
|
||||||
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
|
VALUES (${guestUuid}, ${username}, ${
|
||||||
|
updatedEvent.name + " Attendee"
|
||||||
|
}, 'guest')
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
|
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
|
||||||
const label = `${event.name} Seat #${event.seats_claimed}`;
|
const label = `${updatedEvent.name} Seat #${updatedEvent.seats_claimed}`;
|
||||||
const ttl = (Number(event.lifespan_hours) || 3) * 3600;
|
const ttl = (Number(updatedEvent.lifespan_hours) || 3) * 3600;
|
||||||
|
|
||||||
let customScopes = ["guest", "trial"];
|
let customScopes = ["guest", "trial"];
|
||||||
let appDomain = "";
|
let appDomain = "";
|
||||||
|
|
||||||
if (event.app_id) {
|
if (updatedEvent.app_id) {
|
||||||
const apps = await sqlWrapper
|
const apps = await sqlWrapper
|
||||||
.sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`;
|
.sql`SELECT name, domain FROM apps WHERE id = ${updatedEvent.app_id}`;
|
||||||
if (apps.length > 0) {
|
if (apps.length > 0) {
|
||||||
customScopes = [`app:${apps[0].name}`, event.role || "viewer"];
|
customScopes = [`app:${apps[0].name}`, updatedEvent.role || "viewer"];
|
||||||
appDomain = apps[0].domain || "";
|
appDomain = apps[0].domain || "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -245,10 +319,10 @@ eventRoutes.post("/api/join", async (c) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, {
|
auditWrapper.auditLog(guestUuid, "event_seat_claimed", updatedEvent.id, {
|
||||||
slug: event.slug,
|
slug: updatedEvent.slug,
|
||||||
name: event.name,
|
name: updatedEvent.name,
|
||||||
seatNumber: event.seats_claimed,
|
seatNumber: updatedEvent.seats_claimed,
|
||||||
method: "web",
|
method: "web",
|
||||||
}, getClientIp(c));
|
}, getClientIp(c));
|
||||||
|
|
||||||
@ -289,14 +363,17 @@ eventRoutes.post("/api/join", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
eventRoutes.get("/join/:slug", async (c) => {
|
eventRoutes.get("/join/:slug", async (c) => {
|
||||||
const slug = c.req.param("slug");
|
const slug = c.req.param("slug") || "";
|
||||||
const format = c.req.query("format") || "html";
|
const format = c.req.query("format") || "html";
|
||||||
|
|
||||||
|
const rawNormalized = slug.trim().toLowerCase();
|
||||||
|
const pinNormalized = slug.trim().replace(/[-\s]/g, "");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sqlWrapper.sql`
|
const result = await sqlWrapper.sql`
|
||||||
UPDATE event_passes
|
UPDATE event_passes
|
||||||
SET seats_claimed = seats_claimed + 1
|
SET seats_claimed = seats_claimed + 1
|
||||||
WHERE slug = ${slug}
|
WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized})
|
||||||
AND is_active = TRUE
|
AND is_active = TRUE
|
||||||
AND (expires_at IS NULL OR expires_at > NOW())
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
AND (max_seats = 0 OR seats_claimed < max_seats)
|
AND (max_seats = 0 OR seats_claimed < max_seats)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { valkey } from "../valkey.ts";
|
|||||||
import { rateLimitWrapper } from "../ratelimit.ts";
|
import { rateLimitWrapper } from "../ratelimit.ts";
|
||||||
|
|
||||||
rateLimitWrapper.checkRateLimit = () => Promise.resolve(true);
|
rateLimitWrapper.checkRateLimit = () => Promise.resolve(true);
|
||||||
|
rateLimitWrapper.isRateLimited = () => Promise.resolve(false);
|
||||||
|
|
||||||
Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
||||||
await t.step("POST /api/events creates an event pass", async () => {
|
await t.step("POST /api/events creates an event pass", async () => {
|
||||||
@ -65,14 +66,32 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await t.step(
|
await t.step(
|
||||||
"POST /api/join redeems PIN / slug and mints guest session",
|
"POST /api/join normalizes PIN without hyphens and mints guest session",
|
||||||
async () => {
|
async () => {
|
||||||
const originalSql = sqlWrapper.sql;
|
const originalSql = sqlWrapper.sql;
|
||||||
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
let updateCalled = false;
|
||||||
|
let selectCalled = false;
|
||||||
|
sqlWrapper.sql = ((strings: any, ...values: any[]) => {
|
||||||
const query = Array.isArray(strings)
|
const query = Array.isArray(strings)
|
||||||
? strings.join("?")
|
? strings.join("?")
|
||||||
: String(strings);
|
: 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")) {
|
if (query.includes("UPDATE event_passes")) {
|
||||||
|
updateCalled = true;
|
||||||
return Promise.resolve([{
|
return Promise.resolve([{
|
||||||
id: "event-uuid-1",
|
id: "event-uuid-1",
|
||||||
slug: "deno-lab",
|
slug: "deno-lab",
|
||||||
@ -127,6 +146,8 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
|||||||
assertExists(cookies);
|
assertExists(cookies);
|
||||||
assert(cookies.includes(`session_id=${json.token};`));
|
assert(cookies.includes(`session_id=${json.token};`));
|
||||||
|
|
||||||
|
assert(selectCalled);
|
||||||
|
assert(updateCalled);
|
||||||
assert(auditCalled);
|
assert(auditCalled);
|
||||||
assertEquals(auditPayload.resource, "event-uuid-1");
|
assertEquals(auditPayload.resource, "event-uuid-1");
|
||||||
assertEquals(auditPayload.metadata.slug, "deno-lab");
|
assertEquals(auditPayload.metadata.slug, "deno-lab");
|
||||||
@ -139,6 +160,186 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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(
|
await t.step(
|
||||||
"GET /join/:slug?format=env returns CLI export string",
|
"GET /join/:slug?format=env returns CLI export string",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@ -115,7 +115,9 @@ export const LoginPage = () => {
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div class="links">
|
<div class="links">
|
||||||
Don't have an account? <a href="/register">Register with Invite</a>
|
Don't have an account? <a href="/register">Register with Invite</a> |
|
||||||
|
{" "}
|
||||||
|
<a href="/join">Join with PIN</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -116,7 +116,8 @@ export const RegisterPage = (
|
|||||||
<div id="statusMessage"></div>
|
<div id="statusMessage"></div>
|
||||||
|
|
||||||
<div class="links">
|
<div class="links">
|
||||||
Already registered? <a href="/login">Sign in</a>
|
Already registered? <a href="/login">Sign in</a> |{" "}
|
||||||
|
<a href="/join">Join with PIN</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user