@dependabit/github-client 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +266 -0
  4. package/dist/auth/basic.d.ts +46 -0
  5. package/dist/auth/basic.d.ts.map +1 -0
  6. package/dist/auth/basic.js +88 -0
  7. package/dist/auth/basic.js.map +1 -0
  8. package/dist/auth/oauth.d.ts +48 -0
  9. package/dist/auth/oauth.d.ts.map +1 -0
  10. package/dist/auth/oauth.js +139 -0
  11. package/dist/auth/oauth.js.map +1 -0
  12. package/dist/auth/token.d.ts +40 -0
  13. package/dist/auth/token.d.ts.map +1 -0
  14. package/dist/auth/token.js +67 -0
  15. package/dist/auth/token.js.map +1 -0
  16. package/dist/auth.d.ts +47 -0
  17. package/dist/auth.d.ts.map +1 -0
  18. package/dist/auth.js +78 -0
  19. package/dist/auth.js.map +1 -0
  20. package/dist/client.d.ts +53 -0
  21. package/dist/client.d.ts.map +1 -0
  22. package/dist/client.js +74 -0
  23. package/dist/client.js.map +1 -0
  24. package/dist/commits.d.ts +57 -0
  25. package/dist/commits.d.ts.map +1 -0
  26. package/dist/commits.js +113 -0
  27. package/dist/commits.js.map +1 -0
  28. package/dist/feedback.d.ts +69 -0
  29. package/dist/feedback.d.ts.map +1 -0
  30. package/dist/feedback.js +111 -0
  31. package/dist/feedback.js.map +1 -0
  32. package/dist/index.d.ts +15 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +11 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/issues.d.ts +55 -0
  37. package/dist/issues.d.ts.map +1 -0
  38. package/dist/issues.js +123 -0
  39. package/dist/issues.js.map +1 -0
  40. package/dist/rate-limit.d.ts +71 -0
  41. package/dist/rate-limit.d.ts.map +1 -0
  42. package/dist/rate-limit.js +145 -0
  43. package/dist/rate-limit.js.map +1 -0
  44. package/dist/releases.d.ts +50 -0
  45. package/dist/releases.d.ts.map +1 -0
  46. package/dist/releases.js +113 -0
  47. package/dist/releases.js.map +1 -0
  48. package/package.json +39 -0
  49. package/src/auth/basic.ts +102 -0
  50. package/src/auth/oauth.ts +183 -0
  51. package/src/auth/token.ts +81 -0
  52. package/src/auth.ts +100 -0
  53. package/src/client.test.ts +115 -0
  54. package/src/client.ts +109 -0
  55. package/src/commits.ts +184 -0
  56. package/src/feedback.ts +166 -0
  57. package/src/index.ts +15 -0
  58. package/src/issues.ts +185 -0
  59. package/src/rate-limit.ts +210 -0
  60. package/src/releases.ts +149 -0
  61. package/test/auth/basic.test.ts +122 -0
  62. package/test/auth/oauth.test.ts +196 -0
  63. package/test/auth/token.test.ts +97 -0
  64. package/test/commits.test.ts +169 -0
  65. package/test/feedback.test.ts +203 -0
  66. package/test/issues.test.ts +197 -0
  67. package/test/rate-limit.test.ts +154 -0
  68. package/test/releases.test.ts +187 -0
  69. package/tsconfig.json +10 -0
  70. package/tsconfig.tsbuildinfo +1 -0
package/src/issues.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Issue Manager
3
+ * Handles GitHub issue creation and management for dependency changes
4
+ */
5
+
6
+ import { Octokit } from 'octokit';
7
+
8
+ export interface IssueData {
9
+ owner: string;
10
+ repo: string;
11
+ title: string;
12
+ body: string;
13
+ severity: 'breaking' | 'major' | 'minor';
14
+ dependency: {
15
+ id: string;
16
+ url: string;
17
+ };
18
+ assignee?: string;
19
+ }
20
+
21
+ export interface IssueResult {
22
+ number: number;
23
+ url: string;
24
+ labels: string[];
25
+ assignees?: string[] | undefined;
26
+ }
27
+
28
+ export interface UpdateIssueData {
29
+ owner: string;
30
+ repo: string;
31
+ issueNumber: number;
32
+ body: string;
33
+ severity?: 'breaking' | 'major' | 'minor';
34
+ append?: boolean;
35
+ }
36
+
37
+ export class IssueManager {
38
+ private octokit: Octokit;
39
+
40
+ constructor(auth?: string) {
41
+ this.octokit = new Octokit({
42
+ auth: auth || process.env['GITHUB_TOKEN']
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Creates a new issue for a dependency change
48
+ */
49
+ async createIssue(data: IssueData): Promise<IssueResult> {
50
+ const { owner, repo, title, body, severity, dependency, assignee } = data;
51
+
52
+ // Prepare labels
53
+ const labels = ['dependabit', `severity:${severity}`, 'dependency-update'];
54
+
55
+ // Create the issue
56
+ const response = await this.octokit.rest.issues.create({
57
+ owner,
58
+ repo,
59
+ title,
60
+ body: this.formatIssueBody(body, dependency),
61
+ labels,
62
+ ...(assignee && { assignees: [assignee] })
63
+ });
64
+
65
+ return {
66
+ number: response.data.number,
67
+ url: response.data.html_url,
68
+ labels,
69
+ ...(assignee && { assignees: [assignee] })
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Finds an existing issue for a dependency
75
+ */
76
+ async findExistingIssue(params: {
77
+ owner: string;
78
+ repo: string;
79
+ dependencyId: string;
80
+ }): Promise<IssueResult | null> {
81
+ const { owner, repo, dependencyId } = params;
82
+
83
+ try {
84
+ // Search for open issues with dependabit label and dependency ID
85
+ const query = `repo:${owner}/${repo} is:issue is:open label:dependabit ${dependencyId}`;
86
+
87
+ const response = await this.octokit.rest.search.issuesAndPullRequests({
88
+ q: query,
89
+ per_page: 1
90
+ });
91
+
92
+ if (response.data.items.length === 0) {
93
+ return null;
94
+ }
95
+
96
+ const issue = response.data.items[0];
97
+ if (!issue) {
98
+ return null;
99
+ }
100
+
101
+ return {
102
+ number: issue.number,
103
+ url: issue.html_url,
104
+ labels: issue.labels.map((l) => (typeof l === 'string' ? l : l.name || ''))
105
+ };
106
+ } catch (error) {
107
+ console.error('Error finding existing issue:', error);
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Updates an existing issue
114
+ */
115
+ async updateIssue(data: UpdateIssueData): Promise<IssueResult> {
116
+ const { owner, repo, issueNumber, body, severity, append } = data;
117
+
118
+ let finalBody = body;
119
+
120
+ // If appending, fetch current body first
121
+ if (append) {
122
+ const current = await this.octokit.rest.issues.get({
123
+ owner,
124
+ repo,
125
+ issue_number: issueNumber
126
+ });
127
+ finalBody = `${current.data.body}\n\n---\n\n${body}`;
128
+ }
129
+
130
+ // Update labels if severity changed
131
+ const updateParams: {
132
+ owner: string;
133
+ repo: string;
134
+ issue_number: number;
135
+ body: string;
136
+ labels?: string[];
137
+ } = {
138
+ owner,
139
+ repo,
140
+ issue_number: issueNumber,
141
+ body: finalBody
142
+ };
143
+
144
+ if (severity) {
145
+ const current = await this.octokit.rest.issues.get({
146
+ owner,
147
+ repo,
148
+ issue_number: issueNumber
149
+ });
150
+
151
+ const existingLabels = current.data.labels
152
+ .map((l) => (typeof l === 'string' ? l : l.name))
153
+ .filter((l): l is string => !!l);
154
+
155
+ const severityLabels = ['dependabit', `severity:${severity}`, 'dependency-update'];
156
+
157
+ const mergedLabels = Array.from(new Set([...existingLabels, ...severityLabels]));
158
+
159
+ updateParams.labels = mergedLabels;
160
+ }
161
+
162
+ const response = await this.octokit.rest.issues.update(updateParams);
163
+
164
+ return {
165
+ number: response.data.number,
166
+ url: response.data.html_url,
167
+ labels: response.data.labels.map((l) => (typeof l === 'string' ? l : l.name || ''))
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Formats the issue body with dependency metadata
173
+ */
174
+ private formatIssueBody(body: string, dependency: { id: string; url: string }): string {
175
+ return `${body}
176
+
177
+ ---
178
+
179
+ **Dependency Information**
180
+ - ID: \`${dependency.id}\`
181
+ - URL: ${dependency.url}
182
+
183
+ *This issue was automatically created by dependabit. Add \`false-positive\` or \`true-positive\` label to provide feedback.*`;
184
+ }
185
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Rate Limit Handler
3
+ * Manages GitHub API rate limits and request budgeting
4
+ */
5
+
6
+ import { Octokit } from 'octokit';
7
+
8
+ export interface RateLimitInfo {
9
+ limit: number;
10
+ remaining: number;
11
+ reset: Date;
12
+ used: number;
13
+ warning?: string;
14
+ }
15
+
16
+ export interface RateLimitStatus {
17
+ core: RateLimitInfo & { percentageRemaining: number };
18
+ search: RateLimitInfo & { percentageRemaining: number };
19
+ graphql: RateLimitInfo & { percentageRemaining: number };
20
+ }
21
+
22
+ export interface BudgetReservation {
23
+ reserved: boolean;
24
+ reason?: string;
25
+ waitTime?: number;
26
+ }
27
+
28
+ export class RateLimitHandler {
29
+ private octokit: Octokit;
30
+ private lastCheck?: RateLimitStatus;
31
+ private lastCheckTime?: Date;
32
+
33
+ constructor(auth?: string) {
34
+ this.octokit = new Octokit({
35
+ auth: auth || process.env['GITHUB_TOKEN']
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Checks current rate limit status
41
+ */
42
+ async checkRateLimit(): Promise<RateLimitInfo> {
43
+ const response = await this.octokit.rest.rateLimit.get();
44
+ const { rate } = response.data;
45
+
46
+ const info: RateLimitInfo = {
47
+ limit: rate.limit,
48
+ remaining: rate.remaining,
49
+ reset: new Date(rate.reset * 1000),
50
+ used: rate.used
51
+ };
52
+
53
+ // Add warning if approaching limit
54
+ if (info.remaining < info.limit * 0.1) {
55
+ info.warning = `Only ${info.remaining} requests remaining. Reset at ${info.reset.toISOString()}`;
56
+ }
57
+
58
+ return info;
59
+ }
60
+
61
+ /**
62
+ * Waits if rate limited
63
+ */
64
+ async waitIfNeeded(): Promise<void> {
65
+ const rateLimit = await this.checkRateLimit();
66
+
67
+ if (rateLimit.remaining === 0) {
68
+ const waitTime = this.calculateWaitTime(rateLimit);
69
+ if (waitTime > 0) {
70
+ console.log(`Rate limited. Waiting ${Math.ceil(waitTime / 1000)} seconds until reset...`);
71
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Calculates wait time until rate limit resets
78
+ */
79
+ calculateWaitTime(rateLimitInfo: RateLimitInfo): number {
80
+ if (rateLimitInfo.remaining > 0) {
81
+ return 0;
82
+ }
83
+
84
+ const now = Date.now();
85
+ const resetTime = rateLimitInfo.reset.getTime();
86
+ const waitTime = Math.max(0, resetTime - now);
87
+
88
+ return waitTime;
89
+ }
90
+
91
+ /**
92
+ * Attempts to reserve API call budget with proactive checking
93
+ */
94
+ async reserveBudget(
95
+ callsNeeded: number,
96
+ options?: {
97
+ safetyMargin?: number; // Additional buffer (default: 10% of calls needed)
98
+ maxWaitTime?: number; // Max time to wait in ms
99
+ }
100
+ ): Promise<BudgetReservation> {
101
+ const safetyMargin = options?.safetyMargin ?? Math.ceil(callsNeeded * 0.1);
102
+ const totalNeeded = callsNeeded + safetyMargin;
103
+
104
+ const rateLimit = await this.checkRateLimit();
105
+
106
+ if (rateLimit.remaining >= totalNeeded) {
107
+ return {
108
+ reserved: true
109
+ };
110
+ }
111
+
112
+ const waitTime = this.calculateWaitTime(rateLimit);
113
+
114
+ // Check if wait time exceeds maximum allowed
115
+ if (options?.maxWaitTime && waitTime > options.maxWaitTime) {
116
+ return {
117
+ reserved: false,
118
+ reason: `Wait time (${Math.ceil(waitTime / 1000)}s) exceeds maximum (${Math.ceil(options.maxWaitTime / 1000)}s)`,
119
+ waitTime
120
+ };
121
+ }
122
+
123
+ return {
124
+ reserved: false,
125
+ reason: `Insufficient API quota. Need ${callsNeeded} + ${safetyMargin} margin, have ${rateLimit.remaining}`,
126
+ waitTime
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Proactively check if operation can proceed without hitting rate limit
132
+ */
133
+ async canProceed(
134
+ estimatedCalls: number,
135
+ options?: {
136
+ threshold?: number; // Minimum remaining calls (default: 100)
137
+ safetyMargin?: number;
138
+ }
139
+ ): Promise<{ canProceed: boolean; reason?: string }> {
140
+ const threshold = options?.threshold ?? 100;
141
+ const safetyMargin = options?.safetyMargin ?? Math.ceil(estimatedCalls * 0.1);
142
+ const totalNeeded = estimatedCalls + safetyMargin;
143
+
144
+ const rateLimit = await this.checkRateLimit();
145
+
146
+ // Check if we have enough remaining calls
147
+ if (rateLimit.remaining < totalNeeded) {
148
+ return {
149
+ canProceed: false,
150
+ reason: `Insufficient quota: need ${totalNeeded}, have ${rateLimit.remaining}`
151
+ };
152
+ }
153
+
154
+ // Check if we'd drop below threshold
155
+ if (rateLimit.remaining - totalNeeded < threshold) {
156
+ return {
157
+ canProceed: false,
158
+ reason: `Operation would leave only ${rateLimit.remaining - totalNeeded} calls (threshold: ${threshold})`
159
+ };
160
+ }
161
+
162
+ return { canProceed: true };
163
+ }
164
+
165
+ /**
166
+ * Gets detailed rate limit status for all API categories
167
+ */
168
+ async getRateLimitStatus(): Promise<RateLimitStatus> {
169
+ const response = await this.octokit.rest.rateLimit.get();
170
+ const { resources } = response.data;
171
+
172
+ const createInfo = (resource: {
173
+ limit: number;
174
+ remaining: number;
175
+ reset: number;
176
+ used: number;
177
+ }): RateLimitInfo & { percentageRemaining: number } => ({
178
+ limit: resource.limit,
179
+ remaining: resource.remaining,
180
+ reset: new Date(resource.reset * 1000),
181
+ used: resource.used,
182
+ percentageRemaining: resource.limit > 0 ? (resource.remaining / resource.limit) * 100 : 0
183
+ });
184
+
185
+ const status: RateLimitStatus = {
186
+ core: createInfo(resources.core),
187
+ search: createInfo(resources.search),
188
+ graphql: createInfo(resources.graphql || { limit: 0, remaining: 0, reset: 0, used: 0 })
189
+ };
190
+
191
+ this.lastCheck = status;
192
+ this.lastCheckTime = new Date();
193
+
194
+ return status;
195
+ }
196
+
197
+ /**
198
+ * Gets cached rate limit status (avoids API call)
199
+ */
200
+ getCachedStatus(): RateLimitStatus | undefined {
201
+ // Return cached status if less than 60 seconds old
202
+ if (this.lastCheck && this.lastCheckTime) {
203
+ const age = Date.now() - this.lastCheckTime.getTime();
204
+ if (age < 60000) {
205
+ return this.lastCheck;
206
+ }
207
+ }
208
+ return undefined;
209
+ }
210
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Release Manager
3
+ * Handles fetching and comparing GitHub releases
4
+ */
5
+
6
+ import { Octokit } from 'octokit';
7
+
8
+ export interface Release {
9
+ tagName: string;
10
+ name: string;
11
+ publishedAt: Date;
12
+ body?: string | undefined;
13
+ htmlUrl: string;
14
+ prerelease?: boolean;
15
+ draft?: boolean;
16
+ }
17
+
18
+ export interface ReleaseComparison {
19
+ newReleases: Release[];
20
+ oldReleases: Release[];
21
+ }
22
+
23
+ export class ReleaseManager {
24
+ private octokit: Octokit;
25
+
26
+ constructor(auth?: string) {
27
+ this.octokit = new Octokit({
28
+ auth: auth || process.env['GITHUB_TOKEN']
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Fetches the latest release from a repository
34
+ */
35
+ async getLatestRelease(params: { owner: string; repo: string }): Promise<Release | null> {
36
+ const { owner, repo } = params;
37
+
38
+ try {
39
+ const response = await this.octokit.rest.repos.getLatestRelease({
40
+ owner,
41
+ repo
42
+ });
43
+
44
+ return {
45
+ tagName: response.data.tag_name,
46
+ name: response.data.name || response.data.tag_name,
47
+ publishedAt: new Date(response.data.published_at || response.data.created_at),
48
+ body: response.data.body || undefined,
49
+ htmlUrl: response.data.html_url,
50
+ prerelease: response.data.prerelease,
51
+ draft: response.data.draft
52
+ };
53
+ } catch (error) {
54
+ if ((error as { status?: number }).status === 404) {
55
+ return null;
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Fetches all releases from a repository
63
+ */
64
+ async getAllReleases(params: {
65
+ owner: string;
66
+ repo: string;
67
+ page?: number;
68
+ perPage?: number;
69
+ }): Promise<Release[]> {
70
+ const { owner, repo, page = 1, perPage = 30 } = params;
71
+
72
+ try {
73
+ const response = await this.octokit.rest.repos.listReleases({
74
+ owner,
75
+ repo,
76
+ page,
77
+ per_page: perPage
78
+ });
79
+
80
+ return response.data.map((release) => ({
81
+ tagName: release.tag_name,
82
+ name: release.name || release.tag_name,
83
+ publishedAt: new Date(release.published_at || release.created_at),
84
+ body: release.body || undefined,
85
+ htmlUrl: release.html_url,
86
+ prerelease: release.prerelease,
87
+ draft: release.draft
88
+ }));
89
+ } catch (error) {
90
+ if ((error as { status?: number }).status === 404) {
91
+ return [];
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Compares two sets of releases to find new ones
99
+ */
100
+ compareReleases(oldReleases: Release[], newReleases: Release[]): ReleaseComparison {
101
+ const oldTags = new Set(oldReleases.map((r) => r.tagName));
102
+ const newTags = new Set(newReleases.map((r) => r.tagName));
103
+
104
+ // Find releases in new but not in old
105
+ const newOnes = newReleases.filter((r) => !oldTags.has(r.tagName));
106
+
107
+ // Find releases in old but not in new (removed/deleted)
108
+ const oldOnes = oldReleases.filter((r) => !newTags.has(r.tagName));
109
+
110
+ return {
111
+ newReleases: newOnes,
112
+ oldReleases: oldOnes
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Fetches release notes for a specific tag
118
+ */
119
+ async getReleaseByTag(params: {
120
+ owner: string;
121
+ repo: string;
122
+ tag: string;
123
+ }): Promise<Release | null> {
124
+ const { owner, repo, tag } = params;
125
+
126
+ try {
127
+ const response = await this.octokit.rest.repos.getReleaseByTag({
128
+ owner,
129
+ repo,
130
+ tag
131
+ });
132
+
133
+ return {
134
+ tagName: response.data.tag_name,
135
+ name: response.data.name || response.data.tag_name,
136
+ publishedAt: new Date(response.data.published_at || response.data.created_at),
137
+ body: response.data.body || undefined,
138
+ htmlUrl: response.data.html_url,
139
+ prerelease: response.data.prerelease,
140
+ draft: response.data.draft
141
+ };
142
+ } catch (error) {
143
+ if ((error as { status?: number }).status === 404) {
144
+ return null;
145
+ }
146
+ throw error;
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { BasicAuthHandler } from '../../src/auth/basic';
3
+
4
+ describe('BasicAuthHandler', () => {
5
+ beforeEach(() => {
6
+ vi.clearAllMocks();
7
+ });
8
+
9
+ describe('constructor', () => {
10
+ it('should create handler with username and password', () => {
11
+ const handler = new BasicAuthHandler('user', 'pass');
12
+ expect(handler).toBeInstanceOf(BasicAuthHandler);
13
+ });
14
+
15
+ it('should throw error for empty username', () => {
16
+ expect(() => new BasicAuthHandler('', 'pass')).toThrow('Username cannot be empty');
17
+ });
18
+
19
+ it('should throw error for empty password', () => {
20
+ expect(() => new BasicAuthHandler('user', '')).toThrow('Password cannot be empty');
21
+ });
22
+ });
23
+
24
+ describe('authenticate', () => {
25
+ it('should return auth object with credentials', async () => {
26
+ const handler = new BasicAuthHandler('testuser', 'testpass');
27
+ const auth = await handler.authenticate();
28
+
29
+ expect(auth).toEqual({
30
+ type: 'basic',
31
+ username: 'testuser',
32
+ password: 'testpass'
33
+ });
34
+ });
35
+
36
+ it('should encode credentials properly', async () => {
37
+ const handler = new BasicAuthHandler('user@example.com', 'p@ssw0rd!');
38
+ const auth = await handler.authenticate();
39
+
40
+ expect(auth.username).toBe('user@example.com');
41
+ expect(auth.password).toBe('p@ssw0rd!');
42
+ });
43
+ });
44
+
45
+ describe('getAuthHeader', () => {
46
+ it('should generate base64 encoded auth header', () => {
47
+ const handler = new BasicAuthHandler('user', 'pass');
48
+ const header = handler.getAuthHeader();
49
+
50
+ // "user:pass" in base64 is "dXNlcjpwYXNz"
51
+ expect(header).toBe('Basic dXNlcjpwYXNz');
52
+ });
53
+
54
+ it('should handle special characters in credentials', () => {
55
+ const handler = new BasicAuthHandler('user@example.com', 'p@ss:word');
56
+ const header = handler.getAuthHeader();
57
+
58
+ expect(header).toMatch(/^Basic [A-Za-z0-9+/=]+$/);
59
+ });
60
+ });
61
+
62
+ describe('validate', () => {
63
+ it('should validate credentials format', () => {
64
+ const handler = new BasicAuthHandler('user', 'pass');
65
+ expect(handler.validate()).toBe(true);
66
+ });
67
+
68
+ it('should reject username with invalid characters', () => {
69
+ const handler = new BasicAuthHandler('user\n', 'pass');
70
+ expect(handler.validate()).toBe(false);
71
+ });
72
+
73
+ it('should reject password with newline', () => {
74
+ const handler = new BasicAuthHandler('user', 'pass\n');
75
+ expect(handler.validate()).toBe(false);
76
+ });
77
+
78
+ it('should accept email as username', () => {
79
+ const handler = new BasicAuthHandler('user@example.com', 'pass');
80
+ expect(handler.validate()).toBe(true);
81
+ });
82
+ });
83
+
84
+ describe('getType', () => {
85
+ it('should return basic type', () => {
86
+ const handler = new BasicAuthHandler('user', 'pass');
87
+ expect(handler.getType()).toBe('basic');
88
+ });
89
+ });
90
+
91
+ describe('credential rotation', () => {
92
+ it('should allow password update', () => {
93
+ const handler = new BasicAuthHandler('user', 'oldpass');
94
+ handler.updateCredentials('user', 'newpass');
95
+
96
+ const header = handler.getAuthHeader();
97
+ expect(header).toContain(Buffer.from('user:newpass').toString('base64'));
98
+ });
99
+
100
+ it('should throw error on empty password update', () => {
101
+ const handler = new BasicAuthHandler('user', 'pass');
102
+ expect(() => handler.updateCredentials('user', '')).toThrow('Password cannot be empty');
103
+ });
104
+ });
105
+
106
+ describe('security', () => {
107
+ it('should not expose password in toString', () => {
108
+ const handler = new BasicAuthHandler('user', 'secretpass');
109
+ const str = handler.toString();
110
+
111
+ expect(str).not.toContain('secretpass');
112
+ expect(str).toContain('***');
113
+ });
114
+
115
+ it('should not expose password in JSON', () => {
116
+ const handler = new BasicAuthHandler('user', 'secretpass');
117
+ const json = JSON.stringify(handler);
118
+
119
+ expect(json).not.toContain('secretpass');
120
+ });
121
+ });
122
+ });