warmpath 0.3.2__tar.gz → 0.3.4__tar.gz
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.
- {warmpath-0.3.2 → warmpath-0.3.4}/PKG-INFO +2 -1
- warmpath-0.3.4/README.md +88 -0
- {warmpath-0.3.2 → warmpath-0.3.4}/docs/skill.md +1 -1
- {warmpath-0.3.2 → warmpath-0.3.4}/pyproject.toml +2 -1
- {warmpath-0.3.2 → warmpath-0.3.4}/tests/test_auth.py +130 -39
- warmpath-0.3.4/tests/test_auth_http.py +405 -0
- warmpath-0.3.4/tests/test_browser_http.py +163 -0
- {warmpath-0.3.2 → warmpath-0.3.4}/tests/test_cli_commands.py +1 -0
- warmpath-0.3.4/tests/test_companies.py +323 -0
- {warmpath-0.3.2 → warmpath-0.3.4}/uv.lock +155 -1
- {warmpath-0.3.2 → warmpath-0.3.4}/warmpath/auth.py +78 -25
- warmpath-0.3.4/warmpath/auth_http.py +182 -0
- warmpath-0.3.4/warmpath/browser_http.py +122 -0
- {warmpath-0.3.2 → warmpath-0.3.4}/warmpath/cli.py +156 -21
- warmpath-0.3.4/warmpath/companies.py +305 -0
- warmpath-0.3.2/README.md +0 -55
- {warmpath-0.3.2 → warmpath-0.3.4}/.gitignore +0 -0
- {warmpath-0.3.2 → warmpath-0.3.4}/AGENTS.md +0 -0
- {warmpath-0.3.2 → warmpath-0.3.4}/Taskfile.yaml +0 -0
- {warmpath-0.3.2 → warmpath-0.3.4}/UNLICENSE +0 -0
- {warmpath-0.3.2 → warmpath-0.3.4}/scripts/release.sh +0 -0
- {warmpath-0.3.2 → warmpath-0.3.4}/tests/test_mutual_connections.py +0 -0
- {warmpath-0.3.2 → warmpath-0.3.4}/warmpath/__init__.py +0 -0
- {warmpath-0.3.2 → warmpath-0.3.4}/warmpath.jpg +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: warmpath
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Local LinkedIn visible-connections scraper.
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: browser-cookie3>=0.20.1
|
|
7
|
+
Requires-Dist: curl-cffi>=0.16.3
|
|
7
8
|
Requires-Dist: open-linkedin-api>=2.3.1
|
|
8
9
|
Requires-Dist: platformdirs>=4.11.8
|
warmpath-0.3.4/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# Warmpath
|
|
4
|
+
|
|
5
|
+
Find LinkedIn mutuals and warm paths using your logged-in browser session.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Log in to LinkedIn in a regular browser window, then import that session. Replace `chrome` with your browser's name if needed.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
# Import and check your browser session
|
|
13
|
+
uvx warmpath auth import --browser chrome
|
|
14
|
+
uvx warmpath auth status
|
|
15
|
+
|
|
16
|
+
# Find people who can introduce you to a company
|
|
17
|
+
uvx warmpath company HashiCorp
|
|
18
|
+
uvx warmpath company https://www.linkedin.com/company/hashicorp/ --max-degree 2 --limit 5
|
|
19
|
+
|
|
20
|
+
# List the current employers of all your direct connections
|
|
21
|
+
uvx warmpath companies
|
|
22
|
+
uvx warmpath companies --urls
|
|
23
|
+
uvx warmpath companies --refresh-cache
|
|
24
|
+
|
|
25
|
+
# Find reachable people with a skill
|
|
26
|
+
uvx warmpath skill Flutter
|
|
27
|
+
uvx warmpath skill Leadership --max-depth 2
|
|
28
|
+
|
|
29
|
+
# Find mutual connections with a person
|
|
30
|
+
uvx warmpath human https://www.linkedin.com/in/mitchellh/
|
|
31
|
+
|
|
32
|
+
# Refresh cached results
|
|
33
|
+
uvx warmpath human https://www.linkedin.com/in/mitchellh/ --refresh-cache
|
|
34
|
+
|
|
35
|
+
# Show options for a command; also works with companies, auth, skill, and human
|
|
36
|
+
uvx warmpath company --help
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`companies` walks the complete 1st-degree connections list, looks up current
|
|
40
|
+
employers, and deduplicates by company ID. It includes multiple current employers
|
|
41
|
+
and keeps unlinked employer names; `--urls` prints only known LinkedIn company
|
|
42
|
+
URLs. It does not infer employment from profile headlines.
|
|
43
|
+
|
|
44
|
+
Connections are requested in pages of 1,000. Profiles are looked up once per
|
|
45
|
+
unique connection when employer data is absent from the connection row. Company
|
|
46
|
+
names and URLs come from those responses, with no separate company lookups.
|
|
47
|
+
Completed lookups are cached so interrupted runs can resume, and repeating a
|
|
48
|
+
completed snapshot makes no HTTP calls. Use `--refresh-cache` for updated
|
|
49
|
+
connections and employment. Caches are scoped to the imported session.
|
|
50
|
+
|
|
51
|
+
If LinkedIn restricts a profile or truncates its employment data, the command
|
|
52
|
+
prints the known companies, reports the incomplete result on stderr, and exits
|
|
53
|
+
with status 1. Cached snapshots preserve that status until refreshed.
|
|
54
|
+
|
|
55
|
+
## Browser sessions
|
|
56
|
+
|
|
57
|
+
Supported browsers are Chrome, Chromium, Firefox, Edge, Brave, Safari, Arc, Vivaldi, Opera, Opera GX (`opera-gx`), and LibreWolf; availability depends on your operating system. Your OS may ask for keychain/keyring access. If the cookie store is locked, close the browser and retry. Private-window sessions cannot be imported.
|
|
58
|
+
|
|
59
|
+
Warmpath imports LinkedIn's `li_at` and `JSESSIONID` tokens together with its device, security, and routing cookies, and manages the saved session automatically. `company`, `companies`, `skill`, and `human` use it on subsequent runs. Failed imports preserve the previous session.
|
|
60
|
+
|
|
61
|
+
`auth import` and `auth status` print only `Logged in as <name>` after confirming your session with LinkedIn. If `auth status` cannot confirm a login, it prints `Not logged in` and exits with status 1. Both commands require a connection to LinkedIn.
|
|
62
|
+
|
|
63
|
+
Successful requests preserve updated cookies returned by LinkedIn. When a saved session expires or is rejected, Warmpath reads the current session from the previously imported browser and retries a read once. Healthy requests need no extra auth checks or browser access. If LinkedIn has also signed out the browser, sign in there again and retry; cookie expiry dates alone cannot indicate whether LinkedIn has revoked a session. Rate limits and server errors are reported rather than treated as empty search results.
|
|
64
|
+
|
|
65
|
+
LinkedIn requests use [curl_cffi](https://curl-cffi.readthedocs.io/en/latest/impersonate/_index.html) to match the browser family's TLS and HTTP/2 behavior, rather than just changing the user-agent. Chrome, Chromium, and Edge imports use the installed browser version for the user-agent and client hints, with the closest supported transport preset. Requests are spaced at least half a second apart within a client; this does not add HTTP calls. The CLI reads the browser's cookie store without writing to it. Because the copied cookies share the browser's LinkedIn session, server-side revocation can still affect both.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
Browser cookie import uses [browser-cookie3](https://github.com/borisbabic/browser_cookie3).
|
|
70
|
+
|
|
71
|
+
To test changes in this checkout, use `uv run warmpath` instead of `uvx warmpath`,
|
|
72
|
+
which runs the published package. For example:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
uv run warmpath auth import --browser chrome
|
|
76
|
+
uv run warmpath auth status
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
[go-task](https://taskfile.dev/) runs the repository checks. Install it with Homebrew on macOS, or follow the [installation guide](https://taskfile.dev/docs/installation) for other platforms. Then verify the tool, install the Python development dependencies, and run the checks:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
# macOS
|
|
83
|
+
brew install go-task
|
|
84
|
+
|
|
85
|
+
task --version
|
|
86
|
+
uv sync
|
|
87
|
+
task check
|
|
88
|
+
```
|
|
@@ -18,7 +18,7 @@ For the `skill` subcommand, the call path is:
|
|
|
18
18
|
4. `find_skill_connections(...)`
|
|
19
19
|
5. `render_skill_connections_result(result)`
|
|
20
20
|
|
|
21
|
-
`run_skill_command` builds a LinkedIn API client from the session saved by `warmpath auth import --browser <browser>`, resolves the cache directory, calls the skill search pipeline, and prints the rendered result. Use `warmpath auth status` to check
|
|
21
|
+
`run_skill_command` builds a LinkedIn API client from the session saved by `warmpath auth import --browser <browser>`, resolves the cache directory, calls the skill search pipeline, and prints the rendered result. Use `warmpath auth status` to check whether you are logged in and as whom.
|
|
22
22
|
|
|
23
23
|
## Defaults
|
|
24
24
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "warmpath"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.4"
|
|
4
4
|
description = "Local LinkedIn visible-connections scraper."
|
|
5
5
|
requires-python = ">=3.10"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"browser-cookie3>=0.20.1",
|
|
8
|
+
"curl-cffi>=0.16.3",
|
|
8
9
|
"open-linkedin-api>=2.3.1",
|
|
9
10
|
"platformdirs>=4.11.8",
|
|
10
11
|
]
|
|
@@ -148,42 +148,41 @@ def test_imported_session_reaches_linkedin_with_csrf_and_cookie_attributes(reade
|
|
|
148
148
|
assert "Cookie" not in session.prepare_request(Request("GET", "http://www.linkedin.com/")).headers
|
|
149
149
|
|
|
150
150
|
|
|
151
|
-
def
|
|
151
|
+
def test_import_and_status_print_only_the_logged_in_user(reader, capsys, auth_path, profile_fetch):
|
|
152
152
|
cli.main(["auth", "import", "--browser", "Chrome"])
|
|
153
|
-
|
|
153
|
+
output = capsys.readouterr()
|
|
154
|
+
assert output.out == "Logged in as Ada Lovelace\n"
|
|
155
|
+
assert output.err == ""
|
|
156
|
+
profile_fetch.assert_called_once_with("/me", timeout=15, evade=cli.no_delay)
|
|
157
|
+
profile_fetch.reset_mock()
|
|
154
158
|
before = auth_path.read_bytes()
|
|
155
159
|
reader.reset_mock()
|
|
156
160
|
cli.main(["auth", "status"])
|
|
157
161
|
|
|
158
162
|
output = capsys.readouterr()
|
|
159
163
|
assert output.err == ""
|
|
160
|
-
assert output.out
|
|
161
|
-
assert output.out.count("User: Ada Lovelace") == 1
|
|
162
|
-
assert "Browser: chrome" in output.out
|
|
163
|
-
assert "Imported:" in output.out
|
|
164
|
-
assert str(auth_path) in output.out
|
|
165
|
-
assert "Cookie: JSESSIONID; expires: session" in output.out
|
|
166
|
-
assert "Cookie: li_at; expires: 2100-01-01T00:00:00+00:00" in output.out
|
|
167
|
-
assert "private-linkedin-token" not in output.out
|
|
168
|
-
assert "private-csrf-token" not in output.out
|
|
164
|
+
assert output.out == "Logged in as Ada Lovelace\n"
|
|
169
165
|
assert auth_path.read_bytes() == before
|
|
170
166
|
reader.assert_not_called()
|
|
171
167
|
profile_fetch.assert_called_once_with("/me", timeout=15, evade=cli.no_delay)
|
|
172
168
|
|
|
173
169
|
|
|
170
|
+
@pytest.mark.parametrize("command", [["status"], ["import", "--browser", "chrome"]])
|
|
174
171
|
@pytest.mark.parametrize("profile,expected", [
|
|
175
172
|
({"firstName": " Ada ", "lastName": " Lovelace "}, "Ada Lovelace"),
|
|
176
173
|
({"firstName": "Адель", "lastName": "Низамутдинов"}, "Адель Низамутдинов"),
|
|
177
174
|
({"firstName": "Ada"}, "Ada"),
|
|
178
175
|
({"lastName": "Lovelace"}, "Lovelace"),
|
|
179
176
|
])
|
|
180
|
-
def
|
|
177
|
+
def test_auth_prints_the_logged_in_users_name(command, profile, expected, reader, profile_fetch, capsys):
|
|
181
178
|
auth.import_browser("chrome")
|
|
182
179
|
profile_fetch.return_value.json.return_value = {"miniProfile": profile}
|
|
183
180
|
|
|
184
|
-
cli.main(["auth",
|
|
181
|
+
cli.main(["auth", *command])
|
|
185
182
|
|
|
186
|
-
|
|
183
|
+
output = capsys.readouterr()
|
|
184
|
+
assert output.out == f"Logged in as {expected}\n"
|
|
185
|
+
assert output.err == ""
|
|
187
186
|
|
|
188
187
|
|
|
189
188
|
@pytest.mark.parametrize("payload", [
|
|
@@ -198,11 +197,10 @@ def test_status_rejects_a_profile_without_a_name(payload, reader, profile_fetch,
|
|
|
198
197
|
with pytest.raises(SystemExit) as error:
|
|
199
198
|
cli.main(["auth", "status"])
|
|
200
199
|
|
|
201
|
-
assert error.value.code ==
|
|
200
|
+
assert error.value.code == 1
|
|
202
201
|
output = capsys.readouterr()
|
|
203
|
-
assert
|
|
204
|
-
assert "
|
|
205
|
-
assert "private-linkedin-token" not in output.out + output.err
|
|
202
|
+
assert output.out == "Not logged in\n"
|
|
203
|
+
assert output.err == ""
|
|
206
204
|
|
|
207
205
|
|
|
208
206
|
@pytest.mark.parametrize("failure", ["connection", "timeout", "http", "json"])
|
|
@@ -222,12 +220,10 @@ def test_status_handles_failed_user_lookup_without_exposing_secrets(failure, rea
|
|
|
222
220
|
with pytest.raises(SystemExit) as error:
|
|
223
221
|
cli.main(["auth", "status"])
|
|
224
222
|
|
|
225
|
-
assert error.value.code ==
|
|
223
|
+
assert error.value.code == 1
|
|
226
224
|
output = capsys.readouterr()
|
|
227
|
-
assert
|
|
228
|
-
assert
|
|
229
|
-
assert "User:" not in output.out
|
|
230
|
-
assert "private-linkedin-token" not in output.out + output.err
|
|
225
|
+
assert output.out == "Not logged in\n"
|
|
226
|
+
assert output.err == ""
|
|
231
227
|
assert auth_path.read_bytes() == before
|
|
232
228
|
reader.assert_not_called()
|
|
233
229
|
|
|
@@ -240,14 +236,36 @@ def test_status_reports_rejected_sessions(status_code, reader, profile_fetch, ca
|
|
|
240
236
|
with pytest.raises(SystemExit) as error:
|
|
241
237
|
cli.main(["auth", "status"])
|
|
242
238
|
|
|
243
|
-
assert error.value.code ==
|
|
239
|
+
assert error.value.code == 1
|
|
244
240
|
output = capsys.readouterr()
|
|
245
|
-
assert
|
|
246
|
-
assert
|
|
247
|
-
assert "User:" not in output.out
|
|
241
|
+
assert output.out == "Not logged in\n"
|
|
242
|
+
assert output.err == ""
|
|
248
243
|
profile_fetch.return_value.json.assert_not_called()
|
|
249
244
|
|
|
250
245
|
|
|
246
|
+
@pytest.mark.parametrize("failure", ["rejected", "connection", "missing_name"])
|
|
247
|
+
def test_failed_import_lookup_preserves_previous_session(failure, reader, browser_cookies, auth_path, profile_fetch, capsys):
|
|
248
|
+
auth.import_browser("chrome")
|
|
249
|
+
before = auth_path.read_bytes()
|
|
250
|
+
next(cookie for cookie in browser_cookies if cookie.name == "li_at").value = "new-private-token"
|
|
251
|
+
if failure == "rejected":
|
|
252
|
+
profile_fetch.return_value.status_code = 401
|
|
253
|
+
elif failure == "connection":
|
|
254
|
+
profile_fetch.side_effect = ConnectionError("new-private-token")
|
|
255
|
+
else:
|
|
256
|
+
profile_fetch.return_value.json.return_value = {}
|
|
257
|
+
|
|
258
|
+
with pytest.raises(SystemExit) as error:
|
|
259
|
+
cli.main(["auth", "import", "--browser", "chrome"])
|
|
260
|
+
|
|
261
|
+
assert error.value.code == 2
|
|
262
|
+
output = capsys.readouterr()
|
|
263
|
+
assert output.out == ""
|
|
264
|
+
assert output.err
|
|
265
|
+
assert "new-private-token" not in output.err
|
|
266
|
+
assert auth_path.read_bytes() == before
|
|
267
|
+
|
|
268
|
+
|
|
251
269
|
@pytest.mark.skipif(os.name != "posix", reason="Unix file permissions")
|
|
252
270
|
def test_auth_store_is_private_even_when_replacing_an_existing_file(reader, auth_path):
|
|
253
271
|
auth_path.parent.mkdir()
|
|
@@ -309,7 +327,7 @@ def test_failed_save_preserves_previous_session_and_cleans_up(reader, auth_path,
|
|
|
309
327
|
|
|
310
328
|
|
|
311
329
|
@pytest.mark.parametrize("command", [
|
|
312
|
-
["
|
|
330
|
+
["company", "Acme"], ["skill", "Python"],
|
|
313
331
|
["human", "https://www.linkedin.com/in/example/"],
|
|
314
332
|
])
|
|
315
333
|
def test_missing_auth_has_import_guidance_without_browser_or_api_access(command, reader, monkeypatch, capsys, auth_path):
|
|
@@ -326,19 +344,34 @@ def test_missing_auth_has_import_guidance_without_browser_or_api_access(command,
|
|
|
326
344
|
linkedin.assert_not_called()
|
|
327
345
|
|
|
328
346
|
|
|
347
|
+
def test_missing_auth_status_prints_not_logged_in(reader, monkeypatch, capsys, auth_path):
|
|
348
|
+
linkedin = Mock()
|
|
349
|
+
monkeypatch.setattr(cli, "Linkedin", linkedin)
|
|
350
|
+
|
|
351
|
+
with pytest.raises(SystemExit) as error:
|
|
352
|
+
cli.main(["auth", "status"])
|
|
353
|
+
|
|
354
|
+
assert error.value.code == 1
|
|
355
|
+
output = capsys.readouterr()
|
|
356
|
+
assert output.out == "Not logged in\n"
|
|
357
|
+
assert output.err == ""
|
|
358
|
+
assert not auth_path.exists()
|
|
359
|
+
reader.assert_not_called()
|
|
360
|
+
linkedin.assert_not_called()
|
|
361
|
+
|
|
362
|
+
|
|
329
363
|
@pytest.mark.parametrize("content", [b"", b"[]", b"{}", b"private-token", b"\xff"])
|
|
330
|
-
def
|
|
364
|
+
def test_corrupt_store_status_prints_not_logged_in(content, auth_path, capsys):
|
|
331
365
|
auth_path.parent.mkdir()
|
|
332
366
|
auth_path.write_bytes(content)
|
|
333
367
|
|
|
334
368
|
with pytest.raises(SystemExit) as error:
|
|
335
369
|
cli.main(["auth", "status"])
|
|
336
370
|
|
|
337
|
-
assert error.value.code ==
|
|
371
|
+
assert error.value.code == 1
|
|
338
372
|
output = capsys.readouterr()
|
|
339
|
-
assert
|
|
340
|
-
assert
|
|
341
|
-
assert "private-token" not in output.out + output.err
|
|
373
|
+
assert output.out == "Not logged in\n"
|
|
374
|
+
assert output.err == ""
|
|
342
375
|
assert auth_path.read_bytes() == content
|
|
343
376
|
|
|
344
377
|
|
|
@@ -358,7 +391,7 @@ def test_invalid_stored_cookie_is_rejected(field, value, reader, auth_path):
|
|
|
358
391
|
auth.load_cookies()
|
|
359
392
|
|
|
360
393
|
|
|
361
|
-
def
|
|
394
|
+
def test_expired_session_is_reported_when_browser_also_has_no_valid_session(reader, capsys, profile_fetch):
|
|
362
395
|
session = auth.import_browser("chrome")
|
|
363
396
|
next(cookie for cookie in session.cookies if cookie.name == "li_at").expires = 1
|
|
364
397
|
auth.save_auth(session)
|
|
@@ -369,19 +402,77 @@ def test_expired_session_is_reported_and_rejected_without_reimport(reader, capsy
|
|
|
369
402
|
|
|
370
403
|
assert status_error.value.code == 1
|
|
371
404
|
output = capsys.readouterr()
|
|
372
|
-
assert
|
|
373
|
-
assert
|
|
374
|
-
assert "warmpath auth import --browser chrome" in output.out
|
|
405
|
+
assert output.out == "Not logged in\n"
|
|
406
|
+
assert output.err == ""
|
|
375
407
|
|
|
376
408
|
with pytest.raises(SystemExit) as api_error:
|
|
377
|
-
cli.
|
|
409
|
+
cli.main(["company", "Acme"])
|
|
378
410
|
|
|
379
411
|
assert api_error.value.code == 2
|
|
380
412
|
assert "warmpath auth import --browser chrome" in capsys.readouterr().err
|
|
381
|
-
reader.
|
|
413
|
+
assert reader.call_count == 2
|
|
382
414
|
profile_fetch.assert_not_called()
|
|
383
415
|
|
|
384
416
|
|
|
417
|
+
def test_expired_saved_session_is_replaced_by_valid_browser_session(reader, capsys):
|
|
418
|
+
from copy import deepcopy
|
|
419
|
+
|
|
420
|
+
session = auth.import_browser("chrome")
|
|
421
|
+
session.cookies = deepcopy(session.cookies)
|
|
422
|
+
next(cookie for cookie in session.cookies if cookie.name == "li_at").expires = 1
|
|
423
|
+
auth.save_auth(session)
|
|
424
|
+
reader.reset_mock()
|
|
425
|
+
|
|
426
|
+
cli.main(["auth", "status"])
|
|
427
|
+
|
|
428
|
+
assert capsys.readouterr().out == "Logged in as Ada Lovelace\n"
|
|
429
|
+
assert not auth.load_auth().missing_cookies()
|
|
430
|
+
reader.assert_called_once_with(domain_name="linkedin.com")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_import_keeps_linkedin_device_security_and_routing_cookies(reader, browser_cookies):
|
|
434
|
+
for name in ("bcookie", "bscookie", "lidc", "liap", "dfpfpt", "fptctx2"):
|
|
435
|
+
browser_cookies.set(name, f"value-for-{name}", domain=".linkedin.com", secure=True)
|
|
436
|
+
|
|
437
|
+
auth.import_browser("chrome")
|
|
438
|
+
|
|
439
|
+
saved = auth.load_cookies()
|
|
440
|
+
for name in ("bcookie", "bscookie", "lidc", "liap", "dfpfpt", "fptctx2"):
|
|
441
|
+
assert saved.get(name) == f"value-for-{name}"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def test_import_persists_browser_user_agent(reader, monkeypatch):
|
|
445
|
+
monkeypatch.setattr(auth, "browser_user_agent", lambda browser: "Browser/152.0.0.0")
|
|
446
|
+
|
|
447
|
+
auth.import_browser("chrome")
|
|
448
|
+
|
|
449
|
+
assert auth.load_auth().user_agent == "Browser/152.0.0.0"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_chrome_user_agent_uses_installed_major_version(monkeypatch):
|
|
453
|
+
import plistlib
|
|
454
|
+
|
|
455
|
+
monkeypatch.setattr(auth.sys, "platform", "darwin")
|
|
456
|
+
monkeypatch.setattr(Path, "read_bytes", lambda self: plistlib.dumps({"CFBundleShortVersionString": "152.0.7977.83"}))
|
|
457
|
+
|
|
458
|
+
agent = auth.browser_user_agent("chrome")
|
|
459
|
+
|
|
460
|
+
assert agent is not None
|
|
461
|
+
assert "Chrome/152.0.0.0" in agent
|
|
462
|
+
assert "Macintosh; Intel Mac OS X 10_15_7" in agent
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@pytest.mark.parametrize("value", ["Browser\r\nInjected: private-token", "", 123, ["Browser"]])
|
|
466
|
+
def test_invalid_stored_user_agent_is_rejected(value, reader, auth_path):
|
|
467
|
+
auth.import_browser("chrome")
|
|
468
|
+
payload = json.loads(auth_path.read_text())
|
|
469
|
+
payload["user_agent"] = value
|
|
470
|
+
auth_path.write_text(json.dumps(payload))
|
|
471
|
+
|
|
472
|
+
with pytest.raises(auth.AuthError, match="saved LinkedIn session is invalid"):
|
|
473
|
+
auth.load_auth()
|
|
474
|
+
|
|
475
|
+
|
|
385
476
|
@pytest.mark.parametrize("arguments", [[], ["import"], ["import", "--browser", "unknown"], ["login"]])
|
|
386
477
|
def test_auth_rejects_missing_and_unknown_arguments(arguments, reader):
|
|
387
478
|
with pytest.raises(SystemExit) as error:
|