studio-os 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -3
- package/.gitattributes +8 -0
- package/.github/FUNDING.yml +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +49 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +30 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +20 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/guards.yml +45 -0
- package/CHANGELOG.md +159 -86
- package/README.md +270 -253
- package/SECURITY.md +27 -0
- package/SPEC.md +4 -4
- package/package.json +54 -54
- package/runner-lock.json +15 -0
- package/src/app.js +163 -141
- package/src/db/003-stripe-invoices.sql +6 -0
- package/src/db/index.js +83 -82
- package/src/routes/me.js +1 -1
- package/src/routes/public.js +13 -3
- package/src/services/auth.js +88 -81
- package/src/services/booking.js +19 -13
- package/src/services/stripe.js +44 -1
- package/test/booking-link.test.js +47 -0
- package/test/deps.test.js +108 -0
- package/test/error-paths.test.js +49 -0
- package/test/membership-cycle.test.js +78 -0
- package/test/proxy-auth.test.js +114 -0
- package/test/stripe-halfconfig.test.js +98 -0
- package/test/stripe.test.js +84 -1
- package/test/web.test.js +10 -1
- package/views/admin/dashboard.ejs +1 -1
- package/views/admin/settings.ejs +7 -2
- package/views/public/book_result.ejs +5 -5
- package/PROGRESS.md +0 -60
package/src/routes/public.js
CHANGED
|
@@ -117,9 +117,19 @@ export default function publicRoutes(services) {
|
|
|
117
117
|
});
|
|
118
118
|
mailer.send({ to: result.client.email, ...tpl });
|
|
119
119
|
|
|
120
|
+
// Same order promoteNext() uses to pick who gets the next free spot.
|
|
121
|
+
const b = result.booking;
|
|
122
|
+
const waitlistPosition = result.waitlisted ? db.prepare(
|
|
123
|
+
`SELECT COUNT(*) c FROM bookings WHERE class_instance_id = ? AND status = 'waitlist'
|
|
124
|
+
AND (created_at < ? OR (created_at = ? AND id <= ?))`
|
|
125
|
+
).get(inst.id, b.created_at, b.created_at, b.id).c : null;
|
|
126
|
+
|
|
127
|
+
// Booking needs nothing but an email, so the link can't go on screen when
|
|
128
|
+
// it can go to the inbox: anyone who knows a client's address could book
|
|
129
|
+
// a class in their name and walk off with their self-service link.
|
|
120
130
|
res.render('public/book_result', {
|
|
121
131
|
title: result.waitlisted ? 'Waitlisted' : 'Booked',
|
|
122
|
-
inst, result, magicUrl,
|
|
132
|
+
inst, result, magicUrl: mailer.smtpConfigured ? null : magicUrl, waitlistPosition,
|
|
123
133
|
});
|
|
124
134
|
} catch (err) {
|
|
125
135
|
if (err instanceof BookingError) {
|
|
@@ -150,7 +160,7 @@ export default function publicRoutes(services) {
|
|
|
150
160
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
|
151
161
|
return res.status(400).render('public/buy_manual', { title: 'Buy', product, plan: null, email: null, error: 'A valid email is required.' });
|
|
152
162
|
}
|
|
153
|
-
if (services.stripe.
|
|
163
|
+
if (services.stripe.checkoutReady) {
|
|
154
164
|
try {
|
|
155
165
|
const session = await services.stripe.createPackCheckout({
|
|
156
166
|
product, clientEmail: email, baseUrl: services.baseUrl(req),
|
|
@@ -171,7 +181,7 @@ export default function publicRoutes(services) {
|
|
|
171
181
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
|
172
182
|
return res.status(400).render('public/buy_manual', { title: 'Buy', product: null, plan, email: null, error: 'A valid email is required.' });
|
|
173
183
|
}
|
|
174
|
-
if (services.stripe.
|
|
184
|
+
if (services.stripe.checkoutReady && plan.stripe_price_id) {
|
|
175
185
|
try {
|
|
176
186
|
const session = await services.stripe.createMembershipCheckout({
|
|
177
187
|
plan, clientEmail: email, baseUrl: services.baseUrl(req),
|
package/src/services/auth.js
CHANGED
|
@@ -1,81 +1,88 @@
|
|
|
1
|
-
// Staff auth (bcryptjs) + HMAC magic-link tokens for client self-service.
|
|
2
|
-
import bcrypt from 'bcryptjs';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import { getSetting } from '../db/index.js';
|
|
5
|
-
|
|
6
|
-
export function hashPassword(plain) {
|
|
7
|
-
return bcrypt.hashSync(plain, 10);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function verifyPassword(plain, hash) {
|
|
11
|
-
return bcrypt.compareSync(plain, hash);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function createUser(db, { email, password, name = '', role = 'staff' }) {
|
|
15
|
-
const info = db.prepare(
|
|
16
|
-
'INSERT INTO users (email, password_hash, name, role) VALUES (?, ?, ?, ?)'
|
|
17
|
-
).run(email.trim().toLowerCase(), hashPassword(password), name, role);
|
|
18
|
-
return info.lastInsertRowid;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
next();
|
|
81
|
-
}
|
|
1
|
+
// Staff auth (bcryptjs) + HMAC magic-link tokens for client self-service.
|
|
2
|
+
import bcrypt from 'bcryptjs';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { getSetting } from '../db/index.js';
|
|
5
|
+
|
|
6
|
+
export function hashPassword(plain) {
|
|
7
|
+
return bcrypt.hashSync(plain, 10);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function verifyPassword(plain, hash) {
|
|
11
|
+
return bcrypt.compareSync(plain, hash);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createUser(db, { email, password, name = '', role = 'staff' }) {
|
|
15
|
+
const info = db.prepare(
|
|
16
|
+
'INSERT INTO users (email, password_hash, name, role) VALUES (?, ?, ?, ?)'
|
|
17
|
+
).run(email.trim().toLowerCase(), hashPassword(password), name, role);
|
|
18
|
+
return info.lastInsertRowid;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Returning early for an unknown email skips bcrypt entirely, which answers in
|
|
22
|
+
// microseconds instead of tens of milliseconds and turns the login form into a
|
|
23
|
+
// staff-email oracle. Comparing against a throwaway hash of the same cost keeps
|
|
24
|
+
// both paths the same shape. /magic-link already refuses to leak which emails
|
|
25
|
+
// exist; this is the same promise on the staff side.
|
|
26
|
+
const DUMMY_HASH = bcrypt.hashSync('password-that-is-never-valid', 10);
|
|
27
|
+
|
|
28
|
+
export function authenticate(db, email, password) {
|
|
29
|
+
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(String(email || '').trim().toLowerCase());
|
|
30
|
+
const ok = verifyPassword(String(password || ''), user ? user.password_hash : DUMMY_HASH);
|
|
31
|
+
return user && ok ? user : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- magic links -----------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function hmac(secret, payload) {
|
|
37
|
+
return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Signed token for a client, valid for ttlHours (default 7 days). */
|
|
41
|
+
export function makeMagicToken(db, clientId, { ttlHours = 24 * 7, now = Date.now() } = {}) {
|
|
42
|
+
const secret = getSetting(db, 'app_secret');
|
|
43
|
+
const expires = now + ttlHours * 3600000;
|
|
44
|
+
const payload = `${clientId}.${expires}`;
|
|
45
|
+
return `${payload}.${hmac(secret, payload)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Returns clientId or null. Constant-time signature check. */
|
|
49
|
+
export function verifyMagicToken(db, token, { now = Date.now() } = {}) {
|
|
50
|
+
if (typeof token !== 'string') return null;
|
|
51
|
+
const parts = token.split('.');
|
|
52
|
+
if (parts.length !== 3) return null;
|
|
53
|
+
const [idStr, expStr, sig] = parts;
|
|
54
|
+
const secret = getSetting(db, 'app_secret');
|
|
55
|
+
const expected = hmac(secret, `${idStr}.${expStr}`);
|
|
56
|
+
const a = Buffer.from(sig);
|
|
57
|
+
const b = Buffer.from(expected);
|
|
58
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
|
59
|
+
if (Number(expStr) < now) return null;
|
|
60
|
+
const id = Number(idStr);
|
|
61
|
+
return Number.isInteger(id) && id > 0 ? id : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- express middleware ----------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export function requireStaff(req, res, next) {
|
|
67
|
+
if (req.session && req.session.userId) return next();
|
|
68
|
+
return res.redirect('/admin/login');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Admin area: any logged-in owner/staff. Instructors get a hard 403. */
|
|
72
|
+
export function requireAdmin(req, res, next) {
|
|
73
|
+
if (!req.session || !req.session.userId || !res.locals.user) return res.redirect('/admin/login');
|
|
74
|
+
if (res.locals.user.role === 'instructor') {
|
|
75
|
+
return res.status(403).render('error', {
|
|
76
|
+
title: 'Forbidden',
|
|
77
|
+
message: 'Instructor accounts cannot access the admin area.',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
next();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Instructor portal: instructor role only; admins are sent to their own UI. */
|
|
84
|
+
export function requireInstructor(req, res, next) {
|
|
85
|
+
if (!req.session || !req.session.userId || !res.locals.user) return res.redirect('/admin/login');
|
|
86
|
+
if (res.locals.user.role !== 'instructor') return res.redirect('/admin');
|
|
87
|
+
next();
|
|
88
|
+
}
|
package/src/services/booking.js
CHANGED
|
@@ -24,22 +24,28 @@ function bookedCount(db, instanceId) {
|
|
|
24
24
|
/** Lazy monthly-credit cycle reset for a credit-based membership. */
|
|
25
25
|
function refreshMembershipCycle(db, m, todayLocal) {
|
|
26
26
|
if (m.unlimited || !m.credits_per_month) return m;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
// Cycles anchor on the join date rather than stepping from the previous
|
|
28
|
+
// cycle. Stepping compounds February's day clamp: a membership joined on the
|
|
29
|
+
// 31st becomes the 28th that month and then stays the 28th for good. From
|
|
30
|
+
// the anchor it clamps only where it has to — 31 Jan, 28 Feb, 31 Mar.
|
|
31
|
+
const anchor = m.started_on;
|
|
32
|
+
const [ay, am] = anchor.split('-').map(Number);
|
|
33
|
+
const [ty, tm] = todayLocal.split('-').map(Number);
|
|
34
|
+
let n = (ty - ay) * 12 + (tm - am);
|
|
35
|
+
if (n > 0 && addMonths(anchor, n) > todayLocal) n -= 1;
|
|
36
|
+
const cycleStart = addMonths(anchor, Math.max(0, n));
|
|
37
|
+
|
|
38
|
+
if (m.cycle_started_on === cycleStart) return m;
|
|
39
|
+
// Only a new month refills credits. Correcting a drifted day inside the
|
|
40
|
+
// current month must not hand the client a free reset.
|
|
41
|
+
const newMonth = (m.cycle_started_on || anchor).slice(0, 7) !== cycleStart.slice(0, 7);
|
|
42
|
+
if (newMonth) {
|
|
35
43
|
db.prepare('UPDATE memberships SET cycle_started_on = ?, credits_used_this_cycle = 0 WHERE id = ?')
|
|
36
44
|
.run(cycleStart, m.id);
|
|
37
|
-
|
|
38
|
-
} else if (!m.cycle_started_on) {
|
|
39
|
-
db.prepare('UPDATE memberships SET cycle_started_on = ? WHERE id = ?').run(cycleStart, m.id);
|
|
40
|
-
m = { ...m, cycle_started_on: cycleStart };
|
|
45
|
+
return { ...m, cycle_started_on: cycleStart, credits_used_this_cycle: 0 };
|
|
41
46
|
}
|
|
42
|
-
|
|
47
|
+
db.prepare('UPDATE memberships SET cycle_started_on = ? WHERE id = ?').run(cycleStart, m.id);
|
|
48
|
+
return { ...m, cycle_started_on: cycleStart };
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
function addMonths(dateStr, n) {
|
package/src/services/stripe.js
CHANGED
|
@@ -23,6 +23,12 @@ export function createStripeService({ env = process.env, client } = {}) {
|
|
|
23
23
|
return {
|
|
24
24
|
configured: Boolean(secretKey),
|
|
25
25
|
webhookConfigured: Boolean(webhookSecret),
|
|
26
|
+
// Checkout is only safe to offer when the webhook that fulfils it will be
|
|
27
|
+
// accepted. With a secret key but no webhook secret the client pays at
|
|
28
|
+
// Stripe and /webhooks/stripe answers 501, so no pass is ever created and
|
|
29
|
+
// no payment is recorded — the money lands in Stripe with nothing to show
|
|
30
|
+
// for it. Half-configured falls back to the manual pay-at-studio flow.
|
|
31
|
+
checkoutReady: Boolean(secretKey && webhookSecret),
|
|
26
32
|
publishableKey,
|
|
27
33
|
|
|
28
34
|
/** One-time Checkout for a class pack. Returns the session (url etc). */
|
|
@@ -80,6 +86,9 @@ export function fulfillCheckoutSession(db, session) {
|
|
|
80
86
|
const email = (meta.client_email || session.customer_email
|
|
81
87
|
|| (session.customer_details && session.customer_details.email) || '').toLowerCase();
|
|
82
88
|
if (!email) throw new Error('Checkout session has no client email');
|
|
89
|
+
// Bank debits and other delayed methods complete the session before the money
|
|
90
|
+
// arrives. checkout.session.async_payment_succeeded brings it back as 'paid'.
|
|
91
|
+
if (session.payment_status === 'unpaid') return { fulfilled: false, reason: 'awaiting_payment' };
|
|
83
92
|
|
|
84
93
|
return db.transaction(() => {
|
|
85
94
|
const existing = db.prepare('SELECT id FROM payments WHERE stripe_session_id = ?').get(session.id);
|
|
@@ -152,11 +161,45 @@ export function fulfillCheckoutSession(db, session) {
|
|
|
152
161
|
})();
|
|
153
162
|
}
|
|
154
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Record a membership renewal payment. The subscription's first invoice was
|
|
166
|
+
* already recorded from its Checkout session, so only later cycles count.
|
|
167
|
+
* Idempotent via payments.stripe_invoice_id.
|
|
168
|
+
*/
|
|
169
|
+
export function recordMembershipRenewal(db, invoice) {
|
|
170
|
+
if (invoice.billing_reason === 'subscription_create') return { fulfilled: false, reason: 'first_invoice' };
|
|
171
|
+
// Newer API versions moved this under parent.subscription_details, and a
|
|
172
|
+
// webhook payload follows the endpoint's API version, so read both.
|
|
173
|
+
const subscriptionId = (invoice.parent && invoice.parent.subscription_details
|
|
174
|
+
&& invoice.parent.subscription_details.subscription) || invoice.subscription;
|
|
175
|
+
if (!subscriptionId) return { fulfilled: false, reason: 'not_a_subscription' };
|
|
176
|
+
if (!invoice.amount_paid) return { fulfilled: false, reason: 'nothing_paid' };
|
|
177
|
+
|
|
178
|
+
return db.transaction(() => {
|
|
179
|
+
const membership = db.prepare('SELECT * FROM memberships WHERE stripe_subscription_id = ?').get(subscriptionId);
|
|
180
|
+
if (!membership) return { fulfilled: false, reason: 'unknown_subscription' };
|
|
181
|
+
const existing = db.prepare('SELECT id FROM payments WHERE stripe_invoice_id = ?').get(invoice.id);
|
|
182
|
+
if (existing) return { fulfilled: false, reason: 'already_processed' };
|
|
183
|
+
|
|
184
|
+
const currency = (invoice.currency || getSetting(db, 'currency', 'HKD')).toUpperCase();
|
|
185
|
+
db.prepare(
|
|
186
|
+
`INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status, stripe_invoice_id)
|
|
187
|
+
VALUES (?, ?, ?, 'stripe', ?, 'membership', 'paid', ?)`
|
|
188
|
+
).run(membership.client_id, invoice.amount_paid, currency, membership.plan_name, invoice.id);
|
|
189
|
+
audit(db, 'stripe-webhook', 'membership_renewed', `membership:${membership.id} invoice:${invoice.id}`);
|
|
190
|
+
return { fulfilled: true, kind: 'membership_renewal', clientId: membership.client_id };
|
|
191
|
+
})();
|
|
192
|
+
}
|
|
193
|
+
|
|
155
194
|
/** Handle a parsed Stripe event. Returns a result object for logging. */
|
|
156
195
|
export function handleStripeEvent(db, event) {
|
|
157
|
-
if (event.type === 'checkout.session.completed'
|
|
196
|
+
if (event.type === 'checkout.session.completed'
|
|
197
|
+
|| event.type === 'checkout.session.async_payment_succeeded') {
|
|
158
198
|
return fulfillCheckoutSession(db, event.data.object);
|
|
159
199
|
}
|
|
200
|
+
if (event.type === 'invoice.paid') {
|
|
201
|
+
return recordMembershipRenewal(db, event.data.object);
|
|
202
|
+
}
|
|
160
203
|
if (event.type === 'customer.subscription.deleted') {
|
|
161
204
|
const sub = event.data.object;
|
|
162
205
|
const r = db.prepare(
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Booking needs only an email, so the page it returns must not hand over the
|
|
2
|
+
// self-service link for whatever address was typed.
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import request from 'supertest';
|
|
6
|
+
import { createApp } from '../src/app.js';
|
|
7
|
+
import { testDb, makeClient, makeClassType, makeInstance, csrfToken } from './helpers.js';
|
|
8
|
+
|
|
9
|
+
function appWith(db, { smtp }) {
|
|
10
|
+
const sent = [];
|
|
11
|
+
const mailer = { smtpConfigured: smtp, async send(msg) { sent.push(msg); return true; } };
|
|
12
|
+
return { app: createApp({ db, mailer, env: {} }), sent };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function bookAs(app, instanceId, email) {
|
|
16
|
+
const agent = request.agent(app);
|
|
17
|
+
const _csrf = await csrfToken(agent, `/class/${instanceId}`);
|
|
18
|
+
return agent.post(`/class/${instanceId}/book`).type('form').send({ _csrf, email, waiver_agree: '1' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('with SMTP on, booking in someone else\'s name does not reveal their link', async () => {
|
|
22
|
+
const db = testDb();
|
|
23
|
+
makeClient(db, { email: 'member@test.hk', name: 'Member' });
|
|
24
|
+
const inst = makeInstance(db, makeClassType(db));
|
|
25
|
+
const { app, sent } = appWith(db, { smtp: true });
|
|
26
|
+
|
|
27
|
+
const res = await bookAs(app, inst, 'member@test.hk');
|
|
28
|
+
|
|
29
|
+
assert.equal(res.status, 200);
|
|
30
|
+
assert.doesNotMatch(res.text, /\/me\?token=/);
|
|
31
|
+
assert.match(res.text, /on its way/);
|
|
32
|
+
// The owner of the address still gets their link, by email.
|
|
33
|
+
assert.equal(sent.length, 1);
|
|
34
|
+
assert.equal(sent[0].to, 'member@test.hk');
|
|
35
|
+
assert.match(sent[0].text, /\/me\?token=/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('with SMTP off, the link is still shown so the studio works offline', async () => {
|
|
39
|
+
const db = testDb();
|
|
40
|
+
const inst = makeInstance(db, makeClassType(db));
|
|
41
|
+
const { app } = appWith(db, { smtp: false });
|
|
42
|
+
|
|
43
|
+
const res = await bookAs(app, inst, 'walkin@test.hk');
|
|
44
|
+
|
|
45
|
+
assert.equal(res.status, 200);
|
|
46
|
+
assert.match(res.text, /\/me\?token=/);
|
|
47
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The Stripe and mailer suites both run against stand-ins: test/stripe.test.js
|
|
2
|
+
// injects a mock client, and every other test leaves SMTP_HOST unset so the
|
|
3
|
+
// mailer takes the outbox branch. Neither imports the real package, so a major
|
|
4
|
+
// bump can rewrite the API and still see a green suite. These tests pin the two
|
|
5
|
+
// bits of third-party behaviour the app cannot work without.
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { createStripeService } from '../src/services/stripe.js';
|
|
11
|
+
import { createMailer } from '../src/services/mailer.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Enough of an SMTP server for nodemailer to complete a send. Resolves with the
|
|
15
|
+
* DATA payload it received. No STARTTLS advertised, so the client stays plain.
|
|
16
|
+
*/
|
|
17
|
+
function smtpSink() {
|
|
18
|
+
let resolveData;
|
|
19
|
+
const received = new Promise((r) => { resolveData = r; });
|
|
20
|
+
const server = net.createServer((socket) => {
|
|
21
|
+
let inData = false;
|
|
22
|
+
let body = '';
|
|
23
|
+
socket.write('220 sink.test ESMTP\r\n');
|
|
24
|
+
socket.on('data', (chunk) => {
|
|
25
|
+
if (inData) {
|
|
26
|
+
body += chunk.toString('utf8');
|
|
27
|
+
if (body.includes('\r\n.\r\n')) {
|
|
28
|
+
inData = false;
|
|
29
|
+
socket.write('250 2.0.0 Ok: queued\r\n');
|
|
30
|
+
resolveData(body.slice(0, body.indexOf('\r\n.\r\n')));
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const line of chunk.toString('utf8').split('\r\n').filter(Boolean)) {
|
|
35
|
+
const verb = line.slice(0, 4).toUpperCase();
|
|
36
|
+
if (verb === 'EHLO' || verb === 'HELO') socket.write('250-sink.test\r\n250 8BITMIME\r\n');
|
|
37
|
+
else if (verb === 'DATA') { inData = true; socket.write('354 End data with <CR><LF>.<CR><LF>\r\n'); }
|
|
38
|
+
else if (verb === 'QUIT') { socket.write('221 Bye\r\n'); socket.end(); }
|
|
39
|
+
else socket.write('250 2.0.0 Ok\r\n');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
socket.on('error', () => {});
|
|
43
|
+
});
|
|
44
|
+
return { server, received };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test('mailer: the real nodemailer SMTP path delivers when SMTP_HOST is set', async (t) => {
|
|
48
|
+
const { server, received } = smtpSink();
|
|
49
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
50
|
+
t.after(() => server.close());
|
|
51
|
+
|
|
52
|
+
const mailer = createMailer({
|
|
53
|
+
env: { SMTP_HOST: '127.0.0.1', SMTP_PORT: String(server.address().port), SMTP_FROM: 'studio@test.hk' },
|
|
54
|
+
});
|
|
55
|
+
assert.equal(mailer.smtpConfigured, true);
|
|
56
|
+
|
|
57
|
+
const ok = await mailer.send({ to: 'rider@test.hk', subject: 'Booked: Yoga', text: 'See you there' });
|
|
58
|
+
assert.equal(ok, true, 'send() reported failure — it swallows errors, so check the mailer log');
|
|
59
|
+
|
|
60
|
+
const raw = await received;
|
|
61
|
+
assert.match(raw, /^To: rider@test\.hk$/m);
|
|
62
|
+
assert.match(raw, /^From: studio@test\.hk$/m);
|
|
63
|
+
assert.match(raw, /Booked: Yoga/);
|
|
64
|
+
assert.match(raw, /See you there/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('mailer: send() resolves false rather than throwing when SMTP is unreachable', async () => {
|
|
68
|
+
// A dead port: booking flows call send() fire-and-forget and must not 500.
|
|
69
|
+
const mailer = createMailer({ env: { SMTP_HOST: '127.0.0.1', SMTP_PORT: '1' } });
|
|
70
|
+
assert.equal(await mailer.send({ to: 'a@test.hk', subject: 's', text: 't' }), false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('stripe: the real SDK verifies a good webhook signature and rejects a forged one', async () => {
|
|
74
|
+
const secret = 'whsec_deps_test';
|
|
75
|
+
const svc = createStripeService({
|
|
76
|
+
env: { STRIPE_SECRET_KEY: 'sk_test_deps', STRIPE_WEBHOOK_SECRET: secret },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const payload = JSON.stringify({
|
|
80
|
+
id: 'evt_1', type: 'checkout.session.completed', data: { object: { id: 'cs_1' } },
|
|
81
|
+
});
|
|
82
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
83
|
+
const sign = (body) => crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
|
|
84
|
+
|
|
85
|
+
const event = await svc.parseWebhook(payload, `t=${ts},v1=${sign(payload)}`);
|
|
86
|
+
assert.equal(event.type, 'checkout.session.completed');
|
|
87
|
+
assert.equal(event.data.object.id, 'cs_1');
|
|
88
|
+
|
|
89
|
+
// A payload swapped after signing must not verify — this is the only thing
|
|
90
|
+
// stopping anyone who knows the URL from granting themselves a membership.
|
|
91
|
+
const forged = payload.replace('cs_1', 'cs_forged');
|
|
92
|
+
await assert.rejects(
|
|
93
|
+
() => svc.parseWebhook(forged, `t=${ts},v1=${sign(payload)}`),
|
|
94
|
+
/signature/i,
|
|
95
|
+
);
|
|
96
|
+
await assert.rejects(() => svc.parseWebhook(payload, `t=${ts},v1=deadbeef`), /signature/i);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('stripe: the real SDK still exposes the Checkout surface the mock stands in for', async () => {
|
|
100
|
+
const svc = createStripeService({ env: { STRIPE_SECRET_KEY: 'sk_test_deps' } });
|
|
101
|
+
// Reaches into the client without a network call: a rename of
|
|
102
|
+
// checkout.sessions.create is a TypeError here, not a runtime 500 in prod.
|
|
103
|
+
const Stripe = (await import('stripe')).default;
|
|
104
|
+
const client = new Stripe('sk_test_deps');
|
|
105
|
+
assert.equal(typeof client.checkout.sessions.create, 'function');
|
|
106
|
+
assert.equal(typeof client.webhooks.constructEvent, 'function');
|
|
107
|
+
assert.equal(svc.configured, true);
|
|
108
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Requests that used to end in a 500, or in a crashed error page.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import request from 'supertest';
|
|
5
|
+
import { createApp } from '../src/app.js';
|
|
6
|
+
import { createUser } from '../src/services/auth.js';
|
|
7
|
+
import { testDb, csrfToken } from './helpers.js';
|
|
8
|
+
|
|
9
|
+
function mindbodyClientsCsv(n) {
|
|
10
|
+
const rows = ['First Name,Last Name,Email,Mobile Phone,Notes,Liability Waiver'];
|
|
11
|
+
for (let i = 0; i < n; i++) {
|
|
12
|
+
rows.push(`Client${i},Surname${i},client${i}@example.com,+852 5555 ${String(i).padStart(4, '0')},Prefers a mat by the window,Yes`);
|
|
13
|
+
}
|
|
14
|
+
return rows.join('\n');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('Admin → Import takes a Mindbody export bigger than 100kb', async () => {
|
|
18
|
+
const db = testDb();
|
|
19
|
+
createUser(db, { email: 'owner@test.hk', password: 'password123', role: 'owner' });
|
|
20
|
+
const agent = request.agent(createApp({ db, env: {} }));
|
|
21
|
+
let _csrf = await csrfToken(agent, '/admin/login');
|
|
22
|
+
await agent.post('/admin/login').type('form').send({ email: 'owner@test.hk', password: 'password123', _csrf });
|
|
23
|
+
_csrf = await csrfToken(agent, '/admin/import');
|
|
24
|
+
|
|
25
|
+
const csv = mindbodyClientsCsv(1500);
|
|
26
|
+
assert.ok(csv.length > 100 * 1024);
|
|
27
|
+
const res = await agent.post('/admin/import').type('form').send({ _csrf, kind: 'clients', csv });
|
|
28
|
+
|
|
29
|
+
assert.equal(res.status, 200);
|
|
30
|
+
assert.equal(db.prepare('SELECT COUNT(*) c FROM clients').get().c, 1500);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('an oversized body elsewhere gets a 413 page, not a crashed error page', async () => {
|
|
34
|
+
const db = testDb();
|
|
35
|
+
const res = await request(createApp({ db, env: {} }))
|
|
36
|
+
.post('/magic-link').type('form').send({ email: 'x'.repeat(200 * 1024) });
|
|
37
|
+
|
|
38
|
+
assert.equal(res.status, 413);
|
|
39
|
+
assert.match(res.text, /<main class="container">/);
|
|
40
|
+
assert.doesNotMatch(res.text, /ReferenceError|at Layer/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('/me without a token asks for a new link instead of a 500', async () => {
|
|
44
|
+
const db = testDb();
|
|
45
|
+
const res = await request(createApp({ db, env: {} })).get('/me');
|
|
46
|
+
|
|
47
|
+
assert.equal(res.status, 401);
|
|
48
|
+
assert.match(res.text, /invalid or has expired/);
|
|
49
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Monthly credit cycles for a membership joined near the end of a month.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { resolvePayment } from '../src/services/booking.js';
|
|
5
|
+
import { testDb, makeClient, makeMembership } from './helpers.js';
|
|
6
|
+
|
|
7
|
+
/** Run the lazy cycle refresh for `today` and report the stored cycle start. */
|
|
8
|
+
function cycleOn(startedOn, today) {
|
|
9
|
+
const db = testDb();
|
|
10
|
+
const clientId = makeClient(db);
|
|
11
|
+
const id = makeMembership(db, clientId, { unlimited: false, creditsPerMonth: 4, startedOn });
|
|
12
|
+
db.prepare('UPDATE memberships SET cycle_started_on = ? WHERE id = ?').run(startedOn, id);
|
|
13
|
+
resolvePayment(db, clientId, { starts_at: `${today}T10:00:00Z`, credits_required: 1 }, { now: `${today}T09:00:00Z` });
|
|
14
|
+
return db.prepare('SELECT cycle_started_on FROM memberships WHERE id = ?').get(id).cycle_started_on;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('a cycle anchored on the 31st returns to the 31st after February', () => {
|
|
18
|
+
// Stepping each cycle from the last one clamped to 28 Feb and then kept the
|
|
19
|
+
// 28th for good, quietly moving the client's refill date forward for ever.
|
|
20
|
+
const cases = [
|
|
21
|
+
['2026-01-31', '2026-02-27', '2026-01-31'],
|
|
22
|
+
['2026-01-31', '2026-02-28', '2026-02-28'], // February has to clamp
|
|
23
|
+
['2026-01-31', '2026-03-30', '2026-02-28'],
|
|
24
|
+
['2026-01-31', '2026-03-31', '2026-03-31'], // and March must recover
|
|
25
|
+
['2026-01-31', '2026-04-30', '2026-04-30'],
|
|
26
|
+
['2026-01-31', '2026-05-31', '2026-05-31'],
|
|
27
|
+
];
|
|
28
|
+
for (const [started, today, want] of cases) {
|
|
29
|
+
assert.equal(cycleOn(started, today), want, `started ${started}, on ${today}`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('cycle anchoring handles leap years, year boundaries and mid-month joins', () => {
|
|
34
|
+
const cases = [
|
|
35
|
+
['2024-01-31', '2024-02-29', '2024-02-29'], // leap February
|
|
36
|
+
['2024-01-31', '2024-03-31', '2024-03-31'],
|
|
37
|
+
['2025-11-30', '2026-02-27', '2026-01-30'], // across a year boundary
|
|
38
|
+
['2025-11-30', '2026-02-28', '2026-02-28'], // 30th anchor clamps, and it is today
|
|
39
|
+
['2026-01-15', '2026-03-14', '2026-02-15'], // ordinary anchor, unaffected
|
|
40
|
+
['2026-01-15', '2026-03-15', '2026-03-15'],
|
|
41
|
+
];
|
|
42
|
+
for (const [started, today, want] of cases) {
|
|
43
|
+
assert.equal(cycleOn(started, today), want, `started ${started}, on ${today}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('correcting an already-drifted cycle does not refill credits mid-month', () => {
|
|
48
|
+
const db = testDb();
|
|
49
|
+
const clientId = makeClient(db);
|
|
50
|
+
const id = makeMembership(db, clientId, { unlimited: false, creditsPerMonth: 4, startedOn: '2026-01-31' });
|
|
51
|
+
// The state the old stepping logic left behind: June pinned to the 28th,
|
|
52
|
+
// three of four credits already spent this cycle.
|
|
53
|
+
db.prepare("UPDATE memberships SET cycle_started_on = '2026-06-28', credits_used_this_cycle = 3 WHERE id = ?")
|
|
54
|
+
.run(id);
|
|
55
|
+
|
|
56
|
+
resolvePayment(db, clientId, { starts_at: '2026-06-30T10:00:00Z', credits_required: 1 }, { now: '2026-06-30T09:00:00Z' });
|
|
57
|
+
|
|
58
|
+
const row = db.prepare('SELECT cycle_started_on, credits_used_this_cycle FROM memberships WHERE id = ?').get(id);
|
|
59
|
+
assert.equal(row.cycle_started_on, '2026-06-30', 'the drifted date should be corrected');
|
|
60
|
+
assert.equal(row.credits_used_this_cycle, 3, 'same month, so credits must not be refilled');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a genuinely new month still refills credits', () => {
|
|
64
|
+
const db = testDb();
|
|
65
|
+
const clientId = makeClient(db);
|
|
66
|
+
const id = makeMembership(db, clientId, { unlimited: false, creditsPerMonth: 4, startedOn: '2026-01-31' });
|
|
67
|
+
db.prepare("UPDATE memberships SET cycle_started_on = '2026-01-31', credits_used_this_cycle = 4 WHERE id = ?")
|
|
68
|
+
.run(id);
|
|
69
|
+
|
|
70
|
+
const resolved = resolvePayment(
|
|
71
|
+
db, clientId, { starts_at: '2026-03-31T10:00:00Z', credits_required: 1 }, { now: '2026-03-31T09:00:00Z' },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const row = db.prepare('SELECT cycle_started_on, credits_used_this_cycle FROM memberships WHERE id = ?').get(id);
|
|
75
|
+
assert.equal(row.cycle_started_on, '2026-03-31');
|
|
76
|
+
assert.equal(row.credits_used_this_cycle, 0);
|
|
77
|
+
assert.equal(resolved.paidWith, 'membership', 'a refilled cycle should cover the class again');
|
|
78
|
+
});
|