Build API v2
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { auth } from '$lib/auth';
|
||||
import { prisma } from '$lib/db';
|
||||
import { sendMail } from '$lib/server/resend';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { clientIp, rateLimited } from '$lib/server/v2-helpers';
|
||||
import { rateLimit } from '$lib/server/v2-rate-limit';
|
||||
import { verifyRequestSignature } from '$lib/server/v2-signature';
|
||||
import { MAX_JSON_BODY_BYTES } from '$lib/server/v2-helpers';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function generateLicenseKey(): string {
|
||||
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
|
||||
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
|
||||
}
|
||||
|
||||
function unauthorized(): Response {
|
||||
return new Response(JSON.stringify({ error: 'invalid signature' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, url }) => {
|
||||
const secret = env.API_V2_ONBOARDING_SECRET;
|
||||
if (!secret) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const rawBody = await request.text();
|
||||
if (rawBody.length === 0) {
|
||||
throw error(400, 'request body is required');
|
||||
}
|
||||
if (rawBody.length > MAX_JSON_BODY_BYTES) {
|
||||
throw error(413, 'request body too large');
|
||||
}
|
||||
|
||||
const verification = verifyRequestSignature({
|
||||
method: 'POST',
|
||||
path: url.pathname,
|
||||
headers: request.headers,
|
||||
rawBody,
|
||||
secret
|
||||
});
|
||||
if (!verification.ok) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const ip = clientIp(request);
|
||||
const ipLimit = rateLimit({ key: `onboarding:ip:${ip}`, limit: 5, windowMs: 60_000 });
|
||||
if (!ipLimit.ok) {
|
||||
return rateLimited(ipLimit.retryAfterMs);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(rawBody);
|
||||
} catch {
|
||||
throw error(400, 'invalid JSON body');
|
||||
}
|
||||
|
||||
const { email, name, subscription_level } = body as Record<string, unknown>;
|
||||
|
||||
if (typeof email !== 'string' || !email.trim()) {
|
||||
error(400, 'email is required');
|
||||
}
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
if (cleanEmail.length > 254 || !EMAIL_RE.test(cleanEmail)) {
|
||||
error(400, 'email is required');
|
||||
}
|
||||
|
||||
if (typeof subscription_level !== 'string' || !subscription_level.trim()) {
|
||||
error(400, 'subscription_level is required');
|
||||
}
|
||||
|
||||
const emailLimit = rateLimit({
|
||||
key: `onboarding:email:${cleanEmail}`,
|
||||
limit: 3,
|
||||
windowMs: 24 * 60 * 60 * 1000
|
||||
});
|
||||
if (!emailLimit.ok) {
|
||||
return rateLimited(emailLimit.retryAfterMs);
|
||||
}
|
||||
|
||||
const levelName = subscription_level.trim();
|
||||
const level = await prisma.subscriptionLevel.findFirst({ where: { name: levelName } });
|
||||
if (!level) {
|
||||
error(400, `subscription_level "${levelName}" not found`);
|
||||
}
|
||||
|
||||
const displayName =
|
||||
typeof name === 'string' && name.trim() ? name.trim().slice(0, 120) : cleanEmail.split('@')[0];
|
||||
|
||||
let user = await prisma.user.findUnique({ where: { email: cleanEmail } });
|
||||
|
||||
if (!user) {
|
||||
const tempPassword = crypto.randomUUID().replace(/-/g, '').slice(0, 20);
|
||||
|
||||
try {
|
||||
await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: cleanEmail,
|
||||
password: tempPassword,
|
||||
name: displayName
|
||||
}
|
||||
});
|
||||
|
||||
user = await prisma.user.findUnique({ where: { email: cleanEmail } });
|
||||
|
||||
const verification = await prisma.verification.findFirst({
|
||||
where: {
|
||||
identifier: cleanEmail,
|
||||
expiresAt: { gt: new Date() }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
const verifyUrl = verification
|
||||
? `${env.BETTER_AUTH_URL}/api/auth/verify-email?token=${verification.value}&callbackURL=/login?verified=true`
|
||||
: `${env.BETTER_AUTH_URL}/login`;
|
||||
|
||||
await sendMail({
|
||||
to: cleanEmail,
|
||||
subject: 'Your account is ready',
|
||||
templateId: 'automated-user-registration',
|
||||
data: {
|
||||
firstName: displayName,
|
||||
tempPassword,
|
||||
verifyUrl,
|
||||
loginUrl: `${env.BETTER_AUTH_URL}/login`
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to create user:', err);
|
||||
error(500, 'Failed to create account');
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
error(500, 'Failed to find or create user');
|
||||
}
|
||||
|
||||
const key = generateLicenseKey();
|
||||
|
||||
await prisma.licenseKey.create({
|
||||
data: {
|
||||
key,
|
||||
active: true,
|
||||
level: levelName,
|
||||
userId: user.id
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await sendMail({
|
||||
to: cleanEmail,
|
||||
subject: 'Your Rhemawave License Key',
|
||||
templateId: 'license-key-delivery',
|
||||
data: {
|
||||
firstName: displayName,
|
||||
license_key: key
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to send license key email:', e);
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
message: 'Account and license key created',
|
||||
user_id: user.id
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { isHttpError } from '@sveltejs/kit';
|
||||
import { computeSignature, buildCanonicalRequest, _clearNonces } from '$lib/server/v2-signature';
|
||||
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
|
||||
|
||||
const { authApi, sendMailMock } = vi.hoisted(() => ({
|
||||
authApi: { signUpEmail: vi.fn() },
|
||||
sendMailMock: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: {
|
||||
API_V2_ONBOARDING_SECRET: 'test-onboarding-secret',
|
||||
BETTER_AUTH_URL: 'http://localhost:5173'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/auth', () => ({
|
||||
auth: { api: authApi }
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/resend', () => ({
|
||||
sendMail: sendMailMock
|
||||
}));
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
subscriptionLevel: { findFirst: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
verification: { findFirst: vi.fn() },
|
||||
licenseKey: { create: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { POST } from './+server';
|
||||
|
||||
type POSTEvent = Parameters<typeof POST>[0];
|
||||
|
||||
const SECRET = 'test-onboarding-secret';
|
||||
const PATH = '/api/v2/automations/create/account';
|
||||
|
||||
const mockLevelFindFirst = prisma.subscriptionLevel.findFirst as ReturnType<typeof vi.fn>;
|
||||
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const mockVerification = prisma.verification.findFirst as ReturnType<typeof vi.fn>;
|
||||
const mockLicenseCreate = prisma.licenseKey.create as ReturnType<typeof vi.fn>;
|
||||
|
||||
function signedOnboardingRequest(body: object, secret = SECRET): Request {
|
||||
const method = 'POST';
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const nonce = crypto.randomUUID();
|
||||
const rawBody = JSON.stringify(body);
|
||||
const signature = computeSignature(
|
||||
secret,
|
||||
buildCanonicalRequest(method, PATH, timestamp, nonce, rawBody)
|
||||
);
|
||||
|
||||
return new Request(`http://localhost${PATH}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-timestamp': timestamp,
|
||||
'x-nonce': nonce,
|
||||
'x-signature': signature
|
||||
},
|
||||
body: rawBody
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
describe('POST /api/v2/automations/create/account', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_clearNonces();
|
||||
_clearRateLimits();
|
||||
});
|
||||
|
||||
it('creates a license key for an existing user without a new signup', async () => {
|
||||
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
|
||||
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
||||
mockLicenseCreate.mockResolvedValue({ id: 'lic-1' });
|
||||
sendMailMock.mockResolvedValue({});
|
||||
|
||||
const res = await POST({
|
||||
request: signedOnboardingRequest({ email: 'user@example.com', subscription_level: 'Basic' }),
|
||||
url: new URL(`http://localhost${PATH}`)
|
||||
} as POSTEvent);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
success: true,
|
||||
message: 'Account and license key created',
|
||||
user_id: 'user-1'
|
||||
});
|
||||
expect(authApi.signUpEmail).not.toHaveBeenCalled();
|
||||
expect(mockLicenseCreate).toHaveBeenCalledTimes(1);
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('signs up a new user and sends registration + license emails', async () => {
|
||||
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
|
||||
mockUserFindUnique
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 'user-2', email: 'new@example.com' });
|
||||
mockVerification.mockResolvedValue({ value: 'verify-token' });
|
||||
mockLicenseCreate.mockResolvedValue({ id: 'lic-2' });
|
||||
sendMailMock.mockResolvedValue({});
|
||||
authApi.signUpEmail.mockResolvedValue({});
|
||||
|
||||
const res = await POST({
|
||||
request: signedOnboardingRequest({ email: 'new@example.com', subscription_level: 'Elite' }),
|
||||
url: new URL(`http://localhost${PATH}`)
|
||||
} as POSTEvent);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
success: true,
|
||||
message: 'Account and license key created',
|
||||
user_id: 'user-2'
|
||||
});
|
||||
expect(authApi.signUpEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({ email: 'new@example.com', name: 'new' })
|
||||
})
|
||||
);
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects a request without a valid signature', async () => {
|
||||
const req = new Request(`http://localhost${PATH}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'user@example.com', subscription_level: 'Basic' })
|
||||
});
|
||||
|
||||
const res = await POST({ request: req, url: new URL(`http://localhost${PATH}`) } as POSTEvent);
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ error: 'invalid signature' });
|
||||
});
|
||||
|
||||
it('rejects a request signed with the wrong secret', async () => {
|
||||
const res = await POST({
|
||||
request: signedOnboardingRequest(
|
||||
{ email: 'user@example.com', subscription_level: 'Basic' },
|
||||
'wrong-secret'
|
||||
),
|
||||
url: new URL(`http://localhost${PATH}`)
|
||||
} as POSTEvent);
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ error: 'invalid signature' });
|
||||
});
|
||||
|
||||
it('rejects a missing email', async () => {
|
||||
await expectHttpError(
|
||||
POST({
|
||||
request: signedOnboardingRequest({ subscription_level: 'Basic' }),
|
||||
url: new URL(`http://localhost${PATH}`)
|
||||
} as POSTEvent),
|
||||
400,
|
||||
'email is required'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an oversized body with 413', async () => {
|
||||
const rawBody = JSON.stringify({
|
||||
email: 'user@example.com',
|
||||
subscription_level: 'Basic',
|
||||
name: 'x'.repeat(64 * 1024 + 1)
|
||||
});
|
||||
expect(rawBody.length).toBeGreaterThan(64 * 1024);
|
||||
|
||||
const req = new Request(`http://localhost${PATH}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: rawBody
|
||||
});
|
||||
|
||||
await expectHttpError(
|
||||
POST({ request: req, url: new URL(`http://localhost${PATH}`) } as POSTEvent),
|
||||
413,
|
||||
'request body too large'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown subscription level', async () => {
|
||||
mockLevelFindFirst.mockResolvedValue(null);
|
||||
await expectHttpError(
|
||||
POST({
|
||||
request: signedOnboardingRequest({
|
||||
email: 'user@example.com',
|
||||
subscription_level: 'Titanium'
|
||||
}),
|
||||
url: new URL(`http://localhost${PATH}`)
|
||||
} as POSTEvent),
|
||||
400,
|
||||
'subscription_level "Titanium" not found'
|
||||
);
|
||||
});
|
||||
|
||||
it('rate limits repeated requests for the same email', async () => {
|
||||
mockLevelFindFirst.mockResolvedValue({ id: 1, name: 'Basic', description: 'General only' });
|
||||
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
||||
mockLicenseCreate.mockResolvedValue({ id: 'lic-1' });
|
||||
sendMailMock.mockResolvedValue({});
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await POST({
|
||||
request: signedOnboardingRequest({
|
||||
email: 'user@example.com',
|
||||
subscription_level: 'Basic'
|
||||
}),
|
||||
url: new URL(`http://localhost${PATH}`)
|
||||
} as POSTEvent);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
const res = await POST({
|
||||
request: signedOnboardingRequest({ email: 'user@example.com', subscription_level: 'Basic' }),
|
||||
url: new URL(`http://localhost${PATH}`)
|
||||
} as POSTEvent);
|
||||
expect(res.status).toBe(429);
|
||||
expect(await res.json()).toEqual({ message: 'rate limit exceeded' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { jsonOk } from '$lib/server/v2-helpers';
|
||||
import { serializeCategory } from '$lib/server/v2-serializers';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const categories = await prisma.proclamationCategory.findMany();
|
||||
return jsonOk(categories.map(serializeCategory));
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { jsonOk } from '$lib/server/v2-helpers';
|
||||
import { CANONICAL_FREQUENCIES, CANONICAL_PALETTES } from '$lib/server/v2-serializers';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const rows = await prisma.colorPalette.findMany();
|
||||
const byId = new Map(rows.map((r) => [r.solfeggioFrequencyId, r.colors as unknown as string[]]));
|
||||
|
||||
const palettes = CANONICAL_FREQUENCIES.map((f) => ({
|
||||
solfeggioFrequencyId: f.id,
|
||||
colors: byId.get(f.id) ?? CANONICAL_PALETTES[f.id]
|
||||
}));
|
||||
|
||||
return jsonOk(palettes);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { jsonOk } from '$lib/server/v2-helpers';
|
||||
import { CANONICAL_FREQUENCIES } from '$lib/server/v2-serializers';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
return jsonOk(CANONICAL_FREQUENCIES);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
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 { serializeLicense } from '$lib/server/v2-serializers';
|
||||
|
||||
const LICENSE_KEY_RE = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const ip = clientIp(request);
|
||||
const rl = rateLimit({ key: `license:${ip}`, limit: 10, windowMs: 60_000 });
|
||||
if (!rl.ok) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
|
||||
const body = (await readJsonBody(request)) as Record<string, unknown>;
|
||||
const licenseKey = typeof body.license_key === 'string' ? body.license_key.trim() : '';
|
||||
const email =
|
||||
typeof body.email_address === 'string' ? body.email_address.trim().toLowerCase() : '';
|
||||
|
||||
if (!licenseKey) {
|
||||
throw error(400, 'license_key is required');
|
||||
}
|
||||
if (!email) {
|
||||
throw error(400, 'email_address is required');
|
||||
}
|
||||
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
throw error(400, 'email_address is required');
|
||||
}
|
||||
if (!LICENSE_KEY_RE.test(licenseKey)) {
|
||||
throw error(400, 'license key not found');
|
||||
}
|
||||
|
||||
const storedKey = await prisma.licenseKey.findFirst({
|
||||
where: { key: licenseKey }
|
||||
});
|
||||
if (!storedKey) {
|
||||
throw error(400, 'license key not found');
|
||||
}
|
||||
if (!storedKey.active) {
|
||||
throw error(400, 'license key is not active');
|
||||
}
|
||||
|
||||
const userAccount = await prisma.user.findUnique({ where: { email } });
|
||||
if (!userAccount) {
|
||||
throw error(400, 'user does not exist');
|
||||
}
|
||||
if (storedKey.userId !== userAccount.id) {
|
||||
throw error(400, 'key does not belong to specified user');
|
||||
}
|
||||
|
||||
const level = await prisma.subscriptionLevel.findFirst({
|
||||
where: { name: storedKey.level }
|
||||
});
|
||||
|
||||
return jsonOk(
|
||||
serializeLicense({
|
||||
key: storedKey.key,
|
||||
emailAddress: email,
|
||||
levelName: storedKey.level,
|
||||
level
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
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: { findFirst: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
subscriptionLevel: { findFirst: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { POST } from './+server';
|
||||
|
||||
type POSTEvent = Parameters<typeof POST>[0];
|
||||
|
||||
const mockKeyFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
|
||||
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const mockLevelFindFirst = prisma.subscriptionLevel.findFirst as ReturnType<typeof vi.fn>;
|
||||
|
||||
function licenseRequest(body: unknown): Request {
|
||||
return new Request('http://localhost/api/v2/license', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const storedKey = {
|
||||
id: 'key-1',
|
||||
key: 'ABCD-EFGH-IJKL-MNOP',
|
||||
active: true,
|
||||
level: 'Advanced',
|
||||
userId: 'user-1'
|
||||
};
|
||||
|
||||
describe('POST /api/v2/license', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_clearRateLimits();
|
||||
});
|
||||
|
||||
it('returns the subscription_level object for a valid key', async () => {
|
||||
mockKeyFindFirst.mockResolvedValue(storedKey);
|
||||
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
||||
mockLevelFindFirst.mockResolvedValue({
|
||||
id: 2,
|
||||
name: 'Advanced',
|
||||
description: 'General + Specific'
|
||||
});
|
||||
|
||||
const res = await POST({
|
||||
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
|
||||
} as POSTEvent);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
key: storedKey.key,
|
||||
email_address: 'user@example.com',
|
||||
subscription_level: { id: 2, name: 'Advanced', description: 'General + Specific' }
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a structurally consistent subscription_level for an unknown level name', async () => {
|
||||
mockKeyFindFirst.mockResolvedValue({ ...storedKey, level: 'SomeCustomLevel' });
|
||||
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
||||
mockLevelFindFirst.mockResolvedValue(null);
|
||||
|
||||
const res = await POST({
|
||||
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
|
||||
} as POSTEvent);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
key: storedKey.key,
|
||||
email_address: 'user@example.com',
|
||||
subscription_level: { id: null, name: 'SomeCustomLevel', description: null }
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a missing license_key', async () => {
|
||||
await expectHttpError(
|
||||
POST({ request: licenseRequest({ email_address: 'user@example.com' }) } as POSTEvent),
|
||||
400,
|
||||
'license_key is required'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a missing email_address', async () => {
|
||||
await expectHttpError(
|
||||
POST({ request: licenseRequest({ license_key: storedKey.key }) } as POSTEvent),
|
||||
400,
|
||||
'email_address is required'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown key', async () => {
|
||||
mockKeyFindFirst.mockResolvedValue(null);
|
||||
await expectHttpError(
|
||||
POST({
|
||||
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
|
||||
} as POSTEvent),
|
||||
400,
|
||||
'license key not found'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an inactive key', async () => {
|
||||
mockKeyFindFirst.mockResolvedValue({ ...storedKey, active: false });
|
||||
await expectHttpError(
|
||||
POST({
|
||||
request: licenseRequest({ license_key: storedKey.key, email_address: 'user@example.com' })
|
||||
} as POSTEvent),
|
||||
400,
|
||||
'license key is not active'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a nonexistent user', async () => {
|
||||
mockKeyFindFirst.mockResolvedValue(storedKey);
|
||||
mockUserFindUnique.mockResolvedValue(null);
|
||||
await expectHttpError(
|
||||
POST({
|
||||
request: licenseRequest({ license_key: storedKey.key, email_address: 'nobody@example.com' })
|
||||
} as POSTEvent),
|
||||
400,
|
||||
'user does not exist'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a key owned by a different user', async () => {
|
||||
mockKeyFindFirst.mockResolvedValue(storedKey);
|
||||
mockUserFindUnique.mockResolvedValue({ id: 'user-2', email: 'other@example.com' });
|
||||
await expectHttpError(
|
||||
POST({
|
||||
request: licenseRequest({ license_key: storedKey.key, email_address: 'other@example.com' })
|
||||
} as POSTEvent),
|
||||
400,
|
||||
'key does not belong to specified user'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a malformed license key format', async () => {
|
||||
await expectHttpError(
|
||||
POST({
|
||||
request: licenseRequest({ license_key: 'not-a-key', email_address: 'user@example.com' })
|
||||
} as POSTEvent),
|
||||
400,
|
||||
'license key not found'
|
||||
);
|
||||
expect(mockKeyFindFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { jsonOk } from '$lib/server/v2-helpers';
|
||||
import {
|
||||
canonicalIdForFrequency,
|
||||
serializeProclamation,
|
||||
type ProclamationWithFrequency
|
||||
} from '$lib/server/v2-serializers';
|
||||
|
||||
const PROCLAMATION_INCLUDE = { solfeggioFrequency: true } as const;
|
||||
|
||||
export const GET: RequestHandler = async ({ request }) => {
|
||||
const licenseKey = request.headers.get('x-license-key') ?? '';
|
||||
const email = request.headers.get('x-email') ?? '';
|
||||
|
||||
const hasEntitlementHeaders = licenseKey !== '' || email !== '';
|
||||
|
||||
let proclamations: ProclamationWithFrequency[];
|
||||
|
||||
if (hasEntitlementHeaders) {
|
||||
if (licenseKey === '' || email === '') {
|
||||
throw error(400, 'not-authorized');
|
||||
}
|
||||
|
||||
const verifiedKey = await prisma.licenseKey.findUnique({
|
||||
where: { key: licenseKey }
|
||||
});
|
||||
if (!verifiedKey) {
|
||||
throw error(400, 'not-authorized');
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: { licenseKeys: true }
|
||||
});
|
||||
if (!existingUser) {
|
||||
throw error(400, 'not-authorized');
|
||||
}
|
||||
|
||||
const owned = existingUser.licenseKeys.some((k) => k.key === verifiedKey.key);
|
||||
if (!owned) {
|
||||
throw error(400, "this key doesn't appear to belong to you");
|
||||
}
|
||||
|
||||
const userProclamations = await prisma.userProclamations.findMany({
|
||||
where: { userId: existingUser.id },
|
||||
include: { proclamation: { include: PROCLAMATION_INCLUDE } }
|
||||
});
|
||||
|
||||
proclamations = userProclamations.map((up) => up.proclamation);
|
||||
} else {
|
||||
proclamations = await prisma.proclamation.findMany({
|
||||
include: PROCLAMATION_INCLUDE
|
||||
});
|
||||
}
|
||||
|
||||
const paletteRows = await prisma.colorPalette.findMany();
|
||||
const palettesByCanonicalId = new Map(
|
||||
paletteRows.map((p) => [p.solfeggioFrequencyId, p.colors as unknown as string[]])
|
||||
);
|
||||
|
||||
const records = proclamations
|
||||
.map((p) => {
|
||||
const canonicalId = canonicalIdForFrequency(p.solfeggioFrequency);
|
||||
const palette = canonicalId ? palettesByCanonicalId.get(canonicalId) : undefined;
|
||||
return serializeProclamation(p, palette);
|
||||
})
|
||||
.filter((r) => r !== null);
|
||||
|
||||
return jsonOk(records);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { isHttpError } from '@sveltejs/kit';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
proclamation: { findMany: vi.fn() },
|
||||
licenseKey: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
userProclamations: { findMany: vi.fn() },
|
||||
colorPalette: { findMany: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { GET } from './+server';
|
||||
|
||||
type GETEvent = Parameters<typeof GET>[0];
|
||||
|
||||
const mockProclamationFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
|
||||
const mockLicenseKey = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
|
||||
const mockUser = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const mockUserProclamations = prisma.userProclamations.findMany as ReturnType<typeof vi.fn>;
|
||||
const mockColorPalette = prisma.colorPalette.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
const palettes = [
|
||||
{
|
||||
solfeggioFrequencyId: 5,
|
||||
colors: ['#32CD32', '#3CB371', '#90EE90', '#98FB98', '#ADFFA9', '#C8FFD2', '#F0FFF0']
|
||||
},
|
||||
{
|
||||
solfeggioFrequencyId: 7,
|
||||
colors: ['#00BFFF', '#1E90FF', '#4169E1', '#6495ED', '#87CEEB', '#ADD8E6', '#F0F8FF']
|
||||
}
|
||||
];
|
||||
|
||||
const peaceProclamation = {
|
||||
id: 'b40fdcb9-703c-420e-af32-a081964c6f08',
|
||||
name: 'Peace & Relaxation',
|
||||
content: '<p>The text content</p>',
|
||||
categoryId: 5,
|
||||
subscriptionLevelId: 1,
|
||||
solfeggioFrequencyId: 5,
|
||||
solfeggioFrequency: { id: 5, name: 'Solar Plexus', value: 528 }
|
||||
};
|
||||
|
||||
const crown936 = {
|
||||
id: '936-hz-proclamation',
|
||||
name: 'Crown 936',
|
||||
content: '<p>no canonical id</p>',
|
||||
categoryId: 4,
|
||||
subscriptionLevelId: 1,
|
||||
solfeggioFrequencyId: 1,
|
||||
solfeggioFrequency: { id: 1, name: 'Crown', value: 936 }
|
||||
};
|
||||
|
||||
function getRequest(headers: Record<string, string> = {}): Request {
|
||||
return new Request('http://localhost/api/v2/proclamations', { headers });
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
describe('GET /api/v2/proclamations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns full proclamation records with canonical ids and embedded palettes', async () => {
|
||||
mockProclamationFindMany.mockResolvedValue([peaceProclamation]);
|
||||
mockColorPalette.mockResolvedValue(palettes);
|
||||
|
||||
const res = await GET({ request: getRequest() } as GETEvent);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual([
|
||||
{
|
||||
id: peaceProclamation.id,
|
||||
name: peaceProclamation.name,
|
||||
content: peaceProclamation.content,
|
||||
categoryId: 5,
|
||||
solfeggioFrequencyId: 5,
|
||||
subscriptionLevelId: 1,
|
||||
colorPalette: palettes[0].colors
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops proclamations whose frequency has no canonical id (936 Hz)', async () => {
|
||||
mockProclamationFindMany.mockResolvedValue([peaceProclamation, crown936]);
|
||||
mockColorPalette.mockResolvedValue(palettes);
|
||||
|
||||
const res = await GET({ request: getRequest() } as GETEvent);
|
||||
const body = (await res.json()) as { id: string }[];
|
||||
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0].id).toBe(peaceProclamation.id);
|
||||
});
|
||||
|
||||
it('returns the entitled proclamations as full records when headers are supplied', async () => {
|
||||
mockLicenseKey.mockResolvedValue({ id: 'key-1', key: 'ABCD-EFGH-IJKL-MNOP' });
|
||||
mockUser.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
licenseKeys: [{ id: 'key-1', key: 'ABCD-EFGH-IJKL-MNOP' }]
|
||||
});
|
||||
mockUserProclamations.mockResolvedValue([
|
||||
{ userId: 'user-1', proclamationId: peaceProclamation.id, proclamation: peaceProclamation }
|
||||
]);
|
||||
mockColorPalette.mockResolvedValue(palettes);
|
||||
|
||||
const res = await GET({
|
||||
request: getRequest({
|
||||
'x-license-key': 'ABCD-EFGH-IJKL-MNOP',
|
||||
'x-email': 'user@example.com'
|
||||
})
|
||||
} as GETEvent);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { id: string }[];
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0].id).toBe(peaceProclamation.id);
|
||||
expect(mockUserProclamations).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { userId: 'user-1' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when only one entitlement header is supplied', async () => {
|
||||
await expectHttpError(
|
||||
GET({ request: getRequest({ 'x-license-key': 'ABCD-EFGH-IJKL-MNOP' }) } as GETEvent),
|
||||
400,
|
||||
'not-authorized'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown license key', async () => {
|
||||
mockLicenseKey.mockResolvedValue(null);
|
||||
await expectHttpError(
|
||||
GET({
|
||||
request: getRequest({
|
||||
'x-license-key': 'UNKNOWN-KEY',
|
||||
'x-email': 'user@example.com'
|
||||
})
|
||||
} as GETEvent),
|
||||
400,
|
||||
'not-authorized'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a key that does not belong to the user', async () => {
|
||||
mockLicenseKey.mockResolvedValue({ id: 'key-2', key: 'OTHER-KEY' });
|
||||
mockUser.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
licenseKeys: [{ id: 'key-1', key: 'ABCD-EFGH-IJKL-MNOP' }]
|
||||
});
|
||||
await expectHttpError(
|
||||
GET({
|
||||
request: getRequest({
|
||||
'x-license-key': 'OTHER-KEY',
|
||||
'x-email': 'user@example.com'
|
||||
})
|
||||
} as GETEvent),
|
||||
400,
|
||||
"this key doesn't appear to belong to you"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { prisma } from '$lib/db';
|
||||
import { jsonOk } from '$lib/server/v2-helpers';
|
||||
import { serializeSubscriptionLevel } from '$lib/server/v2-serializers';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const levels = await prisma.subscriptionLevel.findMany({
|
||||
orderBy: { id: 'asc' }
|
||||
});
|
||||
return jsonOk(levels.map(serializeSubscriptionLevel));
|
||||
};
|
||||
Reference in New Issue
Block a user