Checkout: forward only the checkout fields, show clear errors, fix the contact form #16

Open
libretech wants to merge 6 commits from fix/checkout-errors into main
29 changed files with 1108 additions and 71 deletions
+21 -1
View File
@@ -8,7 +8,27 @@ on:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
container:
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# The tests need no dependencies. They run in the image's base (Dockerfile FROM), so on the runtime's Node,
# without network. The source is piped in because the job container's paths do not exist on the Docker host.
- name: npm test, on the Node of the image
run: |
base=$(sed -n 's/^FROM \([^ ]*\).*/\1/p' Dockerfile | head -1)
tar -c --exclude=.git . | docker run -i --rm --network none -e npm_config_update_notifier=false "$base" \
sh -c 'mkdir /w && cd /w && tar -x && npm test'
# The image is built to check a pull request, and built and published only for a release tag.
# Nothing pulls per-commit images, so main no longer publishes :main and :sha-* images.
build:
needs: test
if: github.event_name == 'pull_request' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
container:
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
@@ -21,4 +41,4 @@ jobs:
registry: ${{ secrets.REGISTRY }}
registry_user: ${{ secrets.REGISTRY_USER }}
registry_pass: ${{ secrets.REGISTRY_PASS }}
publish: ${{ github.event_name == 'push' && vars.PUBLISH_ENABLED == 'true' }}
publish: ${{ startsWith(github.ref, 'refs/tags/v') && vars.PUBLISH_ENABLED == 'true' }}
+7 -2
View File
@@ -3,8 +3,8 @@
Vue/Nuxt-style storefront base.
Part of the [libreshop](https://git.librete.ch/libreshop) toolkit. Image
published at `git.librete.ch/libreshop/shop` on every push to `main`
and on `v*` tags.
published at `git.librete.ch/libreshop/shop` on `v*` tags; pull requests
build it without publishing.
## Source
@@ -18,6 +18,11 @@ pulls `git.librete.ch/libreshop/shop:<pin>` instead of building locally.
docker build -t libreshop/shop:dev .
```
## Test
`npm test` runs the unit tests in `tests/unit/` on Node 22.6 or later, without
installing dependencies.
## Adapter contract
See `docker-entrypoint.sh` and `Dockerfile` for the runtime surface.
+4 -1
View File
@@ -5,6 +5,7 @@
<span :class="spanClasses" class="absolute left-2 top-0 transition-all select-none">{{ label }}</span>
<input
:value="modelValue"
:type="type"
@input="update(($event.target as HTMLInputElement).value)"
:required="required"
class="outline-none w-full h-full rounded-sm pt-4 p-2 border-transparent"
@@ -40,9 +41,11 @@ const props = withDefaults(
labelClass?: string;
inputClass?: string;
autocomplete?: Autocomplete;
type?: "text" | "email";
}>(),
{
autocomplete: "off"
autocomplete: "off",
type: "text"
}
);
+4 -2
View File
@@ -9,6 +9,7 @@ import type {
PaymentMethod,
ApiResponse
} from "~/types";
import type { AlreadyCaptured } from "~/utils/captureResponse";
/**
* Shop API composable.
@@ -116,8 +117,9 @@ export function useShopApi() {
});
},
async capturePayment(uuid: string, paypalOrderId: string): Promise<Order> {
return await $fetch<Order>(`/api/orders/${uuid}/capture/${paypalOrderId}`, {
/** The paid order, or { success: true, alreadyCaptured: true } when an earlier capture of this PayPal order went through. */
async capturePayment(uuid: string, paypalOrderId: string): Promise<Order | AlreadyCaptured> {
return await $fetch<Order | AlreadyCaptured>(`/api/orders/${uuid}/capture/${paypalOrderId}`, {
method: "POST"
});
},
+6 -1
View File
@@ -29,6 +29,7 @@ export default defineNuxtConfig({
// Server-only (private) - never exposed to client
shopApiToken: "", // NUXT_SHOP_API_TOKEN
cmsInternalUrl: "http://cms:5555", // NUXT_CMS_INTERNAL_URL - internal docker network URL
mailApiUrl: "http://mail:2222", // NUXT_MAIL_API_URL - mail service (libreshop/mail) on the internal docker network
siteUrl: "https://muellerprints-paperwork.com", // NUXT_SITE_URL - for sitemap
// Public (client + server)
@@ -72,7 +73,11 @@ export default defineNuxtConfig({
// TypeScript
typescript: {
strict: true
strict: true,
// The unit tests run on Node (npm test), not in the app: keep them out of the app's type check (.nuxt/tsconfig.app.json).
tsConfig: {
exclude: ["../tests/**/*"]
}
},
// Tailwind
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"test": "node --experimental-strip-types --import ./tests/unit/support/register.mjs --test 'tests/unit/**/*.test.ts'"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
+9 -1
View File
@@ -29,7 +29,7 @@
<div v-if="uuid">
<form @submit.prevent="submit" class="flex flex-col gap-12">
<Input label="E-Mail-Adresse" v-model="emailAddress" :required="true" autocomplete="email" />
<Input label="E-Mail-Adresse" v-model="emailAddress" type="email" :required="true" autocomplete="email" />
<label class="flex gap-4 text-sm cursor-pointer">
<input v-model="acceptedTermsAndConditions" required type="checkbox" class="w-4 cursor-pointer" />
@@ -44,6 +44,10 @@
<hr />
<div v-if="errorMessage" class="p-4 lg:p-8 text-center mx-auto rounded-md bg-rose-100" role="alert" data-e2e="checkout-error">
<span class="text-rose-800">{{ errorMessage }}</span>
</div>
<Button type="submit" classes="w-full" :is-pending="formSubmitIsPending">Weiter zur Lieferadresse</Button>
</form>
</div>
@@ -60,6 +64,7 @@
<script setup lang="ts">
import { trackEvent } from "~/utils/trackEvent";
import { checkoutErrorMessageFor } from "~/utils/checkoutError";
const COOKIE_CONSENT_KEY = "shop:cookie-consent";
@@ -68,6 +73,7 @@ const cart = useCart();
const emailAddress = ref("");
const acceptedTermsAndConditions = ref(false);
const formSubmitIsPending = ref(false);
const errorMessage = ref("");
const uuid = computed(() => cart.uuid.value);
@@ -120,6 +126,7 @@ async function submit() {
try {
formSubmitIsPending.value = true;
errorMessage.value = "";
// Use cart.update() to sync local state after API call
await cart.update({
@@ -138,6 +145,7 @@ async function submit() {
navigateTo("/checkout/2");
} catch (error) {
console.error("Error submitting email address", error);
errorMessage.value = checkoutErrorMessageFor(error);
} finally {
formSubmitIsPending.value = false;
}
+8
View File
@@ -97,6 +97,10 @@
<hr class="my-12" />
<div v-if="errorMessage" class="p-4 lg:p-8 text-center mx-auto rounded-md bg-rose-100" role="alert" data-e2e="checkout-error">
<span class="text-rose-800">{{ errorMessage }}</span>
</div>
<div class="mt-12">
<Button classes="w-full" type="submit" :is-pending="formSubmitIsPending">Weiter zur Zahlung</Button>
</div>
@@ -110,6 +114,7 @@
import type { Address, StructuredAddress } from "~/types";
import { trackEvent } from "~/utils/trackEvent";
import { numberFormatter } from "~/utils/numberFormatter";
import { checkoutErrorMessageFor } from "~/utils/checkoutError";
const cart = useCart();
const shopApi = useShopApi();
@@ -134,6 +139,7 @@ const displayState = computed(() => {
});
const showOptionalDeliveryAddress = ref(false);
const formSubmitIsPending = ref(false);
const errorMessage = ref("");
const deliveryMethods = ref<any[]>([]);
const selectedDeliveryMethod = ref<number | null>(null);
@@ -243,6 +249,7 @@ async function submit() {
try {
formSubmitIsPending.value = true;
errorMessage.value = "";
// Use cart.update() to sync local state after API call
await cart.update({
@@ -262,6 +269,7 @@ async function submit() {
navigateTo("/checkout/3");
} catch (error) {
console.error("Error submitting address form:", error);
errorMessage.value = checkoutErrorMessageFor(error);
} finally {
formSubmitIsPending.value = false;
}
+13 -4
View File
@@ -64,7 +64,7 @@
<div v-else>
<div v-if="!hasPaymentError" id="paypal-button-container" class="payment sticky top-4" ref="paymentContainer"></div>
<div v-else class="p-4 lg:p-8 text-center mx-auto rounded-md bg-rose-100" data-e2e="payment-error">
<span class="text-rose-800">Es ist ein Fehler aufgetreten. Bitte versuche es erneut.</span>
<span class="text-rose-800">{{ paymentErrorMessage || "Es ist ein Fehler aufgetreten. Bitte versuche es erneut." }}</span>
</div>
<!-- Trust signals -->
@@ -190,6 +190,8 @@
<script setup lang="ts">
import { numberFormatter } from "~/utils/numberFormatter";
import { trackEvent } from "~/utils/trackEvent";
import { checkoutErrorMessageFor } from "~/utils/checkoutError";
import { isAlreadyCaptured } from "~/utils/captureResponse";
import type { Order } from "~/types";
const config = useRuntimeConfig();
@@ -197,6 +199,7 @@ const shopApi = useShopApi();
const CART_UUID_KEY = "shop:cart";
const hasPaymentError = ref(false);
const paymentErrorMessage = ref("");
const hasOrderError = ref(false);
const hasAuthorisedPayment = ref(false);
const orderData = ref<Order>({} as Order);
@@ -298,6 +301,7 @@ async function initializePayPalButtons() {
return response.id;
} catch (error) {
console.error("Error creating PayPal order:", error);
paymentErrorMessage.value = checkoutErrorMessageFor(error);
hasPaymentError.value = true;
trackEvent("checkout-payment-error", {
stage: "create-order",
@@ -309,10 +313,14 @@ async function initializePayPalButtons() {
onApprove: async (data) => {
try {
// Capture payment server-side (CMS captures via PayPal server SDK and updates order)
const capturedOrder = await shopApi.capturePayment(orderData.value.uuid, data.orderID!);
const captured = await shopApi.capturePayment(orderData.value.uuid, data.orderID!);
if (capturedOrder.paymentAuthorised) {
orderData.value = capturedOrder;
if (isAlreadyCaptured(captured)) {
// An earlier capture of this PayPal order went through: reload the paid order, keeping the shown one if that fails.
orderData.value = await shopApi.getOrder(orderData.value.uuid).catch(() => orderData.value);
await handleSuccessfulPayment();
} else if (captured.paymentAuthorised) {
orderData.value = captured;
await handleSuccessfulPayment();
} else {
console.error("Payment capture did not result in authorisation");
@@ -324,6 +332,7 @@ async function initializePayPalButtons() {
}
} catch (error) {
console.error("Error capturing payment:", error);
paymentErrorMessage.value = checkoutErrorMessageFor(error);
hasPaymentError.value = true;
trackEvent("checkout-payment-error", {
stage: "capture-exception",
+7 -44
View File
@@ -1,55 +1,18 @@
import { contactMail, mailSendUrl } from "~/server/utils/contactMail";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const { name, email, subject, message } = body;
if (!name || !email || !message) {
throw createError({
statusCode: 400,
message: "Name, E-Mail und Nachricht sind erforderlich",
});
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw createError({
statusCode: 400,
message: "Ungültige E-Mail-Adresse",
});
const result = contactMail(await readBody(event));
if (result.ok === false) {
throw createError({ statusCode: 400, message: result.message });
}
try {
// Send email via Mail service
const config = useRuntimeConfig();
const mailApiUrl = config.mailApiUrl || "http://mail:2222";
await $fetch(`${mailApiUrl}/send`, {
method: "POST",
body: {
to: "paperwork@muellerprints.de",
subject: `Kontaktanfrage: ${subject || "Anfrage über Website"}`,
body: `
Name: ${name}
E-Mail: ${email}
Betreff: ${subject || "Kontaktanfrage über Website"}
Nachricht:
${message}
---
Diese Nachricht wurde über das Kontaktformular auf muellerprints.de gesendet.
`.trim(),
replyTo: email,
},
});
await $fetch(mailSendUrl(config.mailApiUrl || "http://mail:2222"), { method: "POST", body: result.mail });
return { success: true };
} catch (error) {
console.error("Failed to send contact email:", error);
throw createError({
statusCode: 500,
message: "E-Mail konnte nicht gesendet werden",
});
throw createError({ statusCode: 500, message: "E-Mail konnte nicht gesendet werden" });
}
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { forwardToCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
@@ -6,5 +6,5 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: "Missing order UUID" });
}
return await fetchCms(`/orders/${uuid}/cart`);
return await forwardToCms(`/orders/${uuid}/cart`);
});
+9 -4
View File
@@ -1,4 +1,5 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { forwardToCms } from "~/server/utils/cmsApi";
import { invalidOrderUpdate, pickCustomerUpdate } from "~/server/utils/customerUpdate";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
@@ -6,10 +7,14 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: "Missing order UUID" });
}
const body = await readBody(event);
// Only the fields of the checkout's steps reach the CMS, which checks their values. Any other field is answered 400 here.
const update = pickCustomerUpdate(await readBody(event));
if (update.ok === false) {
throw createError(invalidOrderUpdate(update.errors));
}
return await fetchCms(`/orders/${uuid}/cart`, {
return await forwardToCms(`/orders/${uuid}/cart`, {
method: "PUT",
body
body: { data: update.data }
});
});
@@ -1,4 +1,4 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { forwardToCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
const count = query.count || 1;
return await fetchCms(`/orders/${uuid}/add-product/${productId}?count=${count}`, {
return await forwardToCms(`/orders/${uuid}/add-product/${productId}?count=${count}`, {
method: "PUT"
});
});
@@ -1,4 +1,4 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { forwardToCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
@@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: "Missing PayPal order ID" });
}
return await fetchCms(`/orders/${uuid}/capture/${paypalOrderId}`, {
return await forwardToCms(`/orders/${uuid}/capture/${paypalOrderId}`, {
method: "POST"
});
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { forwardToCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
@@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => {
const returnUrl = query.returnUrl || "";
return await fetchCms(`/orders/${uuid}/checkout?returnUrl=${encodeURIComponent(String(returnUrl))}`, {
return await forwardToCms(`/orders/${uuid}/checkout?returnUrl=${encodeURIComponent(String(returnUrl))}`, {
method: "POST"
});
});
@@ -1,4 +1,4 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { forwardToCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
const count = query.count || 1;
return await fetchCms(`/orders/${uuid}/remove-product/${productId}?count=${count}`, {
return await forwardToCms(`/orders/${uuid}/remove-product/${productId}?count=${count}`, {
method: "PUT"
});
});
+17
View File
@@ -1,3 +1,5 @@
import { cmsErrorLogLine, shopErrorFromCms } from "./cmsError";
/**
* Server-side CMS API utility.
* Use this for direct CMS access during SSR.
@@ -15,3 +17,18 @@ export async function fetchCms<T>(endpoint: string, options: Parameters<typeof $
}
});
}
/**
* fetchCms for a route that forwards the browser's request to the CMS: a CMS error answers the browser with the CMS's status
* and the safe fields of its error (cmsError.ts), instead of an unhandled error, whose message and data Nitro hides.
*/
export async function forwardToCms<T>(endpoint: string, options: Parameters<typeof $fetch>[1] = {}): Promise<T> {
try {
return await fetchCms<T>(endpoint, options);
} catch (error) {
const shopError = shopErrorFromCms(error);
if (!shopError) throw error;
if (shopError.statusCode >= 500) console.error(cmsErrorLogLine(String(options.method ?? "GET"), endpoint, shopError));
throw createError(shopError);
}
}
+81
View File
@@ -0,0 +1,81 @@
// How a failed CMS request answers the browser: with the CMS's status and only the fields of its error that are safe to show.
// The CMS (Strapi) answers an error as { data: null, error: { status, name, message, details } }, which $fetch throws as ofetch's
// FetchError with the response's status and parsed body. Thrown on as it is, Nitro treats it as unhandled: it hides the message and
// the data from the browser and logs the CMS URL. forwardToCms (cmsApi.ts) throws createError(shopErrorFromCms(error)) instead.
// Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/cmsError.test.ts.
/** What the browser receives as the data of the error. errors and missing are the CMS's details.errors and details.missing. */
export type ShopErrorData = { message: string; errors?: string[]; missing?: string[] };
/** The argument for h3's createError. */
export type ShopError = { statusCode: number; statusMessage: string; data: ShopErrorData };
// The statuses passed on: the CMS's answers about the order and its payment. Any other status, such as 401 or 403 for a wrong
// API token, is the shop's own fault and answered 500.
const STATUS_TEXTS = new Map<number, string>([
[400, "Bad Request"],
[404, "Not Found"],
[409, "Conflict"],
[500, "Internal Server Error"],
[502, "Bad Gateway"],
[503, "Service Unavailable"],
[504, "Gateway Timeout"]
]);
const MAX_MESSAGE_LENGTH = 200;
const MAX_STATUS_MESSAGE_LENGTH = 100;
const MAX_FIELD_LENGTH = 64;
const MAX_LIST_LENGTH = 20;
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const cut = (text: string, maxLength: number): string => (text.length > maxLength ? `${text.slice(0, maxLength)}…` : text);
// The strings of a list in the CMS's details, e.g. ["data.email: must be an email address of at most 254 characters"].
const stringList = (value: unknown, maxLength: number): string[] | undefined => {
if (!Array.isArray(value)) return undefined;
const list = value
.filter((item): item is string => typeof item === "string")
.slice(0, MAX_LIST_LENGTH)
.map((item) => cut(item, maxLength));
return list.length > 0 ? list : undefined;
};
// The status message is the HTTP reason phrase, which allows printable ASCII only.
const isReasonPhrase = (text: string): boolean => text.length <= MAX_STATUS_MESSAGE_LENGTH && /^[\x20-\x7e]+$/.test(text);
/**
* The error to throw to the browser for an error of $fetch to the CMS, or undefined if the error is not ofetch's FetchError
* (a bug, to rethrow as it is). The CMS's status is kept for 400, 404, 409, 500, 502, 503 and 504, any other is answered 500,
* and a CMS that does not answer 503. The data holds the CMS's message, details.errors and details.missing, and nothing else.
*/
export const shopErrorFromCms = (error: unknown): ShopError | undefined => {
if (!(error instanceof Error) || error.name !== "FetchError") return undefined;
const { status, data } = error as Error & { status?: unknown; data?: unknown };
if (typeof status !== "number") {
return { statusCode: 503, statusMessage: "Service Unavailable", data: { message: "The CMS did not answer" } };
}
const statusText = STATUS_TEXTS.get(status);
if (statusText === undefined) {
return { statusCode: 500, statusMessage: "Internal Server Error", data: { message: "Internal Server Error" } };
}
const cmsError: Record<string, unknown> = isObject(data) && isObject(data.error) ? data.error : {};
const details: Record<string, unknown> = isObject(cmsError.details) ? cmsError.details : {};
const message = typeof cmsError.message === "string" && cmsError.message !== "" ? cut(cmsError.message, MAX_MESSAGE_LENGTH) : statusText;
const errors = stringList(details.errors, MAX_MESSAGE_LENGTH);
const missing = stringList(details.missing, MAX_FIELD_LENGTH);
return {
statusCode: status,
statusMessage: isReasonPhrase(message) ? message : statusText,
data: { message, ...(errors ? { errors } : {}), ...(missing ? { missing } : {}) }
};
};
/** The log line for a failed CMS request: without its query, and with order uuids replaced, since a uuid opens its order. */
export const cmsErrorLogLine = (method: string, endpoint: string, error: ShopError): string =>
`[cms] ${method.toUpperCase()} ${endpoint.replace(/\?.*$/, "").replace(UUID, ":uuid")}: ${error.statusCode} ${error.data.message}`;
+56
View File
@@ -0,0 +1,56 @@
// The mail of the contact form (components/ContactForm.vue → POST /api/contact). The mail service (libreshop/mail src/app.py)
// sends it from POST /v1/send/message, whose JSON body is { to_email, subject, message, html? }. It sets no Reply-To, so the
// sender's address is in the text. Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/contactMail.test.ts.
export const CONTACT_RECIPIENT = "paperwork@muellerprints.de";
export const MAIL_SEND_PATH = "/v1/send/message";
/** The body of POST /v1/send/message. */
export type ContactMail = { to_email: string; subject: string; message: string };
/** The mail to send, or the German message of the 400 for the form. */
export type ContactMailResult = { ok: true; mail: ContactMail } | { ok: false; message: string };
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const isFilled = (value: unknown): value is string => typeof value === "string" && value.trim() !== "";
// A line break in the subject would end its header and start another one.
const oneLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim();
/** The URL of the mail service's send endpoint, from its base URL (runtimeConfig.mailApiUrl). */
export const mailSendUrl = (mailApiUrl: string): string => `${mailApiUrl.replace(/\/+$/, "")}${MAIL_SEND_PATH}`;
/** Checks the form's body { name, email, subject?, message } and builds the mail to the shop. */
export const contactMail = (body: unknown): ContactMailResult => {
const fields: Record<string, unknown> = isObject(body) ? body : {};
const { name, email, subject, message } = fields;
if (!isFilled(name) || !isFilled(email) || !isFilled(message)) {
return { ok: false, message: "Name, E-Mail und Nachricht sind erforderlich" };
}
if (!EMAIL.test(email)) {
return { ok: false, message: "Ungültige E-Mail-Adresse" };
}
const topic = typeof subject === "string" ? oneLine(subject) : "";
return {
ok: true,
mail: {
to_email: CONTACT_RECIPIENT,
subject: `Kontaktanfrage: ${topic || "Anfrage über Website"}`,
message: [
`Name: ${name}`,
`E-Mail: ${email}`,
`Betreff: ${topic || "Kontaktanfrage über Website"}`,
"",
"Nachricht:",
message,
"",
"---",
"Diese Nachricht wurde über das Kontaktformular auf muellerprints.de gesendet."
].join("\n")
}
};
};
+79
View File
@@ -0,0 +1,79 @@
// What PUT /api/orders/:uuid forwards to the CMS: the fields of the checkout's steps, and nothing else.
// Step 1 (pages/checkout/1.vue) sends { data: { email, acceptedTermsAndConditionsAt } }, step 2 (pages/checkout/2.vue) sends
// { data: { invoiceAddress, deliveryAddress, invoiceAddressStructured, deliveryAddressStructured, delivery } }.
// The CMS checks the values and stays the authority (libreshop/cms src/checkout/customer-update.ts). This check is defence in depth:
// a body with any other field is answered 400 without calling the CMS, in the format of the CMS's own answer.
// Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/customerUpdate.test.ts.
import type { ShopError } from "./cmsError";
/** The order fields a customer may set, in the order of the checkout's steps. */
export const CUSTOMER_UPDATE_FIELDS = [
"email",
"acceptedTermsAndConditionsAt",
"invoiceAddress",
"deliveryAddress",
"invoiceAddressStructured",
"deliveryAddressStructured",
"delivery"
] as const;
export type CustomerUpdateField = (typeof CUSTOMER_UPDATE_FIELDS)[number];
/** The fields to forward, with the values the browser sent: the CMS checks them. */
export type CustomerUpdate = Partial<Record<CustomerUpdateField, unknown>>;
/**
* unknown: the names of the rejected fields, such as "data.paymentAuthorised", or "email" for a field sent beside data.
* errors: one message per rejected field or malformed part, in the CMS's format "name: reason".
*/
export type PickedCustomerUpdate = { ok: true; data: CustomerUpdate } | { ok: false; unknown: string[]; errors: string[] };
export const INVALID_ORDER_UPDATE = "Invalid order update";
// A field name is chosen by the client and ends up in the answer, so a long one is cut.
const MAX_NAME_LENGTH = 64;
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const isCustomerUpdateField = (key: string): key is CustomerUpdateField => (CUSTOMER_UPDATE_FIELDS as readonly string[]).includes(key);
const fieldName = (key: string): string => (key.length > MAX_NAME_LENGTH ? `${key.slice(0, MAX_NAME_LENGTH)}…` : key);
/** Picks the fields a customer may set from the body { data: { … } }. Any other field, or another shape, rejects the whole body. */
export const pickCustomerUpdate = (body: unknown): PickedCustomerUpdate => {
if (!isObject(body)) {
return { ok: false, unknown: [], errors: ["body: must be an object of the form { data: { … } }"] };
}
const unknown: string[] = [];
const errors: string[] = [];
for (const key of Object.keys(body)) {
if (key === "data") continue;
unknown.push(fieldName(key));
errors.push(`${fieldName(key)}: not accepted, the fields belong in data`);
}
const fields = body.data;
const data: CustomerUpdate = {};
if (!isObject(fields)) {
errors.push(fields === undefined ? "data: missing" : "data: must be an object");
} else {
for (const key of Object.keys(fields)) {
if (isCustomerUpdateField(key)) {
data[key] = fields[key];
} else {
unknown.push(`data.${fieldName(key)}`);
errors.push(`data.${fieldName(key)}: not a field the customer may set`);
}
}
}
return errors.length > 0 ? { ok: false, unknown, errors } : { ok: true, data };
};
/** The 400 for a rejected body: the shape the shop answers for the CMS's own 400 (cmsError.ts). */
export const invalidOrderUpdate = (errors: string[]): ShopError => ({
statusCode: 400,
statusMessage: INVALID_ORDER_UPDATE,
data: { message: INVALID_ORDER_UPDATE, errors }
});
+21
View File
@@ -0,0 +1,21 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { isAlreadyCaptured } from "../../utils/captureResponse.ts";
test("recognises the CMS's answer to a repeated capture", () => {
assert.equal(isAlreadyCaptured({ success: true, alreadyCaptured: true }), true);
});
test("takes the paid order of a first capture for no repeated capture", () => {
const order = { uuid: "11111111-2222-4333-8444-555555555555", total: 22.85, paymentAuthorised: true, email: "erika@example.org" };
assert.equal(isAlreadyCaptured(order), false);
assert.equal(isAlreadyCaptured({ ...order, alreadyCaptured: false }), false);
});
test("takes nothing but true for both fields as a repeated capture", () => {
assert.equal(isAlreadyCaptured({ alreadyCaptured: true }), false);
assert.equal(isAlreadyCaptured({ success: true, alreadyCaptured: "true" }), false);
assert.equal(isAlreadyCaptured({ success: false, alreadyCaptured: true }), false);
for (const response of [undefined, null, "alreadyCaptured", 1, [true]]) assert.equal(isAlreadyCaptured(response), false);
});
+185
View File
@@ -0,0 +1,185 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CHECKOUT_ERROR_MESSAGES as M, checkoutErrorMessage, checkoutErrorMessageFor } from "../../utils/checkoutError.ts";
// The shop's answer for a CMS error (server/utils/cmsError.ts), as Nitro writes it for a handled error.
const shopError = (statusCode: number, data: { message: string; errors?: string[]; missing?: string[] }) => ({
error: true,
url: "/api/orders/11111111-2222-4333-8444-555555555555",
statusCode,
statusMessage: data.message,
message: data.message,
data
});
// The CMS's own answer, Strapi's error body.
const strapiError = (status: number, message: string, details: Record<string, unknown> = {}) => ({
data: null,
error: { status, name: "Error", message, details }
});
// Built like ofetch's FetchError in the browser: getters for the answer's status and parsed body.
const fetchError = (status: number | undefined, body: unknown): Error => {
const error = new Error(`[PUT] "/api/orders/11111111-2222-4333-8444-555555555555": ${status ?? "<no response>"}`);
error.name = "FetchError";
for (const [key, value] of [
["data", body],
["status", status],
["statusCode", status]
] as const) {
Object.defineProperty(error, key, { get: () => value });
}
return error;
};
const ALL_MESSAGES = new Set<string>(Object.values(M));
test("asks for a valid e-mail address when the CMS rejects data.email", () => {
const body = shopError(400, { message: "Invalid order update", errors: ["data.email: must be an email address of at most 254 characters"] });
assert.equal(checkoutErrorMessage(400, body), "Bitte gib eine gültige E-Mail-Adresse ein.");
});
test("asks to check the input when the CMS rejects another field", () => {
const body = shopError(400, {
message: "Invalid order update",
errors: ["data.invoiceAddressStructured.postalCode: must be a string of at most 200 characters, or null"]
});
assert.equal(checkoutErrorMessage(400, body), M.invalidInput);
assert.equal(
checkoutErrorMessage(400, shopError(400, { message: "Invalid order update", errors: ["data.delivery: no delivery method has this id"] })),
M.invalidInput
);
});
test("asks to check the input when the shop rejects a field before the CMS", () => {
const body = shopError(400, { message: "Invalid order update", errors: ["data.paymentAuthorised: not a field the customer may set"] });
assert.equal(checkoutErrorMessage(400, body), M.invalidInput);
});
test("mentions the e-mail address when it is among several rejected fields", () => {
const body = shopError(400, {
message: "Invalid order update",
errors: [
"data.acceptedTermsAndConditionsAt: must be sent only to accept the terms, as true or a timestamp",
"data.email: must be an email address"
]
});
assert.equal(checkoutErrorMessage(400, body), M.invalidEmail);
});
test("says that a paid order can no longer be changed", () => {
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "Order can no longer be changed" })), M.alreadyPaid);
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "Order is already paid" })), M.alreadyPaid);
assert.equal(M.alreadyPaid, "Diese Bestellung ist bereits bezahlt und kann nicht mehr geändert werden.");
});
test("says that a product is no longer available", () => {
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "A product in the cart is no longer available" })), M.productUnavailable);
assert.equal(M.productUnavailable, "Ein Produkt in deinem Warenkorb ist nicht mehr verfügbar. Bitte prüfe deinen Warenkorb.");
});
test("names the fields an order that is not ready for checkout misses", () => {
const body = (missing: string[]) => shopError(400, { message: "Order is not ready for checkout", missing });
assert.equal(checkoutErrorMessage(400, body(["email"])), "Deine Bestellung ist noch nicht vollständig. Bitte ergänze: E-Mail-Adresse.");
assert.equal(
checkoutErrorMessage(400, body(["email", "invoiceAddress", "delivery"])),
"Deine Bestellung ist noch nicht vollständig. Bitte ergänze: E-Mail-Adresse, Rechnungsadresse, Versandart."
);
});
test("says that an order is not ready without naming a field the shop does not know", () => {
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Order is not ready for checkout" })), M.notReady);
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Order is not ready for checkout", missing: ["<b>phone</b>"] })), M.notReady);
assert.equal(
checkoutErrorMessage(400, shopError(400, { message: "Order is not ready for checkout", missing: ["phone", "delivery"] })),
"Deine Bestellung ist noch nicht vollständig. Bitte ergänze: Versandart."
);
});
test("asks to restart the payment when it does not fit the order or the order changed", () => {
for (const message of ["Payment does not belong to this order", "Payment does not match this order", "Order changed during the payment"]) {
assert.equal(checkoutErrorMessage(409, shopError(409, { message })), M.restartPayment, message);
}
assert.match(M.restartPayment, /Bitte starte die Zahlung neu\.$/);
});
test("asks to try again in a few minutes when PayPal is not reachable", () => {
for (const message of ["Could not create the PayPal order", "PayPal could not capture the payment"]) {
assert.equal(checkoutErrorMessage(502, shopError(502, { message })), M.paypalUnavailable, message);
}
assert.equal(checkoutErrorMessage(502, "<html>502 Bad Gateway</html>"), M.paypalUnavailable);
assert.match(M.paypalUnavailable, /versuche es in ein paar Minuten noch einmal\.$/);
});
test("asks not to pay again when PayPal may have taken the money but the order is not marked paid", () => {
assert.equal(checkoutErrorMessage(502, shopError(502, { message: "Payment could not be confirmed" })), M.paymentUnconfirmed);
assert.equal(checkoutErrorMessage(500, shopError(500, { message: "Payment could not be recorded" })), M.paymentUnconfirmed);
});
test("falls back to a general message for anything else", () => {
assert.equal(checkoutErrorMessage(404, shopError(404, { message: "Order not found" })), M.generic);
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Missing order UUID" })), M.generic);
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Product not found in cart" })), M.generic);
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "Something else" })), M.generic);
assert.equal(checkoutErrorMessage(500, { error: true, statusCode: 500, statusMessage: "Server Error", message: "Server Error" }), M.generic);
assert.equal(checkoutErrorMessage(503, shopError(503, { message: "The CMS did not answer" })), M.generic);
assert.equal(checkoutErrorMessage(undefined, undefined), M.generic);
assert.equal(checkoutErrorMessage(409, null), M.generic);
assert.equal(M.generic, "Es ist ein Fehler aufgetreten. Bitte versuche es erneut.");
});
test("reads the CMS's own error body as well", () => {
assert.equal(
checkoutErrorMessage(400, strapiError(400, "Invalid order update", { errors: ["data.email: must be an email address"] })),
M.invalidEmail
);
assert.equal(
checkoutErrorMessage(400, strapiError(400, "Order is not ready for checkout", { missing: ["delivery"] })),
"Deine Bestellung ist noch nicht vollständig. Bitte ergänze: Versandart."
);
assert.equal(checkoutErrorMessage(409, strapiError(409, "Order can no longer be changed")), M.alreadyPaid);
assert.equal(checkoutErrorMessage(409, strapiError(409, "Order changed during the payment")), M.restartPayment);
});
test("never shows the server's text or personal data", () => {
const personal = "erika@example.org, Musterstraße 1, 70190 Stuttgart";
const bodies = [
shopError(400, { message: personal, errors: [`data.email: ${personal}`], missing: [personal] }),
shopError(409, { message: personal }),
shopError(502, { message: personal }),
strapiError(400, personal, { errors: [personal] }),
{ message: personal },
personal
];
for (const status of [undefined, 400, 404, 409, 500, 502, 503]) {
for (const body of bodies) {
const message = checkoutErrorMessage(status, body);
assert.ok(ALL_MESSAGES.has(message) || message.startsWith("Deine Bestellung ist noch nicht vollständig."), message);
assert.ok(!message.includes("erika") && !message.includes("Muster"), message);
}
}
});
test("reads the status and the body of the error $fetch throws", () => {
const body = shopError(409, { message: "Order can no longer be changed" });
assert.equal(checkoutErrorMessageFor(fetchError(409, body)), M.alreadyPaid);
assert.equal(checkoutErrorMessageFor(fetchError(502, shopError(502, { message: "Could not create the PayPal order" }))), M.paypalUnavailable);
assert.equal(
checkoutErrorMessageFor({ statusCode: 400, data: shopError(400, { message: "Invalid order update", errors: ["data.email: x"] }) }),
M.invalidEmail
);
});
test("falls back to the general message for an error without an answer", () => {
assert.equal(checkoutErrorMessageFor(fetchError(undefined, undefined)), M.generic);
assert.equal(checkoutErrorMessageFor(new TypeError("Failed to fetch")), M.generic);
assert.equal(checkoutErrorMessageFor(undefined), M.generic);
assert.equal(checkoutErrorMessageFor("error"), M.generic);
});
+201
View File
@@ -0,0 +1,201 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { cmsErrorLogLine, shopErrorFromCms } from "../../server/utils/cmsError.ts";
const UUID = "11111111-2222-4333-8444-555555555555";
const CART = `http://cms:5555/api/orders/${UUID}/cart`;
const CHECKOUT = `http://cms:5555/api/orders/${UUID}/checkout?returnUrl=https%3A%2F%2Fshop.example%2Fcheckout%2F3`;
const CAPTURE = `http://cms:5555/api/orders/${UUID}/capture/5O190127TN364715T`;
// Built like ofetch's createFetchError (ofetch 1.5): an Error named FetchError, with getters for the response's status and parsed body.
const fetchError = (method: string, url: string, response?: { status: number; statusText: string; body: unknown }): Error => {
const status = response ? `${response.status} ${response.statusText}` : "<no response> fetch failed";
const error = new Error(`[${method}] ${JSON.stringify(url)}: ${status}`);
error.name = "FetchError";
const fields: [string, unknown][] = [
["data", response?.body],
["status", response?.status],
["statusCode", response?.status],
["statusText", response?.statusText],
["statusMessage", response?.statusText]
];
for (const [key, value] of fields) Object.defineProperty(error, key, { get: () => value });
return error;
};
// Strapi's error body, as ctx.badRequest, ctx.notFound, ctx.conflict and ctx.badGateway write it.
const strapiError = (status: number, name: string, message: string, details: Record<string, unknown> = {}) => ({
data: null,
error: { status, name, message, details }
});
const cmsAnswer = (method: string, url: string, status: number, statusText: string, body: unknown) =>
shopErrorFromCms(fetchError(method, url, { status, statusText, body }));
test("passes a rejected order update on as 400 with the CMS's errors", () => {
const errors = ["data.email: must be an email address of at most 254 characters", "data.total: not a field the customer may set"];
assert.deepEqual(cmsAnswer("PUT", CART, 400, "Bad Request", strapiError(400, "BadRequestError", "Invalid order update", { errors })), {
statusCode: 400,
statusMessage: "Invalid order update",
data: { message: "Invalid order update", errors }
});
});
test("passes a checkout that is not ready on as 400 with the fields it misses", () => {
const body = strapiError(400, "BadRequestError", "Order is not ready for checkout", { missing: ["email", "delivery"] });
assert.deepEqual(cmsAnswer("POST", CHECKOUT, 400, "Bad Request", body), {
statusCode: 400,
statusMessage: "Order is not ready for checkout",
data: { message: "Order is not ready for checkout", missing: ["email", "delivery"] }
});
});
test("passes on no detail but errors and missing", () => {
const details = { reason: "deliveryAddress has no postal code", order: { email: "erika@example.org" }, errors: "data.email" };
const body = { ...strapiError(400, "BadRequestError", "Order is not ready for checkout", details), meta: { email: "erika@example.org" } };
assert.deepEqual(cmsAnswer("POST", CHECKOUT, 400, "Bad Request", body), {
statusCode: 400,
statusMessage: "Order is not ready for checkout",
data: { message: "Order is not ready for checkout" }
});
});
test("passes the conflicts of a paid order, an unavailable product and a payment that does not fit the order on as 409", () => {
for (const [url, message] of [
[CART, "Order can no longer be changed"],
[CHECKOUT, "A product in the cart is no longer available"],
[CAPTURE, "Order is already paid"],
[CAPTURE, "Payment does not belong to this order"],
[CAPTURE, "Payment does not match this order"],
[CAPTURE, "Order changed during the payment"]
] as const) {
assert.deepEqual(
cmsAnswer("POST", url, 409, "Conflict", strapiError(409, "ConflictError", message)),
{ statusCode: 409, statusMessage: message, data: { message } },
message
);
}
});
test("passes a missing order on as 404", () => {
assert.deepEqual(cmsAnswer("GET", CART, 404, "Not Found", strapiError(404, "NotFoundError", "Order not found")), {
statusCode: 404,
statusMessage: "Order not found",
data: { message: "Order not found" }
});
});
test("passes PayPal's errors on as 502", () => {
for (const [url, message] of [
[CHECKOUT, "Could not create the PayPal order"],
[CAPTURE, "PayPal could not capture the payment"],
[CAPTURE, "Payment could not be confirmed"]
] as const) {
assert.deepEqual(
cmsAnswer("POST", url, 502, "Bad Gateway", strapiError(502, "BadGatewayError", message)),
{ statusCode: 502, statusMessage: message, data: { message } },
message
);
}
});
test("passes a payment the CMS could not record on as 500 with its message", () => {
const body = strapiError(500, "InternalServerError", "Payment could not be recorded");
assert.deepEqual(cmsAnswer("POST", CAPTURE, 500, "Internal Server Error", body), {
statusCode: 500,
statusMessage: "Payment could not be recorded",
data: { message: "Payment could not be recorded" }
});
});
test("answers 500 without the CMS's message for a status that is the shop's own fault", () => {
for (const [status, statusText] of [
[401, "Unauthorized"],
[403, "Forbidden"],
[405, "Method Not Allowed"],
[413, "Payload Too Large"]
] as const) {
assert.deepEqual(
cmsAnswer("PUT", CART, status, statusText, strapiError(status, "Error", "Missing or invalid credentials")),
{ statusCode: 500, statusMessage: "Internal Server Error", data: { message: "Internal Server Error" } },
String(status)
);
}
});
test("answers 503 when the CMS does not answer", () => {
assert.deepEqual(shopErrorFromCms(fetchError("GET", CART)), {
statusCode: 503,
statusMessage: "Service Unavailable",
data: { message: "The CMS did not answer" }
});
});
test("leaves any other error to be rethrown as it is", () => {
assert.equal(shopErrorFromCms(new TypeError("Cannot read properties of undefined (reading 'uuid')")), undefined);
assert.equal(shopErrorFromCms({ name: "FetchError", status: 409, data: strapiError(409, "ConflictError", "Order is already paid") }), undefined);
assert.equal(shopErrorFromCms("FetchError"), undefined);
assert.equal(shopErrorFromCms(undefined), undefined);
});
test("keeps only the strings of the CMS's lists, at most 20, and cuts a long one", () => {
// Strapi's own validation errors are objects with the path and the message: they are dropped.
const objects = strapiError(400, "ValidationError", "Invalid order update", {
errors: [{ path: ["email"], message: "email must be a valid email" }]
});
assert.deepEqual(cmsAnswer("PUT", CART, 400, "Bad Request", objects)?.data, { message: "Invalid order update" });
const many = Array.from({ length: 25 }, (_, index) => `data.field${index}: not a field the customer may set`);
const long = `data.${"x".repeat(300)}: not a field the customer may set`;
const body = strapiError(400, "BadRequestError", "Invalid order update", { errors: [long, 7, ...many] });
const data = cmsAnswer("PUT", CART, 400, "Bad Request", body)?.data;
assert.equal(data?.errors?.length, 20);
assert.equal(data?.errors?.[0], `${long.slice(0, 200)}…`);
assert.equal(data?.errors?.[1], many[0]);
});
test("answers with the status's reason phrase when the body is no CMS error", () => {
assert.deepEqual(cmsAnswer("POST", CHECKOUT, 502, "Bad Gateway", "<html><body>502 Bad Gateway</body></html>"), {
statusCode: 502,
statusMessage: "Bad Gateway",
data: { message: "Bad Gateway" }
});
assert.deepEqual(cmsAnswer("GET", CART, 404, "Not Found", strapiError(404, "NotFoundError", "")), {
statusCode: 404,
statusMessage: "Not Found",
data: { message: "Not Found" }
});
});
test("keeps a message the status line cannot carry in the data only", () => {
const long = `Order ${"x".repeat(250)}`;
assert.deepEqual(cmsAnswer("PUT", CART, 409, "Conflict", strapiError(409, "ConflictError", "Bestellung gesperrt…")), {
statusCode: 409,
statusMessage: "Conflict",
data: { message: "Bestellung gesperrt…" }
});
assert.deepEqual(cmsAnswer("PUT", CART, 409, "Conflict", strapiError(409, "ConflictError", long)), {
statusCode: 409,
statusMessage: "Conflict",
data: { message: `${long.slice(0, 200)}…` }
});
});
test("logs a failed request without its query and without the order's uuid", () => {
const error = { statusCode: 502, statusMessage: "Bad Gateway", data: { message: "Could not create the PayPal order" } };
assert.equal(
cmsErrorLogLine("post", `/orders/${UUID}/checkout?returnUrl=https%3A%2F%2Fshop.example`, error),
"[cms] POST /orders/:uuid/checkout: 502 Could not create the PayPal order"
);
assert.equal(
cmsErrorLogLine("GET", `/orders/${UUID.toUpperCase()}/cart`, error),
"[cms] GET /orders/:uuid/cart: 502 Could not create the PayPal order"
);
});
+70
View File
@@ -0,0 +1,70 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CONTACT_RECIPIENT, contactMail, mailSendUrl } from "../../server/utils/contactMail.ts";
// components/ContactForm.vue posts these fields; it sends "Kontaktanfrage über Website" when the subject is left empty.
const form = {
name: "Erika Mustermann",
email: "erika@example.org",
subject: "Notizbuch mit Firmenlogo",
message: "Hallo,\nkönnt ihr 50 Stück liefern?"
};
const required = { ok: false, message: "Name, E-Mail und Nachricht sind erforderlich" };
test("sends to the mail service's /v1/send/message", () => {
assert.equal(mailSendUrl("http://mail:2222"), "http://mail:2222/v1/send/message");
assert.equal(mailSendUrl("http://mail:2222/"), "http://mail:2222/v1/send/message");
});
test("builds the body the mail service reads: to_email, subject and message", () => {
assert.deepEqual(contactMail(form), {
ok: true,
mail: {
to_email: "paperwork@muellerprints.de",
subject: "Kontaktanfrage: Notizbuch mit Firmenlogo",
message: [
"Name: Erika Mustermann",
"E-Mail: erika@example.org",
"Betreff: Notizbuch mit Firmenlogo",
"",
"Nachricht:",
"Hallo,\nkönnt ihr 50 Stück liefern?",
"",
"---",
"Diese Nachricht wurde über das Kontaktformular auf muellerprints.de gesendet."
].join("\n")
}
});
assert.equal(CONTACT_RECIPIENT, "paperwork@muellerprints.de");
});
test("fills in the subject when the form sends none", () => {
for (const subject of [undefined, "", " "]) {
const result = contactMail({ ...form, subject });
assert.equal(result.ok && result.mail.subject, "Kontaktanfrage: Anfrage über Website");
assert.equal(result.ok && result.mail.message.split("\n")[2], "Betreff: Kontaktanfrage über Website");
}
});
test("keeps a line break in the subject from starting another mail header", () => {
const result = contactMail({ ...form, subject: "Frage\r\nBcc: someone@example.org\nX-Spam: yes" });
assert.equal(result.ok && result.mail.subject, "Kontaktanfrage: Frage Bcc: someone@example.org X-Spam: yes");
});
test("requires a name, an e-mail address and a message", () => {
for (const field of ["name", "email", "message"]) {
assert.deepEqual(contactMail({ ...form, [field]: undefined }), required, field);
assert.deepEqual(contactMail({ ...form, [field]: " " }), required, field);
assert.deepEqual(contactMail({ ...form, [field]: ["a"] }), required, field);
}
for (const body of [undefined, null, "name=Erika", [form]]) assert.deepEqual(contactMail(body), required);
});
test("rejects an invalid e-mail address", () => {
for (const email of ["erika", "erika@example", "erika @example.org", "@example.org"]) {
assert.deepEqual(contactMail({ ...form, email }), { ok: false, message: "Ungültige E-Mail-Adresse" }, email);
}
});
+168
View File
@@ -0,0 +1,168 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CUSTOMER_UPDATE_FIELDS, invalidOrderUpdate, pickCustomerUpdate } from "../../server/utils/customerUpdate.ts";
// The body as readBody hands it to the route: sent by useShopApi().updateOrder as JSON.
const overTheWire = (body: unknown): unknown => JSON.parse(JSON.stringify(body));
const address = {
givenName: "Erika",
familyName: "Mustermann",
streetAddress: "Musterstraße 1",
postalCode: "70190",
addressLevel2: "Stuttgart",
country: "DE"
};
const otherAddress = {
givenName: "Max",
familyName: "Muster",
streetAddress: "Hauptstraße 5",
postalCode: "10115",
addressLevel2: "Berlin",
country: "DE"
};
// pages/checkout/1.vue: cart.update({ email, acceptedTermsAndConditionsAt }).
const step1 = { data: { email: "erika@example.org", acceptedTermsAndConditionsAt: "2026-10-09T08:15:00.000Z" } };
// pages/checkout/2.vue without a separate delivery address: the invoice address is sent as the delivery address too.
const step2 = {
data: {
invoiceAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
invoiceAddressStructured: address,
deliveryAddressStructured: address,
delivery: 1
}
};
// The order's attributes in the CMS (src/api/order/content-types/order/schema.json) that only the server writes, and Strapi's own.
const SERVER_FIELDS = [
"id",
"uuid",
"date",
"customer",
"invoice",
"deliveryNote",
"hash",
"payment",
"VAT",
"subtotal",
"total",
"cart",
"paymentAuthorised",
"paymentStatus",
"paypalOrderId",
"paypalCaptureId",
"paymentCapturedAt",
"emailSent",
"invoiceSent",
"deliveryNoteSent",
"invoiceNumber",
"deliveryNoteNumber",
"deliveryTrackingNumber",
"createdAt",
"updatedAt",
"publishedAt"
];
test("lists the seven fields of the checkout's steps", () => {
assert.deepEqual(
[...CUSTOMER_UPDATE_FIELDS],
[
"email",
"acceptedTermsAndConditionsAt",
"invoiceAddress",
"deliveryAddress",
"invoiceAddressStructured",
"deliveryAddressStructured",
"delivery"
]
);
});
test("passes the payload of checkout step 1 unchanged", () => {
assert.deepEqual(pickCustomerUpdate(overTheWire(step1)), { ok: true, data: step1.data });
});
test("passes the payload of checkout step 2 unchanged", () => {
assert.deepEqual(pickCustomerUpdate(overTheWire(step2)), { ok: true, data: step2.data });
});
test("passes the payload of checkout step 2 with a separate delivery address unchanged", () => {
const body = {
data: {
...step2.data,
deliveryAddress: "Max Muster\nHauptstraße 5\n10115 Berlin",
deliveryAddressStructured: otherAddress,
delivery: 2
}
};
assert.deepEqual(pickCustomerUpdate(overTheWire(body)), { ok: true, data: body.data });
});
test("leaves the values to the CMS, which checks them", () => {
const body = { data: { email: "no address", delivery: null, invoiceAddressStructured: { street: "x" } } };
assert.deepEqual(pickCustomerUpdate(body), { ok: true, data: body.data });
});
test("rejects each field only the server writes", () => {
for (const field of SERVER_FIELDS) {
assert.deepEqual(
pickCustomerUpdate({ data: { [field]: 1 } }),
{ ok: false, unknown: [`data.${field}`], errors: [`data.${field}: not a field the customer may set`] },
field
);
}
});
test("rejects the whole body instead of dropping the field, and names every rejected field", () => {
const body = { data: { ...step1.data, paymentAuthorised: true, total: 0.01 } };
assert.deepEqual(pickCustomerUpdate(body), {
ok: false,
unknown: ["data.paymentAuthorised", "data.total"],
errors: ["data.paymentAuthorised: not a field the customer may set", "data.total: not a field the customer may set"]
});
});
test("rejects a field sent beside data", () => {
assert.deepEqual(pickCustomerUpdate({ email: "erika@example.org", data: {} }), {
ok: false,
unknown: ["email"],
errors: ["email: not accepted, the fields belong in data"]
});
});
test("rejects a body without data, with data that is not an object, and a body that is not an object", () => {
assert.deepEqual(pickCustomerUpdate({}), { ok: false, unknown: [], errors: ["data: missing"] });
assert.deepEqual(pickCustomerUpdate({ data: [step1.data] }), { ok: false, unknown: [], errors: ["data: must be an object"] });
assert.deepEqual(pickCustomerUpdate({ data: null }), { ok: false, unknown: [], errors: ["data: must be an object"] });
for (const body of [undefined, null, "data", [step1]]) {
assert.deepEqual(pickCustomerUpdate(body), { ok: false, unknown: [], errors: ["body: must be an object of the form { data: { … } }"] });
}
});
test("rejects __proto__ and constructor without touching any prototype", () => {
const result = pickCustomerUpdate(JSON.parse('{"data":{"__proto__":{"paymentAuthorised":true},"constructor":{"prototype":{}}}}'));
assert.equal(result.ok, false);
assert.deepEqual(result.ok === false && result.unknown, ["data.__proto__", "data.constructor"]);
assert.equal(({} as Record<string, unknown>).paymentAuthorised, undefined);
});
test("cuts a long field name in the answer", () => {
const result = pickCustomerUpdate({ data: { ["x".repeat(100)]: 1 } });
assert.deepEqual(result.ok === false && result.unknown, [`data.${"x".repeat(64)}…`]);
});
test("answers a rejected body with a 400 in the shape of the CMS's own 400", () => {
assert.deepEqual(invalidOrderUpdate(["data.total: not a field the customer may set"]), {
statusCode: 400,
statusMessage: "Invalid order update",
data: { message: "Invalid order update", errors: ["data.total: not a field the customer may set"] }
});
});
+6
View File
@@ -0,0 +1,6 @@
// Loaded by `npm test` through `node --import`. Node runs the .ts sources directly (type stripping), but unlike Vite and Nitro it does
// not resolve extensionless relative imports such as `import { … } from "./cmsError"` in a server/utils module.
// The hook below retries such an import from a .ts file with ".ts" appended. Test files import sources with explicit .ts paths.
import { register } from "node:module";
register("./resolve-ts.mjs", import.meta.url);
+14
View File
@@ -0,0 +1,14 @@
// Module resolve hook, registered by ./register.mjs: `./cmsError` imported from a .ts file resolves to `./cmsError.ts`.
export async function resolve(specifier, context, nextResolve) {
try {
return await nextResolve(specifier, context);
} catch (error) {
const relative = specifier.startsWith("./") || specifier.startsWith("../");
if (error?.code !== "ERR_MODULE_NOT_FOUND" || !relative || !context.parentURL?.endsWith(".ts")) throw error;
try {
return await nextResolve(`${specifier}.ts`, context);
} catch {
throw error;
}
}
}
+12
View File
@@ -0,0 +1,12 @@
// The answer of POST /api/orders/:uuid/capture/:paypalOrderId: the paid order, or, when the order is already paid with this
// PayPal order (a repeated capture), { success: true, alreadyCaptured: true } (libreshop/cms src/api/order/controllers/order.ts).
// Pure: no Nuxt or Vue imports, tested in tests/unit/captureResponse.test.ts.
/** The CMS's answer to a repeated capture: the payment went through before, and the order is paid. */
export type AlreadyCaptured = { success: true; alreadyCaptured: true };
export const isAlreadyCaptured = (response: unknown): response is AlreadyCaptured =>
typeof response === "object" &&
response !== null &&
(response as Record<string, unknown>).success === true &&
(response as Record<string, unknown>).alreadyCaptured === true;
+98
View File
@@ -0,0 +1,98 @@
// The message a customer sees when a checkout request fails: German, addressed with "du" like the rest of the shop, chosen from the
// constants below, so it never shows the server's text or personal data. The shop's order routes answer a CMS error with its status
// and { …, data: { message, errors?, missing? } } (server/utils/cmsError.ts); a raw CMS (Strapi) error body,
// { data: null, error: { status, name, message, details } }, is read as well.
// Pure: no Nuxt or Vue imports, tested in tests/unit/checkoutError.test.ts.
// The CMS's messages (libreshop/cms src/api/order/controllers/order.ts and src/checkout/), matched but never shown.
const INVALID_ORDER_UPDATE = "Invalid order update";
const ORDER_LOCKED = "Order can no longer be changed";
const ALREADY_PAID = "Order is already paid";
const CHECKOUT_NOT_READY = "Order is not ready for checkout";
const PRODUCT_UNAVAILABLE = "A product in the cart is no longer available";
const PAYMENT_NOT_THIS_ORDER = "Payment does not belong to this order";
const PAYMENT_NOT_MATCHING = "Payment does not match this order";
const ORDER_CHANGED = "Order changed during the payment";
// PayPal answered the capture, or captured the payment, but the CMS could not confirm or record it: the money may be taken.
const PAYMENT_NOT_CONFIRMED = "Payment could not be confirmed";
const PAYMENT_NOT_RECORDED = "Payment could not be recorded";
export const CHECKOUT_ERROR_MESSAGES = {
invalidEmail: "Bitte gib eine gültige E-Mail-Adresse ein.",
invalidInput: "Deine Angaben konnten nicht gespeichert werden. Bitte prüfe sie und versuche es noch einmal.",
alreadyPaid: "Diese Bestellung ist bereits bezahlt und kann nicht mehr geändert werden.",
productUnavailable: "Ein Produkt in deinem Warenkorb ist nicht mehr verfügbar. Bitte prüfe deinen Warenkorb.",
notReady: "Deine Bestellung ist noch nicht vollständig. Bitte prüfe deine Angaben in den vorherigen Schritten.",
restartPayment: "Die Zahlung passt nicht zu deiner Bestellung, oder deine Bestellung wurde während der Zahlung geändert. Bitte starte die Zahlung neu.",
paypalUnavailable: "PayPal ist gerade nicht erreichbar. Bitte versuche es in ein paar Minuten noch einmal.",
paymentUnconfirmed: "Deine Zahlung konnte nicht bestätigt werden. Bitte bezahle nicht noch einmal, sondern melde dich bei uns.",
generic: "Es ist ein Fehler aufgetreten. Bitte versuche es erneut."
} as const;
// The fields a checkout can miss (the CMS's missingCheckoutFields), as the customer knows them from the checkout's steps.
const MISSING_FIELD_LABELS = new Map<string, string>([
["email", "E-Mail-Adresse"],
["invoiceAddress", "Rechnungsadresse"],
["delivery", "Versandart"]
]);
type CheckoutErrorFields = { message: string | undefined; errors: string[]; missing: string[] };
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const text = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
const readCheckoutError = (body: unknown): CheckoutErrorFields => {
if (!isObject(body)) return { message: undefined, errors: [], missing: [] };
// The shop's answer: { error: true, url, statusCode, statusMessage, message, data: { message, errors?, missing? } }.
if (isObject(body.data)) {
return { message: text(body.data.message) ?? text(body.message), errors: strings(body.data.errors), missing: strings(body.data.missing) };
}
// The CMS's answer: { data: null, error: { status, name, message, details: { errors?, missing? } } }.
if (isObject(body.error)) {
const details: Record<string, unknown> = isObject(body.error.details) ? body.error.details : {};
return { message: text(body.error.message), errors: strings(details.errors), missing: strings(details.missing) };
}
return { message: text(body.message), errors: [], missing: [] };
};
// The CMS names a rejected field first, as in "data.email: must be an email address of at most 254 characters".
const isEmailError = (error: string): boolean => error === "data.email" || error.startsWith("data.email:");
const notReadyMessage = (missing: string[]): string => {
const labels = [...new Set(missing.map((field) => MISSING_FIELD_LABELS.get(field)).filter((label) => label !== undefined))];
return labels.length > 0 ? `Deine Bestellung ist noch nicht vollständig. Bitte ergänze: ${labels.join(", ")}.` : CHECKOUT_ERROR_MESSAGES.notReady;
};
/** The customer's message for a failed checkout request, from the status and the parsed body of its answer. */
export const checkoutErrorMessage = (status: number | undefined, body: unknown): string => {
const { message, errors, missing } = readCheckoutError(body);
if (status === 400) {
if (message === INVALID_ORDER_UPDATE || errors.length > 0) {
return errors.some(isEmailError) ? CHECKOUT_ERROR_MESSAGES.invalidEmail : CHECKOUT_ERROR_MESSAGES.invalidInput;
}
if (message === CHECKOUT_NOT_READY || missing.length > 0) return notReadyMessage(missing);
return CHECKOUT_ERROR_MESSAGES.generic;
}
if (status === 409) {
if (message === ORDER_LOCKED || message === ALREADY_PAID) return CHECKOUT_ERROR_MESSAGES.alreadyPaid;
if (message === PRODUCT_UNAVAILABLE) return CHECKOUT_ERROR_MESSAGES.productUnavailable;
if (message === PAYMENT_NOT_THIS_ORDER || message === PAYMENT_NOT_MATCHING || message === ORDER_CHANGED) {
return CHECKOUT_ERROR_MESSAGES.restartPayment;
}
return CHECKOUT_ERROR_MESSAGES.generic;
}
if (message === PAYMENT_NOT_CONFIRMED || message === PAYMENT_NOT_RECORDED) return CHECKOUT_ERROR_MESSAGES.paymentUnconfirmed;
if (status === 502) return CHECKOUT_ERROR_MESSAGES.paypalUnavailable;
return CHECKOUT_ERROR_MESSAGES.generic;
};
/** The customer's message for an error thrown by $fetch: ofetch's FetchError carries the answer's status and parsed body. */
export const checkoutErrorMessageFor = (error: unknown): string => {
if (!isObject(error)) return CHECKOUT_ERROR_MESSAGES.generic;
const status = typeof error.status === "number" ? error.status : typeof error.statusCode === "number" ? error.statusCode : undefined;
return checkoutErrorMessage(status, error.data);
};