1import { expect, it } from "@effect/vitest";
2import { HostedSourceConnection } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import { HttpClient, HttpClientResponse } from "effect/http";
9import * as SourceConnection from "./SourceConnection.ts";
10
11const sourceCodec = Schema.toCodecJson(HostedSourceConnection);
12const encodeSource = Schema.encodeEffect(sourceCodec);
13const decodeSource = Schema.decodeEffect(sourceCodec);
14const scopes = ["archive:read", "archive:write", "environment:connect"];
15const fixture = Effect.gen(function* () {
16 const now = yield* DateTime.now;
17 const expiresAt = DateTime.add(now, { minutes: 15 });
18 const input = {
19 serverUrl: "https://host.example",
20 authorization: "Bearer narrow-token",
21 expiresAt,
22 };
23 let requests = 0;
24 let permissions = scopes;
25 let authenticated = true;
26 let status = 200;
27 let remoteExpiry = DateTime.formatIso(expiresAt);
28 const http = HttpClient.make((request) =>
29 Effect.sync(() => {
30 requests++;
31 expect(request.url).toBe("https://host.example/api/auth/session");
32 expect(request.headers.authorization).toBe("Bearer narrow-token");
33 return HttpClientResponse.fromWeb(
34 request,
35 Response.json(
36 {
37 authenticated,
38 permissions,
39 expiresAt: remoteExpiry,
40 sessionMethod: "bearer-access-token",
41 },
42 { status },
43 ),
44 );
45 }),
46 );
47 const source = yield* SourceConnection.SourceConnection.pipe(
48 Effect.provide(
49 SourceConnection.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, http))),
50 ),
51 );
52 return {
53 source,
54 input,
55 requests: () => requests,
56 setPermissions: (value: string[]) => {
57 permissions = value;
58 },
59 deny: () => {
60 status = 401;
61 },
62 setAuthenticated: (value: boolean) => {
63 authenticated = value;
64 },
65 setExpiry: (value: string) => {
66 remoteExpiry = value;
67 },
68 };
69});
70
71it.effect(
72 "accepts only verified narrow source grants, normalizes URL and deduplicates replay",
73 () =>
74 Effect.gen(function* () {
75 const f = yield* fixture;
76 const wire = yield* encodeSource(f.input);
77 const input = yield* decodeSource(wire);
78 yield* f.source.update(input);
79 yield* f.source.setConnected(true);
80 yield* f.source.update({ ...input, serverUrl: "https://host.example/" });
81 expect(f.requests()).toBe(1);
82 expect(yield* f.source.connected).toBe(true);
83 expect(Option.getOrThrow(yield* f.source.current).serverUrl).toBe("https://host.example/");
84 yield* f.source.update(undefined);
85 expect(Option.isNone(yield* f.source.current)).toBe(true);
86 }),
87);
88
89it.effect.each([
90 { permissions: scopes.slice(0, 2) },
91 { permissions: [...scopes, "providers:manage"] },
92 { permissions: [...scopes, "access:write"] },
93 { permissions: ["archive:read", "archive:read", "environment:connect"] },
94])("rejects non-source permission set $permissions", ({ permissions }) =>
95 Effect.gen(function* () {
96 const f = yield* fixture;
97 f.setPermissions(permissions);
98 expect((yield* f.source.update(f.input).pipe(Effect.flip)).reason).toBe("credential");
99 expect(Option.isNone(yield* f.source.current)).toBe(true);
100 }),
101);
102
103it.effect("rejects denied, anonymous and shorter-lived remote credentials", () =>
104 Effect.gen(function* () {
105 for (const change of [
106 (f: Effect.Success<typeof fixture>) => f.deny(),
107 (f: Effect.Success<typeof fixture>) => f.setAuthenticated(false),
108 (f: Effect.Success<typeof fixture>) => f.setExpiry("1970-01-01T00:00:00.000Z"),
109 ]) {
110 const f = yield* fixture;
111 change(f);
112 expect((yield* f.source.update(f.input).pipe(Effect.flip)).reason).toBe("credential");
113 expect(Option.isNone(yield* f.source.current)).toBe(true);
114 }
115 }),
116);
117
118it.effect("rejects unsafe origins and invalid grant lifetimes before sending the token", () =>
119 Effect.gen(function* () {
120 const f = yield* fixture;
121 const now = yield* DateTime.now;
122 for (const input of [
123 { ...f.input, serverUrl: "http://host.example" },
124 { ...f.input, serverUrl: "https://host.example/another-path" },
125 { ...f.input, serverUrl: "https://user:password@host.example" },
126 { ...f.input, expiresAt: now },
127 { ...f.input, expiresAt: DateTime.add(now, { hours: 2 }) },
128 ])
129 expect((yield* f.source.update(input).pipe(Effect.result))._tag).toBe("Failure");
130 expect(f.requests()).toBe(0);
131 }),
132);