1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";
3
4import {
5 AuthEnvironmentScopes,
6 AuthEnvironmentScope,
7 authScopeRequiredResponse,
8 AuthGrantScopes,
9 AuthStandardClientScopes,
10 authScopeResponse,
11 AuthSessionState,
12 sessionGrantsScope,
13 sessionHasLegacyPermissions,
14} from "./auth.ts";
15
16describe("authorization grants", () => {
17 it("decodes legacy review credentials without offering them in new grants", () => {
18 expect(Schema.decodeUnknownSync(AuthEnvironmentScopes)(["review:write"])).toEqual([
19 "review:write",
20 ]);
21 expect(() => Schema.decodeUnknownSync(AuthGrantScopes)(["review:write"])).toThrow();
22 expect(AuthStandardClientScopes).not.toContain("review:write");
23 });
24
25 // Frozen vocabulary from the client before granular scopes shipped. Do not
26 // derive it from the current enum: that would hide compatibility regressions.
27 const oldScopes = Schema.Array(
28 Schema.Literals([
29 "orchestration:read",
30 "orchestration:operate",
31 "terminal:operate",
32 "review:write",
33 "access:read",
34 "access:write",
35 "relay:read",
36 "relay:write",
37 ]),
38 );
39
40 const decodeOldScopes = Schema.decodeUnknownSync(oldScopes);
41
42 it.each(AuthEnvironmentScope.literals)(
43 "keeps %s permission errors decodable by old clients",
44 (scope) => {
45 const response = authScopeRequiredResponse(scope);
46 expect(decodeOldScopes([response.requiredScope])).toEqual([response.requiredScope]);
47 expect(response.requiredPermission).toBe(scope);
48 },
49 );
50
51 it("keeps old clients able to decode grants with new permissions", () => {
52 const response = authScopeResponse(AuthStandardClientScopes);
53 expect(decodeOldScopes(response.scopes)).toEqual(response.scopes);
54 expect(response.permissions).toEqual(AuthStandardClientScopes);
55 expect(response.scopes).not.toContain("filesystem:read");
56 });
57
58 it("ignores unknown response permissions without falling back to broader scopes", () => {
59 const session = Schema.decodeUnknownSync(AuthSessionState)({
60 authenticated: true,
61 auth: {
62 policy: "loopback-browser",
63 bootstrapMethods: [],
64 sessionMethods: [],
65 sessionCookieName: "session",
66 },
67 scopes: ["orchestration:operate"],
68 permissions: ["future:permission"],
69 });
70 expect(session.permissions).toEqual([]);
71 expect(sessionGrantsScope(session, "orchestration:operate")).toBe(false);
72 expect(sessionGrantsScope(session, "settings:write")).toBe(false);
73 });
74
75 it.each([
76 {
77 label: "exact permissions over the legacy presentation",
78 session: {
79 authenticated: true,
80 scopes: ["orchestration:operate"],
81 permissions: ["filesystem:read"],
82 },
83 scope: "settings:write",
84 expected: false,
85 },
86 {
87 label: "a permission absent from the legacy presentation",
88 session: { authenticated: true, scopes: [], permissions: ["filesystem:read"] },
89 scope: "filesystem:read",
90 expected: true,
91 },
92
93 {
94 label: "the parent on a server that predates the split",
95 session: { authenticated: true, scopes: ["orchestration:operate"], auth: {} },
96 scope: "settings:write",
97 expected: true,
98 },
99 {
100 label: "only the exact scope on a server that knows the split",
101 session: {
102 authenticated: true,
103 scopes: ["orchestration:operate"],
104 auth: { serverUpdateScope: "environment:maintain" },
105 },
106 scope: "settings:write",
107 expected: false,
108 },
109 {
110 label: "the exact scope regardless of server version",
111 session: { authenticated: true, scopes: ["settings:write"], auth: {} },
112 scope: "settings:write",
113 expected: true,
114 },
115 {
116 label: "nothing for an unauthenticated session",
117 session: { authenticated: false, scopes: ["orchestration:operate"], auth: {} },
118 scope: "settings:write",
119 expected: false,
120 },
121 {
122 label: "no parent for scopes that were never split",
123 session: { authenticated: true, scopes: ["orchestration:operate"], auth: {} },
124 scope: "access:write",
125 expected: false,
126 },
127 ] as const)("sessionGrantsScope accepts $label", ({ session, scope, expected }) => {
128 expect(sessionGrantsScope(session, scope)).toBe(expected);
129 });
130});
131
132describe("legacy permission notice", () => {
133 it.each(["orchestration:read", "orchestration:operate", "terminal:operate"] as const)(
134 "recognizes an old %s grant on an upgraded server",
135 (scope) =>
136 expect(sessionHasLegacyPermissions({ authenticated: true, permissions: [scope] })).toBe(true),
137 );
138 it("waits for a new server and an authenticated session", () => {
139 expect(
140 sessionHasLegacyPermissions({ authenticated: true, scopes: ["orchestration:operate"] }),
141 ).toBe(false);
142 expect(
143 sessionHasLegacyPermissions({ authenticated: false, permissions: ["orchestration:operate"] }),
144 ).toBe(false);
145 });
146 it("skips new grants and old grants that lost no implied permissions", () => {
147 for (const permissions of [
148 AuthStandardClientScopes,
149 ["orchestration:read", "filesystem:read"] as const,
150 ["access:read"] as const,
151 [],
152 ]) {
153 expect(sessionHasLegacyPermissions({ authenticated: true, permissions })).toBe(false);
154 }
155 });
156});