|
| 1 | +import type { BearerProviderContext } from "../provider-runtime.ts"; |
| 2 | + |
| 3 | +import { describe, expect, it, vi } from "vitest"; |
| 4 | +import { ProviderRequestError } from "../provider-runtime.ts"; |
| 5 | +import { credentialValidators } from "./executors.ts"; |
| 6 | +import { giteeActionHandlers, validateGiteeCredential } from "./runtime.ts"; |
| 7 | + |
| 8 | +function context(fetcher: typeof fetch): BearerProviderContext { |
| 9 | + return { |
| 10 | + accessToken: "gitee-token", |
| 11 | + tokenType: "Bearer", |
| 12 | + fetcher, |
| 13 | + }; |
| 14 | +} |
| 15 | + |
| 16 | +describe("Gitee runtime", () => { |
| 17 | + it("validates a token with the current user and preserves OAuth scopes", async () => { |
| 18 | + const fetcher = vi.fn( |
| 19 | + async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => |
| 20 | + Response.json({ |
| 21 | + id: 42, |
| 22 | + login: "octocat-cn", |
| 23 | + name: "Octocat CN", |
| 24 | + html_url: "https://gitee.com/octocat-cn", |
| 25 | + }), |
| 26 | + ); |
| 27 | + |
| 28 | + await expect( |
| 29 | + validateGiteeCredential("gitee-token", fetcher, undefined, ["user_info", "projects"]), |
| 30 | + ).resolves.toMatchObject({ |
| 31 | + profile: { |
| 32 | + accountId: "gitee:42", |
| 33 | + displayName: "Octocat CN", |
| 34 | + }, |
| 35 | + grantedScopes: ["user_info", "projects"], |
| 36 | + metadata: { |
| 37 | + validationEndpoint: "/user", |
| 38 | + currentUser: { |
| 39 | + id: 42, |
| 40 | + login: "octocat-cn", |
| 41 | + }, |
| 42 | + }, |
| 43 | + }); |
| 44 | + |
| 45 | + const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]; |
| 46 | + expect(url.toString()).toBe("https://gitee.com/api/v5/user"); |
| 47 | + expect(init.headers).toMatchObject({ |
| 48 | + authorization: "Bearer gitee-token", |
| 49 | + "user-agent": "oomol-connect/0.1", |
| 50 | + }); |
| 51 | + }); |
| 52 | + |
| 53 | + it("uses the same validation for API keys and OAuth credentials", async () => { |
| 54 | + const fetcher = vi.fn( |
| 55 | + async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => |
| 56 | + Response.json({ id: 7, login: "gitee-user" }), |
| 57 | + ); |
| 58 | + |
| 59 | + const apiKeyResult = await credentialValidators.apiKey?.( |
| 60 | + { apiKey: "personal-token", values: { apiKey: "personal-token" } }, |
| 61 | + { fetcher }, |
| 62 | + ); |
| 63 | + const oauthResult = await credentialValidators.oauth2?.( |
| 64 | + { |
| 65 | + authType: "oauth2", |
| 66 | + accessToken: "oauth-token", |
| 67 | + tokenType: "Bearer", |
| 68 | + profile: { accountId: "oauth2", displayName: "OAuth Credential", grantedScopes: [] }, |
| 69 | + metadata: { scope: "user_info projects" }, |
| 70 | + }, |
| 71 | + { fetcher }, |
| 72 | + ); |
| 73 | + |
| 74 | + expect(apiKeyResult?.grantedScopes).toEqual([]); |
| 75 | + expect(oauthResult?.grantedScopes).toEqual(["user_info", "projects"]); |
| 76 | + expect(fetcher.mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: "Bearer personal-token" }); |
| 77 | + expect(fetcher.mock.calls[1]?.[1]?.headers).toMatchObject({ authorization: "Bearer oauth-token" }); |
| 78 | + expect(new URL(String(fetcher.mock.calls[0]?.[0])).searchParams.has("access_token")).toBe(false); |
| 79 | + expect(new URL(String(fetcher.mock.calls[1]?.[0])).searchParams.has("access_token")).toBe(false); |
| 80 | + }); |
| 81 | + |
| 82 | + it("lists repositories with Gitee pagination parameters", async () => { |
| 83 | + const fetcher = vi.fn( |
| 84 | + async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => |
| 85 | + Response.json([{ id: 1, full_name: "acme/demo" }]), |
| 86 | + ); |
| 87 | + |
| 88 | + await expect( |
| 89 | + giteeActionHandlers.list_my_repositories( |
| 90 | + { |
| 91 | + visibility: "private", |
| 92 | + q: "demo", |
| 93 | + sort: "updated", |
| 94 | + direction: "desc", |
| 95 | + page: 2, |
| 96 | + perPage: 50, |
| 97 | + }, |
| 98 | + context(fetcher), |
| 99 | + ), |
| 100 | + ).resolves.toEqual({ repositories: [{ id: 1, full_name: "acme/demo" }] }); |
| 101 | + |
| 102 | + const url = new URL(String(fetcher.mock.calls[0]?.[0])); |
| 103 | + expect(url.pathname).toBe("/api/v5/user/repos"); |
| 104 | + expect(Object.fromEntries(url.searchParams)).toEqual({ |
| 105 | + visibility: "private", |
| 106 | + q: "demo", |
| 107 | + sort: "updated", |
| 108 | + direction: "desc", |
| 109 | + page: "2", |
| 110 | + per_page: "50", |
| 111 | + }); |
| 112 | + }); |
| 113 | + |
| 114 | + it("encodes repository owner and path segments", async () => { |
| 115 | + const fetcher = vi.fn( |
| 116 | + async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => |
| 117 | + Response.json({ id: 1, full_name: "team name/repo/name" }), |
| 118 | + ); |
| 119 | + |
| 120 | + await giteeActionHandlers.get_repository({ owner: "team name", repo: "repo/name" }, context(fetcher)); |
| 121 | + |
| 122 | + const url = new URL(String(fetcher.mock.calls[0]?.[0])); |
| 123 | + expect(url.pathname).toBe("/api/v5/repos/team%20name/repo%2Fname"); |
| 124 | + expect(url.searchParams.has("access_token")).toBe(false); |
| 125 | + expect(fetcher.mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: "Bearer gitee-token" }); |
| 126 | + }); |
| 127 | + |
| 128 | + it("maps authentication, rate limit, and malformed response failures", async () => { |
| 129 | + const unauthorized = vi.fn( |
| 130 | + async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => |
| 131 | + Response.json({ message: "invalid access token" }, { status: 401 }), |
| 132 | + ); |
| 133 | + const limited = vi.fn( |
| 134 | + async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => |
| 135 | + Response.json({ message: "rate limit exceeded" }, { status: 429 }), |
| 136 | + ); |
| 137 | + const malformed = vi.fn( |
| 138 | + async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => |
| 139 | + new Response("not-json", { status: 200 }), |
| 140 | + ); |
| 141 | + const malformedShape = vi.fn( |
| 142 | + async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => Response.json([]), |
| 143 | + ); |
| 144 | + const notFound = vi.fn( |
| 145 | + async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => |
| 146 | + Response.json({ message: "repository not found" }, { status: 404 }), |
| 147 | + ); |
| 148 | + const networkFailure = vi.fn( |
| 149 | + async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => |
| 150 | + Promise.reject(new Error("connection reset")), |
| 151 | + ); |
| 152 | + |
| 153 | + await expect(validateGiteeCredential("bad-token", unauthorized)).rejects.toMatchObject({ |
| 154 | + status: 400, |
| 155 | + message: "Gitee authentication failed: invalid access token", |
| 156 | + }); |
| 157 | + await expect(giteeActionHandlers.get_current_user({}, context(limited))).rejects.toMatchObject({ |
| 158 | + status: 429, |
| 159 | + }); |
| 160 | + await expect(giteeActionHandlers.get_current_user({}, context(malformed))).rejects.toEqual( |
| 161 | + expect.objectContaining<Partial<ProviderRequestError>>({ |
| 162 | + status: 502, |
| 163 | + message: "Gitee returned invalid JSON", |
| 164 | + }), |
| 165 | + ); |
| 166 | + await expect(validateGiteeCredential("bad-shape", malformedShape)).rejects.toMatchObject({ |
| 167 | + status: 502, |
| 168 | + message: "Gitee current user response is not an object", |
| 169 | + }); |
| 170 | + await expect( |
| 171 | + giteeActionHandlers.get_repository({ owner: "acme", repo: "missing" }, context(notFound)), |
| 172 | + ).rejects.toMatchObject({ status: 404 }); |
| 173 | + await expect(giteeActionHandlers.get_current_user({}, context(networkFailure))).rejects.toMatchObject({ |
| 174 | + status: 502, |
| 175 | + message: "Gitee request failed: connection reset", |
| 176 | + }); |
| 177 | + }); |
| 178 | +}); |
0 commit comments