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:
@@ -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<typeof vi.fn>;
|
||||
const keyUpdate = prisma.licenseKey.update as ReturnType<typeof vi.fn>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<SystemNameStatus> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user