diff --git a/app/lib/transfer-service.server.ts b/app/lib/transfer-service.server.ts new file mode 100644 index 00000000..11369329 --- /dev/null +++ b/app/lib/transfer-service.server.ts @@ -0,0 +1,208 @@ +import { eq } from "drizzle-orm"; +import { drizzleClient } from "~/db.server"; +import { getDevice } from "~/models/device.server"; +import { createTransfer, getTransferByBoxId, isClaimExpired, removeTransfer, updateTransferExpiration } from "~/models/transfer.server"; +import { claim, type Claim, device } from "~/schema"; + + +export const createBoxTransfer = async ( + userId: string, + boxId: string, + expiresAtStr?: string +): Promise => { + const box = await getDevice({ id: boxId }); + + if (!box) { + throw new Error("Box not found"); + } + + if (box.user.id !== userId) { + throw new Error("You don't have permission to transfer this box"); + } + + const existingTransfer = await getTransferByBoxId(boxId); + if (existingTransfer) { + throw new Error("Transfer already exists for this device"); + } + + let expirationDate: Date | undefined; + + if (expiresAtStr) { + expirationDate = new Date(expiresAtStr); + + if (isNaN(expirationDate.getTime())) { + throw new Error("Invalid expiration date format"); + } + + if (expirationDate <= new Date()) { + throw new Error("Expiration date must be in the future"); + } + } + + const transferClaim = await createTransfer(boxId, expirationDate); + + return transferClaim; +}; + +export const getBoxTransfer = async ( + userId: string, + boxId: string +): Promise => { + const box = await getDevice({ id: boxId }); + + if (!box) { + throw new Error("Box not found"); + } + + if (box.user.id !== userId) { + throw new Error("You don't have permission to view this transfer"); + } + + const transfer = await getTransferByBoxId(boxId); + + if (!transfer) { + throw new Error("Transfer not found"); + } + + if (isClaimExpired(transfer.expiresAt)) { + throw new Error("Transfer token has expired"); + } + + return transfer; +}; + +export const removeBoxTransfer = async ( + userId: string, + boxId: string, + token: string + ): Promise => { + const box = await getDevice({id: boxId}); + if (!box) { + throw new Error("Box not found"); + } + + if (box.user.id !== userId) { + throw new Error("You don't have permission to remove this transfer"); + } + + await removeTransfer(boxId, token); + }; + + export const claimBox = async (userId: string, token: string) => { + const [activeClaim] = await drizzleClient + .select() + .from(claim) + .where(eq(claim.token, token)) + .limit(1); + + if (!activeClaim) { + throw new Error("Invalid or expired transfer token"); + } + + if (activeClaim.expiresAt && activeClaim.expiresAt <= new Date()) { + throw new Error("Transfer token has expired"); + } + + const [box] = await drizzleClient + .select() + .from(device) + .where(eq(device.id, activeClaim.boxId)) + .limit(1); + + if (!box) { + throw new Error("Device not found"); + } + + if (box.userId === userId) { + throw new Error("You already own this device"); + } + + await drizzleClient.transaction(async (tx) => { + await tx + .update(device) + .set({ userId, updatedAt: new Date() }) + .where(eq(device.id, activeClaim.boxId)); + + await tx + .delete(claim) + .where(eq(claim.id, activeClaim.id)); + }); + + return { message: "Device successfully claimed!", boxId: activeClaim.boxId }; + }; + +export const validateTransferParams = ( + boxId?: string, + expiresAt?: string + ): { isValid: boolean; error?: string } => { + if (!boxId || boxId.trim() === "") { + return { isValid: false, error: "Box ID is required" }; + } + + if (expiresAt) { + const date = new Date(expiresAt); + if (isNaN(date.getTime())) { + return { isValid: false, error: "Invalid date format" }; + } + if (date <= new Date()) { + return { isValid: false, error: "Expiration date must be in the future" }; + } + } + + return { isValid: true }; + }; + +/** + * Update transfer expiration date + * Only the box owner can update their transfer expiration + * + * @param userId - ID of the requesting user + * @param boxId - ID of the box + * @param token - The transfer token (for verification) + * @param newExpiresAtStr - New expiration date as ISO string + * @returns The updated transfer claim + */ +export const updateBoxTransferExpiration = async ( + userId: string, + boxId: string, + token: string, + newExpiresAtStr: string +): Promise => { + const box = await getDevice({ id: boxId }); + + if (!box) { + throw new Error("Box not found"); + } + + if (box.user.id !== userId) { + throw new Error("You don't have permission to update this transfer"); + } + + const transfer = await getTransferByBoxId(boxId); + + if (!transfer) { + throw new Error("Transfer not found"); + } + + if (transfer.token !== token) { + throw new Error("Invalid transfer token"); + } + + if (isClaimExpired(transfer.expiresAt)) { + throw new Error("Transfer token has expired"); + } + + const newExpiresAt = new Date(newExpiresAtStr); + + if (isNaN(newExpiresAt.getTime())) { + throw new Error("Invalid expiration date format"); + } + + if (newExpiresAt <= new Date()) { + throw new Error("Expiration date must be in the future"); + } + + const updated = await updateTransferExpiration(transfer.id, newExpiresAt); + + return updated; +}; \ No newline at end of file diff --git a/app/models/transfer.server.ts b/app/models/transfer.server.ts new file mode 100644 index 00000000..5cfb61ab --- /dev/null +++ b/app/models/transfer.server.ts @@ -0,0 +1,125 @@ +import { eq } from "drizzle-orm"; +import { drizzleClient } from "~/db.server"; +import { type Claim, claim, device, type Device } from "~/schema"; + +export interface TransferCode { + id: string; + boxId: string; + token: string; + expiresAt: Date; + createdAt: Date; + } + + export const getDefaultExpirationDate = (): Date => { + const now = new Date(); + now.setHours(now.getHours() + 24); + return now; + }; + + export const isClaimExpired = (expiresAt: Date | null): boolean => { + if (!expiresAt) return false; + return expiresAt <= new Date(); + }; + + export const createTransfer = async ( + boxId: string, + expiresAt?: Date + ): Promise => { + const token = generateTransferCode(); + const expirationDate = expiresAt || getDefaultExpirationDate(); + + const [newClaim] = await drizzleClient + .insert(claim) + .values({ + boxId, + token, + expiresAt: expirationDate, + }) + .returning(); + + if (!newClaim) { + throw new Error("Failed to create transfer claim"); + } + + return newClaim; + }; + + + export const generateTransferCode = (): string => { + const crypto = require('crypto'); + return crypto.randomBytes(6).toString('hex'); + }; + + export function getTransfer({ id }: Pick){ + return drizzleClient.query.claim.findFirst({ + where: (claim, {eq}) => eq(claim.boxId, id) + }) + }; + + +export const getTransferByBoxId = async ( + boxId: string +): Promise => { + const [result] = await drizzleClient + .select() + .from(claim) + .where(eq(claim.boxId, boxId)) + .limit(1); + + return result || null; +}; + +export const deleteClaimById = async (claimId: string): Promise => { + await drizzleClient.delete(claim).where(eq(claim.id, claimId)); +}; + +export const removeTransfer = async ( + boxId: string, + token: string +): Promise => { + const [existingClaim] = await drizzleClient + .select() + .from(claim) + .where(eq(claim.token, token) && eq(claim.boxId, boxId)); + + if (!existingClaim) { + throw new Error("Transfer token not found"); + } + + await drizzleClient + .delete(claim) + .where(eq(claim.id, existingClaim.id)); +}; + +export const updateTransferExpiration = async ( + claimId: string, + expiresAt: Date +): Promise => { + const [updated] = await drizzleClient + .update(claim) + .set({ + expiresAt, + updatedAt: new Date() + }) + .where(eq(claim.id, claimId)) + .returning(); + + if (!updated) { + throw new Error("Failed to update transfer claim"); + } + + return updated; +} + +export const getTransferByToken = async ( + token: string +): Promise => { + const [result] = await drizzleClient + .select() + .from(claim) + .where(eq(claim.token, token)) + .limit(1); + + return result || null; +}; + diff --git a/app/routes/api.claim.ts b/app/routes/api.claim.ts new file mode 100644 index 00000000..4810f75e --- /dev/null +++ b/app/routes/api.claim.ts @@ -0,0 +1,90 @@ +import { type ActionFunctionArgs } from "react-router"; +import { getUserFromJwt } from "~/lib/jwt"; +import { claimBox } from "~/lib/transfer-service.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const contentType = request.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + return Response.json( + { + code: "UnsupportedMediaType", + message: "Unsupported content-type. Try application/json", + }, + { + status: 415, + headers: { + "content-type": "application/json; charset=utf-8", + }, + } + ); + } + + if (request.method !== "POST") { + return new Response(null, { status: 405 }); + } + + const jwtResponse = await getUserFromJwt(request); + + + if (typeof jwtResponse === "string") { + return Response.json( + { + code: "Forbidden", + message: "Invalid JWT. Please sign in", + }, + { status: 403 } + ); + } + + try { + const body = await request.json(); + const { token } = body; + + if (!token) { + return Response.json({ error: "token is required" }, { status: 400 }); + } + + const result = await claimBox(jwtResponse.id, token); + + return Response.json( + { + message: "Device successfully claimed!", + data: result, + }, + { status: 200 } + ); + } catch (err) { + console.error("Error claiming box:", err); + return handleClaimError(err); + } +}; + +const handleClaimError = (err: unknown) => { + if (err instanceof Error) { + const message = err.message; + + if ( + message.includes("expired") || + message.includes("Invalid or expired") + ) { + return Response.json({ error: message }, { status: 410 }); + } + + if (message.includes("not found")) { + return Response.json({ error: message }, { status: 404 }); + } + + if ( + message.includes("required") || + message.includes("Invalid") || + message.includes("already own") + ) { + return Response.json({ error: message }, { status: 400 }); + } + } + + return Response.json( + { error: "Internal server error" }, + { status: 500 } + ); +}; \ No newline at end of file diff --git a/app/routes/api.transfer.$deviceId.ts b/app/routes/api.transfer.$deviceId.ts new file mode 100644 index 00000000..7deccc22 --- /dev/null +++ b/app/routes/api.transfer.$deviceId.ts @@ -0,0 +1,167 @@ +import { type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router"; +import { getUserFromJwt } from "~/lib/jwt"; +import { + getBoxTransfer, + updateBoxTransferExpiration, +} from "~/lib/transfer-service.server"; + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const jwtResponse = await getUserFromJwt(request); + + if (typeof jwtResponse === "string") { + return Response.json( + { + code: "Forbidden", + message: + "Invalid JWT authorization. Please sign in to obtain new JWT.", + }, + { status: 403 } + ); + } + + const { deviceId } = params; + + if (!deviceId) { + return Response.json({ error: "Device ID is required" }, { status: 400 }); + } + + try { + // Get transfer details - will throw if user doesn't own the device or transfer doesn't exist + const transfer = await getBoxTransfer(jwtResponse.id, deviceId); + + return Response.json( + { + data: { + id: transfer.id, + token: transfer.token, + boxId: transfer.boxId, + expiresAt: transfer.expiresAt, + createdAt: transfer.createdAt, + updatedAt: transfer.updatedAt, + }, + }, + { status: 200 } + ); + } catch (err) { + console.error("Error fetching transfer:", err); + return handleTransferError(err); + } +}; + +export const action = async ({ params, request }: ActionFunctionArgs) => { + const jwtResponse = await getUserFromJwt(request); + + if (typeof jwtResponse === "string") { + return Response.json( + { + code: "Forbidden", + message: + "Invalid JWT authorization. Please sign in to obtain new JWT.", + }, + { status: 403 } + ); + } + + const { deviceId } = params; + + if (!deviceId) { + return Response.json({ error: "Device ID is required" }, { status: 400 }); + } + + if (request.method !== "PUT") { + return new Response(null, { status: 405 }); + } + + const contentType = request.headers.get("content-type"); + const isJson = contentType?.includes("application/json"); + + return handleUpdateTransfer(request, jwtResponse, deviceId, isJson); +}; + +const handleUpdateTransfer = async ( + request: Request, + user: any, + deviceId: string, + isJson: boolean | undefined +) => { + try { + let token: string | undefined; + let expiresAt: string | undefined; + + if (isJson) { + const body = await request.json(); + token = body.token; + expiresAt = body.expiresAt; + } else { + const formData = await request.formData(); + token = formData.get("token")?.toString(); + expiresAt = formData.get("expiresAt")?.toString(); + } + + if (!token) { + return Response.json({ error: "token is required" }, { status: 400 }); + } + + if (!expiresAt) { + return Response.json({ error: "expiresAt is required" }, { status: 400 }); + } + + const updated = await updateBoxTransferExpiration( + user.id, + deviceId, + token, + expiresAt + ); + + return Response.json( + { + message: "Transfer successfully updated", + data: { + id: updated.id, + boxId: updated.boxId, + token: updated.token, + expiresAt: updated.expiresAt, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + }, + }, + { status: 200 } + ); + } catch (err) { + console.error("Error updating transfer:", err); + return handleTransferError(err); + } +}; + +const handleTransferError = (err: unknown) => { + if (err instanceof Error) { + const message = err.message; + + if (message.includes("not found")) { + return Response.json({ error: message }, { status: 404 }); + } + + if ( + message.includes("permission") || + message.includes("don't have") || + message.includes("not the owner") + ) { + return Response.json({ error: message }, { status: 403 }); + } + + if ( + message.includes("expired") || + message.includes("Invalid") || + message.includes("required") || + message.includes("format") || + message.includes("future") + ) { + return Response.json({ error: message }, { status: 400 }); + } + } + + return Response.json( + { error: "Internal server error" }, + { status: 500 } + ); +}; \ No newline at end of file diff --git a/app/routes/api.transfer.ts b/app/routes/api.transfer.ts new file mode 100644 index 00000000..94991b47 --- /dev/null +++ b/app/routes/api.transfer.ts @@ -0,0 +1,139 @@ +import { type ActionFunctionArgs } from "react-router"; +import { getUserFromJwt } from "~/lib/jwt"; +import { + createBoxTransfer, + removeBoxTransfer, + validateTransferParams, +} from "~/lib/transfer-service.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const jwtResponse = await getUserFromJwt(request); + + if (typeof jwtResponse === "string") { + return Response.json( + { + code: "Forbidden", + message: + "Invalid JWT authorization. Please sign in to obtain new JWT.", + }, + { status: 403 } + ); + } + + if (request.method !== "POST" && request.method !== "DELETE") { + return new Response(null, { status: 405 }); + } + + switch (request.method) { + case "POST": { + return handleCreateTransfer(request, jwtResponse); + } + case "DELETE": { + return handleRemoveTransfer(request, jwtResponse); + } + } +}; + +const handleCreateTransfer = async (request: Request, user: any) => { + try { + let boxId: string | undefined; + let expiresAt: string | undefined; + + const contentType = request.headers.get("content-type"); + if (contentType?.includes("application/json")) { + const body = await request.json(); + boxId = body.boxId; + expiresAt = body.expiresAt || body.date; // Support both param names for backwards compatibility + } else { + const formData = await request.formData(); + boxId = formData.get("boxId")?.toString(); + expiresAt = + formData.get("expiresAt")?.toString() || + formData.get("date")?.toString(); + } + + const validation = validateTransferParams(boxId, expiresAt); + if (!validation.isValid) { + return Response.json({ error: validation.error }, { status: 400 }); + } + + const transferCode = await createBoxTransfer(user.id, boxId!, expiresAt); + + return Response.json( + { + message: "Box successfully prepared for transfer", + data: transferCode, + }, + { status: 201 } + ); + } catch (err) { + console.error("Error creating transfer:", err); + return handleTransferError(err); + } +}; + +const handleRemoveTransfer = async (request: Request, user: any) => { + try { + let boxId: string | undefined; + let token: string | undefined; + + const contentType = request.headers.get("content-type"); + if (contentType?.includes("application/json")) { + const body = await request.json(); + boxId = body.boxId; + token = body.token; + } else { + const formData = await request.formData(); + boxId = formData.get("boxId")?.toString(); + token = formData.get("token")?.toString(); + } + + if (!boxId) { + return Response.json({ error: "boxId is required" }, { status: 400 }); + } + + if (!token) { + return Response.json({ error: "token is required" }, { status: 400 }); + } + + await removeBoxTransfer(user.id, boxId, token); + + return new Response(null, { status: 204 }); + } catch (err) { + console.error("Error removing transfer:", err); + return handleTransferError(err); + } +}; + +const handleTransferError = (err: unknown) => { + if (err instanceof Error) { + const message = err.message; + + if (message.includes("not found")) { + return Response.json({ error: message }, { status: 404 }); + } + + if ( + message.includes("permission") || + message.includes("don't have") || + message.includes("not the owner") + ) { + return Response.json({ error: message }, { status: 403 }); + } + + if ( + message.includes("expired") || + message.includes("Invalid") || + message.includes("required") || + message.includes("format") || + message.includes("future") + ) { + return Response.json({ error: message }, { status: 400 }); + } + } + + return Response.json( + { error: "Internal server error" }, + { status: 500 } + ); +}; \ No newline at end of file diff --git a/app/routes/api.ts b/app/routes/api.ts index edb37256..2a15d59d 100644 --- a/app/routes/api.ts +++ b/app/routes/api.ts @@ -117,26 +117,26 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = { path: `boxes`, method: "POST", }, - // { - // path: `boxes/claim`, - // method: "POST", - // }, - // { - // path: `boxes/transfer`, - // method: "POST", - // }, - // { - // path: `boxes/transfer`, - // method: "DELETE", - // }, - // { - // path: `boxes/transfer/:boxId`, - // method: "GET", - // }, - // { - // path: `boxes/transfer/:boxId`, - // method: "PUT", - // }, + { + path: `boxes/claim`, + method: "POST", + }, + { + path: `boxes/transfer`, + method: "POST", + }, + { + path: `boxes/transfer`, + method: "DELETE", + }, + { + path: `boxes/transfer/:boxId`, + method: "GET", + }, + { + path: `boxes/transfer/:boxId`, + method: "PUT", + }, // { // path: `boxes/:boxId`, // method: "PUT", diff --git a/app/schema/claim.ts b/app/schema/claim.ts new file mode 100644 index 00000000..e03c1824 --- /dev/null +++ b/app/schema/claim.ts @@ -0,0 +1,31 @@ +import { createId } from '@paralleldrive/cuid2' +import { + type InferInsertModel, + type InferSelectModel, +} from 'drizzle-orm' +import { index, pgTable, text, timestamp, unique } from 'drizzle-orm/pg-core' +import { device } from './device' + + +export const claim = pgTable( + 'claim', + { + id: text('id') + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + boxId: text('box_id') + .notNull() + .references(() => device.id, { onDelete: 'cascade' }), + token: text('token').notNull(), + expiresAt: timestamp('expires_at', { mode: 'date' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => [ + unique('unique_box_id').on(table.boxId), + index('claim_expires_at_idx').on(table.expiresAt), + ], +) +export type Claim = InferSelectModel +export type InsertClaim = InferInsertModel diff --git a/app/schema/index.ts b/app/schema/index.ts index b91766a1..8099e8d5 100644 --- a/app/schema/index.ts +++ b/app/schema/index.ts @@ -10,4 +10,5 @@ export * from "./user"; export * from "./location"; export * from "./log-entry"; export * from "./refreshToken"; +export * from "./claim"; export * from "./accessToken"; diff --git a/drizzle/0022_odd_sugar_man.sql b/drizzle/0022_odd_sugar_man.sql new file mode 100644 index 00000000..94076fe4 --- /dev/null +++ b/drizzle/0022_odd_sugar_man.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS "claim" ( + "id" text PRIMARY KEY NOT NULL, + "box_id" text NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "unique_box_id" UNIQUE("box_id") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "claim" ADD CONSTRAINT "claim_box_id_device_id_fk" FOREIGN KEY ("box_id") REFERENCES "public"."device"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "claim_expires_at_idx" ON "claim" USING btree ("expires_at"); \ No newline at end of file diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json new file mode 100644 index 00000000..bb478b93 --- /dev/null +++ b/drizzle/meta/0022_snapshot.json @@ -0,0 +1,1263 @@ +{ + "id": "95fc2b5e-a6d7-426d-bfd8-7c5238f5722b", + "prevId": "85481101-dd0d-4e15-9158-11971b8ba509", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_to_location": { + "name": "device_to_location", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_to_location_device_id_device_id_fk": { + "name": "device_to_location_device_id_device_id_fk", + "tableFrom": "device_to_location", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_to_location_location_id_location_id_fk": { + "name": "device_to_location_location_id_location_id_fk", + "tableFrom": "device_to_location", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_to_location_device_id_location_id_time_pk": { + "name": "device_to_location_device_id_location_id_time_pk", + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "uniqueConstraints": { + "device_to_location_device_id_location_id_time_unique": { + "name": "device_to_location_device_id_location_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.measurement": { + "name": "measurement", + "schema": "", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "measurement_location_id_location_id_fk": { + "name": "measurement_location_id_location_id_fk", + "tableFrom": "measurement", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_request": { + "name": "password_reset_request", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_request_user_id_user_id_fk": { + "name": "password_reset_request_user_id_user_id_fk", + "tableFrom": "password_reset_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_request_user_id_unique": { + "name": "password_reset_request_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_username_unique": { + "name": "profile_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unconfirmed_email": { + "name": "unconfirmed_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_confirmation_token": { + "name": "email_confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_unconfirmed_email_unique": { + "name": "user_unconfirmed_email_unique", + "nullsNotDistinct": false, + "columns": [ + "unconfirmed_email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "location_index": { + "name": "location_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "location_location_unique": { + "name": "location_location_unique", + "nullsNotDistinct": false, + "columns": [ + "location" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entry": { + "name": "log_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_revocation": { + "name": "token_revocation", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.claim": { + "name": "claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "box_id": { + "name": "box_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "claim_expires_at_idx": { + "name": "claim_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "claim_box_id_device_id_fk": { + "name": "claim_box_id_device_id_fk", + "tableFrom": "claim", + "tableTo": "device", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_box_id": { + "name": "unique_box_id", + "nullsNotDistinct": false, + "columns": [ + "box_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_token_device_id_device_id_fk": { + "name": "access_token_device_id_device_id_fk", + "tableFrom": "access_token", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "senseBox:Edu", + "luftdaten.info", + "Custom" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.measurement_10min": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_10min", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1day": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1day", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1hour": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1hour", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1month": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1month", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1year": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1year", + "schema": "public", + "isExisting": true, + "materialized": true + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 01fc353f..176578a1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1759928743654, "tag": "0021_tense_sir_ram", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1761122113831, + "tag": "0022_odd_sugar_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/tests/routes/api.claim.spec.ts b/tests/routes/api.claim.spec.ts new file mode 100644 index 00000000..abf90c98 --- /dev/null +++ b/tests/routes/api.claim.spec.ts @@ -0,0 +1,253 @@ +import { type ActionFunctionArgs } from "react-router"; +import { BASE_URL } from "vitest.setup"; +import { createToken } from "~/lib/jwt"; +import { registerUser } from "~/lib/user-service.server"; +import { createDevice, getDevice } from "~/models/device.server"; +import { deleteUserByEmail } from "~/models/user.server"; +import { action as claimAction } from "~/routes/api.claim"; +import { action as transferAction } from "~/routes/api.transfer"; +import { type Device, type User } from "~/schema"; + +const CLAIM_TEST_USER = { + name: "claimtestuser" + Date.now(), + email: `claimtest${Date.now()}@test.com`, + password: "highlySecurePasswordForTesting", +}; + +const createTestUser = async (suffix: string): Promise => { + const result = await registerUser( + "testuser" + suffix, + `test${suffix}@test.com`, + "password123", + "en_US" + ); + + if (!result || (typeof result === 'object' && 'isValid' in result)) { + throw new Error("Failed to create test user"); + } + + return result as User; +}; + +const generateMinimalDevice = ( + location: number[] | {} = [123, 12, 34], + exposure = "mobile", + name = "" + new Date().getTime() +) => ({ + exposure, + location, + name, + model: "homeV2Ethernet", +}); + +describe("openSenseMap API Routes: /boxes/claim", () => { + let user: User | null = null; + let jwt: string = ""; + let queryableDevice: Device | null = null; + + beforeAll(async () => { + const testUser = await registerUser( + CLAIM_TEST_USER.name, + CLAIM_TEST_USER.email, + CLAIM_TEST_USER.password, + "en_US" + ); + user = testUser as User; + const { token: t } = await createToken(testUser as User); + jwt = t; + + queryableDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 123, longitude: 12 }, + (testUser as User).id + ); + }); + + afterAll(async () => { + await deleteUserByEmail(CLAIM_TEST_USER.email); + }); + + describe("POST /boxes/claim", () => { + it("should claim a device and transfer ownership from one user to another", async () => { + // Create a new transfer for the claim test + const createTransferRequest = new Request(`${BASE_URL}/boxes/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${jwt}`, + }, + body: new URLSearchParams({ boxId: queryableDevice!.id }), + }); + + const transferResponse = (await transferAction({ + request: createTransferRequest, + } as ActionFunctionArgs)) as Response; + + const transferBody = await transferResponse.json(); + const claimToken = transferBody.data.token; + + const newUser = await createTestUser(Date.now().toString()); + const { token: newUserJwt } = await createToken(newUser); + + // Claim the device + const claimRequest = new Request(`${BASE_URL}/boxes/claim`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${newUserJwt}`, + }, + body: JSON.stringify({ token: claimToken }), + }); + + const claimResponse = (await claimAction({ + request: claimRequest, + } as ActionFunctionArgs)) as Response; + + expect(claimResponse.status).toBe(200); + const claimBody = await claimResponse.json(); + expect(claimBody.message).toBe("Device successfully claimed!"); + expect(claimBody.data.boxId).toBe(queryableDevice!.id); + + // Verify the device is now owned by the new user + const updatedDevice = await getDevice({ id: queryableDevice!.id }); + expect(updatedDevice?.user.id).toBe(newUser.id); + + // Verify the transfer token is deleted (can't be used again) + const reusedClaimRequest = new Request(`${BASE_URL}/boxes/claim`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${newUserJwt}`, + }, + body: JSON.stringify({ token: claimToken }), + }); + + const reusedResponse = (await claimAction({ + request: reusedClaimRequest, + } as ActionFunctionArgs)) as Response; + + expect(reusedResponse.status).toBe(410); + + // Cleanup + await deleteUserByEmail((newUser as User).email); + }); + + it("should reject claim with invalid content-type", async () => { + // Create a fresh device for this test + const testDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 456, longitude: 78 }, + (user as User).id + ); + + // Create a transfer for this test + const createTransferRequest = new Request(`${BASE_URL}/boxes/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${jwt}`, + }, + body: new URLSearchParams({ boxId: testDevice!.id }), + }); + + const transferResponse = (await transferAction({ + request: createTransferRequest, + } as ActionFunctionArgs)) as Response; + + expect(transferResponse.status).toBe(201); + const transferBody = await transferResponse.json(); + expect(transferBody.data).toBeDefined(); + const claimToken = transferBody.data.token; + + const claimRequest = new Request(`${BASE_URL}/boxes/claim`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${jwt}`, + }, + body: new URLSearchParams({ token: claimToken }), + }); + + const claimResponse = (await claimAction({ + request: claimRequest, + } as ActionFunctionArgs)) as Response; + + expect(claimResponse.status).toBe(415); + const body = await claimResponse.json(); + expect(body.code).toBe("UnsupportedMediaType"); + expect(body.message).toContain("application/json"); + }); + + it("should reject claim without Authorization header", async () => { + // Create a fresh device for this test + const testDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 789, longitude: 101 }, + (user as User).id + ); + + // Create a transfer for this test + const createTransferRequest = new Request(`${BASE_URL}/boxes/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${jwt}`, + }, + body: new URLSearchParams({ boxId: testDevice!.id }), + }); + + const transferResponse = (await transferAction({ + request: createTransferRequest, + } as ActionFunctionArgs)) as Response; + + expect(transferResponse.status).toBe(201); + const transferBody = await transferResponse.json(); + expect(transferBody.data).toBeDefined(); + const claimToken = transferBody.data.token; + + const claimRequest = new Request(`${BASE_URL}/boxes/claim`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: claimToken }), + }); + + const claimResponse = (await claimAction({ + request: claimRequest, + } as ActionFunctionArgs)) as Response; + + expect(claimResponse.status).toBe(403); + const body = await claimResponse.json(); + expect(body.code).toBe("Forbidden"); + }); + + it("should reject claim with expired transfer token", async () => { + // Create a new user to attempt the claim + const newUser = await registerUser( + "claimer" + Date.now(), + `claimer${Date.now()}@test.com`, + "password123", + "en_US" + ); + const { token: newUserJwt } = await createToken(newUser as User); + + const claimRequest = new Request(`${BASE_URL}/boxes/claim`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${newUserJwt}`, + }, + body: JSON.stringify({ token: "invalid-or-expired-token" }), + }); + + const claimResponse = (await claimAction({ + request: claimRequest, + } as ActionFunctionArgs)) as Response; + + expect(claimResponse.status).toBe(410); + const body = await claimResponse.json(); + expect(body.error).toContain("expired"); + + // Cleanup + await deleteUserByEmail((newUser as User).email); + }); + }); +}); \ No newline at end of file diff --git a/tests/routes/api.transfers.spec.ts b/tests/routes/api.transfers.spec.ts new file mode 100644 index 00000000..830e4772 --- /dev/null +++ b/tests/routes/api.transfers.spec.ts @@ -0,0 +1,384 @@ +import { type LoaderFunctionArgs, type ActionFunctionArgs } from "react-router"; +import { BASE_URL } from "vitest.setup"; +import { createToken } from "~/lib/jwt"; +import { registerUser } from '~/lib/user-service.server' +import { createDevice } from "~/models/device.server"; +import { deleteUserByEmail } from "~/models/user.server"; +import {action as transferAction} from "~/routes/api.transfer" +import {action as transferUpdateAction, loader as transferLoader} from "~/routes/api.transfer.$deviceId" +import { type Device, type User } from "~/schema"; + +const TRANSFER_TEST_USER = { + name: 'asdfhwerskdfsdfnxmcv', + email: 'test@asdfasdasehrasdweradfsdjhgjdfhgf.endpoint', + password: 'highlySecurePasswordForTesting', +} + +const createTestUser = async (suffix: string): Promise => { + const result = await registerUser( + "testuser" + suffix, + `test${suffix}@test.com`, + "password123", + "en_US" + ); + + if (!result || (typeof result === 'object' && 'isValid' in result)) { + throw new Error("Failed to create test user"); + } + + return result as User; +}; + +const generateMinimalDevice = ( + location: number[] | {} = [123, 12, 34], + exposure = 'mobile', + name = '' + new Date().getTime(), +) => ({ + exposure, + location, + name, + model: 'homeV2Ethernet', +}) + +describe("openSenseMap API Routes: /boxes/transfer and /boxes/claim", () => { + + let user: User | null = null + let jwt: string = '' + let queryableDevice: Device | null = null + + let transferToken: string = '' + let transferClaimId: string = '' + + beforeAll(async () => { + const testUser = await registerUser( + TRANSFER_TEST_USER.name, + TRANSFER_TEST_USER.email, + TRANSFER_TEST_USER.password, + 'en_US', + ) + user = testUser as User + const { token: t } = await createToken(testUser as User) + jwt = t + + queryableDevice = await createDevice( + { ...generateMinimalDevice(), latitude: 123, longitude: 12 }, + (testUser as User).id, + ) + + }) + + describe('POST /boxes/transfer', () => { + it("should mark a device for transferring", async () => { + + const request = new Request(`${BASE_URL}/boxes/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": `Bearer ${jwt}` + }, + body: new URLSearchParams({ boxId: queryableDevice!.id }), + }); + + const response = (await transferAction({ + request, + } as ActionFunctionArgs)) as Response; + + const body = await response.json(); + + transferToken = body.data.token; + transferClaimId = body.data.id; + + // Assertions + expect(response.status).toBe(201); + expect(body).toHaveProperty("message", "Box successfully prepared for transfer"); + expect(body).toHaveProperty("data"); + expect(body.data).toBeDefined(); + expect(body.data.token).toBeDefined(); + expect(typeof body.data.token).toBe("string"); + expect(body.data.token).toHaveLength(12); + expect(/^[0-9a-f]{12}$/.test(body.data.token)).toBe(true); // Hex format check + + expect(body.data.expiresAt).toBeDefined(); + const expiresAt = new Date(body.data.expiresAt); + const now = new Date(); + const diffInHours = (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60); + expect(diffInHours).toBeCloseTo(24, 1); + }); + + it("should reject if boxId is missing", async () => { + const request = new Request(`${BASE_URL}/boxes/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": `Bearer ${jwt}` + }, + body: new URLSearchParams({}), + }); + + const response = (await transferAction({ + request, + } as ActionFunctionArgs)) as Response; + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("required"); + }); + + it("should reject if device does not exist", async () => { + const request = new Request(`${BASE_URL}/boxes/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": `Bearer ${jwt}` + }, + body: new URLSearchParams({ boxId: "nonexistent-device-id" }), + }); + + const response = (await transferAction({ + request, + } as ActionFunctionArgs)) as Response; + + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toContain("not found"); + }); + + it("should reject if user does not own the device", async () => { + // Create another user + const otherUser = await registerUser( + "other" + Date.now(), + `other${Date.now()}@test.com`, + "password123", + "en_US" + ); + const { token: otherJwt } = await createToken(otherUser as User); + + const request = new Request(`${BASE_URL}/boxes/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${otherJwt}` + }, + body: JSON.stringify({ boxId: queryableDevice!.id }), + }); + + const response = (await transferAction({ + request, + } as ActionFunctionArgs)) as Response; + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toContain("permission"); + + // Cleanup + await deleteUserByEmail((otherUser as User).email); + }); + }) + + describe('GET /boxes/transfer/:deviceId', () => { + it("should get transfer information for a device", async () => { + const request = new Request( + `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${jwt}`, + }, + }, + ); + + const response = (await transferLoader({ + request, + params: { deviceId: queryableDevice!.id }, + } as unknown as LoaderFunctionArgs)) as Response; + + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toHaveProperty("data"); + expect(body.data).not.toBeNull(); + expect(body.data.boxId).toBe(queryableDevice!.id); + expect(body.data.token).toBe(transferToken); + }); + + it("should reject if user does not own the device", async () => { + const otherUser = await registerUser( + "other" + Date.now(), + `other${Date.now()}@test.com`, + "password123", + "en_US" + ); + const { token: otherJwt } = await createToken(otherUser as User); + + const request = new Request( + `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${otherJwt}`, + }, + }, + ); + + const response = (await transferLoader({ + request, + params: { deviceId: queryableDevice!.id }, + } as unknown as LoaderFunctionArgs)) as Response; + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toContain("permission"); + + // Cleanup + await deleteUserByEmail((otherUser as User).email); + }); + }) + + describe('PUT /boxes/transfer/:deviceId', () => { + it("should update expiresAt of a transfer token", async () => { + const newExpiry = new Date(); + newExpiry.setDate(newExpiry.getDate() + 2); + + const request = new Request( + `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${jwt}`, + }, + body: new URLSearchParams({ + token: transferToken, + expiresAt: newExpiry.toISOString(), + }), + }, + ); + + const response = (await transferUpdateAction({ + request, + params: { deviceId: queryableDevice!.id }, + } as unknown as ActionFunctionArgs)) as Response; + + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.message).toBe("Transfer successfully updated"); + expect(body.data).toBeDefined(); + expect(body.data.token).toHaveLength(12); + expect(body.data.token).toBe(transferToken); + + const expiresAt = new Date(body.data.expiresAt); + const diffInHours = + (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60); + expect(diffInHours).toBeCloseTo(48, 1); + }); + + it("should reject with invalid token", async () => { + const newExpiry = new Date(); + newExpiry.setDate(newExpiry.getDate() + 2); + + const request = new Request( + `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ + token: "invalid-token-12345", + expiresAt: newExpiry.toISOString(), + }), + }, + ); + + const response = (await transferUpdateAction({ + request, + params: { deviceId: queryableDevice!.id }, + } as unknown as ActionFunctionArgs)) as Response; + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("Invalid"); + }); + + it("should reject with past expiration date", async () => { + const pastExpiry = new Date(); + pastExpiry.setDate(pastExpiry.getDate() - 1); + + const request = new Request( + `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ + token: transferToken, + expiresAt: pastExpiry.toISOString(), + }), + }, + ); + + const response = (await transferUpdateAction({ + request, + params: { deviceId: queryableDevice!.id }, + } as unknown as ActionFunctionArgs)) as Response; + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("future"); + }); + }) + + describe('DELETE /boxes/transfer', () => { + it('should revoke and delete a transfer token', async () => { + const request = new Request(`${BASE_URL}/boxes/transfer`, { + method: "DELETE", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": `Bearer ${jwt}` + }, + body: new URLSearchParams({ + boxId: queryableDevice!.id, + token: transferToken + }), + }); + + const response = (await transferAction({ + request, + } as ActionFunctionArgs)) as Response; + + expect(response.status).toBe(204); + + // Verify the transfer token is actually deleted by trying to update it + const verifyRequest = new Request( + `${BASE_URL}/boxes/transfer/${queryableDevice!.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${jwt}`, + }, + body: new URLSearchParams({ + token: transferToken, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }), + }, + ); + + const verifyResponse = (await transferUpdateAction({ + request: verifyRequest, + params: { deviceId: queryableDevice!.id }, + } as unknown as ActionFunctionArgs)) as Response; + + expect(verifyResponse.status).toBe(404); + const verifyBody = await verifyResponse.json(); + expect(verifyBody.error).toContain("not found"); + }); + }); + afterAll(async () => { + await deleteUserByEmail(TRANSFER_TEST_USER.email); + }); +}) \ No newline at end of file