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 @@
1import { Effect, Layer } from "effect";
2
3import { ApiKeyService } from "./api-keys";
4import { ApiKeyManagementError } from "./api-key-errors";
5
6export 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 @@
1import { requestHandler } from "@tanstack/react-start/server";
2import { Context, Effect, Layer } from "effect";
3import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
4import { HttpApiBuilder, HttpApiClient } from "effect/unstable/httpapi";
5
6import {
7 AutumnTestLayer,
8 makeAutumnTestState,
9 type AutumnTestState,
10} from "../services/autumn.test-layer";
11import { ApiKeyServiceTestLayer } from "./api-keys.test-layer";
12import {
13 makeUserStoreTestState,
14 UserStoreTestLayer,
15 type UserStoreTestState,
16} from "./context.test-layer";
17import { CloudAuthPublicHandlers, CloudSessionAuthHandlers, NonProtectedApi } from "./handlers";
18import {
19 makeSessionTestContext,
20 SessionAuthTestLayer,
21 type SessionTestContext,
22} from "./middleware.test-layer";
23import { makeWorkOSTestState, WorkOSTestLayer, type WorkOSTestState } from "./workos.test-layer";
24
25const TEST_BASE_URL = "http://test.local";
26const SESSION_COOKIE = "wos-session=test_session";
27
28export type CloudAuthApiTestState = {
29 readonly workos: WorkOSTestState;
30 readonly autumn: AutumnTestState;
31 readonly userStore: UserStoreTestState;
32 readonly session: SessionTestContext;
33};
34
35export const makeCloudAuthApiTestState = (
36 overrides: Partial<CloudAuthApiTestState> = {},
37): CloudAuthApiTestState => ({
38 workos: makeWorkOSTestState(),
39 autumn: makeAutumnTestState(),
40 userStore: makeUserStoreTestState(),
41 session: makeSessionTestContext(),
42 ...overrides,
43});
44
45const 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
73export type CloudAuthApiTestClient = Effect.Success<ReturnType<typeof makeCloudAuthApiTestClient>>;
74
75export class CloudAuthApiTestContext extends Context.Service<
76 CloudAuthApiTestContext,
77 {
78 readonly state: CloudAuthApiTestState;
79 readonly client: CloudAuthApiTestClient;
80 }
81>()("CloudAuthApiTestContext") {}
82
83export 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 @@
1import { Effect, Layer } from "effect";
2
3import { UserStoreService } from "./context";
4
5type UserStore = Parameters<Parameters<UserStoreService["Service"]["use"]>[0]>[0];
6
7export type UserStoreTestState = {
8 readonly upsertedOrganizations: Array<{ readonly id: string; readonly name: string }>;
9};
10
11export const makeUserStoreTestState = (
12 overrides: Partial<UserStoreTestState> = {},
13): UserStoreTestState => ({
14 upsertedOrganizations: [],
15 ...overrides,
16});
17
18const testDate = () => new Date("2026-01-01T00:00:00.000Z");
19
20const 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
40export const UserStoreTestLayer = (state: UserStoreTestState) =>
41 Layer.succeed(UserStoreService)(makeUserStoreTestService(state));
apps/cloud/src/auth/middleware.test-layer.ts+23−0
@@ -0,0 +1,23 @@
1import { Effect, Layer } from "effect";
2
3import { SessionAuth, SessionContext, type Session } from "./middleware";
4
5export type SessionTestContext = Session;
6
7export 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
20export 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 @@
1import { Data, Effect, Layer } from "effect";
2import type { Organization, OrganizationMembership, OrganizationRole } from "@workos-inc/node";
3
4import { WorkOSAuth, type WorkOSCollectedList } from "./workos";
5
6export 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
16export class UnstubbedWorkOSMethod extends Data.TaggedError("UnstubbedWorkOSMethod")<{
17 readonly method: string;
18}> {}
19
20export const makeWorkOSTestState = (overrides: Partial<WorkOSTestState> = {}): WorkOSTestState => ({
21 memberships: [],
22 createdOrganizations: [],
23 createdMemberships: [],
24 ...overrides,
25});
26
27export 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
40export 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
52export 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
70const collected = <A>(data: readonly A[]): WorkOSCollectedList<A> => ({
71 object: "list",
72 data: [...data],
73 listMetadata: {
74 before: null,
75 after: null,
76 },
77});
78
79const 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
121export const WorkOSTestLayer = (state: WorkOSTestState) =>
122 Layer.succeed(WorkOSAuth)(makeWorkOSTestService(state));
apps/cloud/src/services/autumn.test-layer.ts+36−0
@@ -0,0 +1,36 @@
1import { Effect, Layer } from "effect";
2import type { Autumn } from "autumn-js";
3
4import { AutumnService, type IAutumnService } from "./autumn";
5
6export type AutumnTestSubscriptionSummary = {
7 readonly planId?: string | null;
8 readonly status?: string | null;
9};
10
11export type AutumnTestState = {
12 readonly subscriptionsByOrgId: Readonly<Record<string, readonly AutumnTestSubscriptionSummary[]>>;
13};
14
15export const makeAutumnTestState = (overrides: Partial<AutumnTestState> = {}): AutumnTestState => ({
16 subscriptionsByOrgId: {},
17 ...overrides,
18});
19
20const 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
35export const AutumnTestLayer = (state: AutumnTestState) =>
36 Layer.succeed(AutumnService)(makeAutumnTestService(state));