Skip to content
Source
Sign inSign up

T3ChatAccounts.test.ts

176 lines6.5 KB

1import { describe, expect, it } from "@effect/vitest";
2import { vi } from "vite-plus/test";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as TestClock from "effect/testing/TestClock";
6import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientResponse from "effect/http/HttpClientResponse";
10​
11import * as RelayConfiguration from "../Config.ts";
12import * as T3ChatAccounts from "./T3ChatAccounts.ts";
13​
14const clerk = vi.hoisted(() => ({
15 getUser: async (): Promise<unknown> => ({ primaryEmailAddress: { emailAddress: "a@b.co" } }),
16}));
17vi.mock("@clerk/backend", () => ({
18 createClerkClient: () => ({ users: { getUser: () => clerk.getUser() } }),
19}));
20​
21const SECRET = "shared-link-secret";
22​
23const relaySettings = (
24 t3Chat: RelayConfiguration.T3ChatSettings | null,
25): RelayConfiguration.RelayConfiguration["Service"] => ({
26 relayIssuer: "https://relay.example.test",
27 apns: null,
28 clerkSecretKey: Redacted.make("clerk-secret-key"),
29 clerkPublishableKey: "pk_test_test",
30 clerkJwtAudience: "t3-code-relay",
31 apnsDeliveryJobSigningSecret: Redacted.make("apns-delivery-secret"),
32 cloudMintPrivateKey: Redacted.make("cloud-mint-private-key"),
33 cloudMintPublicKey: "cloud-mint-public-key",
34 managedEndpointBaseDomain: undefined,
35 managedEndpointNamespace: undefined,
36 ...(t3Chat ? { t3Chat } : {}),
37});
38​
39const fakeT3Chat = (body: unknown, status = 200) => {
40 const requests: Array<{ url: string; authorization: string | undefined; body: unknown }> = [];
41 const client = HttpClient.make((request) =>
42 Effect.gen(function* () {
43 requests.push({
44 url: request.url,
45 authorization: request.headers["authorization"],
46 body:
47 request.body._tag === "Uint8Array"
48 ? JSON.parse(new TextDecoder().decode(request.body.body))
49 : null,
50 });
51 return HttpClientResponse.fromWeb(
52 request,
53 new Response(JSON.stringify(body), {
54 status,
55 headers: { "content-type": "application/json" },
56 }),
57 );
58 }),
59 );
60 return { client, requests };
61};
62​
63const provide = (
64 client: HttpClient.HttpClient,
65 t3Chat: RelayConfiguration.T3ChatSettings | null = {
66 baseUrl: "https://chat.example.test",
67 linkSecret: Redacted.make(SECRET),
68 },
69) =>
70 Effect.provide(
71 T3ChatAccounts.layer.pipe(
72 Layer.provide(RelayConfiguration.layer(relaySettings(t3Chat))),
73 Layer.provide(Layer.succeed(HttpClient.HttpClient, client)),
74 ),
75 );
76​
77describe("T3ChatAccounts", () => {
78 it.effect("signs link tokens in the format T3 Chat verifies", () =>
79 Effect.gen(function* () {
80 const accounts = yield* T3ChatAccounts.T3ChatAccounts;
81 const url = new URL(yield* accounts.createLinkUrl({ userId: "user_1" }));
82 expect(`${url.origin}${url.pathname}`).toBe("https://chat.example.test/link/t3code");
83​
84 const [payload = "", signature = ""] = (url.searchParams.get("token") ?? "").split(".");
85 // Verify with Web Crypto, as T3 Chat does, rather than the signing library.
86 const verified = yield* Effect.promise(async () => {
87 const encoder = new TextEncoder();
88 const key = await crypto.subtle.importKey(
89 "raw",
90 encoder.encode(SECRET),
91 { name: "HMAC", hash: "SHA-256" },
92 false,
93 ["verify"],
94 );
95 return crypto.subtle.verify(
96 "HMAC",
97 key,
98 Buffer.from(signature, "base64url"),
99 encoder.encode(payload),
100 );
101 });
102 expect(verified).toBe(true);
103 expect(JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))).toEqual({
104 v: 1,
105 sub: "user_1",
106 email: "a@b.co",
107 exp: 600,
108 });
109 }).pipe(provide(fakeT3Chat({}).client)),
110 );
111​
112 it.effect("asks T3 Chat for the link with the shared secret", () => {
113 const t3Chat = fakeT3Chat({ link: { email: "theo@t3.chat", plan: "premier", linkedAt: 1 } });
114 return Effect.gen(function* () {
115 const accounts = yield* T3ChatAccounts.T3ChatAccounts;
116 const link = yield* accounts.getLink({ userId: "user_1" });
117 expect(link).toEqual({ email: "theo@t3.chat", plan: "premier" });
118 expect(t3Chat.requests).toEqual([
119 {
120 url: "https://chat.example.test/api/t3code/link",
121 authorization: `Bearer ${SECRET}`,
122 body: { clerkUserId: "user_1" },
123 },
124 ]);
125 }).pipe(provide(t3Chat.client));
126 });
127​
128 it.effect("fails instead of guessing when T3 Chat errors", () =>
129 Effect.gen(function* () {
130 const accounts = yield* T3ChatAccounts.T3ChatAccounts;
131 const error = yield* Effect.flip(accounts.getLink({ userId: "user_1" }));
132 expect(error._tag).toBe("T3ChatRequestFailed");
133 }).pipe(provide(fakeT3Chat({ error: "nope" }, 401).client)),
134 );
135​
136 it.effect("times out when T3 Chat stalls while sending the body", () => {
137 const client = HttpClient.make((request) =>
138 Effect.succeed(
139 HttpClientResponse.fromWeb(
140 request,
141 new Response(new ReadableStream({ start: () => {} }), { status: 200 }),
142 ),
143 ),
144 );
145 return Effect.gen(function* () {
146 const accounts = yield* T3ChatAccounts.T3ChatAccounts;
147 const fiber = yield* Effect.forkChild(Effect.flip(accounts.getLink({ userId: "user_1" })));
148 yield* TestClock.adjust("5 seconds");
149 const error = yield* Fiber.join(fiber);
150 expect(error._tag).toBe("T3ChatRequestFailed");
151 }).pipe(provide(client));
152 });
153​
154 it.effect("times out when Clerk stalls on the email lookup", () =>
155 Effect.gen(function* () {
156 const getUser = clerk.getUser;
157 clerk.getUser = () => new Promise(() => {});
158 const accounts = yield* T3ChatAccounts.T3ChatAccounts;
159 const fiber = yield* Effect.forkChild(Effect.flip(accounts.createLinkUrl({ userId: "u" })));
160 yield* TestClock.adjust("5 seconds");
161 const error = yield* Fiber.join(fiber).pipe(
162 Effect.ensuring(Effect.sync(() => (clerk.getUser = getUser))),
163 );
164 expect(error._tag).toBe("ClerkUserLookupFailed");
165 }).pipe(provide(fakeT3Chat({}).client)),
166 );
167​
168 it.effect("reports itself unavailable without a shared secret", () =>
169 Effect.gen(function* () {
170 const accounts = yield* T3ChatAccounts.T3ChatAccounts;
171 expect(accounts.available).toBe(false);
172 const error = yield* Effect.flip(accounts.getLink({ userId: "user_1" }));
173 expect(error._tag).toBe("T3ChatNotConfigured");
174 }).pipe(provide(fakeT3Chat({}).client, null)),
175 );
176});