feat: add share link service, API routes, and short URL redirect
Create share.service.ts with token generation (128-bit base64url), CRUD operations, validation, and visibility transition side effects. Add share endpoints under /api/setups/:id/shares, shared access at /api/shared/:token, and /s/:token short URL redirect. Plan: 32-02 (Setup Sharing System - Share Link Backend) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,9 @@ import { setupRoutes } from "./routes/setups.ts";
|
|||||||
import { tagRoutes } from "./routes/tags.ts";
|
import { tagRoutes } from "./routes/tags.ts";
|
||||||
import { threadRoutes } from "./routes/threads.ts";
|
import { threadRoutes } from "./routes/threads.ts";
|
||||||
import { totalRoutes } from "./routes/totals.ts";
|
import { totalRoutes } from "./routes/totals.ts";
|
||||||
|
import { getSetupWithItemsById } from "./services/setup.service.ts";
|
||||||
|
import { validateShareToken } from "./services/share.service.ts";
|
||||||
|
import { withImageUrls } from "./services/storage.service.ts";
|
||||||
|
|
||||||
// Seed default data on startup
|
// Seed default data on startup
|
||||||
await seedDefaults();
|
await seedDefaults();
|
||||||
@@ -163,6 +166,27 @@ app.use("/api/users/:id/profile", async (c, next) => {
|
|||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Shared setup access via token (no auth required)
|
||||||
|
app.get("/api/shared/:token", async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const token = c.req.param("token");
|
||||||
|
const result = await validateShareToken(db, token);
|
||||||
|
if (!result) return c.json({ error: "Not found" }, 404);
|
||||||
|
const setup = await getSetupWithItemsById(db, result.setupId);
|
||||||
|
if (!setup) return c.json({ error: "Not found" }, 404);
|
||||||
|
const enrichedItems = await withImageUrls(setup.items);
|
||||||
|
return c.json({ ...setup, items: enrichedItems });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Short share URL redirect (no auth required — before SPA catch-all)
|
||||||
|
app.get("/s/:token", async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const token = c.req.param("token");
|
||||||
|
const result = await validateShareToken(db, token);
|
||||||
|
if (!result) return c.redirect("/", 302);
|
||||||
|
return c.redirect(`/setups/${result.setupId}?share=${token}`, 302);
|
||||||
|
});
|
||||||
|
|
||||||
// Auth middleware for all data routes (userId must be available for per-user scoping)
|
// Auth middleware for all data routes (userId must be available for per-user scoping)
|
||||||
app.use("/api/*", async (c, next) => {
|
app.use("/api/*", async (c, next) => {
|
||||||
// Skip auth routes — they handle their own auth
|
// Skip auth routes — they handle their own auth
|
||||||
@@ -178,6 +202,9 @@ app.use("/api/*", async (c, next) => {
|
|||||||
// Skip public tags endpoint (GET /api/tags)
|
// Skip public tags endpoint (GET /api/tags)
|
||||||
if (c.req.path.startsWith("/api/tags") && c.req.method === "GET")
|
if (c.req.path.startsWith("/api/tags") && c.req.method === "GET")
|
||||||
return next();
|
return next();
|
||||||
|
// Skip shared setup access (GET /api/shared/:token)
|
||||||
|
if (c.req.path.startsWith("/api/shared/") && c.req.method === "GET")
|
||||||
|
return next();
|
||||||
// Skip public discovery endpoints (GET /api/discovery/*)
|
// Skip public discovery endpoints (GET /api/discovery/*)
|
||||||
if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET")
|
if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET")
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { zValidator } from "@hono/zod-validator";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
createSetupSchema,
|
createSetupSchema,
|
||||||
|
createShareLinkSchema,
|
||||||
syncSetupItemsSchema,
|
syncSetupItemsSchema,
|
||||||
updateClassificationSchema,
|
updateClassificationSchema,
|
||||||
updateSetupSchema,
|
updateSetupSchema,
|
||||||
@@ -18,6 +19,11 @@ import {
|
|||||||
updateItemClassification,
|
updateItemClassification,
|
||||||
updateSetup,
|
updateSetup,
|
||||||
} from "../services/setup.service.ts";
|
} from "../services/setup.service.ts";
|
||||||
|
import {
|
||||||
|
createShareLink,
|
||||||
|
getShareLinks,
|
||||||
|
revokeShareLink,
|
||||||
|
} from "../services/share.service.ts";
|
||||||
import { withImageUrls } from "../services/storage.service.ts";
|
import { withImageUrls } from "../services/storage.service.ts";
|
||||||
|
|
||||||
type Env = { Variables: { db?: any; userId?: number } };
|
type Env = { Variables: { db?: any; userId?: number } };
|
||||||
@@ -125,4 +131,43 @@ app.delete("/:id/items/:itemId", async (c) => {
|
|||||||
return c.json({ success: true });
|
return c.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Share Links
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/:id/shares",
|
||||||
|
zValidator("json", createShareLinkSchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const userId = c.get("userId")!;
|
||||||
|
const setupId = parseId(c.req.param("id"));
|
||||||
|
if (!setupId) return c.json({ error: "Invalid setup ID" }, 400);
|
||||||
|
const data = c.req.valid("json");
|
||||||
|
const share = await createShareLink(db, userId, setupId, {
|
||||||
|
expiresInDays: data.expiresInDays,
|
||||||
|
});
|
||||||
|
if (!share) return c.json({ error: "Setup not found" }, 404);
|
||||||
|
return c.json(share, 201);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get("/:id/shares", async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const userId = c.get("userId")!;
|
||||||
|
const setupId = parseId(c.req.param("id"));
|
||||||
|
if (!setupId) return c.json({ error: "Invalid setup ID" }, 400);
|
||||||
|
const links = await getShareLinks(db, userId, setupId);
|
||||||
|
if (!links) return c.json({ error: "Setup not found" }, 404);
|
||||||
|
return c.json(links);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/:id/shares/:shareId", async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const userId = c.get("userId")!;
|
||||||
|
const shareId = parseId(c.req.param("shareId"));
|
||||||
|
if (!shareId) return c.json({ error: "Invalid share ID" }, 400);
|
||||||
|
const share = await revokeShareLink(db, userId, shareId);
|
||||||
|
if (!share) return c.json({ error: "Share not found" }, 404);
|
||||||
|
return c.json(share);
|
||||||
|
});
|
||||||
|
|
||||||
export { app as setupRoutes };
|
export { app as setupRoutes };
|
||||||
|
|||||||
@@ -117,6 +117,57 @@ export async function getSetupWithItems(
|
|||||||
return { ...setup, items: itemList };
|
return { ...setup, items: itemList };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get setup with items by setup ID only (no user/visibility check).
|
||||||
|
* Used for share-token-authorized access where the token already authorizes viewing.
|
||||||
|
*/
|
||||||
|
export async function getSetupWithItemsById(db: Db, setupId: number) {
|
||||||
|
const [setup] = await db.select().from(setups).where(eq(setups.id, setupId));
|
||||||
|
if (!setup) return null;
|
||||||
|
|
||||||
|
const itemList = await db
|
||||||
|
.select({
|
||||||
|
id: items.id,
|
||||||
|
name: sql<string>`COALESCE(
|
||||||
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||||
|
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
||||||
|
ELSE ${items.name}
|
||||||
|
END,
|
||||||
|
${items.name}
|
||||||
|
)`.as("name"),
|
||||||
|
weightGrams: sql<number | null>`COALESCE(
|
||||||
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
|
||||||
|
${items.weightGrams}
|
||||||
|
)`.as("weight_grams"),
|
||||||
|
priceCents: sql<number | null>`COALESCE(
|
||||||
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
|
||||||
|
${items.priceCents}
|
||||||
|
)`.as("price_cents"),
|
||||||
|
quantity: items.quantity,
|
||||||
|
categoryId: items.categoryId,
|
||||||
|
notes: items.notes,
|
||||||
|
productUrl: items.productUrl,
|
||||||
|
imageFilename: sql<string | null>`COALESCE(
|
||||||
|
${items.imageFilename},
|
||||||
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
|
||||||
|
)`.as("image_filename"),
|
||||||
|
globalItemId: items.globalItemId,
|
||||||
|
purchasePriceCents: items.purchasePriceCents,
|
||||||
|
createdAt: items.createdAt,
|
||||||
|
updatedAt: items.updatedAt,
|
||||||
|
categoryName: categories.name,
|
||||||
|
categoryIcon: categories.icon,
|
||||||
|
classification: setupItems.classification,
|
||||||
|
})
|
||||||
|
.from(setupItems)
|
||||||
|
.innerJoin(items, eq(setupItems.itemId, items.id))
|
||||||
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
||||||
|
.where(eq(setupItems.setupId, setupId));
|
||||||
|
|
||||||
|
return { ...setup, items: itemList };
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateSetup(
|
export async function updateSetup(
|
||||||
db: Db,
|
db: Db,
|
||||||
userId: number,
|
userId: number,
|
||||||
@@ -124,7 +175,7 @@ export async function updateSetup(
|
|||||||
data: UpdateSetup,
|
data: UpdateSetup,
|
||||||
) {
|
) {
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ id: setups.id })
|
.select({ id: setups.id, visibility: setups.visibility })
|
||||||
.from(setups)
|
.from(setups)
|
||||||
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
|
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
@@ -143,6 +194,25 @@ export async function updateSetup(
|
|||||||
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)))
|
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
// Handle visibility transition side effects for share links
|
||||||
|
if (
|
||||||
|
data.visibility !== undefined &&
|
||||||
|
data.visibility !== existing.visibility
|
||||||
|
) {
|
||||||
|
const { deactivateShareLinks, reactivateShareLinks } = await import(
|
||||||
|
"./share.service.ts"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.visibility === "private" && existing.visibility !== "private") {
|
||||||
|
await deactivateShareLinks(db, setupId);
|
||||||
|
} else if (
|
||||||
|
data.visibility !== "private" &&
|
||||||
|
existing.visibility === "private"
|
||||||
|
) {
|
||||||
|
await reactivateShareLinks(db, setupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
136
src/server/services/share.service.ts
Normal file
136
src/server/services/share.service.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { and, eq, gt, isNotNull, isNull, or } from "drizzle-orm";
|
||||||
|
import type { db as prodDb } from "../../db/index.ts";
|
||||||
|
import { setups, shares } from "../../db/schema.ts";
|
||||||
|
|
||||||
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a share link for a setup.
|
||||||
|
* Returns null if the setup doesn't belong to the user.
|
||||||
|
*/
|
||||||
|
export async function createShareLink(
|
||||||
|
db: Db,
|
||||||
|
userId: number,
|
||||||
|
setupId: number,
|
||||||
|
options: { expiresInDays: number | null },
|
||||||
|
) {
|
||||||
|
// Verify ownership
|
||||||
|
const [setup] = await db
|
||||||
|
.select({ id: setups.id })
|
||||||
|
.from(setups)
|
||||||
|
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
|
||||||
|
if (!setup) return null;
|
||||||
|
|
||||||
|
const token = randomBytes(16).toString("base64url");
|
||||||
|
const expiresAt = options.expiresInDays
|
||||||
|
? new Date(Date.now() + options.expiresInDays * 86400000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(shares)
|
||||||
|
.values({
|
||||||
|
setupId,
|
||||||
|
token,
|
||||||
|
permission: "read",
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all share links for a setup owned by the user.
|
||||||
|
* Returns links ordered by createdAt desc.
|
||||||
|
*/
|
||||||
|
export async function getShareLinks(db: Db, userId: number, setupId: number) {
|
||||||
|
// Verify ownership
|
||||||
|
const [setup] = await db
|
||||||
|
.select({ id: setups.id })
|
||||||
|
.from(setups)
|
||||||
|
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
|
||||||
|
if (!setup) return null;
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(shares)
|
||||||
|
.where(eq(shares.setupId, setupId))
|
||||||
|
.orderBy(shares.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a share link. Returns null if the share doesn't belong to the user's setup.
|
||||||
|
*/
|
||||||
|
export async function revokeShareLink(db: Db, userId: number, shareId: number) {
|
||||||
|
// Join shares with setups to verify ownership
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ shareId: shares.id, setupId: shares.setupId })
|
||||||
|
.from(shares)
|
||||||
|
.innerJoin(setups, eq(shares.setupId, setups.id))
|
||||||
|
.where(and(eq(shares.id, shareId), eq(setups.userId, userId)));
|
||||||
|
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.update(shares)
|
||||||
|
.set({ revokedAt: new Date() })
|
||||||
|
.where(eq(shares.id, shareId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a share token. Returns { setupId, permission } if valid, null otherwise.
|
||||||
|
* Invalid = nonexistent, revoked, or expired.
|
||||||
|
*/
|
||||||
|
export async function validateShareToken(db: Db, token: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
setupId: shares.setupId,
|
||||||
|
permission: shares.permission,
|
||||||
|
expiresAt: shares.expiresAt,
|
||||||
|
})
|
||||||
|
.from(shares)
|
||||||
|
.where(and(eq(shares.token, token), isNull(shares.revokedAt)));
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (row.expiresAt && row.expiresAt < new Date()) return null;
|
||||||
|
|
||||||
|
return { setupId: row.setupId, permission: row.permission };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate all active share links for a setup.
|
||||||
|
* Called when visibility transitions to "private".
|
||||||
|
*/
|
||||||
|
export async function deactivateShareLinks(db: Db, setupId: number) {
|
||||||
|
await db
|
||||||
|
.update(shares)
|
||||||
|
.set({ revokedAt: new Date() })
|
||||||
|
.where(and(eq(shares.setupId, setupId), isNull(shares.revokedAt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactivate share links for a setup.
|
||||||
|
* Called when visibility transitions from "private" back to "link" or "public".
|
||||||
|
* Clears revokedAt on all non-expired shares for the setup.
|
||||||
|
*/
|
||||||
|
export async function reactivateShareLinks(db: Db, setupId: number) {
|
||||||
|
// Per D-03: clear revokedAt on all non-expired shares for the setup.
|
||||||
|
// This reactivates everything including manually revoked — acceptable UX
|
||||||
|
// since user explicitly chose to re-enable sharing.
|
||||||
|
await db
|
||||||
|
.update(shares)
|
||||||
|
.set({ revokedAt: null })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(shares.setupId, setupId),
|
||||||
|
isNotNull(shares.revokedAt),
|
||||||
|
or(isNull(shares.expiresAt), gt(shares.expiresAt, new Date())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -96,6 +96,12 @@ export const updateSetupSchema = z.object({
|
|||||||
visibility: z.enum(["private", "link", "public"]).optional(),
|
visibility: z.enum(["private", "link", "public"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createShareLinkSchema = z.object({
|
||||||
|
expiresInDays: z
|
||||||
|
.union([z.literal(7), z.literal(14), z.literal(30), z.null()])
|
||||||
|
.default(14),
|
||||||
|
});
|
||||||
|
|
||||||
export const syncSetupItemsSchema = z.object({
|
export const syncSetupItemsSchema = z.object({
|
||||||
itemIds: z.array(z.number().int().positive()),
|
itemIds: z.array(z.number().int().positive()),
|
||||||
});
|
});
|
||||||
|
|||||||
222
tests/services/share.service.test.ts
Normal file
222
tests/services/share.service.test.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import * as schema from "../../src/db/schema.ts";
|
||||||
|
import {
|
||||||
|
createSetup,
|
||||||
|
updateSetup,
|
||||||
|
} from "../../src/server/services/setup.service.ts";
|
||||||
|
import {
|
||||||
|
createShareLink,
|
||||||
|
deactivateShareLinks,
|
||||||
|
getShareLinks,
|
||||||
|
reactivateShareLinks,
|
||||||
|
revokeShareLink,
|
||||||
|
validateShareToken,
|
||||||
|
} from "../../src/server/services/share.service.ts";
|
||||||
|
import { createSecondTestUser, createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
|
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
|
||||||
|
|
||||||
|
describe("Share Service", () => {
|
||||||
|
let db: TestDb["db"];
|
||||||
|
let userId: number;
|
||||||
|
let setupId: number;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const testData = await createTestDb();
|
||||||
|
db = testData.db;
|
||||||
|
userId = testData.userId;
|
||||||
|
|
||||||
|
const setup = await createSetup(db, userId, {
|
||||||
|
name: "Test Setup",
|
||||||
|
visibility: "link",
|
||||||
|
});
|
||||||
|
setupId = setup.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createShareLink", () => {
|
||||||
|
it("creates a share link with token and expiration", async () => {
|
||||||
|
const share = await createShareLink(db, userId, setupId, {
|
||||||
|
expiresInDays: 7,
|
||||||
|
});
|
||||||
|
expect(share).not.toBeNull();
|
||||||
|
expect(share!.token).toBeTruthy();
|
||||||
|
expect(share!.token.length).toBeGreaterThanOrEqual(20);
|
||||||
|
expect(share!.setupId).toBe(setupId);
|
||||||
|
expect(share!.permission).toBe("read");
|
||||||
|
expect(share!.expiresAt).not.toBeNull();
|
||||||
|
expect(share!.revokedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a share link with no expiration when null", async () => {
|
||||||
|
const share = await createShareLink(db, userId, setupId, {
|
||||||
|
expiresInDays: null,
|
||||||
|
});
|
||||||
|
expect(share).not.toBeNull();
|
||||||
|
expect(share!.expiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-owned setup", async () => {
|
||||||
|
const otherUserId = await createSecondTestUser(db);
|
||||||
|
const share = await createShareLink(db, otherUserId, setupId, {
|
||||||
|
expiresInDays: 14,
|
||||||
|
});
|
||||||
|
expect(share).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates unique URL-safe tokens", async () => {
|
||||||
|
const share1 = await createShareLink(db, userId, setupId, {
|
||||||
|
expiresInDays: 14,
|
||||||
|
});
|
||||||
|
const share2 = await createShareLink(db, userId, setupId, {
|
||||||
|
expiresInDays: 14,
|
||||||
|
});
|
||||||
|
expect(share1!.token).not.toBe(share2!.token);
|
||||||
|
// base64url should not contain +, /, or =
|
||||||
|
expect(share1!.token).not.toMatch(/[+/=]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getShareLinks", () => {
|
||||||
|
it("returns all shares for a setup owned by the user", async () => {
|
||||||
|
await createShareLink(db, userId, setupId, { expiresInDays: 7 });
|
||||||
|
await createShareLink(db, userId, setupId, { expiresInDays: 14 });
|
||||||
|
|
||||||
|
const links = await getShareLinks(db, userId, setupId);
|
||||||
|
expect(links).not.toBeNull();
|
||||||
|
expect(links!).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-owned setup", async () => {
|
||||||
|
const otherUserId = await createSecondTestUser(db);
|
||||||
|
const links = await getShareLinks(db, otherUserId, setupId);
|
||||||
|
expect(links).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("revokeShareLink", () => {
|
||||||
|
it("sets revokedAt on the share", async () => {
|
||||||
|
const share = await createShareLink(db, userId, setupId, {
|
||||||
|
expiresInDays: 14,
|
||||||
|
});
|
||||||
|
const revoked = await revokeShareLink(db, userId, share!.id);
|
||||||
|
expect(revoked).not.toBeNull();
|
||||||
|
expect(revoked!.revokedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-owned share", async () => {
|
||||||
|
const share = await createShareLink(db, userId, setupId, {
|
||||||
|
expiresInDays: 14,
|
||||||
|
});
|
||||||
|
const otherUserId = await createSecondTestUser(db);
|
||||||
|
const result = await revokeShareLink(db, otherUserId, share!.id);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateShareToken", () => {
|
||||||
|
it("returns setupId for valid token", async () => {
|
||||||
|
const share = await createShareLink(db, userId, setupId, {
|
||||||
|
expiresInDays: 14,
|
||||||
|
});
|
||||||
|
const result = await validateShareToken(db, share!.token);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.setupId).toBe(setupId);
|
||||||
|
expect(result!.permission).toBe("read");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for expired token", async () => {
|
||||||
|
const share = await createShareLink(db, userId, setupId, {
|
||||||
|
expiresInDays: 1,
|
||||||
|
});
|
||||||
|
// Manually set expiresAt to the past
|
||||||
|
await db
|
||||||
|
.update(schema.shares)
|
||||||
|
.set({ expiresAt: new Date(Date.now() - 86400000) })
|
||||||
|
.where(eq(schema.shares.id, share!.id));
|
||||||
|
|
||||||
|
const result = await validateShareToken(db, share!.token);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for revoked token", async () => {
|
||||||
|
const share = await createShareLink(db, userId, setupId, {
|
||||||
|
expiresInDays: 14,
|
||||||
|
});
|
||||||
|
await revokeShareLink(db, userId, share!.id);
|
||||||
|
const result = await validateShareToken(db, share!.token);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for nonexistent token", async () => {
|
||||||
|
const result = await validateShareToken(db, "nonexistent-token");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deactivateShareLinks", () => {
|
||||||
|
it("sets revokedAt on all active links for a setup", async () => {
|
||||||
|
await createShareLink(db, userId, setupId, { expiresInDays: 7 });
|
||||||
|
await createShareLink(db, userId, setupId, { expiresInDays: null });
|
||||||
|
|
||||||
|
await deactivateShareLinks(db, setupId);
|
||||||
|
|
||||||
|
const links = await getShareLinks(db, userId, setupId);
|
||||||
|
for (const link of links!) {
|
||||||
|
expect(link.revokedAt).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reactivateShareLinks", () => {
|
||||||
|
it("clears revokedAt on deactivated links", async () => {
|
||||||
|
await createShareLink(db, userId, setupId, { expiresInDays: null });
|
||||||
|
await createShareLink(db, userId, setupId, { expiresInDays: 14 });
|
||||||
|
|
||||||
|
await deactivateShareLinks(db, setupId);
|
||||||
|
await reactivateShareLinks(db, setupId);
|
||||||
|
|
||||||
|
const links = await getShareLinks(db, userId, setupId);
|
||||||
|
for (const link of links!) {
|
||||||
|
expect(link.revokedAt).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("visibility transition side effects", () => {
|
||||||
|
it("deactivates links when visibility changes to private", async () => {
|
||||||
|
await createShareLink(db, userId, setupId, { expiresInDays: 14 });
|
||||||
|
|
||||||
|
await updateSetup(db, userId, setupId, {
|
||||||
|
name: "Test Setup",
|
||||||
|
visibility: "private",
|
||||||
|
});
|
||||||
|
|
||||||
|
const links = await getShareLinks(db, userId, setupId);
|
||||||
|
for (const link of links!) {
|
||||||
|
expect(link.revokedAt).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reactivates links when visibility changes from private to link", async () => {
|
||||||
|
await createShareLink(db, userId, setupId, { expiresInDays: null });
|
||||||
|
|
||||||
|
// Deactivate by going private
|
||||||
|
await updateSetup(db, userId, setupId, {
|
||||||
|
name: "Test Setup",
|
||||||
|
visibility: "private",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reactivate by going back to link
|
||||||
|
await updateSetup(db, userId, setupId, {
|
||||||
|
name: "Test Setup",
|
||||||
|
visibility: "link",
|
||||||
|
});
|
||||||
|
|
||||||
|
const links = await getShareLinks(db, userId, setupId);
|
||||||
|
for (const link of links!) {
|
||||||
|
expect(link.revokedAt).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user