Portal: System Name PUT endpoint and license-validation status

Add PUT /api/v2/licenses/[licenseKey]/system-name (200/400 invalid_name/
404 unknown_license/409 name_taken, idempotent) and extend POST
/api/v2/license with an optional systemName and additive systemNameStatus
(ok/unchanged/conflict/invalid). Validation always succeeds; an omitted
field never changes or clears a name and empty/whitespace is invalid.
Adds server and helper tests covering trimming, bounds, case-insensitive
uniqueness, cross-account reuse, 404, and idempotency.
This commit is contained in:
NeilClack
2026-09-29 09:51:54 -05:00
parent 1d76e8dff9
commit 8412dfb69c
8 changed files with 463 additions and 9 deletions
+21 -1
View File
@@ -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
})
);
};
+91 -2
View File
@@ -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<string, unknown>;
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<string, unknown> = {
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);
});
});
@@ -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<string, unknown>;
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' });
};
@@ -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<typeof PUT>[0];
const mockFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
const mockFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
const mockUpdate = prisma.licenseKey.update as ReturnType<typeof vi.fn>;
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();
});
});