stack/apps/e2e/tests/backend/backend-helpers.ts
BilalG1 609579abab
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
feat(hexclave): PR 3 — native @hexclave/* source rename + delete dual-publish wiring (#1482)
2026-05-29 15:21:59 -07:00

1780 lines
63 KiB
TypeScript

import type { ProjectConfigOverride } from "@hexclave/shared/dist/config/schema";
import { AdminUserProjectsCrud } from "@hexclave/shared/dist/interface/crud/projects";
import { encodeBase64 } from "@hexclave/shared/dist/utils/bytes";
import { generateSecureRandomString } from "@hexclave/shared/dist/utils/crypto";
import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
import { publishableClientKeyNotNecessarySentinel } from "@hexclave/shared/dist/utils/oauth";
import { filterUndefined, omit } from "@hexclave/shared/dist/utils/objects";
import { wait } from "@hexclave/shared/dist/utils/promises";
import { nicify } from "@hexclave/shared/dist/utils/strings";
import * as jose from "jose";
import { createHmac, randomUUID } from "node:crypto";
import { expect } from "vitest";
import { Context, Mailbox, NiceRequestInit, NiceResponse, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_ADMIN_KEY, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_ID, STACK_INTERNAL_PROJECT_SERVER_KEY, STACK_SVIX_SERVER_URL, generatedEmailSuffix, localRedirectUrl, niceFetch, updateCookiesFromResponse } from "../helpers";
import { localhostUrl, withPortPrefix } from "../helpers/ports";
type BackendContext = {
readonly projectKeys: ProjectKeys,
readonly defaultProjectKeys: ProjectKeys,
readonly currentBranchId: string | null,
readonly mailbox: Mailbox,
readonly userAuth: {
readonly refreshToken?: string,
readonly accessToken?: string,
} | null,
readonly ipData?: {
readonly ipAddress: string,
readonly country: string,
readonly city: string,
readonly region: string,
readonly latitude: number,
readonly longitude: number,
readonly tzIdentifier: string,
},
readonly generatedMailboxNamesCount: number,
};
export const backendContext = new Context<BackendContext, Partial<BackendContext>>(
() => ({
defaultProjectKeys: InternalProjectKeys,
projectKeys: InternalProjectKeys,
currentBranchId: null,
mailbox: createMailbox(`default-mailbox--${randomUUID()}${generatedEmailSuffix}`),
generatedMailboxNamesCount: 0,
userAuth: null,
ipData: undefined,
}),
(acc, update) => {
if ("defaultProjectKeys" in update) {
throw new HexclaveAssertionError("Cannot set defaultProjectKeys");
}
if ("mailbox" in update && !(update.mailbox instanceof Mailbox)) {
throw new HexclaveAssertionError("Must create a mailbox with createMailbox()!");
}
return {
...acc,
...update,
};
},
);
export function createMailbox(email?: string): Mailbox {
if (email === undefined) {
backendContext.set({ generatedMailboxNamesCount: backendContext.value.generatedMailboxNamesCount + 1 });
email = `mailbox-${backendContext.value.generatedMailboxNamesCount}--${randomUUID()}${generatedEmailSuffix}`;
}
if (!email.includes("@")) throw new HexclaveAssertionError(`Invalid mailbox email: ${email}`);
return new Mailbox("(we can ignore the disclaimer here)" as any, email);
}
export type ProjectKeys = "no-project" | {
projectId: string,
branchId?: string,
publishableClientKey?: string,
secretServerKey?: string,
superSecretAdminKey?: string,
adminAccessToken?: string,
};
export const InternalProjectKeys = Object.freeze({
projectId: STACK_INTERNAL_PROJECT_ID,
publishableClientKey: STACK_INTERNAL_PROJECT_CLIENT_KEY,
secretServerKey: STACK_INTERNAL_PROJECT_SERVER_KEY,
superSecretAdminKey: STACK_INTERNAL_PROJECT_ADMIN_KEY,
});
export async function withInternalProject<T>(fn: () => Promise<T>): Promise<T> {
return await backendContext.with({ projectKeys: InternalProjectKeys, userAuth: null }, fn);
}
export const InternalProjectClientKeys = Object.freeze({
projectId: STACK_INTERNAL_PROJECT_ID,
publishableClientKey: STACK_INTERNAL_PROJECT_CLIENT_KEY,
});
// These prefixes must match getMockTurnstileVerificationResponse in apps/mock-oauth-server/src/index.ts
export const mockTurnstileTokens = Object.freeze({
signUpOk: "mock-turnstile-ok:sign_up_with_credential",
magicLinkOk: "mock-turnstile-ok:send_magic_link_email",
oauthOk: "mock-turnstile-ok:oauth_authenticate",
invalid: "mock-turnstile-invalid",
error: "mock-turnstile-error",
visibleSignUpOk: "mock-turnstile-visible-ok:sign_up_with_credential",
visibleMagicLinkOk: "mock-turnstile-visible-ok:send_magic_link_email",
visibleOAuthOk: "mock-turnstile-visible-ok:oauth_authenticate",
});
type TurnstileTestOptions = {
turnstileToken?: string,
turnstilePhase?: "invisible" | "visible",
};
function expectSnakeCase(obj: unknown, path: string): void {
if (typeof obj !== "object" || obj === null) return;
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
expectSnakeCase(obj[i], `${path}[${i}]`);
}
} else {
for (const [key, value] of Object.entries(obj)) {
if (key.match(/^[a-z0-9][A-Z][a-z0-9]+$/) && !key.includes("_") && !["newUser", "afterCallbackRedirectUrl"].includes(key)) {
throw new HexclaveAssertionError(`Object has camelCase key (expected snake_case): ${path}.${key}`);
}
if (["client_metadata", "server_metadata", "options_json", "credential", "authentication_response", "metadata", "variables", "skipped_details"].includes(key)) continue;
// because email templates
if (path === "req.body.content.root") continue;
if (path === "res.body.content.root") continue;
expectSnakeCase(value, `${path}.${key}`);
}
}
}
export async function niceBackendFetch(url: string | URL, options?: Omit<NiceRequestInit, "body" | "headers"> & {
accessType?: null | "client" | "server" | "admin",
body?: unknown,
rawBody?: Uint8Array,
rawContentType?: string,
headers?: Record<string, string | undefined>,
omitPublishableClientKey?: boolean,
userAuth?: {
accessToken?: string,
refreshToken?: string,
},
}): Promise<NiceResponse> {
const { body, rawBody, rawContentType, headers, accessType, omitPublishableClientKey, userAuth: userAuthOverride, ...otherOptions } = options ?? {};
if (body !== undefined && rawBody !== undefined) {
throw new HexclaveAssertionError("niceBackendFetch: pass either body or rawBody, not both");
}
if (rawContentType !== undefined && rawBody === undefined) {
throw new HexclaveAssertionError("niceBackendFetch: rawContentType only makes sense with rawBody");
}
if (typeof body === "object") {
expectSnakeCase(body, "req.body");
}
const projectKeys = backendContext.value.projectKeys;
const userAuth = userAuthOverride ?? backendContext.value.userAuth;
const fullUrl = new URL(url, STACK_BACKEND_BASE_URL);
if (fullUrl.origin !== new URL(STACK_BACKEND_BASE_URL).origin) throw new HexclaveAssertionError(`Invalid niceBackendFetch origin: ${fullUrl.origin}`);
if (fullUrl.protocol !== new URL(STACK_BACKEND_BASE_URL).protocol) throw new HexclaveAssertionError(`Invalid niceBackendFetch protocol: ${fullUrl.protocol}`);
const res = await niceFetch(fullUrl, {
...otherOptions,
...body !== undefined ? { body: JSON.stringify(body) } : {},
...rawBody !== undefined ? { body: rawBody as BodyInit } : {},
headers: filterUndefined({
"content-type": rawBody !== undefined
? (rawContentType ?? "application/octet-stream")
: body !== undefined ? "application/json" : undefined,
"x-stack-access-type": accessType ?? undefined,
...projectKeys !== "no-project" && accessType ? {
"x-stack-project-id": projectKeys.projectId,
"x-stack-publishable-client-key": omitPublishableClientKey ? undefined : projectKeys.publishableClientKey,
"x-stack-secret-server-key": projectKeys.secretServerKey,
"x-stack-super-secret-admin-key": projectKeys.superSecretAdminKey,
'x-stack-admin-access-token': projectKeys.adminAccessToken,
} : {},
"x-stack-branch-id": backendContext.value.currentBranchId ?? undefined,
"x-stack-access-token": userAuth?.accessToken,
"x-stack-refresh-token": userAuth?.refreshToken,
"x-stack-allow-anonymous-user": "true",
...backendContext.value.ipData ? {
"user-agent": "Mozilla/5.0", // pretend to be a browser so our IP gets tracked
"x-forwarded-for": backendContext.value.ipData.ipAddress,
"cf-connecting-ip": backendContext.value.ipData.ipAddress,
"x-vercel-ip-country": backendContext.value.ipData.country,
"cf-ipcountry": backendContext.value.ipData.country,
"x-vercel-ip-country-region": backendContext.value.ipData.region,
"x-vercel-ip-city": backendContext.value.ipData.city,
"x-vercel-ip-latitude": backendContext.value.ipData.latitude.toString(),
"x-vercel-ip-longitude": backendContext.value.ipData.longitude.toString(),
"x-vercel-ip-timezone": backendContext.value.ipData.tzIdentifier,
} : {},
...Object.fromEntries(new Headers(filterUndefined(headers ?? {}) as any).entries()),
}),
});
if (res.status >= 500 && res.status < 600) {
throw new HexclaveAssertionError(`API threw ISE in ${otherOptions.method ?? "GET"} ${url}: ${res.status} ${typeof res.body === "string" ? res.body : nicify(res.body)}`);
}
if (res.headers.has("x-stack-known-error")) {
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
expect(res.body).toMatchObject({
code: res.headers.get("x-stack-known-error"),
});
}
if (typeof res.body === "object" && res.body) {
expectSnakeCase(res.body, "res.body");
}
return res;
}
/**
* Creates a new mailbox with a different email address, and sets it as the current mailbox.
*/
export async function bumpEmailAddress(options: { unindexed?: boolean } = {}) {
let emailAddress = undefined;
if (options.unindexed) {
emailAddress = `unindexed-mailbox--${randomUUID()}${generatedEmailSuffix}`;
}
const mailbox = createMailbox(emailAddress);
backendContext.set({ mailbox });
return mailbox;
}
// Type for outbox email items (simplified - full type is EmailOutboxCrud["Server"]["Read"])
export type OutboxEmail = {
id: string,
subject?: string,
status: string,
simple_status: string,
to?: {
type: string,
user_id?: string,
[key: string]: unknown,
},
[key: string]: unknown,
};
// Helper to get emails from the outbox, filtered by subject if provided
export async function getOutboxEmails(options?: { subject?: string }): Promise<OutboxEmail[]> {
const listResponse = await niceBackendFetch("/api/v1/emails/outbox", {
method: "GET",
accessType: "server",
});
const items = listResponse.body.items as OutboxEmail[];
if (options?.subject) {
return items.filter((e) => e.subject === options.subject);
}
return items;
}
// Helper to poll the outbox until the most recent email with the expected subject has the expected status.
// Note: emails are returned ordered by createdAt desc (newest first), so we check emails[0] specifically
// to ensure we're waiting for the MOST RECENT email, not an older one with the same subject.
export async function waitForOutboxEmailWithStatus(subject: string, status: string): Promise<OutboxEmail[]> {
const maxRetries = 24;
let emails: OutboxEmail[] = [];
for (let i = 0; i < maxRetries; i++) {
emails = await getOutboxEmails({ subject });
// Check the most recent email (first in the list due to createdAt desc ordering)
if (emails.length > 0 && emails[0].status === status) {
return emails;
}
await wait(500);
}
throw new HexclaveAssertionError(
`Timeout waiting for outbox email with subject "${subject}" and status "${status}"`,
{ foundEmails: emails }
);
}
export namespace Auth {
export async function fastSignUp(body: any = {}) {
const { userId } = await User.create(body);
const sessionResponse = await niceBackendFetch(`/api/v1/auth/sessions`, {
method: "POST",
accessType: "server",
body: {
user_id: userId,
expires_in_millis: 1000 * 60 * 60 * 24 * 365,
is_impersonation: false,
},
});
expect(sessionResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"access_token": <stripped field 'access_token'>,
"refresh_token": <stripped field 'refresh_token'>,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
backendContext.set({ userAuth: { accessToken: sessionResponse.body.access_token, refreshToken: sessionResponse.body.refresh_token } });
return {
userId,
accessToken: sessionResponse.body.access_token,
refreshToken: sessionResponse.body.refresh_token,
};
}
export async function ensureParsableAccessToken() {
const accessToken = backendContext.value.userAuth?.accessToken;
if (accessToken) {
const aud = jose.decodeJwt(accessToken).aud;
const jwks = jose.createRemoteJWKSet(
new URL(`api/v1/projects/${aud}/.well-known/jwks.json`, STACK_BACKEND_BASE_URL),
{ timeoutDuration: 20_000 },
);
const expectedIssuer = new URL(`/api/v1/projects/${aud}`, STACK_BACKEND_BASE_URL).toString();
const { payload } = await jose.jwtVerify(accessToken, jwks);
expect(payload).toEqual({
"exp": expect.any(Number),
"iat": expect.any(Number),
"iss": expectedIssuer,
"branch_id": "main",
"refresh_token_id": expect.any(String),
"signed_up_at": expect.any(Number),
"requires_totp_mfa": expect.any(Boolean),
"aud": backendContext.value.projectKeys === "no-project" ? expect.any(String) : backendContext.value.projectKeys.projectId,
"sub": expect.any(String),
"role": "authenticated",
"name": expect.toSatisfy(() => true),
"email": expect.toSatisfy(() => true),
"email_verified": expect.any(Boolean),
"selected_team_id": expect.toSatisfy(() => true),
"is_anonymous": expect.any(Boolean),
"is_restricted": expect.any(Boolean),
"restricted_reason": expect.toSatisfy(() => true),
"project_id": payload.aud
});
}
}
export async function refreshAccessToken() {
const response = await niceBackendFetch("/api/v1/auth/sessions/current/refresh", {
method: "POST",
accessType: "client",
userAuth: {
refreshToken: backendContext.value.userAuth?.refreshToken,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "access_token": <stripped field 'access_token'> },
"headers": Headers { <some fields may have been hidden> },
}
`);
backendContext.set({ userAuth: { accessToken: response.body.access_token, refreshToken: response.body.refresh_token } });
return {
refreshAccessTokenResponse: response,
};
}
/**
* Valid session & valid access token: OK
* Valid session & invalid access token: OK
* Invalid session & valid access token: Error
* Invalid session & invalid access token: Error
*/
export async function expectSessionToBeValid() {
const response = await niceBackendFetch("/api/v1/auth/sessions/current/refresh", { method: "POST", accessType: "client" });
if (response.status !== 200) {
throw new HexclaveAssertionError("Expected session to be valid, but was actually invalid.", { response });
}
expect(response).toMatchObject({
status: 200,
headers: expect.objectContaining({}),
body: expect.objectContaining({}),
});
}
/**
* Valid session & valid access token: Error
* Valid session & invalid access token: Error
* Invalid session & valid access token: OK
* Invalid session & invalid access token: OK
*/
export async function expectSessionToBeInvalid() {
const response = await niceBackendFetch("/api/v1/auth/sessions/current/refresh", { method: "POST", accessType: "client" });
expect(response.status).not.toEqual(200);
}
/**
* Valid session & valid access token: OK
* Valid session & invalid access token: Error
* Invalid session & valid access token: OK
* Invalid session & invalid access token: Error
*/
export async function expectAccessTokenToBeInvalid() {
await ensureParsableAccessToken();
const response = await niceBackendFetch("/api/v1/users/me", { accessType: "client" });
if (response.status === 200) {
throw new HexclaveAssertionError("Expected access token to be invalid, but was actually valid.", { response });
}
}
/**
* Valid session & valid access token: OK
* Valid session & invalid access token: Error
* Invalid session & valid access token: OK
* Invalid session & invalid access token: Error
*/
export async function expectAccessTokenToBeValid() {
await ensureParsableAccessToken();
const response = await niceBackendFetch("/api/v1/users/me", { accessType: "client" });
if (response.status !== 200) {
throw new HexclaveAssertionError("Expected access token to be valid, but was actually invalid.", { response });
}
}
/**
* Valid session & valid access token: OK
* Valid session & invalid access token: Error
* Invalid session & valid access token: Error
* Invalid session & invalid access token: Error
*
* (see comment in the function for rationale, and why "invalid refresh token but valid access token" is not
* considered "signed in")
*/
export async function expectToBeSignedIn() {
// there is a world where we would accept either access token OR session to be "signed in", instead of both
// however, it's better to be strict and throw an error if either is invalid; this helps catch bugs
// if you really want to check only one of them, use expectSessionToBeValid or expectAccessTokenToBeValid
// for more information, see the comment in expectToBeSignedOut
await Auth.expectAccessTokenToBeValid();
await Auth.expectSessionToBeValid();
}
/**
* Valid session & valid access token: Error
* Valid session & invalid access token: Error
* Invalid session & valid access token: Error
* Invalid session & invalid access token: OK
*/
export async function expectToBeSignedOut() {
await Auth.expectAccessTokenToBeInvalid();
// usually, when we mean "signed out" we mean "both access token AND session are invalid"; we'd rather be strict
// so, we additionally check the session
// this has the weird side effect that expectToBeSignedIn (which is also strict, checking that access token AND
// session are valid) may throw, even if expectToBeSignedOut also throws
// if you run into something like that in your tests, use expectSessionToBeInvalid instead
await Auth.expectSessionToBeInvalid();
}
export async function signOut() {
const response = await niceBackendFetch("/api/v1/auth/sessions/current", {
method: "DELETE",
accessType: "client",
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);
if (backendContext.value.userAuth) backendContext.set({ userAuth: { ...backendContext.value.userAuth, accessToken: undefined } });
await Auth.expectToBeSignedOut();
return {
signOutResponse: response,
};
}
export namespace Otp {
export async function sendSignInCode(options: TurnstileTestOptions = {}) {
const mailbox = backendContext.value.mailbox;
const response = await niceBackendFetch("/api/v1/auth/otp/send-sign-in-code", {
method: "POST",
accessType: "client",
body: filterUndefined({
email: mailbox.emailAddress,
callback_url: "http://localhost:12345/some-callback-url",
bot_challenge_token: options.turnstileToken ?? mockTurnstileTokens.magicLinkOk,
bot_challenge_phase: options.turnstilePhase,
}),
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "nonce": <stripped field 'nonce'> },
"headers": Headers { <some fields may have been hidden> },
}
`);
const deadlineMs = 60_000;
const intervalMs = 500;
const deadline = performance.now() + deadlineMs;
while (true) {
const messages = await mailbox.fetchMessages();
const containsSubstring = messages.some(message => message.subject.includes("Sign in to") && message.body?.html.includes(response.body.nonce));
if (containsSubstring) {
break;
}
if (performance.now() >= deadline) {
throw new HexclaveAssertionError(`Sign-in code message not found within ${deadlineMs}ms`, {
response,
messages: messages.map(m => ({ ...m, body: m.body && omit(m.body, ["html"]) })),
});
}
await wait(intervalMs);
}
return {
sendSignInCodeResponse: response,
};
}
export async function signIn() {
const sendSignInCodeRes = await sendSignInCode();
const signInResult = await signInWithCode(await getSignInCodeFromMailbox(sendSignInCodeRes.sendSignInCodeResponse.body.nonce));
return {
...sendSignInCodeRes,
...signInResult,
};
}
export async function getSignInCodeFromMailbox(nonce?: string) {
const mailbox = backendContext.value.mailbox;
const messages = await mailbox.fetchMessages();
const message = messages.filter(message => nonce === undefined || message.body?.html.includes(nonce)).findLast((message) => message.subject.includes("Sign in to")) ?? throwErr("Sign-in code message not found");
const signInCode = message.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1] ?? throwErr("Sign-in URL not found");
return signInCode;
}
export async function signInWithCode(signInCode: string) {
const projectKeys = backendContext.value.projectKeys;
if (projectKeys === "no-project") throw new HexclaveAssertionError("Must provide project keys in the backend context before calling signInWithCode");
const response = await niceBackendFetch("/api/v1/auth/otp/sign-in", {
method: "POST",
accessType: "client",
body: {
code: signInCode,
},
});
expect(response).toMatchObject({
status: 200,
body: {
access_token: expect.any(String),
refresh_token: expect.any(String),
is_new_user: expect.any(Boolean),
user_id: expect.any(String),
},
headers: expect.anything(),
});
backendContext.set({
userAuth: {
accessToken: response.body.access_token,
refreshToken: response.body.refresh_token,
},
});
return {
userId: response.body.user_id,
signInResponse: response,
};
}
}
export namespace Password {
export async function signUpWithEmail(options: { password?: string, noWaitForEmail?: boolean, turnstileToken?: string } = {}) {
const mailbox = backendContext.value.mailbox;
const email = mailbox.emailAddress;
const password = options.password ?? generateSecureRandomString();
const response = await niceBackendFetch("/api/v1/auth/password/sign-up", {
method: "POST",
accessType: "client",
body: filterUndefined({
email,
password,
verification_callback_url: "http://localhost:12345/some-callback-url",
bot_challenge_token: options.turnstileToken ?? mockTurnstileTokens.signUpOk,
}),
});
expect(response).toMatchObject({
status: 200,
body: {
access_token: expect.any(String),
refresh_token: expect.any(String),
user_id: expect.any(String),
},
headers: expect.anything(),
});
// Wait for the verification email to arrive (unless explicitly disabled)
if (!options.noWaitForEmail) {
await mailbox.waitForMessagesWithSubject("Verify your email");
}
backendContext.set({
userAuth: {
accessToken: response.body.access_token,
refreshToken: response.body.refresh_token,
},
});
return {
signUpResponse: response,
userId: response.body.user_id,
email,
password,
};
}
export async function signInWithEmail(options: { password: string }) {
const mailbox = backendContext.value.mailbox;
const email = mailbox.emailAddress;
const response = await niceBackendFetch("/api/v1/auth/password/sign-in", {
method: "POST",
accessType: "client",
body: {
email,
password: options.password,
},
});
expect(response).toMatchObject({
status: 200,
body: {
access_token: expect.any(String),
refresh_token: expect.any(String),
user_id: expect.any(String),
},
headers: expect.anything(),
});
backendContext.set({
userAuth: {
accessToken: response.body.access_token,
refreshToken: response.body.refresh_token,
},
});
return {
signInResponse: response,
userId: response.body.user_id,
};
}
}
export namespace Passkey {
export async function register() {
const initiateRegistrationRes = await Auth.Passkey.initiateRegistration();
const response = await niceBackendFetch("/api/v1/auth/passkey/register", {
method: "POST",
accessType: "client",
body: {
"credential": {
"id": "BBYYB_DKzPZHm1o6ILGo6Sk_cBc",
"rawId": "BBYYB_DKzPZHm1o6ILGo6Sk_cBc",
"response": {
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAQWGAfwysz2R5taOiCxqOkpP3AXpQECAyYgASFYIO7JJihe93CDhZOPFp9pVefZyBvy62JMjSs47id1q0vpIlggNMjLAQG7ESYqRZsBQbX07WWIImEzYFDsJgBOSYiQZL8",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVFU5RFN3Iiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDo4MTAzIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ",
"transports": [
"hybrid",
"internal"
],
"publicKeyAlgorithm": -7,
"publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7skmKF73cIOFk48Wn2lV59nIG_LrYkyNKzjuJ3WrS-k0yMsBAbsRJipFmwFBtfTtZYgiYTNgUOwmAE5JiJBkvw",
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAQWGAfwysz2R5taOiCxqOkpP3AXpQECAyYgASFYIO7JJihe93CDhZOPFp9pVefZyBvy62JMjSs47id1q0vpIlggNMjLAQG7ESYqRZsBQbX07WWIImEzYFDsJgBOSYiQZL8"
},
"type": "public-key",
"clientExtensionResults": {
"credProps": {
"rk": true
}
},
"authenticatorAttachment": "platform"
},
"code": initiateRegistrationRes.code,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "user_handle": "BBYYB_DKzPZHm1o6ILGo6Sk_cBc" },
"headers": Headers { <some fields may have been hidden> },
}
`);
}
export async function initiateRegistration(): Promise<{ code: string }> {
const response = await niceBackendFetch("/api/v1/auth/passkey/initiate-passkey-registration", {
method: "POST",
accessType: "client",
body: {},
});
const original_code = response.body.code;
response.body.options_json.user.id = "<stripped encoded UUID>";
response.body.code = "<stripped code>";
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"code": "<stripped code>",
"options_json": {
"attestation": "none",
"authenticatorSelection": {
"requireResidentKey": true,
"residentKey": "required",
"userVerification": "preferred",
},
"challenge": "TU9DSw",
"excludeCredentials": [],
"extensions": { "credProps": true },
"pubKeyCredParams": [
{
"alg": -8,
"type": "public-key",
},
{
"alg": -7,
"type": "public-key",
},
{
"alg": -257,
"type": "public-key",
},
],
"rp": {
"id": "THIS_VALUE_WILL_BE_REPLACED.example.com",
"name": "New Project",
},
"timeout": 60000,
"user": {
"displayName": "default-mailbox--<stripped UUID>@stack-generated.example.com",
"id": "<stripped encoded UUID>",
"name": "default-mailbox--<stripped UUID>@stack-generated.example.com",
},
},
},
"headers": Headers { <some fields may have been hidden> },
}
`);
return {
code: original_code,
};
}
}
export namespace OAuth {
export async function getAuthorizeQuery(options: TurnstileTestOptions & {
forceBranchId?: string,
includeClientSecret?: boolean,
} = {}) {
const projectKeys = backendContext.value.projectKeys;
if (projectKeys === "no-project") throw new Error("No project keys found in the backend context");
const branchId = options.forceBranchId ?? backendContext.value.currentBranchId;
const userAuth = backendContext.value.userAuth;
const includeClientSecret = options.includeClientSecret ?? true;
const clientSecret = includeClientSecret
? (projectKeys.publishableClientKey ?? publishableClientKeyNotNecessarySentinel)
: publishableClientKeyNotNecessarySentinel;
return filterUndefined({
client_id: !branchId ? projectKeys.projectId : `${projectKeys.projectId}#${branchId}`,
client_secret: clientSecret,
redirect_uri: localRedirectUrl,
scope: "legacy",
response_type: "code",
state: "this-is-some-state",
grant_type: "authorization_code",
code_challenge: "some-code-challenge",
code_challenge_method: "plain",
token: userAuth?.accessToken ?? undefined,
bot_challenge_token: options.turnstileToken ?? mockTurnstileTokens.oauthOk,
bot_challenge_phase: options.turnstilePhase,
});
}
export async function authorize(options: TurnstileTestOptions & {
redirectUrl?: string,
errorRedirectUrl?: string,
forceBranchId?: string,
includeClientSecret?: boolean,
} = {}) {
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/spotify", {
redirect: "manual",
query: {
...await Auth.OAuth.getAuthorizeQuery(options),
...filterUndefined({
redirect_uri: options.redirectUrl ?? undefined,
error_redirect_uri: options.errorRedirectUrl ?? undefined,
}),
},
});
expect(response).toMatchObject({
status: 307,
headers: expect.any(Headers),
});
const location = response.headers.get("location");
expect(location).toBeTruthy();
const locationUrl = new URL(location!);
expect(locationUrl.origin).toBe(localhostUrl("14"));
expect(locationUrl.pathname).toBe("/auth");
// Backend dual-writes oauth-inner cookies under both `stack-` and `hexclave-` prefixes
// (cookie dual-write — see hexclave rename plan); assert via getSetCookie() to avoid
// the comma-join ambiguity that Headers.get("set-cookie") creates with multiple
// Set-Cookie headers whose values contain `, ` (e.g. inside Expires=).
const oauthInnerSetCookies = response.headers.getSetCookie()
.filter(c => /^(?:stack|hexclave)-oauth-inner-/.test(c));
expect(oauthInnerSetCookies.length).toBeGreaterThan(0);
for (const setCookie of oauthInnerSetCookies) {
expect(setCookie).toMatch(/^(?:stack|hexclave)-oauth-inner-[^;]+=[^;]+; Path=\/; Expires=[^;]+; Max-Age=\d+;( Secure;)? HttpOnly$/);
}
return {
authorizeResponse: response,
};
}
export async function getInnerCallbackUrl(options: TurnstileTestOptions & {
authorizeResponse?: NiceResponse,
forceBranchId?: string,
includeClientSecret?: boolean,
} = {}) {
const authorizeResponse = options.authorizeResponse ?? (await Auth.OAuth.authorize(options)).authorizeResponse;
const providerPassword = generateSecureRandomString();
const authLocation = new URL(authorizeResponse.headers.get("location")!);
const redirectResponse1 = await niceFetch(authLocation, {
redirect: "manual",
});
expect(redirectResponse1).toMatchObject({
status: 303,
headers: expect.any(Headers),
body: expect.any(String),
});
const signInInteractionLocation = new URL(redirectResponse1.headers.get("location") ?? throwErr("missing redirect location", { redirectResponse1 }), authLocation);
const signInInteractionCookies = updateCookiesFromResponse("", redirectResponse1);
const response1 = await niceFetch(signInInteractionLocation, {
method: "POST",
redirect: "manual",
body: new URLSearchParams({
prompt: "login",
login: backendContext.value.mailbox.emailAddress,
password: providerPassword,
}),
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: signInInteractionCookies,
},
});
expect(response1).toMatchObject({
status: 303,
headers: expect.any(Headers),
body: expect.any(ArrayBuffer),
});
const redirectResponse2 = await niceFetch(new URL(response1.headers.get("location") ?? throwErr("missing redirect location", { response1 }), signInInteractionLocation), {
redirect: "manual",
headers: {
cookie: updateCookiesFromResponse(signInInteractionCookies, response1),
},
});
expect(redirectResponse2).toMatchObject({
status: 303,
headers: expect.any(Headers),
body: expect.any(String),
});
const authorizeInteractionLocation = new URL(redirectResponse2.headers.get("location") ?? throwErr("missing redirect location", { redirectResponse2 }), authLocation);
const authorizeInteractionCookies = updateCookiesFromResponse(signInInteractionCookies, redirectResponse2);
const response2 = await niceFetch(authorizeInteractionLocation, {
method: "POST",
redirect: "manual",
body: new URLSearchParams({
prompt: "consent",
}),
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: authorizeInteractionCookies,
},
});
expect(response2).toMatchObject({
status: 303,
headers: expect.any(Headers),
body: expect.any(ArrayBuffer),
});
const redirectResponse3 = await niceFetch(new URL(response2.headers.get("location") ?? throwErr("missing redirect location", { response2 }), authLocation), {
redirect: "manual",
headers: {
cookie: updateCookiesFromResponse(authorizeInteractionCookies, response2),
},
});
expect(redirectResponse3).toMatchObject({
status: 303,
headers: expect.any(Headers),
body: expect.any(String),
});
const innerCallbackUrl = new URL(redirectResponse3.headers.get("location") ?? throwErr("missing redirect location", { redirectResponse3 }));
expect(innerCallbackUrl.origin).toBe(localhostUrl("02"));
expect(innerCallbackUrl.pathname).toBe("/api/v1/auth/oauth/callback/spotify");
return {
authorizeResponse,
innerCallbackUrl,
};
}
export async function getMaybeFailingAuthorizationCode(options: TurnstileTestOptions & {
innerCallbackUrl?: URL,
authorizeResponse?: NiceResponse,
forceBranchId?: string,
includeClientSecret?: boolean,
} = {}) {
let authorizeResponse, innerCallbackUrl;
if (options.innerCallbackUrl && options.authorizeResponse) {
innerCallbackUrl = options.innerCallbackUrl;
authorizeResponse = options.authorizeResponse;
} else if (!options.innerCallbackUrl) {
({ authorizeResponse, innerCallbackUrl } = await Auth.OAuth.getInnerCallbackUrl(options));
} else {
throw new Error("If innerCallbackUrl is provided, authorizeResponse must also be provided");
}
const cookie = updateCookiesFromResponse("", authorizeResponse!);
const response = await niceBackendFetch(innerCallbackUrl.toString(), {
redirect: "manual",
headers: {
cookie,
},
});
return {
authorizeResponse,
innerCallbackUrl,
response,
};
}
export async function getAuthorizationCode(options: TurnstileTestOptions & {
innerCallbackUrl?: URL,
authorizeResponse?: NiceResponse,
forceBranchId?: string,
includeClientSecret?: boolean,
} = {}) {
const { response } = await Auth.OAuth.getMaybeFailingAuthorizationCode(options);
expect(response).toMatchObject({
status: 303,
headers: expect.any(Headers),
body: {},
});
const outerCallbackUrl = new URL(response.headers.get("location") ?? throwErr("missing redirect location", { response }));
expect(outerCallbackUrl.origin).toBe(new URL(localRedirectUrl).origin);
expect(outerCallbackUrl.pathname).toBe(new URL(localRedirectUrl).pathname);
expect(Object.fromEntries(outerCallbackUrl.searchParams.entries())).toEqual({
code: expect.any(String),
state: "this-is-some-state",
});
return {
callbackResponse: response,
outerCallbackUrl,
authorizationCode: outerCallbackUrl.searchParams.get("code")!,
};
}
export async function signIn(options: TurnstileTestOptions & {
forceBranchId?: string,
includeClientSecret?: boolean,
} = {}) {
const getAuthorizationCodeResult = await Auth.OAuth.getAuthorizationCode(options);
const projectKeys = backendContext.value.projectKeys;
if (projectKeys === "no-project") throw new Error("No project keys found in the backend context");
const includeClientSecret = options.includeClientSecret ?? true;
const clientSecret = includeClientSecret
? (projectKeys.publishableClientKey ?? publishableClientKeyNotNecessarySentinel)
: publishableClientKeyNotNecessarySentinel;
const tokenResponse = await niceBackendFetch("/api/v1/auth/oauth/token", {
method: "POST",
accessType: "client",
body: filterUndefined({
client_id: projectKeys.projectId,
client_secret: clientSecret,
code: getAuthorizationCodeResult.authorizationCode,
redirect_uri: localRedirectUrl,
code_verifier: "some-code-challenge",
grant_type: "authorization_code",
}),
});
expect(tokenResponse).toMatchObject({
status: 200,
body: {
access_token: expect.any(String),
afterCallbackRedirectUrl: null,
after_callback_redirect_url: null,
refresh_token: expect.any(String),
scope: "legacy",
token_type: "Bearer"
},
});
backendContext.set({
userAuth: {
accessToken: tokenResponse.body.access_token,
refreshToken: tokenResponse.body.refresh_token,
},
});
return {
...getAuthorizationCodeResult,
tokenResponse,
};
}
}
export namespace Mfa {
export async function setupTotpMfa() {
const totpSecretBytes = crypto.getRandomValues(new Uint8Array(20));
const totpSecretBase64 = encodeBase64(totpSecretBytes);
const response = await niceBackendFetch("/api/v1/users/me", {
accessType: "client",
method: "PATCH",
body: {
totp_secret_base64: totpSecretBase64,
},
});
expect(response).toMatchObject({
status: 200,
});
return {
setupTotpMfaResponse: response,
totpSecret: totpSecretBytes,
};
}
}
export namespace Anonymous {
export async function signUp() {
const response = await niceBackendFetch("/api/v1/auth/anonymous/sign-up", {
method: "POST",
accessType: "client",
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"access_token": <stripped field 'access_token'>,
"refresh_token": <stripped field 'refresh_token'>,
"user_id": "<stripped UUID>",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
backendContext.set({
userAuth: {
accessToken: response.body.access_token,
refreshToken: response.body.refresh_token,
},
});
return {
response,
accessToken: response.body.access_token,
refreshToken: response.body.refresh_token,
userId: response.body.user_id,
};
}
}
}
export namespace ContactChannels {
export async function getTheOnlyContactChannel() {
const contactChannels = await ContactChannels.listAllCurrentUserContactChannels();
expect(contactChannels).toHaveLength(1);
return contactChannels[0];
}
export async function listAllCurrentUserContactChannels() {
const response = await niceBackendFetch("/api/v1/contact-channels?user_id=me", {
accessType: "client",
});
return response.body.items;
}
export async function sendVerificationCode(options?: { contactChannelId?: string }) {
const contactChannelId = options?.contactChannelId ?? (await ContactChannels.getTheOnlyContactChannel()).id;
const mailbox = backendContext.value.mailbox;
const messagesBefore = await mailbox.fetchMessages({ noBody: true });
const countBefore = messagesBefore.filter(m => m.subject.includes("Verify your email")).length;
const response = await niceBackendFetch(`/api/v1/contact-channels/me/${contactChannelId}/send-verification-code`, {
method: "POST",
accessType: "client",
body: {
callback_url: "http://localhost:12345/some-callback-url",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);
// Wait for a new verification email to arrive
const messages = await mailbox.waitForMessagesWithSubjectCount("Verify your email", countBefore + 1);
expect(messages.length).toBeGreaterThanOrEqual(countBefore + 1);
return {
sendSignInCodeResponse: response,
};
}
export async function verify(options?: { contactChannelId?: string }) {
const mailbox = backendContext.value.mailbox;
const sendVerificationCodeRes = await sendVerificationCode(options);
const messages = await mailbox.waitForMessagesWithSubject("Verify your email");
const message = messages.findLast((message) => message.subject.includes("Verify your email")) ?? throwErr("Verification code message not found");
const verificationCode = message.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1] ?? throwErr("Verification code not found");
const response = await niceBackendFetch("/api/v1/contact-channels/verify", {
method: "POST",
accessType: "client",
body: {
code: verificationCode,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);
return {
...sendVerificationCodeRes,
verifyResponse: response,
};
}
}
export namespace ProjectApiKey {
export namespace User {
export async function create(data?: any) {
const response = await niceBackendFetch("/api/v1/user-api-keys", {
method: "POST",
accessType: "server",
body: data,
});
expect(response).toMatchObject({
status: 200,
body: expect.objectContaining({
created_at_millis: expect.any(Number),
description: expect.any(String),
id: expect.any(String),
is_public: expect.any(Boolean),
type: expect.any(String),
user_id: expect.any(String),
value: expect.any(String),
}),
headers: expect.any(Headers),
});
return {
createUserApiKeyResponse: response,
};
}
export async function check(apiKey: string) {
const response = await niceBackendFetch(`/api/v1/user-api-keys/check`, {
method: "POST",
accessType: "server",
body: {
api_key: apiKey,
},
});
expect(response.status).oneOf([200, 401, 404]);
return response.body;
}
export async function revoke(apiKeyId: string) {
const response = await niceBackendFetch(`/api/v1/user-api-keys/${apiKeyId}`, {
method: "PATCH",
accessType: "server",
body: {
revoked: true,
},
});
return response;
}
}
export namespace Team {
export async function create(body?: any) {
const response = await niceBackendFetch("/api/v1/team-api-keys", {
method: "POST",
accessType: "client",
body,
});
expect(response.status).toEqual(200);
return {
createTeamApiKeyResponse: response,
};
}
export async function check(apiKey: string) {
const response = await niceBackendFetch(`/api/v1/team-api-keys/check`, {
method: "POST",
accessType: "server",
body: {
api_key: apiKey,
},
});
expect(response.status).oneOf([200, 401, 404]);
return response.body;
}
export async function revoke(apiKeyId: string) {
const response = await niceBackendFetch(`/api/v1/team-api-keys/${apiKeyId}`, {
method: "PATCH",
accessType: "server",
body: {
revoked: true,
},
});
return response;
}
}
}
export namespace InternalApiKey {
export async function create(adminAccessToken?: string, body?: any) {
const oldProjectKeys = backendContext.value.projectKeys;
if (oldProjectKeys === 'no-project') {
throw new Error("Cannot set API key context without a project");
}
const response = await niceBackendFetch("/api/v1/internal/api-keys", {
accessType: "admin",
method: "POST",
body: {
description: "test api key",
has_publishable_client_key: true,
has_secret_server_key: true,
has_super_secret_admin_key: true,
expires_at_millis: new Date().getTime() + 1000 * 60 * 60 * 24,
...body,
},
headers: {
'x-stack-admin-access-token': adminAccessToken ?? (backendContext.value.projectKeys !== "no-project" && backendContext.value.projectKeys.adminAccessToken || undefined),
}
});
expect(response.status).equals(200);
return {
createApiKeyResponse: response,
projectKeys: {
projectId: oldProjectKeys.projectId,
publishableClientKey: response.body.publishable_client_key,
secretServerKey: response.body.secret_server_key,
superSecretAdminKey: response.body.super_secret_admin_key,
},
};
}
export async function createAndSetProjectKeys(adminAccessToken?: string, body?: any) {
const res = await InternalApiKey.create(adminAccessToken, body);
backendContext.set({ projectKeys: res.projectKeys });
return res;
}
export async function list() {
const response = await niceBackendFetch("/api/v1/internal/api-keys", {
accessType: "admin",
});
expect(response.status).toBe(200);
return response.body;
}
}
export namespace Project {
export async function create(body?: any) {
const ownerTeamId = body?.owner_team_id ?? (await User.getCurrent()).selected_team_id;
const response = await niceBackendFetch("/api/v1/internal/projects", {
accessType: "client",
method: "POST",
body: {
display_name: body?.display_name || 'New Project',
owner_team_id: ownerTeamId,
...body,
config: {
credential_enabled: true,
allow_localhost: true,
...body?.config,
},
},
});
expect(response).toMatchObject({
status: 201,
body: {
id: expect.any(String),
},
});
return {
createProjectResponse: response,
projectId: response.body.id as string,
};
}
export async function updateCurrent(adminAccessToken: string, body: Partial<AdminUserProjectsCrud["Admin"]["Create"]>) {
const response = await niceBackendFetch(`/api/v1/internal/projects/current`, {
accessType: "admin",
method: "PATCH",
body,
headers: {
'x-stack-admin-access-token': adminAccessToken,
}
});
return {
updateProjectResponse: response,
};
}
export async function createAndGetAdminToken(body?: Partial<AdminUserProjectsCrud["Admin"]["Create"]>, useExistingUser?: boolean) {
backendContext.set({ projectKeys: InternalProjectKeys });
let userId: string | undefined;
if (!useExistingUser) {
backendContext.set({ userAuth: null });
const { userId: newUserId } = await Auth.fastSignUp();
userId = newUserId;
}
const adminAccessToken = backendContext.value.userAuth?.accessToken;
expect(adminAccessToken).toBeDefined();
const { projectId, createProjectResponse } = await Project.create(body);
backendContext.set({
projectKeys: {
projectId,
},
userAuth: null,
});
return {
creatorUserId: userId,
projectId,
adminAccessToken: adminAccessToken!,
createProjectResponse,
};
}
export async function createAndSwitch(body?: Partial<AdminUserProjectsCrud["Admin"]["Create"]>, useExistingUser?: boolean) {
const createResult = await Project.createAndGetAdminToken(body, useExistingUser);
backendContext.set({
projectKeys: {
projectId: createResult.projectId,
adminAccessToken: createResult.adminAccessToken,
},
userAuth: null
});
const { projectKeys } = await InternalApiKey.create(createResult.adminAccessToken);
backendContext.set({
projectKeys,
userAuth: null
});
return createResult;
}
export async function updateConfig(config: any) {
const response = await niceBackendFetch(`/api/latest/internal/config/override/environment`, {
accessType: "admin",
method: "PATCH",
body: { config_override_string: JSON.stringify(config) },
});
expect(response.body).toMatchInlineSnapshot(`{ "success": true }`);
expect(response.status).toBe(200);
}
export async function updateProjectConfig(config: ProjectConfigOverride) {
const response = await niceBackendFetch(`/api/latest/internal/config/override/project`, {
accessType: "admin",
method: "PATCH",
body: { config_override_string: JSON.stringify(config) },
});
expect(response.body).toMatchInlineSnapshot(`{ "success": true }`);
expect(response.status).toBe(200);
}
export type BranchConfigSource =
| { type: "pushed-from-github", owner: string, repo: string, branch: string, commit_hash: string, config_file_path: string, workflow_path?: string }
| { type: "pushed-from-unknown" }
| { type: "unlinked" };
/**
* Push config to branch level. Source defaults to 'unlinked' if not specified.
*/
export async function pushConfig(config: any, source: BranchConfigSource = { type: "unlinked" }) {
const response = await niceBackendFetch(`/api/latest/internal/config/override/branch`, {
accessType: "admin",
method: "PUT",
body: {
config_string: JSON.stringify(config),
source,
},
});
expect(response.body).toMatchInlineSnapshot(`{ "success": true }`);
expect(response.status).toBe(200);
}
/**
* Update the pushed config (PATCH - preserves source)
*/
export async function updatePushedConfig(config: any) {
const response = await niceBackendFetch(`/api/latest/internal/config/override/branch`, {
accessType: "admin",
method: "PATCH",
body: { config_override_string: JSON.stringify(config) },
});
expect(response.body).toMatchInlineSnapshot(`{ "success": true }`);
expect(response.status).toBe(200);
}
/**
* Get the current branch config source
*/
export async function getConfigSource(): Promise<BranchConfigSource> {
const response = await niceBackendFetch(`/api/latest/internal/config/source`, {
accessType: "admin",
method: "GET",
});
expect(response.status).toBe(200);
return response.body.source;
}
/**
* Unlink the branch config source (set to 'unlinked')
*/
export async function unlinkConfigSource() {
const response = await niceBackendFetch(`/api/latest/internal/config/source`, {
accessType: "admin",
method: "DELETE",
});
expect(response.body).toMatchInlineSnapshot(`{ "success": true }`);
expect(response.status).toBe(200);
}
/**
* Reset specific keys from a config override level.
* Uses the same nested key logic as the override algorithm.
*/
export async function resetConfigOverrideKeys(level: "branch" | "environment", keys: string[]) {
const response = await niceBackendFetch(`/api/latest/internal/config/override/${level}/reset-keys`, {
accessType: "admin",
method: "POST",
body: { keys },
});
expect(response.body).toMatchInlineSnapshot(`{ "success": true }`);
expect(response.status).toBe(200);
}
}
export namespace Team {
export async function create(options: { accessType?: "client" | "server", addCurrentUser?: boolean, creatorUserId?: string } = {}, body?: any) {
const displayName = body?.display_name || 'New Team';
const response = await niceBackendFetch("/api/v1/teams", {
accessType: options.accessType ?? "server",
method: "POST",
body: {
display_name: displayName,
creator_user_id: options.creatorUserId ?? (options.addCurrentUser ? 'me' : undefined),
...body,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 201,
"body": {
"client_metadata": null,
"client_read_only_metadata": null,
"created_at_millis": <stripped field 'created_at_millis'>,
"display_name": ${JSON.stringify(displayName)},
"id": "<stripped UUID>",
"profile_image_url": null,
"server_metadata": null,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
return {
createTeamResponse: response,
teamId: response.body.id,
};
}
export async function createWithCurrentAsCreator(options: { accessType?: "client" | "server" } = {}, body?: any) {
return await Team.create({ ...options, addCurrentUser: true }, body);
}
export async function addMember(teamId: string, userId: string) {
const response = await niceBackendFetch(`/api/v1/team-memberships/${teamId}/${userId}`, {
method: "POST",
accessType: "server",
body: {},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 201,
"body": {
"team_id": "<stripped UUID>",
"user_id": "<stripped UUID>",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
}
export async function addPermission(teamId: string, userId: string, permissionId: string) {
const response = await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${userId}/${permissionId}`, {
method: "POST",
accessType: "server",
body: {},
});
return response;
}
export async function sendInvitation(mail: string | Mailbox, teamId: string) {
const response = await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "client",
body: {
email: typeof mail === 'string' ? mail : mail.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"id": "<stripped UUID>",
"success": true,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
return {
sendTeamInvitationResponse: response,
};
}
export async function acceptInvitation() {
const mailbox = backendContext.value.mailbox;
const messages = await mailbox.waitForMessagesWithSubject("join");
const message = messages.findLast((message) => message.subject.includes("join")) ?? throwErr("Team invitation message not found");
const code = message.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1] ?? throwErr("Team invitation code not found");
const response = await niceBackendFetch("/api/v1/team-invitations/accept", {
method: "POST",
accessType: "client",
body: {
code,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {},
"headers": Headers { <some fields may have been hidden> },
}
`);
return {
acceptTeamInvitationResponse: response,
};
}
}
export namespace User {
export async function getCurrent() {
const response = await niceBackendFetch("/api/v1/users/me", {
accessType: "client",
});
expect(response).toMatchObject({
status: 200,
});
return response.body;
}
export async function create(body?: Record<string, unknown>) {
const createUserResponse = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: body ?? {},
});
expect(createUserResponse).toMatchObject({
status: 201,
body: {
id: expect.any(String),
},
});
return {
userId: createUserResponse.body.id,
};
}
export async function createMultiple(count: number) {
const users = [];
for (let i = 0; i < count; i++) {
const user = await User.create();
users.push(user);
}
return users;
}
}
export namespace Webhook {
export async function createProjectWithEndpoint() {
const { projectId } = await Project.createAndSwitch({
config: {
magic_link_enabled: true,
}
});
const svixTokenResponse = await niceBackendFetch("/api/v1/webhooks/svix-token", {
accessType: "admin",
method: "POST",
body: {},
});
const svixToken = svixTokenResponse.body.token;
const createEndpointResponse = await niceFetch(STACK_SVIX_SERVER_URL + `/api/v1/app/${projectId}/endpoint`, {
method: "POST",
body: JSON.stringify({
url: "http://localhost:12345/webhook"
}),
headers: {
"Authorization": `Bearer ${svixToken}`,
"Content-Type": "application/json",
},
});
return {
projectId,
svixToken,
endpointId: createEndpointResponse.body.id
};
}
export async function findWebhookAttempt(projectId: string, endpointId: string, svixToken: string, fn: (msg: any) => boolean, retryCount: number = 5) {
// retry many times because Svix sucks and is slow
for (let i = 0; i < retryCount; i++) {
const attempts = await Webhook.listWebhookAttempts(projectId, endpointId, svixToken);
const filtered = attempts.filter(fn);
if (filtered.length === 0) {
await wait(500);
continue;
} else if (filtered.length === 1) {
return filtered[0];
} else {
throw new Error(`Found ${filtered.length} webhook attempts for project ${projectId}, endpoint ${endpointId}`);
}
}
throw new Error(`Webhook attempt not found for project ${projectId}, endpoint ${endpointId}`);
}
export async function listWebhookAttempts(projectId: string, endpointId: string, svixToken: string, retryCount: number = 5) {
// retry many times because Svix sucks and is slow
for (let i = 0; i < retryCount; i++) {
const response = await niceFetch(STACK_SVIX_SERVER_URL + `/api/v1/app/${projectId}/attempt/endpoint/${endpointId}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${svixToken}`,
"Content-Type": "application/json",
},
});
const messages = await Promise.all(response.body.data.map(async (attempt: any) => {
const messageResponse = await niceFetch(STACK_SVIX_SERVER_URL + `/api/v1/app/${projectId}/msg/${attempt.msgId}?with_content=true`, {
headers: {
"Authorization": `Bearer ${svixToken}`,
"Content-Type": "application/json",
},
method: "GET",
});
return messageResponse.body;
}));
if (messages.length === 0) {
await wait(500);
continue;
}
return messages.sort((a, b) => {
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
});
}
return [];
}
}
export namespace Payments {
export async function setup() {
const response = await niceBackendFetch("/api/latest/internal/payments/setup", {
accessType: "admin",
method: "POST",
body: {},
});
expect(response.status).toBe(200);
return response.body;
}
export async function createPurchaseUrlAndGetCode() {
await Project.createAndSwitch();
await Payments.setup();
await Project.updateConfig({
payments: {
testMode: false,
products: {
"test-product": {
displayName: "Test Product",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
"monthly": {
USD: "1000",
interval: [1, "month"],
},
},
includedItems: {},
},
},
},
});
const { userId } = await User.create();
const response = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "server",
body: {
customer_type: "user",
customer_id: userId,
product_id: "test-product",
},
});
expect(response.status).toBe(200);
const body = response.body as { url: string };
expect(body.url).toMatch(new RegExp(`^https?:\/\/localhost:${withPortPrefix("01")}\/purchase\/[a-z0-9-_]+$`));
const codeMatch = body.url.match(/\/purchase\/([a-z0-9-_]+)/);
const code = codeMatch ? codeMatch[1] : undefined;
expect(code).toBeDefined();
return {
code,
};
}
export async function sendStripeWebhook(
payload: unknown,
options?: {
invalidSignature?: boolean,
omitSignature?: boolean,
secret?: string,
}
) {
const timestamp = Math.floor(Date.now() / 1000);
const headers: Record<string, string> = { "content-type": "application/json" };
if (!options?.omitSignature) {
let header: string;
if (options?.invalidSignature) {
header = `t=${timestamp},v1=dead`;
} else {
const hmac = createHmac("sha256", options?.secret ?? "mock_stripe_webhook_secret");
hmac.update(`${timestamp}.${JSON.stringify(payload)}`);
const signature = hmac.digest("hex");
header = `t=${timestamp},v1=${signature}`;
}
headers["stripe-signature"] = header;
}
return await niceBackendFetch("/api/latest/integrations/stripe/webhooks", {
method: "POST",
headers,
body: payload,
});
}
}