Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions server/__tests__/loopsContact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import {
MissingMailingListIdError,
UnknownWaitlistSourceError,
WAITLIST_SOURCE,
buildLoopsContactPayload,
} from "../src/utils/loopsContact.js";

const listIds = {
newsletter: "list_newsletter",
launch: "list_launch",
};

describe("buildLoopsContactPayload", () => {
it("puts omitted source on the newsletter list with camelCase names", () => {
const payload = buildLoopsContactPayload(
{
email: "alex@company.com",
firstName: "Alex",
lastName: "Rivera",
},
listIds,
);

assert.deepEqual(payload, {
email: "alex@company.com",
firstName: "Alex",
lastName: "Rivera",
source: WAITLIST_SOURCE.NEWSLETTER,
mailingLists: { list_newsletter: true },
});
});

it("puts launch source on the launch waitlist", () => {
const payload = buildLoopsContactPayload(
{
email: "sam@company.com",
source: WAITLIST_SOURCE.LAUNCH,
},
listIds,
);

assert.deepEqual(payload, {
email: "sam@company.com",
source: WAITLIST_SOURCE.LAUNCH,
mailingLists: { list_launch: true },
});
});

it("omits empty names so a launch signup does not wipe existing Loops names", () => {
const payload = buildLoopsContactPayload(
{
email: "sam@company.com",
firstName: "",
lastName: "",
source: WAITLIST_SOURCE.LAUNCH,
},
listIds,
);

assert.equal("firstName" in payload, false);
assert.equal("lastName" in payload, false);
});

it("rejects an unknown source", () => {
assert.throws(
() =>
buildLoopsContactPayload(
{ email: "alex@company.com", source: "sponsors" },
listIds,
),
UnknownWaitlistSourceError,
);
});

it("fails when the mailing list ID for that source is missing", () => {
assert.throws(
() =>
buildLoopsContactPayload(
{ email: "alex@company.com", source: WAITLIST_SOURCE.LAUNCH },
{ newsletter: "list_newsletter" },
),
MissingMailingListIdError,
);
});
});
153 changes: 153 additions & 0 deletions server/__tests__/safeLogError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { Writable } from "node:stream";

import axios, { AxiosError } from "axios";
import winston from "winston";

import { toSafeLogError } from "../src/utils/safeLogError.js";

const CANARY = "canary-newsletter-token-DO-NOT-LEAK";

function axiosErrorWithBearer(options: {
message: string;
code: string;
status?: number;
}): AxiosError {
const config = {
url: "/contacts/update",
method: "put",
headers: { Authorization: `Bearer ${CANARY}` },
};
const response =
options.status === undefined
? undefined
: {
status: options.status,
statusText: "Unauthorized",
headers: {},
config,
data: { message: "Invalid API key" },
};

return new AxiosError(
options.message,
options.code,
config as AxiosError["config"],
undefined,
response as AxiosError["response"],
);
}

function serializeLikeProductionLogger(error: object): string {
let captured = "";
const logger = winston.createLogger({
level: "error",
format: winston.format.combine(
winston.format.timestamp({
format: "YYYY-MM-DD HH:mm:ss",
}),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json(),
),
defaultMeta: { service: "safe-log-error-test" },
transports: [
new winston.transports.Stream({
stream: new Writable({
write(chunk, _encoding, callback) {
captured += chunk.toString();
callback();
},
}),
}),
],
});

logger.error({
message: "Failed adding waitlist contact",
error,
});
logger.close();
return captured;
}

describe("toSafeLogError", () => {
it("omits axios Authorization from JSON and winston output", () => {
const err = axiosErrorWithBearer({
message: "Request failed with status code 401",
code: "ERR_BAD_REQUEST",
status: 401,
});
assert.equal(axios.isAxiosError(err), true);

const safe = toSafeLogError(err);
const serialized = JSON.stringify(safe);

assert.equal(serialized.includes(CANARY), false);
assert.equal(/authorization/i.test(serialized), false);
assert.equal("config" in safe, false);
assert.equal("request" in safe, false);
assert.equal("response" in safe, false);
assert.equal("headers" in safe, false);

const logged = serializeLikeProductionLogger(safe);
const parsed = JSON.parse(logged);
assert.equal(parsed.message, "Failed adding waitlist contact");
assert.deepEqual(parsed.error, {
name: "AxiosError",
message: "Request failed with status code 401",
code: "ERR_BAD_REQUEST",
status: 401,
});
assert.equal(logged.includes(CANARY), false);
assert.equal(/authorization/i.test(logged), false);
});

it("keeps axios message, code, and upstream status", () => {
const err = axiosErrorWithBearer({
message: "Request failed with status code 401",
code: "ERR_BAD_REQUEST",
status: 401,
});

const safe = toSafeLogError(err);

assert.equal(safe.message, "Request failed with status code 401");
assert.equal(safe.code, "ERR_BAD_REQUEST");
assert.equal(safe.status, 401);
assert.equal(safe.name, "AxiosError");
});

it("keeps axios timeout code without a response status", () => {
const err = axiosErrorWithBearer({
message: "timeout of 50ms exceeded",
code: "ECONNABORTED",
});

const safe = toSafeLogError(err);

assert.equal(safe.message, "timeout of 50ms exceeded");
assert.equal(safe.code, "ECONNABORTED");
assert.equal("status" in safe, false);
assert.equal(JSON.stringify(safe).includes(CANARY), false);
});

it("keeps Error name, message, and code for non-axios failures", () => {
const err = Object.assign(new Error("duplicate key value"), {
code: "23505",
});

const safe = toSafeLogError(err);

assert.deepEqual(safe, {
name: "Error",
message: "duplicate key value",
code: "23505",
});
});

it("stringifies non-error throws", () => {
assert.deepEqual(toSafeLogError("boom"), { message: "boom" });
});
});
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"db:startover": "rimraf src/models/migration && npm run build && npm run db:generate && node dist/src/models/scripts/startover.js",
"db:import": "node dist/src/models/scripts/import-data.js src/models/contacts.csv",
"db:export": "node dist/src/models/scripts/export-data.js",
"test": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json && node --test dist/__tests__/*.js",
"postinstall": "node ./drizzle-pg-timestamp-patch.js"
},
"devDependencies": {
Expand Down
52 changes: 38 additions & 14 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import db from "./config/db.js";
import emailTransporter from "./config/emailTransporter.js";
import { waitlist } from "./models/waitlist.js";
import { generateUniqueID } from "./utils/generateId.js";
import {
MissingMailingListIdError,
UnknownWaitlistSourceError,
buildLoopsContactPayload,
} from "./utils/loopsContact.js";
import { toSafeLogError } from "./utils/safeLogError.js";
import { Inquiry } from "./interfaces/Inquiry.js";
import Mail from "nodemailer/lib/mailer/index.js";
import axios from "axios";
Expand All @@ -30,35 +36,53 @@ app.get("/", async (_, res) => {
res.send("API is running...");
});
app.post("/api/waitlist", async (req, res) => {
const { email, firstName, lastName } = req.body;
if (!email) res.status(400).json({ error: "Email is required!" });
if (!firstName) res.status(400).json({ error: "First name is required!" });
const { email, firstName, lastName, source } = req.body;
if (!email) {
res.status(400).json({ error: "Email is required!" });
return;
}

try {
await dbClient
.insert(waitlist)
.values({ id: generateUniqueID(), email, lastName, firstName });
const loopsContact = buildLoopsContactPayload(
{ email, firstName, lastName, source },
env.newsletter.mailingListIds,
);

await axios.post(
`${env.newsletter.baseUrl}/subscribers`,
{ email, lastname: lastName, firstname: firstName },
await axios.put(
`${env.newsletter.baseUrl}/contacts/update`,
loopsContact,
{
headers: {
Authorization: `Bearer ${env.newsletter.apiToken}`,
},
},
);

try {
await dbClient
.insert(waitlist)
.values({ id: generateUniqueID(), email, lastName, firstName });
} catch (err) {
if (!(err instanceof DatabaseError && err.code === "23505")) {
throw err;
}
}

res.status(201).json({ message: "Success adding to newsletter.", email });
} catch (err) {
if (err instanceof DatabaseError && err.code === "23505") {
const column = err.detail?.split(")=")[0]?.slice(5);

res.status(409).json({ error: `${column} is already used.` });

if (err instanceof UnknownWaitlistSourceError) {
res.status(400).json({ error: err.message });
return;
}
if (err instanceof MissingMailingListIdError) {
res.status(500).json({ error: err.message });
return;
}

logger.error({
message: "Failed adding waitlist contact",
error: toSafeLogError(err),
});
res.status(500).json({ error: "Unknown internal server error" });
}
});
Expand Down
4 changes: 4 additions & 0 deletions server/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,9 @@ export default {
newsletter: {
baseUrl: process.env.NEWSLETTER_BASE_URL,
apiToken: process.env.NEWSLETTER_API_TOKEN,
mailingListIds: {
newsletter: process.env.NEWSLETTER_MAILING_LIST_ID,
launch: process.env.LAUNCH_MAILING_LIST_ID,
},
},
};
Loading