Skip to content

Commit 62b8b7d

Browse files
authored
Add cloud auth API test context (#828)
1 parent 142a56d commit 62b8b7d

6 files changed

Lines changed: 323 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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));
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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));
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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));

0 commit comments

Comments
 (0)