Add basic client lib tests (#601)

This commit is contained in:
Zai Shi 2025-04-03 20:05:44 +02:00 committed by GitHub
parent 33e42afd77
commit c2cb2aac76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 132 additions and 51 deletions

View File

@ -0,0 +1,62 @@
import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { it } from "../helpers";
import { createApp, scaffoldProject } from "./js-helpers";
it("should scaffold the project", async ({ expect }) => {
const { project } = await scaffoldProject();
expect(project.displayName).toBe("New Project");
});
it("should sign up with credential", async ({ expect }) => {
const { clientApp } = await createApp();
const result1 = await clientApp.signUpWithCredential({
email: "test@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
expect(result1).toMatchInlineSnapshot(`
{
"data": undefined,
"status": "ok",
}
`);
const result2 = await clientApp.signInWithCredential({
email: "test@test.com",
password: "password",
});
expect(result2).toMatchInlineSnapshot(`
{
"data": undefined,
"status": "ok",
}
`);
});
it("should create user on the server", async ({ expect }) => {
const { serverApp } = await createApp();
const user = await serverApp.createUser({
primaryEmail: "test@test.com",
password: "password",
primaryEmailAuthEnabled: true,
});
expect(isUuid(user.id)).toBe(true);
const user2 = await serverApp.getUser(user.id);
expect(user2?.id).toBe(user.id);
const result = await serverApp.signInWithCredential({
email: "test@test.com",
password: "password",
});
expect(result).toMatchInlineSnapshot(`
{
"data": undefined,
"status": "ok",
}
`);
});

View File

@ -1,7 +0,0 @@
import { it } from "../helpers";
import { scaffoldProject } from "./js-helpers";
it("should scaffold the project", async ({ expect }) => {
const { project } = await scaffoldProject();
expect(project.displayName).toBe("New Project");
});

View File

@ -1,5 +1,4 @@
import { AdminProjectUpdateOptions, StackAdminApp } from '@stackframe/js';
import { wait } from '@stackframe/stack-shared/dist/utils/promises';
import { AdminProjectUpdateOptions, StackAdminApp, StackClientApp, StackServerApp } from '@stackframe/js';
import { STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_ADMIN_KEY, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_SERVER_KEY } from '../helpers';
export async function scaffoldProject(body?: AdminProjectUpdateOptions) {
@ -19,17 +18,56 @@ export async function scaffoldProject(body?: AdminProjectUpdateOptions) {
password: "password",
verificationCallbackUrl: "https://stack-js-test.example.com/verify",
});
const user = await internalApp.getUser({
const adminUser = await internalApp.getUser({
or: 'throw',
});
const project = await user.createProject({
const project = await adminUser.createProject({
displayName: body?.displayName || 'New Project',
...body,
});
return {
project,
user,
adminUser,
};
}
export async function createApp(body?: AdminProjectUpdateOptions) {
const { project, adminUser } = await scaffoldProject(body);
const adminApp = new StackAdminApp({
projectId: project.id,
baseUrl: STACK_BACKEND_BASE_URL,
projectOwnerSession: adminUser._internalSession,
tokenStore: "memory",
});
const apiKey = await adminApp.createApiKey({
description: 'test',
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
hasPublishableClientKey: true,
hasSecretServerKey: true,
hasSuperSecretAdminKey: false,
});
const serverApp = new StackServerApp({
baseUrl: STACK_BACKEND_BASE_URL,
projectId: project.id,
publishableClientKey: apiKey.publishableClientKey,
secretServerKey: apiKey.secretServerKey,
tokenStore: "memory",
});
const clientApp = new StackClientApp({
baseUrl: STACK_BACKEND_BASE_URL,
projectId: project.id,
publishableClientKey: apiKey.publishableClientKey,
tokenStore: "memory",
});
return {
serverApp,
clientApp,
adminApp,
};
}

View File

@ -19,8 +19,8 @@ export async function signInWithOAuth(
const { codeChallenge, state } = await saveVerifierAndState();
const location = await iface.getOAuthUrl({
provider: options.provider,
redirectUrl: constructRedirectUrl(options.redirectUrl),
errorRedirectUrl: constructRedirectUrl(options.errorRedirectUrl),
redirectUrl: constructRedirectUrl(options.redirectUrl, "redirectUrl"),
errorRedirectUrl: constructRedirectUrl(options.errorRedirectUrl, "errorRedirectUrl"),
codeChallenge,
state,
type: "authenticate",
@ -43,9 +43,9 @@ export async function addNewOAuthProviderOrScope(
const { codeChallenge, state } = await saveVerifierAndState();
const location = await iface.getOAuthUrl({
provider: options.provider,
redirectUrl: constructRedirectUrl(options.redirectUrl),
errorRedirectUrl: constructRedirectUrl(options.errorRedirectUrl),
afterCallbackRedirectUrl: constructRedirectUrl(window.location.href),
redirectUrl: constructRedirectUrl(options.redirectUrl, "redirectUrl"),
errorRedirectUrl: constructRedirectUrl(options.errorRedirectUrl, "errorRedirectUrl"),
afterCallbackRedirectUrl: constructRedirectUrl(window.location.href, "afterCallbackRedirectUrl"),
codeChallenge,
state,
type: "link",
@ -129,7 +129,7 @@ export async function callOAuthCallback(
try {
return Result.ok(await iface.callOAuthCallback({
oauthParams: consumed.originalUrl.searchParams,
redirectUri: constructRedirectUrl(redirectUrl),
redirectUri: constructRedirectUrl(redirectUrl, "redirectUri"),
codeVerifier: consumed.codeVerifier,
state: consumed.state,
}));

View File

@ -628,14 +628,11 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
clientMetadata: crud.client_metadata,
clientReadOnlyMetadata: crud.client_read_only_metadata,
async inviteUser(options: { email: string, callbackUrl?: string }) {
if (!options.callbackUrl && !await app._getCurrentUrl()) {
throw new Error("Cannot invite user without a callback URL from the server or without a redirect method. Make sure you pass the `callbackUrl` option: `inviteUser({ email, callbackUrl: ... })`");
}
await app._interface.sendTeamInvitation({
teamId: crud.id,
email: options.email,
session,
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app.urls.teamInvitation),
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app.urls.teamInvitation, "callbackUrl"),
});
await app._teamInvitationsCache.refresh([session, crud.id]);
},
@ -680,8 +677,12 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
isPrimary: crud.is_primary,
usedForAuth: crud.used_for_auth,
async sendVerificationEmail() {
await app._interface.sendCurrentUserContactChannelVerificationEmail(crud.id, constructRedirectUrl(app.urls.emailVerification), session);
async sendVerificationEmail(options?: { callbackUrl?: string }) {
await app._interface.sendCurrentUserContactChannelVerificationEmail(
crud.id,
options?.callbackUrl || constructRedirectUrl(app.urls.emailVerification, "callbackUrl"),
session
);
},
async update(data: ContactChannelUpdateOptions) {
await app._interface.updateClientContactChannel(crud.id, contactChannelUpdateOptionsToCrud(data), session);
@ -938,10 +939,11 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
if (!crud.primary_email) {
throw new StackAssertionError("User does not have a primary email");
}
if (!options?.callbackUrl && !await app._getCurrentUrl()) {
throw new Error("Cannot send verification email without a callback URL from the server or without a redirect method. Make sure you pass the `callbackUrl` option: `sendVerificationEmail({ callbackUrl: ... })`");
}
return await app._interface.sendVerificationEmail(crud.primary_email, options?.callbackUrl ?? constructRedirectUrl(app.urls.emailVerification), session);
return await app._interface.sendVerificationEmail(
crud.primary_email,
options?.callbackUrl ?? constructRedirectUrl(app.urls.emailVerification, "callbackUrl"),
session
);
},
async updatePassword(options: { oldPassword: string, newPassword: string}) {
const result = await app._interface.updatePassword(options, session);
@ -1156,17 +1158,11 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
async redirectToTeamInvitation(options?: RedirectToOptions) { return await this._redirectToHandler("teamInvitation", options); }
async sendForgotPasswordEmail(email: string, options?: { callbackUrl?: string }): Promise<Result<undefined, KnownErrors["UserNotFound"]>> {
if (!options?.callbackUrl && !await this._getCurrentUrl()) {
throw new Error("Cannot send forgot password email without a callback URL from the server or without a redirect method. Make sure you pass the `callbackUrl` option: `sendForgotPasswordEmail({ email, callbackUrl: ... })`");
}
return await this._interface.sendForgotPasswordEmail(email, options?.callbackUrl ?? constructRedirectUrl(this.urls.passwordReset));
return await this._interface.sendForgotPasswordEmail(email, options?.callbackUrl ?? constructRedirectUrl(this.urls.passwordReset, "callbackUrl"));
}
async sendMagicLinkEmail(email: string, options?: { callbackUrl?: string }): Promise<Result<{ nonce: string }, KnownErrors["RedirectUrlNotWhitelisted"]>> {
if (!options?.callbackUrl && !await this._getCurrentUrl()) {
throw new Error("Cannot send magic link email without a callback URL from the server or without a redirect method. Make sure you pass the `callbackUrl` option: `sendMagicLinkEmail({ email, callbackUrl: ... })`");
}
return await this._interface.sendMagicLinkEmail(email, options?.callbackUrl ?? constructRedirectUrl(this.urls.magicLinkCallback));
return await this._interface.sendMagicLinkEmail(email, options?.callbackUrl ?? constructRedirectUrl(this.urls.magicLinkCallback, "callbackUrl"));
}
async resetPassword(options: { password: string, code: string }): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>> {
@ -1398,7 +1394,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}): Promise<Result<undefined, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors['PasswordRequirementsNotMet']>> {
this._ensurePersistentTokenStore();
const session = await this._getSession();
const emailVerificationRedirectUrl = options.verificationCallbackUrl ?? constructRedirectUrl(this.urls.emailVerification);
const emailVerificationRedirectUrl = options.verificationCallbackUrl ?? constructRedirectUrl(this.urls.emailVerification, "verificationCallbackUrl");
const result = await this._interface.signUpWithCredential(
options.email,
options.password,

View File

@ -1,9 +1,9 @@
import { KnownErrors, StackServerInterface } from "@stackframe/stack-shared";
import { ContactChannelsCrud } from "@stackframe/stack-shared/dist/interface/crud/contact-channels";
import { ProjectPermissionDefinitionsCrud, ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions";
import { TeamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation";
import { TeamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/crud/team-member-profiles";
import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions";
import { ProjectPermissionDefinitionsCrud, ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions";
import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
@ -17,7 +17,7 @@ import { constructRedirectUrl } from "../../../../utils/url";
import { GetUserOptions, HandlerUrls, OAuthScopesOnSignIn, TokenStoreInit } from "../../common";
import { OAuthConnection } from "../../connected-accounts";
import { ServerContactChannel, ServerContactChannelCreateOptions, ServerContactChannelUpdateOptions, serverContactChannelCreateOptionsToCrud, serverContactChannelUpdateOptionsToCrud } from "../../contact-channels";
import { AdminTeamPermission, AdminTeamPermissionDefinition, AdminProjectPermissionDefinition } from "../../permissions";
import { AdminProjectPermissionDefinition, AdminTeamPermission, AdminTeamPermissionDefinition } from "../../permissions";
import { EditableTeamMemberProfile, ServerListUsersOptions, ServerTeam, ServerTeamCreateOptions, ServerTeamUpdateOptions, ServerTeamUser, Team, TeamInvitation, serverTeamCreateOptionsToCrud, serverTeamUpdateOptionsToCrud } from "../../teams";
import { ProjectCurrentServerUser, ServerUser, ServerUserCreateOptions, ServerUserUpdateOptions, serverUserCreateOptionsToCrud, serverUserUpdateOptionsToCrud } from "../../users";
import { StackServerAppConstructorOptions } from "../interfaces/server-app";
@ -151,11 +151,7 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
isPrimary: crud.is_primary,
usedForAuth: crud.used_for_auth,
async sendVerificationEmail(options?: { callbackUrl?: string }) {
if (!options?.callbackUrl && !await app._getCurrentUrl()) {
throw new Error("Cannot send verification email without a callback URL from the server or without a redirect method. Make sure you pass the `callbackUrl` option: `sendVerificationEmail({ callbackUrl: ... })`");
}
await app._interface.sendServerContactChannelVerificationEmail(userId, crud.id, options?.callbackUrl ?? constructRedirectUrl(app.urls.emailVerification));
await app._interface.sendServerContactChannelVerificationEmail(userId, crud.id, options?.callbackUrl ?? constructRedirectUrl(app.urls.emailVerification, "callbackUrl"));
},
async update(data: ServerContactChannelUpdateOptions) {
await app._interface.updateServerContactChannel(userId, crud.id, serverContactChannelUpdateOptionsToCrud(data));
@ -506,14 +502,10 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
await app._serverTeamMemberProfilesCache.refresh([crud.id]);
},
async inviteUser(options: { email: string, callbackUrl?: string }) {
if (!options.callbackUrl && !await app._getCurrentUrl()) {
throw new Error("Cannot invite user without a callback URL from the server or without a redirect method. Make sure you pass the `callbackUrl` option: `inviteUser({ email, callbackUrl: ... })`");
}
await app._interface.sendServerTeamInvitation({
teamId: crud.id,
email: options.email,
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app.urls.teamInvitation),
callbackUrl: options.callbackUrl ?? constructRedirectUrl(app.urls.teamInvitation, "callbackUrl"),
});
await app._serverTeamInvitationsCache.refresh([crud.id]);
},

View File

@ -1,10 +1,10 @@
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
export function constructRedirectUrl(redirectUrl: URL | string | undefined) {
export function constructRedirectUrl(redirectUrl: URL | string | undefined, callbackUrlName: string) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (typeof window === 'undefined' || !window.location) {
throw new StackAssertionError("Attempted to call constructRedirectUrl in a non-browser environment. You may be able to fix this by passing the `callbackUrl` option with your function call.", { redirectUrl });
throw new StackAssertionError(`${callbackUrlName} option is required in a non-browser environment.`, { redirectUrl });
}
const retainedQueryParams = ["after_auth_return_to"];