History

Add cloud auth API test context (#828)

Rhys Sullivan committed 5 months ago

Commit 62b8b7d1 parent 142a56d6 files · +323 −0Browse files

Unified · Split

6 files
  1. apps/cloud/src/auth/api-keys.test-layer.ts +11 −0
  2. apps/cloud/src/auth/cloud-auth-api.test-context.ts +90 −0
  3. apps/cloud/src/auth/context.test-layer.ts +41 −0
  4. apps/cloud/src/auth/middleware.test-layer.ts +23 −0
  5. apps/cloud/src/auth/workos.test-layer.ts +122 −0
  6. apps/cloud/src/services/autumn.test-layer.ts +36 −0
apps/cloud/src/auth/api-keys.test-layer.ts+11−0
@@ -0,0 +1,11 @@
1+import { Effect, Layer } from "effect";
2+
3+import { ApiKeyService } from "./api-keys";
4+import { ApiKeyManagementError } from "./api-key-errors";
5+
6+export const ApiKeyServiceTestLayer = Layer.succeed(ApiKeyService)({
7+ validate: () => Effect.succeed(null),
8+ listUserKeys: () => Effect.succeed([]),
9+ createUserKey: () => Effect.fail(new ApiKeyManagementError({ cause: "unstubbed" })),
10+ revokeUserKey: () => Effect.void,
11+});
apps/cloud/src/auth/cloud-auth-api.test-context.ts+90−0
@@ -0,0 +1,90 @@
1+import { requestHandler } from "@tanstack/react-start/server";
2+import { Context, Effect, Layer } from "effect";
3+import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
4+import { HttpApiBuilder, HttpApiClient } from "effect/unstable/httpapi";
5+
6+import {
7+ AutumnTestLayer,
8+ makeAutumnTestState,
9+ type AutumnTestState,
10+} from "../services/autumn.test-layer";
11+import { ApiKeyServiceTestLayer } from "./api-keys.test-layer";
12+import {
13+ makeUserStoreTestState,
14+ UserStoreTestLayer,
15+ type UserStoreTestState,
16+} from "./context.test-layer";
17+import { CloudAuthPublicHandlers, CloudSessionAuthHandlers, NonProtectedApi } from "./handlers";
18+import {
19+ makeSessionTestContext,
20+ SessionAuthTestLayer,
21+ type SessionTestContext,
22+} from "./middleware.test-layer";
23+import { makeWorkOSTestState, WorkOSTestLayer, type WorkOSTestState } from "./workos.test-layer";
24+
25+const TEST_BASE_URL = "http://test.local";
26+const SESSION_COOKIE = "wos-session=test_session";
27+
28+export type CloudAuthApiTestState = {
29+ readonly workos: WorkOSTestState;
30+ readonly autumn: AutumnTestState;
31+ readonly userStore: UserStoreTestState;
32+ readonly session: SessionTestContext;
33+};
34+
35+export const makeCloudAuthApiTestState = (
36+ overrides: Partial<CloudAuthApiTestState> = {},
37+): CloudAuthApiTestState => ({
38+ workos: makeWorkOSTestState(),
39+ autumn: makeAutumnTestState(),
40+ userStore: makeUserStoreTestState(),
41+ session: makeSessionTestContext(),
42+ ...overrides,
43+});
44+
45+const makeCloudAuthApiTestClient = (state: CloudAuthApiTestState) => {
46+ const ApiLive = HttpApiBuilder.layer(NonProtectedApi).pipe(
47+ Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),
48+ Layer.provide(SessionAuthTestLayer(state.session)),
49+ Layer.provideMerge(WorkOSTestLayer(state.workos)),
50+ Layer.provideMerge(AutumnTestLayer(state.autumn)),
51+ Layer.provideMerge(UserStoreTestLayer(state.userStore)),
52+ Layer.provideMerge(ApiKeyServiceTestLayer),
53+ Layer.provideMerge(HttpServer.layerServices),
54+ Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })),
55+ );
56+
57+ const handler = HttpRouter.toWebHandler(ApiLive, { disableLogger: true }).handler;
58+ const startHandler = requestHandler((request) => handler(request, undefined));
59+ const fetchViaHandler: typeof globalThis.fetch = async (input, init) => {
60+ const request = input instanceof Request ? input : new Request(input, init);
61+ request.headers.set("cookie", SESSION_COOKIE);
62+ return await startHandler(request, undefined);
63+ };
64+ const clientLayer = FetchHttpClient.layer.pipe(
65+ Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchViaHandler)),
66+ );
67+
68+ return HttpApiClient.make(NonProtectedApi, { baseUrl: TEST_BASE_URL }).pipe(
69+ Effect.provide(clientLayer),
70+ );
71+};
72+
73+export type CloudAuthApiTestClient = Effect.Success<ReturnType<typeof makeCloudAuthApiTestClient>>;
74+
75+export class CloudAuthApiTestContext extends Context.Service<
76+ CloudAuthApiTestContext,
77+ {
78+ readonly state: CloudAuthApiTestState;
79+ readonly client: CloudAuthApiTestClient;
80+ }
81+>()("CloudAuthApiTestContext") {}
82+
83+export const CloudAuthApiTestContextLayer = (state = makeCloudAuthApiTestState()) =>
84+ Layer.effect(
85+ CloudAuthApiTestContext,
86+ Effect.gen(function* () {
87+ const client = yield* makeCloudAuthApiTestClient(state);
88+ return { state, client };
89+ }),
90+ );
apps/cloud/src/auth/context.test-layer.ts+41−0
@@ -0,0 +1,41 @@
1+import { Effect, Layer } from "effect";
2+
3+import { UserStoreService } from "./context";
4+
5+type UserStore = Parameters<Parameters<UserStoreService["Service"]["use"]>[0]>[0];
6+
7+export type UserStoreTestState = {
8+ readonly upsertedOrganizations: Array<{ readonly id: string; readonly name: string }>;
9+};
10+
11+export const makeUserStoreTestState = (
12+ overrides: Partial<UserStoreTestState> = {},
13+): UserStoreTestState => ({
14+ upsertedOrganizations: [],
15+ ...overrides,
16+});
17+
18+const testDate = () => new Date("2026-01-01T00:00:00.000Z");
19+
20+const makeUserStoreTestService = (state: UserStoreTestState): UserStoreService["Service"] => {
21+ const store = {
22+ ensureAccount: async (id: string) => ({ id, createdAt: testDate() }),
23+ getAccount: async (id: string) => ({ id, createdAt: testDate() }),
24+ upsertOrganization: async (org: { readonly id: string; readonly name: string }) => {
25+ state.upsertedOrganizations.push(org);
26+ return { ...org, createdAt: testDate() };
27+ },
28+ getOrganization: async (id: string) => ({
29+ id,
30+ name: id,
31+ createdAt: testDate(),
32+ }),
33+ } satisfies UserStore;
34+
35+ return {
36+ use: (fn) => Effect.promise(() => fn(store)),
37+ };
38+};
39+
40+export const UserStoreTestLayer = (state: UserStoreTestState) =>
41+ Layer.succeed(UserStoreService)(makeUserStoreTestService(state));
apps/cloud/src/auth/middleware.test-layer.ts+23−0
@@ -0,0 +1,23 @@
1+import { Effect, Layer } from "effect";
2+
3+import { SessionAuth, SessionContext, type Session } from "./middleware";
4+
5+export type SessionTestContext = Session;
6+
7+export const makeSessionTestContext = (
8+ overrides: Partial<SessionTestContext> = {},
9+): SessionTestContext => ({
10+ accountId: "user_1",
11+ email: "test@example.com",
12+ name: "Test User",
13+ avatarUrl: null,
14+ organizationId: "org_existing_1",
15+ sealedSession: "test_session",
16+ refreshedSession: null,
17+ ...overrides,
18+});
19+
20+export const SessionAuthTestLayer = (session: Session = makeSessionTestContext()) =>
21+ Layer.succeed(SessionAuth)({
22+ cookie: (httpEffect) => Effect.provideService(httpEffect, SessionContext, session),
23+ });
apps/cloud/src/auth/workos.test-layer.ts+122−0
@@ -0,0 +1,122 @@
1+import { Data, Effect, Layer } from "effect";
2+import type { Organization, OrganizationMembership, OrganizationRole } from "@workos-inc/node";
3+
4+import { WorkOSAuth, type WorkOSCollectedList } from "./workos";
5+
6+export type WorkOSTestState = {
7+ readonly memberships: readonly OrganizationMembership[];
8+ readonly createdOrganizations: Array<{ readonly id: string; readonly name: string }>;
9+ readonly createdMemberships: Array<{
10+ readonly organizationId: string;
11+ readonly userId: string;
12+ readonly roleSlug: string | undefined;
13+ }>;
14+};
15+
16+export class UnstubbedWorkOSMethod extends Data.TaggedError("UnstubbedWorkOSMethod")<{
17+ readonly method: string;
18+}> {}
19+
20+export const makeWorkOSTestState = (overrides: Partial<WorkOSTestState> = {}): WorkOSTestState => ({
21+ memberships: [],
22+ createdOrganizations: [],
23+ createdMemberships: [],
24+ ...overrides,
25+});
26+
27+export const WorkOSTestRole: OrganizationRole = {
28+ object: "role",
29+ id: "role_admin",
30+ name: "Admin",
31+ slug: "admin",
32+ description: null,
33+ permissions: [],
34+ resourceTypeSlug: "organization",
35+ type: "OrganizationRole",
36+ createdAt: "2026-01-01T00:00:00.000Z",
37+ updatedAt: "2026-01-01T00:00:00.000Z",
38+};
39+
40+export const makeWorkOSTestOrganization = (id: string, name = id): Organization => ({
41+ object: "organization",
42+ id,
43+ name,
44+ allowProfilesOutsideOrganization: false,
45+ domains: [],
46+ createdAt: "2026-01-01T00:00:00.000Z",
47+ updatedAt: "2026-01-01T00:00:00.000Z",
48+ externalId: null,
49+ metadata: {},
50+});
51+
52+export const makeWorkOSTestMembership = (
53+ organizationId: string,
54+ status: OrganizationMembership["status"],
55+) =>
56+ ({
57+ object: "organization_membership",
58+ id: `membership_${organizationId}`,
59+ organizationId,
60+ organizationName: organizationId,
61+ status,
62+ userId: "user_1",
63+ role: WorkOSTestRole,
64+ directoryManaged: false,
65+ createdAt: "2026-01-01T00:00:00.000Z",
66+ updatedAt: "2026-01-01T00:00:00.000Z",
67+ customAttributes: {},
68+ }) satisfies OrganizationMembership;
69+
70+const collected = <A>(data: readonly A[]): WorkOSCollectedList<A> => ({
71+ object: "list",
72+ data: [...data],
73+ listMetadata: {
74+ before: null,
75+ after: null,
76+ },
77+});
78+
79+const makeWorkOSTestService = (state: WorkOSTestState): WorkOSAuth["Service"] => {
80+ const nextOrgId = "org_created";
81+ const service: Partial<WorkOSAuth["Service"]> = {
82+ listUserMemberships: () => Effect.succeed(collected(state.memberships)),
83+ createOrganization: (name) =>
84+ Effect.sync(() => {
85+ const org = makeWorkOSTestOrganization(nextOrgId, name);
86+ state.createdOrganizations.push({ id: org.id, name: org.name });
87+ return org;
88+ }),
89+ createMembership: (organizationId, userId, roleSlug) =>
90+ Effect.sync(() => {
91+ state.createdMemberships.push({ organizationId, userId, roleSlug });
92+ return makeWorkOSTestMembership(organizationId, "active");
93+ }),
94+ refreshSession: (_sealedSession, organizationId) => Effect.succeed(`session_${organizationId}`),
95+ authenticateSealedSession: (sealedSession) =>
96+ Effect.succeed({
97+ userId: "user_1",
98+ email: "test@example.com",
99+ firstName: "Test",
100+ lastName: "User",
101+ avatarUrl: null,
102+ organizationId: sealedSession.replace("session_", ""),
103+ sessionId: "session_id",
104+ refreshedSession: undefined,
105+ }),
106+ };
107+
108+ return new Proxy(service as WorkOSAuth["Service"], {
109+ get: (target, prop) => {
110+ if (prop in target) return target[prop as keyof typeof target];
111+ return () =>
112+ Effect.fail(
113+ new UnstubbedWorkOSMethod({
114+ method: typeof prop === "string" ? prop : (prop.description ?? "symbol"),
115+ }),
116+ );
117+ },
118+ });
119+};
120+
121+export const WorkOSTestLayer = (state: WorkOSTestState) =>
122+ Layer.succeed(WorkOSAuth)(makeWorkOSTestService(state));
apps/cloud/src/services/autumn.test-layer.ts+36−0
@@ -0,0 +1,36 @@
1+import { Effect, Layer } from "effect";
2+import type { Autumn } from "autumn-js";
3+
4+import { AutumnService, type IAutumnService } from "./autumn";
5+
6+export type AutumnTestSubscriptionSummary = {
7+ readonly planId?: string | null;
8+ readonly status?: string | null;
9+};
10+
11+export type AutumnTestState = {
12+ readonly subscriptionsByOrgId: Readonly<Record<string, readonly AutumnTestSubscriptionSummary[]>>;
13+};
14+
15+export const makeAutumnTestState = (overrides: Partial<AutumnTestState> = {}): AutumnTestState => ({
16+ subscriptionsByOrgId: {},
17+ ...overrides,
18+});
19+
20+const makeAutumnTestService = (state: AutumnTestState): IAutumnService => {
21+ const fakeClient = {
22+ customers: {
23+ getOrCreate: async ({ customerId }: { readonly customerId: string }) => ({
24+ subscriptions: state.subscriptionsByOrgId[customerId] ?? [],
25+ }),
26+ },
27+ } as Autumn;
28+
29+ return {
30+ use: (fn) => Effect.promise(() => fn(fakeClient)),
31+ trackExecution: () => Effect.void,
32+ };
33+};
34+
35+export const AutumnTestLayer = (state: AutumnTestState) =>
36+ Layer.succeed(AutumnService)(makeAutumnTestService(state));