1import {
2 AuthFilesystemReadScope,
3 EnvironmentId,
4 ThreadId,
5 type AuthSessionState,
6} from "@t3tools/contracts";
7import { AsyncResult } from "effect/reactivity";
8import { beforeEach, expect, it, vi } from "vite-plus/test";
9
10const state = vi.hoisted(() => ({
11 sessions: new Map<string, Pick<AuthSessionState, "authenticated" | "scopes">>(),
12 refresh: vi.fn(),
13 download: vi.fn(),
14 shareLocal: vi.fn(),
15 shareDraft: vi.fn(),
16 navigate: vi.fn(),
17 copy: vi.fn(),
18}));
19
20vi.mock("react", () => ({
21 useEffect: () => {},
22 useRef: <A>(current: A) => ({ current }),
23 useState: <A>(initial: A) => [initial, () => {}],
24}));
25vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
26vi.mock("@react-navigation/native", () => ({
27 useNavigation: () => ({ navigate: state.navigate }),
28}));
29vi.mock("./markdownLinks", () => ({
30 normalizeNativeMarkdownUrl: (uri: string) => uri,
31}));
32vi.mock("../state/assets", () => ({
33 useRefreshAssetUrl: (environmentId: string, resource: unknown) => () =>
34 state.refresh(environmentId, resource),
35}));
36vi.mock("../state/session", () => ({
37 environmentSession: { sessionStateAtom: (environmentId: string) => environmentId },
38}));
39vi.mock("../state/query", () => ({
40 useEnvironmentQuery: (environmentId: string) => ({
41 data: state.sessions.get(environmentId) ?? null,
42 error: null,
43 }),
44}));
45vi.mock("../state/atom-registry", () => ({
46 appAtomRegistry: {
47 get: (environmentId: string) => {
48 const session = state.sessions.get(environmentId);
49 return session === undefined ? AsyncResult.initial() : AsyncResult.success(session);
50 },
51 },
52}));
53vi.mock("./attachmentDownload", () => ({
54 downloadAndShareAttachment: state.download,
55 shareLocalAttachment: state.shareLocal,
56}));
57vi.mock("./copyTextWithHaptic", () => ({ copyTextWithHaptic: state.copy }));
58vi.mock("./localAttachmentPreview", () => ({
59 loadLocalAttachmentPreview: async () => ({ share: state.shareDraft, dispose: vi.fn() }),
60}));
61
62import { useMediaActions } from "../state/mediaActions";
63import type { MediaActionsSource } from "./mediaActionsSource";
64
65const environmentId = EnvironmentId.make("media-environment");
66const otherEnvironmentId = EnvironmentId.make("other-environment");
67const threadId = ThreadId.make("media-thread");
68const granted: Pick<AuthSessionState, "authenticated" | "scopes"> = {
69 authenticated: true,
70 scopes: [AuthFilesystemReadScope],
71};
72const denied: Pick<AuthSessionState, "authenticated" | "scopes"> = {
73 authenticated: true,
74 scopes: [],
75};
76
77function hostSource(_tag: "workspace-file" | "media-file" = "media-file"): MediaActionsSource {
78 return {
79 environmentId,
80 threadId,
81 resource: { _tag, threadId, path: "/repo/image.png" },
82 reference: { kind: "file", path: "/repo/image.png", relativePath: "image.png" },
83 name: "image.png",
84 mimeType: "image/png",
85 };
86}
87
88beforeEach(() => {
89 state.sessions.clear();
90 state.sessions.set(environmentId, denied);
91 state.refresh.mockReset().mockResolvedValue("https://host.test/image.png");
92 state.download.mockReset().mockResolvedValue(undefined);
93 state.shareLocal.mockReset().mockResolvedValue(undefined);
94 state.shareDraft.mockReset().mockResolvedValue(undefined);
95 state.navigate.mockReset();
96 state.copy.mockReset();
97});
98
99it.each(["workspace-file", "media-file"] as const)(
100 "waits for the %s grant before enabling host menu actions",
101 (_tag) => {
102 for (const [session, disabled] of [
103 [null, true],
104 [granted, false],
105 [denied, true],
106 ] as const) {
107 if (session === null) state.sessions.delete(environmentId);
108 else state.sessions.set(environmentId, session);
109 const media = useMediaActions(hostSource(_tag));
110
111 expect(media.actions.find(({ id }) => id === "save")?.disabled).toBe(disabled);
112 expect(media.actions.find(({ id }) => id === "open-file")?.disabled).toBe(disabled);
113 const copyPath = media.actions.find(({ id }) => id === "copy-full-path")!;
114 expect(copyPath.disabled).not.toBe(true);
115 copyPath.run();
116 }
117 expect(state.copy).toHaveBeenCalledTimes(3);
118 expect(state.refresh).not.toHaveBeenCalled();
119 expect(state.download).not.toHaveBeenCalled();
120 },
121);
122
123it("keeps nonhost menu actions available with pending or denied host grants", () => {
124 const sources: MediaActionsSource[] = [
125 { uri: "https://cdn.test/image.png", name: "image.png", mimeType: "image/png" },
126 { uri: "file:///device/image.png", name: "image.png", mimeType: "image/png" },
127 {
128 environmentId,
129 resource: { _tag: "attachment", attachmentId: "upload" },
130 name: "image.png",
131 mimeType: "image/png",
132 },
133 {
134 attachment: {
135 id: "draft",
136 type: "file",
137 fileUri: "file:///device/image.png",
138 name: "image.png",
139 mimeType: "image/png",
140 sizeBytes: 1,
141 },
142 name: "image.png",
143 mimeType: "image/png",
144 },
145 ];
146 for (const session of [null, denied]) {
147 if (session === null) state.sessions.delete(environmentId);
148 else state.sessions.set(environmentId, session);
149 for (const source of sources) {
150 expect(useMediaActions(source).actions.find(({ id }) => id === "save")?.disabled).toBe(false);
151 }
152 }
153});
154
155it.each(["workspace-file", "media-file"] as const)(
156 "blocks denied %s sharing and file opening while preserving path copying",
157 async (_tag) => {
158 const media = useMediaActions(hostSource(_tag));
159 await media.share();
160 media.actions.find(({ id }) => id === "open-file")!.run();
161 media.actions.find(({ id }) => id === "copy-full-path")!.run();
162
163 expect(state.refresh).not.toHaveBeenCalled();
164 expect(state.download).not.toHaveBeenCalled();
165 expect(state.navigate).not.toHaveBeenCalled();
166 expect(state.copy).toHaveBeenCalledWith("/repo/image.png");
167 expect(media.actions.find(({ id }) => id === "save")?.disabled).toBe(true);
168 expect(media.actions.find(({ id }) => id === "open-file")?.disabled).toBe(true);
169 },
170);
171
172it("rechecks a retained menu action after revocation", async () => {
173 state.sessions.set(environmentId, granted);
174 const media = useMediaActions(hostSource());
175 state.sessions.set(environmentId, denied);
176
177 await media.share();
178 media.actions.find(({ id }) => id === "open-file")!.run();
179
180 expect(state.refresh).not.toHaveBeenCalled();
181 expect(state.navigate).not.toHaveBeenCalled();
182});
183
184it("reenables sharing after gaining access while preserving a retained callback", async () => {
185 const media = useMediaActions(hostSource());
186 state.sessions.set(environmentId, granted);
187
188 await media.share();
189
190 expect(state.download).toHaveBeenCalledOnce();
191 expect(useMediaActions(hostSource()).actions.find(({ id }) => id === "save")?.disabled).toBe(
192 false,
193 );
194});
195
196it.each([false, true])("uses the media environment's grant (allowed: %s)", async (allowed) => {
197 state.sessions.set(environmentId, allowed ? granted : denied);
198 state.sessions.set(otherEnvironmentId, allowed ? denied : granted);
199
200 await useMediaActions(hostSource()).share();
201
202 expect(state.refresh).toHaveBeenCalledTimes(allowed ? 1 : 0);
203 expect(state.download).toHaveBeenCalledTimes(allowed ? 1 : 0);
204});
205
206it("stops before downloading if access is revoked while the URL is refreshed", async () => {
207 state.sessions.set(environmentId, granted);
208 state.refresh.mockImplementation(async () => {
209 state.sessions.set(environmentId, denied);
210 return "https://host.test/image.png";
211 });
212
213 await useMediaActions(hostSource()).share();
214
215 expect(state.refresh).toHaveBeenCalledOnce();
216 expect(state.download).not.toHaveBeenCalled();
217});
218
219it("lets an unresolved grant be authorized by an explicit server request", async () => {
220 state.sessions.delete(environmentId);
221
222 await useMediaActions(hostSource()).share();
223
224 expect(state.refresh).toHaveBeenCalledOnce();
225 expect(state.download).toHaveBeenCalledOnce();
226});
227
228it("shares uploaded attachments without host filesystem access", async () => {
229 await useMediaActions({
230 environmentId,
231 resource: { _tag: "attachment", attachmentId: "upload" },
232 name: "image.png",
233 mimeType: "image/png",
234 }).share();
235
236 expect(state.refresh).toHaveBeenCalledOnce();
237 expect(state.download).toHaveBeenCalledOnce();
238});
239
240it.each(["https://cdn.test/image.png", "file:///device/image.png"])(
241 "shares direct media without a host grant: %s",
242 async (uri) => {
243 await useMediaActions({ uri, name: "image.png", mimeType: "image/png" }).share();
244
245 expect(state.refresh).not.toHaveBeenCalled();
246 expect(uri.startsWith("file:") ? state.shareLocal : state.download).toHaveBeenCalledOnce();
247 },
248);
249
250it("shares device draft attachments without a host grant", async () => {
251 await useMediaActions({
252 attachment: {
253 id: "draft",
254 type: "file",
255 fileUri: "file:///device/image.png",
256 name: "image.png",
257 mimeType: "image/png",
258 sizeBytes: 1,
259 },
260 name: "image.png",
261 mimeType: "image/png",
262 }).share();
263
264 expect(state.refresh).not.toHaveBeenCalled();
265 expect(state.shareDraft).toHaveBeenCalledOnce();
266});