librechat-data-provider 0.8.300 → 0.8.301
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.
- package/dist/index.es.js +1 -1
- package/dist/index.es.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/react-query/index.es.js +1 -1
- package/dist/react-query/index.es.js.map +1 -1
- package/package.json +3 -3
- package/specs/api-endpoints.spec.ts +86 -0
- package/specs/bedrock.spec.ts +170 -0
- package/specs/headers-helpers.spec.ts +24 -0
- package/specs/parsers.spec.ts +14 -13
- package/specs/request-interceptor.spec.ts +299 -0
- package/src/api-endpoints.ts +19 -0
- package/src/bedrock.ts +28 -7
- package/src/config.ts +10 -2
- package/src/file-config.spec.ts +113 -0
- package/src/file-config.ts +54 -3
- package/src/headers-helpers.ts +6 -2
- package/src/index.ts +1 -1
- package/src/mcp.ts +3 -0
- package/src/parameterSettings.ts +88 -2
- package/src/parsers.ts +8 -8
- package/src/request.ts +12 -6
- package/src/schemas.ts +24 -0
- package/src/types/files.ts +1 -0
- package/src/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "librechat-data-provider",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.301",
|
|
4
4
|
"description": "data services for librechat apps",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.es.js",
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
"jest": "^30.2.0",
|
|
63
63
|
"jest-junit": "^16.0.0",
|
|
64
64
|
"openapi-types": "^12.1.3",
|
|
65
|
-
"rimraf": "^6.1.
|
|
66
|
-
"rollup": "^4.
|
|
65
|
+
"rimraf": "^6.1.3",
|
|
66
|
+
"rollup": "^4.34.9",
|
|
67
67
|
"rollup-plugin-peer-deps-external": "^2.2.4",
|
|
68
68
|
"rollup-plugin-typescript2": "^0.35.0",
|
|
69
69
|
"typescript": "^5.0.4"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { buildLoginRedirectUrl } from '../src/api-endpoints';
|
|
5
|
+
|
|
6
|
+
describe('buildLoginRedirectUrl', () => {
|
|
7
|
+
let savedLocation: Location;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
savedLocation = window.location;
|
|
11
|
+
Object.defineProperty(window, 'location', {
|
|
12
|
+
value: { pathname: '/c/abc123', search: '?model=gpt-4', hash: '#msg-5' },
|
|
13
|
+
writable: true,
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
Object.defineProperty(window, 'location', { value: savedLocation, writable: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('builds a login URL from explicit args', () => {
|
|
22
|
+
const result = buildLoginRedirectUrl('/c/new', '?q=hello', '');
|
|
23
|
+
expect(result).toBe('/login?redirect_to=%2Fc%2Fnew%3Fq%3Dhello');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('encodes complex paths with query and hash', () => {
|
|
27
|
+
const result = buildLoginRedirectUrl('/c/new', '?q=hello&submit=true', '#section');
|
|
28
|
+
expect(result).toContain('redirect_to=');
|
|
29
|
+
const encoded = result.split('redirect_to=')[1];
|
|
30
|
+
expect(decodeURIComponent(encoded)).toBe('/c/new?q=hello&submit=true#section');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('falls back to window.location when no args provided', () => {
|
|
34
|
+
const result = buildLoginRedirectUrl();
|
|
35
|
+
const encoded = result.split('redirect_to=')[1];
|
|
36
|
+
expect(decodeURIComponent(encoded)).toBe('/c/abc123?model=gpt-4#msg-5');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('falls back to "/" when all location parts are empty', () => {
|
|
40
|
+
Object.defineProperty(window, 'location', {
|
|
41
|
+
value: { pathname: '', search: '', hash: '' },
|
|
42
|
+
writable: true,
|
|
43
|
+
});
|
|
44
|
+
const result = buildLoginRedirectUrl();
|
|
45
|
+
expect(result).toBe('/login?redirect_to=%2F');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns plain /login when pathname is /login (prevents recursive redirect)', () => {
|
|
49
|
+
const result = buildLoginRedirectUrl('/login', '?redirect_to=%2Fc%2Fnew', '');
|
|
50
|
+
expect(result).toBe('/login');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns plain /login when window.location is already /login', () => {
|
|
54
|
+
Object.defineProperty(window, 'location', {
|
|
55
|
+
value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' },
|
|
56
|
+
writable: true,
|
|
57
|
+
});
|
|
58
|
+
const result = buildLoginRedirectUrl();
|
|
59
|
+
expect(result).toBe('/login');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns plain /login for /login sub-paths', () => {
|
|
63
|
+
const result = buildLoginRedirectUrl('/login/2fa', '', '');
|
|
64
|
+
expect(result).toBe('/login');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns plain /login for basename-prefixed /login (e.g. /librechat/login)', () => {
|
|
68
|
+
Object.defineProperty(window, 'location', {
|
|
69
|
+
value: { pathname: '/librechat/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' },
|
|
70
|
+
writable: true,
|
|
71
|
+
});
|
|
72
|
+
const result = buildLoginRedirectUrl();
|
|
73
|
+
expect(result).toBe('/login');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns plain /login for basename-prefixed /login sub-paths', () => {
|
|
77
|
+
const result = buildLoginRedirectUrl('/librechat/login/2fa', '', '');
|
|
78
|
+
expect(result).toBe('/login');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does NOT match paths where "login" is a substring of a segment', () => {
|
|
82
|
+
const result = buildLoginRedirectUrl('/c/loginhistory', '', '');
|
|
83
|
+
expect(result).toContain('redirect_to=');
|
|
84
|
+
expect(decodeURIComponent(result.split('redirect_to=')[1])).toBe('/c/loginhistory');
|
|
85
|
+
});
|
|
86
|
+
});
|
package/specs/bedrock.spec.ts
CHANGED
|
@@ -688,5 +688,175 @@ describe('bedrockInputParser', () => {
|
|
|
688
688
|
expect(amrf.anthropic_beta).toBeDefined();
|
|
689
689
|
expect(Array.isArray(amrf.anthropic_beta)).toBe(true);
|
|
690
690
|
});
|
|
691
|
+
|
|
692
|
+
test('should strip stale reasoning_config when switching to Anthropic model', () => {
|
|
693
|
+
const staleConversationData = {
|
|
694
|
+
model: 'anthropic.claude-sonnet-4-6',
|
|
695
|
+
additionalModelRequestFields: {
|
|
696
|
+
reasoning_config: 'high',
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
const result = bedrockInputParser.parse(staleConversationData) as Record<string, unknown>;
|
|
700
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown>;
|
|
701
|
+
expect(amrf.reasoning_config).toBeUndefined();
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test('should strip stale reasoning_config when switching from Moonshot to Meta model', () => {
|
|
705
|
+
const staleData = {
|
|
706
|
+
model: 'meta.llama-3-1-70b',
|
|
707
|
+
additionalModelRequestFields: {
|
|
708
|
+
reasoning_config: 'high',
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
const result = bedrockInputParser.parse(staleData) as Record<string, unknown>;
|
|
712
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
|
|
713
|
+
expect(amrf?.reasoning_config).toBeUndefined();
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test('should strip stale reasoning_config when switching from ZAI to DeepSeek model', () => {
|
|
717
|
+
const staleData = {
|
|
718
|
+
model: 'deepseek.deepseek-r1',
|
|
719
|
+
additionalModelRequestFields: {
|
|
720
|
+
reasoning_config: 'medium',
|
|
721
|
+
},
|
|
722
|
+
};
|
|
723
|
+
const result = bedrockInputParser.parse(staleData) as Record<string, unknown>;
|
|
724
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
|
|
725
|
+
expect(amrf?.reasoning_config).toBeUndefined();
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
describe('Bedrock reasoning_effort → reasoning_config for Moonshot/ZAI models', () => {
|
|
730
|
+
test('should map reasoning_effort to reasoning_config for moonshotai.kimi-k2.5', () => {
|
|
731
|
+
const input = {
|
|
732
|
+
model: 'moonshotai.kimi-k2.5',
|
|
733
|
+
reasoning_effort: 'high',
|
|
734
|
+
};
|
|
735
|
+
const result = bedrockInputParser.parse(input) as Record<string, unknown>;
|
|
736
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown>;
|
|
737
|
+
expect(amrf.reasoning_config).toBe('high');
|
|
738
|
+
expect(amrf.reasoning_effort).toBeUndefined();
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test('should map reasoning_effort to reasoning_config for moonshot.kimi-k2.5', () => {
|
|
742
|
+
const input = {
|
|
743
|
+
model: 'moonshot.kimi-k2.5',
|
|
744
|
+
reasoning_effort: 'medium',
|
|
745
|
+
};
|
|
746
|
+
const result = bedrockInputParser.parse(input) as Record<string, unknown>;
|
|
747
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown>;
|
|
748
|
+
expect(amrf.reasoning_config).toBe('medium');
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test('should map reasoning_effort to reasoning_config for zai.glm-4.7', () => {
|
|
752
|
+
const input = {
|
|
753
|
+
model: 'zai.glm-4.7',
|
|
754
|
+
reasoning_effort: 'high',
|
|
755
|
+
};
|
|
756
|
+
const result = bedrockInputParser.parse(input) as Record<string, unknown>;
|
|
757
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown>;
|
|
758
|
+
expect(amrf.reasoning_config).toBe('high');
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test('should map reasoning_effort "low" to reasoning_config for Moonshot model', () => {
|
|
762
|
+
const input = {
|
|
763
|
+
model: 'moonshotai.kimi-k2.5',
|
|
764
|
+
reasoning_effort: 'low',
|
|
765
|
+
};
|
|
766
|
+
const result = bedrockInputParser.parse(input) as Record<string, unknown>;
|
|
767
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown>;
|
|
768
|
+
expect(amrf.reasoning_config).toBe('low');
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test('should not include reasoning_config when reasoning_effort is unset (empty string)', () => {
|
|
772
|
+
const input = {
|
|
773
|
+
model: 'moonshotai.kimi-k2.5',
|
|
774
|
+
reasoning_effort: '',
|
|
775
|
+
};
|
|
776
|
+
const result = bedrockInputParser.parse(input) as Record<string, unknown>;
|
|
777
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
|
|
778
|
+
expect(amrf?.reasoning_config).toBeUndefined();
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test('should not include reasoning_config when reasoning_effort is not provided', () => {
|
|
782
|
+
const input = {
|
|
783
|
+
model: 'moonshotai.kimi-k2.5',
|
|
784
|
+
};
|
|
785
|
+
const result = bedrockInputParser.parse(input) as Record<string, unknown>;
|
|
786
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
|
|
787
|
+
expect(amrf?.reasoning_config).toBeUndefined();
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
test('should not forward reasoning_effort "none" to reasoning_config', () => {
|
|
791
|
+
const result = bedrockInputParser.parse({
|
|
792
|
+
model: 'moonshotai.kimi-k2.5',
|
|
793
|
+
reasoning_effort: 'none',
|
|
794
|
+
}) as Record<string, unknown>;
|
|
795
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
|
|
796
|
+
expect(amrf?.reasoning_config).toBeUndefined();
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test('should not forward reasoning_effort "minimal" to reasoning_config', () => {
|
|
800
|
+
const result = bedrockInputParser.parse({
|
|
801
|
+
model: 'moonshotai.kimi-k2.5',
|
|
802
|
+
reasoning_effort: 'minimal',
|
|
803
|
+
}) as Record<string, unknown>;
|
|
804
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
|
|
805
|
+
expect(amrf?.reasoning_config).toBeUndefined();
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test('should not forward reasoning_effort "xhigh" to reasoning_config', () => {
|
|
809
|
+
const result = bedrockInputParser.parse({
|
|
810
|
+
model: 'zai.glm-4.7',
|
|
811
|
+
reasoning_effort: 'xhigh',
|
|
812
|
+
}) as Record<string, unknown>;
|
|
813
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown> | undefined;
|
|
814
|
+
expect(amrf?.reasoning_config).toBeUndefined();
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test('should not add reasoning_config to Anthropic models', () => {
|
|
818
|
+
const input = {
|
|
819
|
+
model: 'anthropic.claude-sonnet-4-6',
|
|
820
|
+
reasoning_effort: 'high',
|
|
821
|
+
};
|
|
822
|
+
const result = bedrockInputParser.parse(input) as Record<string, unknown>;
|
|
823
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown>;
|
|
824
|
+
expect(amrf.reasoning_config).toBeUndefined();
|
|
825
|
+
expect(amrf.reasoning_effort).toBeUndefined();
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test('should not add thinking or anthropic_beta to Moonshot models with reasoning_effort', () => {
|
|
829
|
+
const input = {
|
|
830
|
+
model: 'moonshotai.kimi-k2.5',
|
|
831
|
+
reasoning_effort: 'high',
|
|
832
|
+
};
|
|
833
|
+
const result = bedrockInputParser.parse(input) as Record<string, unknown>;
|
|
834
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown>;
|
|
835
|
+
expect(amrf.thinking).toBeUndefined();
|
|
836
|
+
expect(amrf.thinkingBudget).toBeUndefined();
|
|
837
|
+
expect(amrf.anthropic_beta).toBeUndefined();
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test('should pass reasoning_config through bedrockOutputParser', () => {
|
|
841
|
+
const parsed = bedrockInputParser.parse({
|
|
842
|
+
model: 'moonshotai.kimi-k2.5',
|
|
843
|
+
reasoning_effort: 'high',
|
|
844
|
+
}) as Record<string, unknown>;
|
|
845
|
+
const output = bedrockOutputParser(parsed);
|
|
846
|
+
const amrf = output.additionalModelRequestFields as Record<string, unknown>;
|
|
847
|
+
expect(amrf.reasoning_config).toBe('high');
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
test('should strip stale reasoning_config from additionalModelRequestFields for Anthropic models', () => {
|
|
851
|
+
const staleData = {
|
|
852
|
+
model: 'anthropic.claude-opus-4-6-v1',
|
|
853
|
+
additionalModelRequestFields: {
|
|
854
|
+
reasoning_config: 'high',
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
const result = bedrockInputParser.parse(staleData) as Record<string, unknown>;
|
|
858
|
+
const amrf = result.additionalModelRequestFields as Record<string, unknown>;
|
|
859
|
+
expect(amrf.reasoning_config).toBeUndefined();
|
|
860
|
+
});
|
|
691
861
|
});
|
|
692
862
|
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { setTokenHeader } from '../src/headers-helpers';
|
|
3
|
+
|
|
4
|
+
describe('setTokenHeader', () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
delete axios.defaults.headers.common['Authorization'];
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('sets the Authorization header with a Bearer token', () => {
|
|
10
|
+
setTokenHeader('my-token');
|
|
11
|
+
expect(axios.defaults.headers.common['Authorization']).toBe('Bearer my-token');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('deletes the Authorization header when called with undefined', () => {
|
|
15
|
+
axios.defaults.headers.common['Authorization'] = 'Bearer old-token';
|
|
16
|
+
setTokenHeader(undefined);
|
|
17
|
+
expect(axios.defaults.headers.common['Authorization']).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('is a no-op when clearing an already absent header', () => {
|
|
21
|
+
setTokenHeader(undefined);
|
|
22
|
+
expect(axios.defaults.headers.common['Authorization']).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
});
|
package/specs/parsers.spec.ts
CHANGED
|
@@ -7,22 +7,24 @@ import type { TUser, TConversation } from '../src/types';
|
|
|
7
7
|
|
|
8
8
|
// Mock dayjs module with consistent date/time values regardless of environment
|
|
9
9
|
jest.mock('dayjs', () => {
|
|
10
|
-
// Create a mock implementation that returns fixed values
|
|
11
10
|
const mockDayjs = () => ({
|
|
12
11
|
format: (format: string) => {
|
|
13
12
|
if (format === 'YYYY-MM-DD') {
|
|
14
13
|
return '2024-04-29';
|
|
15
14
|
}
|
|
16
|
-
if (format === 'YYYY-MM-DD HH:mm:ss') {
|
|
17
|
-
return '2024-04-29 12:34:56';
|
|
15
|
+
if (format === 'YYYY-MM-DD HH:mm:ss Z') {
|
|
16
|
+
return '2024-04-29 12:34:56 -04:00';
|
|
18
17
|
}
|
|
19
|
-
|
|
18
|
+
if (format === 'dddd') {
|
|
19
|
+
return 'Monday';
|
|
20
|
+
}
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Unhandled dayjs().format() call in mock: "${format}". Update the mock in parsers.spec.ts`,
|
|
23
|
+
);
|
|
20
24
|
},
|
|
21
|
-
day: () => 1, // 1 = Monday
|
|
22
25
|
toISOString: () => '2024-04-29T16:34:56.000Z',
|
|
23
26
|
});
|
|
24
27
|
|
|
25
|
-
// Add any static methods needed
|
|
26
28
|
mockDayjs.extend = jest.fn();
|
|
27
29
|
|
|
28
30
|
return mockDayjs;
|
|
@@ -47,13 +49,12 @@ describe('replaceSpecialVars', () => {
|
|
|
47
49
|
|
|
48
50
|
test('should replace {{current_date}} with the current date', () => {
|
|
49
51
|
const result = replaceSpecialVars({ text: 'Today is {{current_date}}' });
|
|
50
|
-
|
|
51
|
-
expect(result).toBe('Today is 2024-04-29 (1)');
|
|
52
|
+
expect(result).toBe('Today is 2024-04-29 (Monday)');
|
|
52
53
|
});
|
|
53
54
|
|
|
54
55
|
test('should replace {{current_datetime}} with the current datetime', () => {
|
|
55
56
|
const result = replaceSpecialVars({ text: 'Now is {{current_datetime}}' });
|
|
56
|
-
expect(result).toBe('Now is 2024-04-29 12:34:56 (
|
|
57
|
+
expect(result).toBe('Now is 2024-04-29 12:34:56 -04:00 (Monday)');
|
|
57
58
|
});
|
|
58
59
|
|
|
59
60
|
test('should replace {{iso_datetime}} with the ISO datetime', () => {
|
|
@@ -90,7 +91,7 @@ describe('replaceSpecialVars', () => {
|
|
|
90
91
|
user: mockUser,
|
|
91
92
|
});
|
|
92
93
|
expect(result).toBe(
|
|
93
|
-
'Hello Test User! Today is 2024-04-29 (
|
|
94
|
+
'Hello Test User! Today is 2024-04-29 (Monday) and the time is 2024-04-29 12:34:56 -04:00 (Monday). ISO: 2024-04-29T16:34:56.000Z',
|
|
94
95
|
);
|
|
95
96
|
});
|
|
96
97
|
|
|
@@ -99,7 +100,7 @@ describe('replaceSpecialVars', () => {
|
|
|
99
100
|
text: 'Date: {{CURRENT_DATE}}, User: {{Current_User}}',
|
|
100
101
|
user: mockUser,
|
|
101
102
|
});
|
|
102
|
-
expect(result).toBe('Date: 2024-04-29 (
|
|
103
|
+
expect(result).toBe('Date: 2024-04-29 (Monday), User: Test User');
|
|
103
104
|
});
|
|
104
105
|
|
|
105
106
|
test('should confirm all specialVariables from config.ts get parsed', () => {
|
|
@@ -120,8 +121,8 @@ describe('replaceSpecialVars', () => {
|
|
|
120
121
|
});
|
|
121
122
|
|
|
122
123
|
// Verify the expected replacements
|
|
123
|
-
expect(result).toContain('2024-04-29 (
|
|
124
|
-
expect(result).toContain('2024-04-29 12:34:56 (
|
|
124
|
+
expect(result).toContain('2024-04-29 (Monday)'); // current_date
|
|
125
|
+
expect(result).toContain('2024-04-29 12:34:56 -04:00 (Monday)'); // current_datetime
|
|
125
126
|
expect(result).toContain('2024-04-29T16:34:56.000Z'); // iso_datetime
|
|
126
127
|
expect(result).toContain('Test User'); // current_user
|
|
127
128
|
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import axios from 'axios';
|
|
5
|
+
import { setTokenHeader } from '../src/headers-helpers';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The response interceptor in request.ts registers at import time when
|
|
9
|
+
* `typeof window !== 'undefined'` (jsdom provides window).
|
|
10
|
+
*
|
|
11
|
+
* We use axios's built-in request adapter mock to avoid real HTTP calls,
|
|
12
|
+
* and verify the interceptor's behavior by observing whether a 401 triggers
|
|
13
|
+
* a refresh POST or is immediately rejected.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const mockAdapter = jest.fn();
|
|
17
|
+
let originalAdapter: typeof axios.defaults.adapter;
|
|
18
|
+
let savedLocation: Location;
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
originalAdapter = axios.defaults.adapter;
|
|
22
|
+
axios.defaults.adapter = mockAdapter;
|
|
23
|
+
|
|
24
|
+
await import('../src/request');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
mockAdapter.mockReset();
|
|
29
|
+
savedLocation = window.location;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(() => {
|
|
33
|
+
axios.defaults.adapter = originalAdapter;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
delete axios.defaults.headers.common['Authorization'];
|
|
38
|
+
Object.defineProperty(window, 'location', {
|
|
39
|
+
value: savedLocation,
|
|
40
|
+
writable: true,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function setWindowLocation(overrides: Partial<Location>) {
|
|
45
|
+
Object.defineProperty(window, 'location', {
|
|
46
|
+
value: { ...window.location, ...overrides },
|
|
47
|
+
writable: true,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('axios 401 interceptor — Authorization header guard', () => {
|
|
52
|
+
it('skips refresh and rejects when Authorization header is cleared', async () => {
|
|
53
|
+
expect.assertions(1);
|
|
54
|
+
setTokenHeader(undefined);
|
|
55
|
+
|
|
56
|
+
mockAdapter.mockRejectedValueOnce({
|
|
57
|
+
response: { status: 401 },
|
|
58
|
+
config: { url: '/api/messages', headers: {} },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await axios.get('/api/messages');
|
|
63
|
+
} catch {
|
|
64
|
+
// expected rejection
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(mockAdapter).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('attempts refresh on shared link page even without Authorization header', async () => {
|
|
71
|
+
expect.assertions(2);
|
|
72
|
+
setTokenHeader(undefined);
|
|
73
|
+
|
|
74
|
+
setWindowLocation({
|
|
75
|
+
href: 'http://localhost/share/abc123',
|
|
76
|
+
pathname: '/share/abc123',
|
|
77
|
+
search: '',
|
|
78
|
+
hash: '',
|
|
79
|
+
} as Partial<Location>);
|
|
80
|
+
|
|
81
|
+
mockAdapter.mockRejectedValueOnce({
|
|
82
|
+
response: { status: 401 },
|
|
83
|
+
config: { url: '/api/share/abc123', headers: {} },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
mockAdapter.mockResolvedValueOnce({
|
|
87
|
+
data: { token: 'new-token' },
|
|
88
|
+
status: 200,
|
|
89
|
+
headers: {},
|
|
90
|
+
config: {},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
mockAdapter.mockResolvedValueOnce({
|
|
94
|
+
data: { sharedLink: {} },
|
|
95
|
+
status: 200,
|
|
96
|
+
headers: {},
|
|
97
|
+
config: {},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await axios.get('/api/share/abc123');
|
|
102
|
+
} catch {
|
|
103
|
+
// may reject depending on exact flow
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
expect(mockAdapter.mock.calls.length).toBe(3);
|
|
107
|
+
|
|
108
|
+
const refreshCall = mockAdapter.mock.calls[1];
|
|
109
|
+
expect(refreshCall[0].url).toContain('api/auth/refresh');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not bypass guard when share/ appears only in query params', async () => {
|
|
113
|
+
expect.assertions(1);
|
|
114
|
+
setTokenHeader(undefined);
|
|
115
|
+
|
|
116
|
+
setWindowLocation({
|
|
117
|
+
href: 'http://localhost/c/chat?ref=share/token',
|
|
118
|
+
pathname: '/c/chat',
|
|
119
|
+
search: '?ref=share/token',
|
|
120
|
+
hash: '',
|
|
121
|
+
} as Partial<Location>);
|
|
122
|
+
|
|
123
|
+
mockAdapter.mockRejectedValueOnce({
|
|
124
|
+
response: { status: 401 },
|
|
125
|
+
config: { url: '/api/messages', headers: {} },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await axios.get('/api/messages');
|
|
130
|
+
} catch {
|
|
131
|
+
// expected rejection
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
expect(mockAdapter).toHaveBeenCalledTimes(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('redirects to login with redirect_to when unauthenticated on share page and refresh fails', async () => {
|
|
138
|
+
expect.assertions(1);
|
|
139
|
+
setTokenHeader(undefined);
|
|
140
|
+
|
|
141
|
+
setWindowLocation({
|
|
142
|
+
href: 'http://localhost/share/abc123',
|
|
143
|
+
pathname: '/share/abc123',
|
|
144
|
+
search: '',
|
|
145
|
+
hash: '',
|
|
146
|
+
} as Partial<Location>);
|
|
147
|
+
|
|
148
|
+
mockAdapter.mockRejectedValueOnce({
|
|
149
|
+
response: { status: 401 },
|
|
150
|
+
config: { url: '/api/share/abc123', headers: {} },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
mockAdapter.mockResolvedValueOnce({
|
|
154
|
+
data: { token: '' },
|
|
155
|
+
status: 200,
|
|
156
|
+
headers: {},
|
|
157
|
+
config: {},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await axios.get('/api/share/abc123');
|
|
162
|
+
} catch {
|
|
163
|
+
// expected rejection
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
expect(window.location.href).toBe('/login?redirect_to=%2Fshare%2Fabc123');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('redirects to login with redirect_to when authenticated and refresh returns no token on share page', async () => {
|
|
170
|
+
expect.assertions(1);
|
|
171
|
+
setTokenHeader('some-token');
|
|
172
|
+
|
|
173
|
+
setWindowLocation({
|
|
174
|
+
href: 'http://localhost/share/abc123',
|
|
175
|
+
pathname: '/share/abc123',
|
|
176
|
+
search: '',
|
|
177
|
+
hash: '',
|
|
178
|
+
} as Partial<Location>);
|
|
179
|
+
|
|
180
|
+
mockAdapter.mockRejectedValueOnce({
|
|
181
|
+
response: { status: 401 },
|
|
182
|
+
config: { url: '/api/share/abc123', headers: {} },
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
mockAdapter.mockResolvedValueOnce({
|
|
186
|
+
data: { token: '' },
|
|
187
|
+
status: 200,
|
|
188
|
+
headers: {},
|
|
189
|
+
config: {},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
await axios.get('/api/share/abc123');
|
|
194
|
+
} catch {
|
|
195
|
+
// expected rejection
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
expect(window.location.href).toBe('/login?redirect_to=%2Fshare%2Fabc123');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('redirects to login with redirect_to when refresh returns no token on regular page', async () => {
|
|
202
|
+
expect.assertions(1);
|
|
203
|
+
setTokenHeader('some-token');
|
|
204
|
+
|
|
205
|
+
setWindowLocation({
|
|
206
|
+
href: 'http://localhost/c/some-conversation',
|
|
207
|
+
pathname: '/c/some-conversation',
|
|
208
|
+
search: '',
|
|
209
|
+
hash: '',
|
|
210
|
+
} as Partial<Location>);
|
|
211
|
+
|
|
212
|
+
mockAdapter.mockRejectedValueOnce({
|
|
213
|
+
response: { status: 401 },
|
|
214
|
+
config: { url: '/api/messages', headers: {} },
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
mockAdapter.mockResolvedValueOnce({
|
|
218
|
+
data: { token: '' },
|
|
219
|
+
status: 200,
|
|
220
|
+
headers: {},
|
|
221
|
+
config: {},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
await axios.get('/api/messages');
|
|
226
|
+
} catch {
|
|
227
|
+
// expected rejection
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
expect(window.location.href).toBe('/login?redirect_to=%2Fc%2Fsome-conversation');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('redirects to plain /login without redirect_to when already on a login path', async () => {
|
|
234
|
+
expect.assertions(1);
|
|
235
|
+
setTokenHeader('some-token');
|
|
236
|
+
|
|
237
|
+
setWindowLocation({
|
|
238
|
+
href: 'http://localhost/login/2fa',
|
|
239
|
+
pathname: '/login/2fa',
|
|
240
|
+
search: '',
|
|
241
|
+
hash: '',
|
|
242
|
+
} as Partial<Location>);
|
|
243
|
+
|
|
244
|
+
mockAdapter.mockRejectedValueOnce({
|
|
245
|
+
response: { status: 401 },
|
|
246
|
+
config: { url: '/api/messages', headers: {} },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
mockAdapter.mockResolvedValueOnce({
|
|
250
|
+
data: { token: '' },
|
|
251
|
+
status: 200,
|
|
252
|
+
headers: {},
|
|
253
|
+
config: {},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await axios.get('/api/messages');
|
|
258
|
+
} catch {
|
|
259
|
+
// expected rejection
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
expect(window.location.href).toBe('/login');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('attempts refresh when Authorization header is present', async () => {
|
|
266
|
+
expect.assertions(2);
|
|
267
|
+
setTokenHeader('valid-token');
|
|
268
|
+
|
|
269
|
+
mockAdapter.mockRejectedValueOnce({
|
|
270
|
+
response: { status: 401 },
|
|
271
|
+
config: { url: '/api/messages', headers: {}, _retry: false },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
mockAdapter.mockResolvedValueOnce({
|
|
275
|
+
data: { token: 'new-token' },
|
|
276
|
+
status: 200,
|
|
277
|
+
headers: {},
|
|
278
|
+
config: {},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
mockAdapter.mockResolvedValueOnce({
|
|
282
|
+
data: { messages: [] },
|
|
283
|
+
status: 200,
|
|
284
|
+
headers: {},
|
|
285
|
+
config: {},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
await axios.get('/api/messages');
|
|
290
|
+
} catch {
|
|
291
|
+
// may reject depending on exact flow
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
expect(mockAdapter.mock.calls.length).toBe(3);
|
|
295
|
+
|
|
296
|
+
const refreshCall = mockAdapter.mock.calls[1];
|
|
297
|
+
expect(refreshCall[0].url).toContain('api/auth/refresh');
|
|
298
|
+
});
|
|
299
|
+
});
|