diff --git a/src/lib/server/system-name.spec.ts b/src/lib/server/system-name.spec.ts new file mode 100644 index 0000000..d40cba9 --- /dev/null +++ b/src/lib/server/system-name.spec.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/db', () => ({ + prisma: { + licenseKey: { findFirst: vi.fn(), update: vi.fn() } + } +})); + +import { prisma } from '$lib/db'; +import { applySystemName, normalizeSystemName, SYSTEM_NAME_MAX_LENGTH } from './system-name'; + +const keyFindFirst = prisma.licenseKey.findFirst as ReturnType; +const keyUpdate = prisma.licenseKey.update as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('normalizeSystemName', () => { + it('trims and returns a case-folded copy', () => { + expect(normalizeSystemName(' Living Room ')).toEqual({ + ok: true, + name: 'Living Room', + lower: 'living room' + }); + }); + + it('accepts the length bounds', () => { + expect(normalizeSystemName('a'.repeat(SYSTEM_NAME_MAX_LENGTH)).ok).toBe(true); + expect(normalizeSystemName('a')).toEqual({ ok: true, name: 'a', lower: 'a' }); + }); + + it('rejects empty, whitespace, and out-of-range values', () => { + expect(normalizeSystemName('')).toEqual({ ok: false }); + expect(normalizeSystemName(' ')).toEqual({ ok: false }); + expect(normalizeSystemName('a'.repeat(SYSTEM_NAME_MAX_LENGTH + 1))).toEqual({ ok: false }); + }); + + it('rejects non-strings', () => { + expect(normalizeSystemName(undefined)).toEqual({ ok: false }); + expect(normalizeSystemName(null)).toEqual({ ok: false }); + expect(normalizeSystemName(42)).toEqual({ ok: false }); + }); +}); + +describe('applySystemName', () => { + it('reports unchanged without writing when identical', async () => { + const status = await applySystemName( + { id: 'k1', userId: 'u1', systemName: 'Living Room' }, + 'Living Room', + 'living room' + ); + + expect(status).toBe('unchanged'); + expect(keyFindFirst).not.toHaveBeenCalled(); + expect(keyUpdate).not.toHaveBeenCalled(); + }); + + it('reports conflict when another license on the account holds the name', async () => { + keyFindFirst.mockResolvedValue({ id: 'other' }); + + const status = await applySystemName( + { id: 'k1', userId: 'u1', systemName: null }, + 'Den', + 'den' + ); + + expect(status).toBe('conflict'); + expect(keyUpdate).not.toHaveBeenCalled(); + }); + + it('writes the name and its case-folded copy when free', async () => { + keyFindFirst.mockResolvedValue(null); + keyUpdate.mockResolvedValue({}); + + const status = await applySystemName( + { id: 'k1', userId: 'u1', systemName: null }, + 'Den', + 'den' + ); + + expect(status).toBe('ok'); + expect(keyUpdate).toHaveBeenCalledWith({ + where: { id: 'k1' }, + data: { systemName: 'Den', systemNameLower: 'den' } + }); + }); + + it('skips the uniqueness check for a license with no account', async () => { + keyUpdate.mockResolvedValue({}); + + const status = await applySystemName( + { id: 'k1', userId: null, systemName: null }, + 'Den', + 'den' + ); + + expect(status).toBe('ok'); + expect(keyFindFirst).not.toHaveBeenCalled(); + expect(keyUpdate).toHaveBeenCalled(); + }); + + it('maps a unique-constraint violation to conflict', async () => { + keyFindFirst.mockResolvedValue(null); + keyUpdate.mockRejectedValue({ code: 'P2002' }); + + const status = await applySystemName( + { id: 'k1', userId: 'u1', systemName: null }, + 'Den', + 'den' + ); + + expect(status).toBe('conflict'); + }); +}); diff --git a/src/lib/server/system-name.ts b/src/lib/server/system-name.ts new file mode 100644 index 0000000..6c45d9e --- /dev/null +++ b/src/lib/server/system-name.ts @@ -0,0 +1,73 @@ +// System Name helpers. A System Name is a human-readable label for a License +// Key, set only by the Kiosk and displayed read-only by the Portal. It is never +// a reference or foreign key. Uniqueness is per account (the owning User) and +// case-insensitive when non-null, enforced by the unique index on +// (userId, systemNameLower). + +import { prisma } from '$lib/db'; + +export const SYSTEM_NAME_MAX_LENGTH = 40; + +export type SystemNameStatus = 'ok' | 'unchanged' | 'conflict' | 'invalid'; + +export type NormalizedName = { ok: true; name: string; lower: string } | { ok: false }; + +/** Trim and validate a caller-supplied System Name. Stored as typed (trimmed); + * `lower` is the case-folded copy used for uniqueness. */ +export function normalizeSystemName(raw: unknown): NormalizedName { + if (typeof raw !== 'string') return { ok: false }; + const name = raw.trim(); + if (name.length < 1 || name.length > SYSTEM_NAME_MAX_LENGTH) return { ok: false }; + return { ok: true, name, lower: name.toLowerCase() }; +} + +type SystemNameLicense = { + id: string; + userId: string | null; + systemName: string | null; +}; + +function isUniqueViolation(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: string }).code === 'P2002' + ); +} + +/** + * Apply a normalized name to a license. Returns the resulting status: + * - `unchanged` when the stored name is already identical + * - `conflict` when another license on the same account holds the name + * - `ok` when the name was written + * A license with no owning account (legacy, userId null) is written without a + * uniqueness check; per-account uniqueness cannot apply without an account. + */ +export async function applySystemName( + license: SystemNameLicense, + name: string, + lower: string +): Promise { + if (license.systemName === name) return 'unchanged'; + + if (license.userId) { + const conflict = await prisma.licenseKey.findFirst({ + where: { + userId: license.userId, + systemNameLower: lower, + id: { not: license.id } + }, + select: { id: true } + }); + if (conflict) return 'conflict'; + } + + try { + await prisma.licenseKey.update({ + where: { id: license.id }, + data: { systemName: name, systemNameLower: lower } + }); + return 'ok'; + } catch (error) { + if (isUniqueViolation(error)) return 'conflict'; + throw error; + } +} diff --git a/src/lib/server/v2-serializers.spec.ts b/src/lib/server/v2-serializers.spec.ts index 2e3a102..0b94e3a 100644 --- a/src/lib/server/v2-serializers.spec.ts +++ b/src/lib/server/v2-serializers.spec.ts @@ -139,14 +139,16 @@ describe('serializeGroup', () => { }); describe('serializeLicense', () => { - it('returns only key and email_address (levels removed)', () => { + it('returns key, email_address, and the advisory systemNameStatus', () => { const result = serializeLicense({ key: 'XXXX-XXXX-XXXX-XXXX', - emailAddress: 'user@example.com' + emailAddress: 'user@example.com', + systemNameStatus: 'unchanged' }); expect(result).toEqual({ key: 'XXXX-XXXX-XXXX-XXXX', - email_address: 'user@example.com' + email_address: 'user@example.com', + systemNameStatus: 'unchanged' }); expect(Object.keys(result)).not.toContain('subscription_level'); }); diff --git a/src/lib/server/v2-serializers.ts b/src/lib/server/v2-serializers.ts index 5700851..28276f3 100644 --- a/src/lib/server/v2-serializers.ts +++ b/src/lib/server/v2-serializers.ts @@ -119,13 +119,20 @@ export function serializeGroup(g: { id: number; name: string; sortOrder: number export type V2License = { key: string; email_address: string; + systemNameStatus: string; }; -/** Build the POST /license response. */ -export function serializeLicense(opts: { key: string; emailAddress: string }): V2License { +/** Build the POST /license response. `systemNameStatus` is advisory: the Kiosk + * treats "ok"/"unchanged" as synced and reverts on "conflict"/"invalid". */ +export function serializeLicense(opts: { + key: string; + emailAddress: string; + systemNameStatus: string; +}): V2License { return { key: opts.key, - email_address: opts.emailAddress + email_address: opts.emailAddress, + systemNameStatus: opts.systemNameStatus }; } diff --git a/src/routes/api/v2/license/+server.ts b/src/routes/api/v2/license/+server.ts index b21644f..c7b9acc 100644 --- a/src/routes/api/v2/license/+server.ts +++ b/src/routes/api/v2/license/+server.ts @@ -4,6 +4,11 @@ import { prisma } from '$lib/db'; import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers'; import { rateLimit } from '$lib/server/v2-rate-limit'; import { serializeLicense } from '$lib/server/v2-serializers'; +import { + applySystemName, + normalizeSystemName, + type SystemNameStatus +} from '$lib/server/system-name'; const LICENSE_KEY_RE = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/; @@ -50,6 +55,20 @@ export const POST: RequestHandler = async ({ request }) => { throw error(400, 'key does not belong to specified user'); } + // Optional System Name (Kiosk is the only writer). Validation always + // succeeds; a non-ok name status is advisory. An omitted field never + // changes or clears the stored name; empty/whitespace is invalid, never a + // clear. + let systemNameStatus: SystemNameStatus = 'unchanged'; + if ('systemName' in body) { + const parsed = normalizeSystemName(body.systemName); + if (!parsed.ok) { + systemNameStatus = 'invalid'; + } else { + systemNameStatus = await applySystemName(storedKey, parsed.name, parsed.lower); + } + } + // Record that this active key was validated. A failure to record the // timestamp should not fail an otherwise valid validation. try { @@ -64,7 +83,8 @@ export const POST: RequestHandler = async ({ request }) => { return jsonOk( serializeLicense({ key: storedKey.key, - emailAddress: email + emailAddress: email, + systemNameStatus }) ); }; diff --git a/src/routes/api/v2/license/server.spec.ts b/src/routes/api/v2/license/server.spec.ts index 3fde419..cf63304 100644 --- a/src/routes/api/v2/license/server.spec.ts +++ b/src/routes/api/v2/license/server.spec.ts @@ -43,7 +43,8 @@ const storedKey = { id: 'key-1', key: 'ABCD-EFGH-IJKL-MNOP', active: true, - userId: 'user-1' + userId: 'user-1', + systemName: null }; describe('POST /api/v2/license', () => { @@ -64,7 +65,8 @@ describe('POST /api/v2/license', () => { const body = (await res.json()) as Record; expect(body).toEqual({ key: storedKey.key, - email_address: 'user@example.com' + email_address: 'user@example.com', + systemNameStatus: 'unchanged' }); expect(Object.keys(body)).not.toContain('subscription_level'); expect(mockKeyUpdate).toHaveBeenCalledWith({ @@ -147,3 +149,90 @@ describe('POST /api/v2/license', () => { expect(mockKeyFindFirst).not.toHaveBeenCalled(); }); }); + +describe('POST /api/v2/license systemName handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + _clearRateLimits(); + mockKeyFindFirst.mockResolvedValue(storedKey); + mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' }); + mockKeyUpdate.mockResolvedValue({}); + }); + + async function post(...args: unknown[]) { + const body: Record = { + license_key: storedKey.key, + email_address: 'user@example.com' + }; + if (args.length > 0) body.systemName = args[0]; + const res = await POST({ request: licenseRequest(body) } as POSTEvent); + return res; + } + + it('leaves the name untouched and reports unchanged when omitted', async () => { + const res = await post(); + + expect((await res.json()).systemNameStatus).toBe('unchanged'); + expect(mockKeyUpdate).toHaveBeenCalledTimes(1); + expect(mockKeyUpdate).toHaveBeenCalledWith({ + where: { id: storedKey.id }, + data: { lastValidatedAt: expect.any(Date) } + }); + }); + + it('sets a trimmed name, case-folded for uniqueness (ok)', async () => { + mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce(null); + + const res = await post(' Living Room '); + + expect((await res.json()).systemNameStatus).toBe('ok'); + expect(mockKeyUpdate).toHaveBeenCalledWith({ + where: { id: storedKey.id }, + data: { systemName: 'Living Room', systemNameLower: 'living room' } + }); + }); + + it('reports conflict without writing when another key holds the name', async () => { + mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce({ id: 'other' }); + + const res = await post('Living Room'); + + expect((await res.json()).systemNameStatus).toBe('conflict'); + expect(mockKeyUpdate).toHaveBeenCalledTimes(1); + }); + + it('treats differing casing as a conflict', async () => { + mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce({ id: 'other' }); + + const res = await post('living room'); + + expect((await res.json()).systemNameStatus).toBe('conflict'); + }); + + it('allows the same name in a different account', async () => { + mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce(null); + + const res = await post('Living Room'); + + expect((await res.json()).systemNameStatus).toBe('ok'); + }); + + it('reports invalid without writing for empty, whitespace, too long, or non-string', async () => { + for (const bad of ['', ' ', 'a'.repeat(41), 42]) { + const res = await post(bad); + expect((await res.json()).systemNameStatus).toBe('invalid'); + } + // Only the lastValidatedAt stamp is written; no name writes. + for (const call of mockKeyUpdate.mock.calls) { + expect(call[0].data).toEqual({ lastValidatedAt: expect.any(Date) }); + } + }); + + it('validation still succeeds (200) on conflict', async () => { + mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce({ id: 'other' }); + + const res = await post('Living Room'); + + expect(res.status).toBe(200); + }); +}); diff --git a/src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts b/src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts new file mode 100644 index 0000000..f6cdc46 --- /dev/null +++ b/src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts @@ -0,0 +1,40 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { prisma } from '$lib/db'; +import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers'; +import { rateLimit } from '$lib/server/v2-rate-limit'; +import { applySystemName, normalizeSystemName } from '$lib/server/system-name'; + +/** + * Kiosk-facing. Sets the System Name for a License Key. The Kiosk is the only + * writer; the Portal never calls this. Idempotent: re-sending the current name + * returns 200 without a write. Responds 400 `invalid_name`, 404 + * `unknown_license`, or 409 `name_taken`. + */ +export const PUT: RequestHandler = async ({ request, params }) => { + const ip = clientIp(request); + const rl = rateLimit({ key: `system-name:${ip}`, limit: 10, windowMs: 60_000 }); + if (!rl.ok) { + return rateLimited(rl.retryAfterMs); + } + + const licenseKey = (params.licenseKey ?? '').trim(); + const body = (await readJsonBody(request)) as Record; + + const parsed = normalizeSystemName(body.systemName); + if (!parsed.ok) { + throw error(400, 'invalid_name'); + } + + const license = await prisma.licenseKey.findUnique({ where: { key: licenseKey } }); + if (!license) { + throw error(404, 'unknown_license'); + } + + const status = await applySystemName(license, parsed.name, parsed.lower); + if (status === 'conflict') { + throw error(409, 'name_taken'); + } + + return jsonOk({ systemName: parsed.name, systemNameStatus: 'ok' }); +}; diff --git a/src/routes/api/v2/licenses/[licenseKey]/system-name/server.spec.ts b/src/routes/api/v2/licenses/[licenseKey]/system-name/server.spec.ts new file mode 100644 index 0000000..d2949fe --- /dev/null +++ b/src/routes/api/v2/licenses/[licenseKey]/system-name/server.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { isHttpError } from '@sveltejs/kit'; +import { _clearRateLimits } from '$lib/server/v2-rate-limit'; + +vi.mock('$lib/db', () => ({ + prisma: { + licenseKey: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn() } + } +})); + +import { prisma } from '$lib/db'; +import { PUT } from './+server'; + +type PUTEvent = Parameters[0]; + +const mockFindUnique = prisma.licenseKey.findUnique as ReturnType; +const mockFindFirst = prisma.licenseKey.findFirst as ReturnType; +const mockUpdate = prisma.licenseKey.update as ReturnType; + +const KEY = 'ABCD-EFGH-IJKL-MNOP'; + +const storedKey = { + id: 'key-1', + key: KEY, + active: true, + userId: 'user-1', + systemName: null as string | null +}; + +function putRequest(body: unknown): Request { + return new Request(`http://localhost/api/v2/licenses/${KEY}/system-name`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) + }); +} + +function event(body: unknown): PUTEvent { + return { request: putRequest(body), params: { licenseKey: KEY } } as PUTEvent; +} + +async function expectHttpError(promise: unknown, status: number, message: string) { + try { + await promise; + } catch (e) { + expect(isHttpError(e)).toBe(true); + const err = e as { status: number; body: { message: string } }; + expect(err.status).toBe(status); + expect(err.body.message).toBe(message); + return; + } + throw new Error('expected handler to throw an HttpError'); +} + +beforeEach(() => { + vi.clearAllMocks(); + _clearRateLimits(); +}); + +describe('PUT /api/v2/licenses/[licenseKey]/system-name', () => { + it('sets the name and returns it', async () => { + mockFindUnique.mockResolvedValue(storedKey); + mockFindFirst.mockResolvedValue(null); + mockUpdate.mockResolvedValue({}); + + const res = await PUT(event({ systemName: ' Living Room ' })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ systemName: 'Living Room', systemNameStatus: 'ok' }); + expect(mockUpdate).toHaveBeenCalledWith({ + where: { id: 'key-1' }, + data: { systemName: 'Living Room', systemNameLower: 'living room' } + }); + }); + + it('is idempotent when the name is unchanged', async () => { + mockFindUnique.mockResolvedValue({ ...storedKey, systemName: 'Living Room' }); + + const res = await PUT(event({ systemName: 'Living Room' })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ systemName: 'Living Room', systemNameStatus: 'ok' }); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('rejects an invalid name', async () => { + await expectHttpError(PUT(event({ systemName: ' ' })), 400, 'invalid_name'); + await expectHttpError(PUT(event({ systemName: '' })), 400, 'invalid_name'); + await expectHttpError(PUT(event({ systemName: 'a'.repeat(41) })), 400, 'invalid_name'); + await expectHttpError(PUT(event({})), 400, 'invalid_name'); + expect(mockFindUnique).not.toHaveBeenCalled(); + }); + + it('returns 404 for an unknown license', async () => { + mockFindUnique.mockResolvedValue(null); + + await expectHttpError(PUT(event({ systemName: 'Den' })), 404, 'unknown_license'); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('returns 409 when another license on the account holds the name', async () => { + mockFindUnique.mockResolvedValue(storedKey); + mockFindFirst.mockResolvedValue({ id: 'other' }); + + await expectHttpError(PUT(event({ systemName: 'Den' })), 409, 'name_taken'); + expect(mockUpdate).not.toHaveBeenCalled(); + }); +});