warmpath 0.1.0__tar.gz → 0.3.0__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.0/AGENTS.md +7 -0
- {warmpath-0.1.0 → warmpath-0.3.0}/PKG-INFO +4 -2
- warmpath-0.3.0/README.md +80 -0
- warmpath-0.3.0/Taskfile.yaml +37 -0
- {warmpath-0.1.0 → warmpath-0.3.0}/docs/skill.md +1 -1
- {warmpath-0.1.0 → warmpath-0.3.0}/pyproject.toml +5 -7
- warmpath-0.3.0/scripts/release.sh +86 -0
- warmpath-0.3.0/tests/test_auth.py +269 -0
- warmpath-0.3.0/tests/test_cli_commands.py +218 -0
- {warmpath-0.1.0 → warmpath-0.3.0}/tests/test_mutual_connections.py +23 -50
- {warmpath-0.1.0 → warmpath-0.3.0}/uv.lock +186 -7
- warmpath-0.3.0/warmpath/auth.py +232 -0
- {warmpath-0.1.0 → warmpath-0.3.0}/warmpath/cli.py +227 -236
- warmpath-0.1.0/README.md +0 -56
- warmpath-0.1.0/Taskfile.yaml +0 -20
- warmpath-0.1.0/tests/test_cli_commands.py +0 -725
- {warmpath-0.1.0 → warmpath-0.3.0}/.gitignore +0 -0
- {warmpath-0.1.0 → warmpath-0.3.0}/UNLICENSE +0 -0
- {warmpath-0.1.0 → warmpath-0.3.0}/warmpath/__init__.py +0 -0
- {warmpath-0.1.0 → warmpath-0.3.0}/warmpath.jpg +0 -0
warmpath-0.3.0/AGENTS.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Agent Instructions
|
|
2
|
+
|
|
3
|
+
## Verification
|
|
4
|
+
|
|
5
|
+
- Run `task check` after important code or configuration changes.
|
|
6
|
+
- Report any failures from `task check`, or explain why it could not be run.
|
|
7
|
+
- Do not claim that verification succeeded unless `task check` completed successfully.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: warmpath
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Local LinkedIn visible-connections scraper.
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: browser-cookie3>=0.20.1
|
|
6
7
|
Requires-Dist: open-linkedin-api>=2.3.1
|
|
8
|
+
Requires-Dist: platformdirs>=4.11.8
|
warmpath-0.3.0/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# Warmpath
|
|
4
|
+
|
|
5
|
+
Find LinkedIn mutuals and warm paths using your logged-in browser session.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
Log in to LinkedIn in a regular browser window, then import that session:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
uvx warmpath auth import --browser chrome
|
|
13
|
+
uvx warmpath auth status
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Import uses [browser-cookie3](https://github.com/borisbabic/browser_cookie3) to read your browser's local cookie store. 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.
|
|
17
|
+
|
|
18
|
+
Warmpath imports only LinkedIn's `li_at` and `JSESSIONID` cookies and manages the saved session automatically. `company`, `skill`, and `human` use it on subsequent runs. Failed imports preserve the previous session.
|
|
19
|
+
|
|
20
|
+
`auth status` shows the source browser, import time, cookie expiry, and storage location without displaying cookie values. It checks the saved session locally; LinkedIn may revoke a session before its cookies expire. If the session expires or LinkedIn stops accepting it, log in again and repeat `auth import`.
|
|
21
|
+
|
|
22
|
+
## Development
|
|
23
|
+
|
|
24
|
+
[go-task](https://taskfile.dev/) is required to run the repository checks. Install it with Homebrew on macOS:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
brew install go-task
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
For other platforms, follow the [go-task installation guide](https://taskfile.dev/docs/installation). Verify that the `task` command is available before running any checks:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
task --version
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then install the Python development dependencies and run the full check suite:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
uv sync
|
|
40
|
+
task check
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Company
|
|
46
|
+
|
|
47
|
+
Who can introduce me into this company?
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
uvx warmpath company HashiCorp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Skill
|
|
54
|
+
|
|
55
|
+
Which reachable people match this recruiting need?
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
uvx warmpath skill Flutter
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Human
|
|
62
|
+
|
|
63
|
+
Can I reach this exact person, and through whom?
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
uvx warmpath human https://www.linkedin.com/in/mitchellh/
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## More Examples
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
uvx warmpath company "HashiCorp" --max-degree 2 --limit 5
|
|
73
|
+
uvx warmpath auth import --browser firefox
|
|
74
|
+
uvx warmpath company https://www.linkedin.com/company/hashicorp/
|
|
75
|
+
uvx warmpath skill Leadership --max-depth 2
|
|
76
|
+
uvx warmpath human https://www.linkedin.com/in/mitchellh/ --refresh-cache
|
|
77
|
+
uvx warmpath company --help
|
|
78
|
+
uvx warmpath skill --help
|
|
79
|
+
uvx warmpath human --help
|
|
80
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
version: 3
|
|
2
|
+
|
|
3
|
+
env:
|
|
4
|
+
UV_CACHE_DIR: .uv-cache
|
|
5
|
+
|
|
6
|
+
tasks:
|
|
7
|
+
typecheck:
|
|
8
|
+
run: once
|
|
9
|
+
desc: Type-check Python with Pyright.
|
|
10
|
+
cmd: uv run python -m pyright warmpath
|
|
11
|
+
|
|
12
|
+
test:
|
|
13
|
+
run: once
|
|
14
|
+
desc: Run Python unit and live integration tests.
|
|
15
|
+
cmd: uv run pytest
|
|
16
|
+
|
|
17
|
+
check:
|
|
18
|
+
run: once
|
|
19
|
+
deps:
|
|
20
|
+
- typecheck
|
|
21
|
+
- test
|
|
22
|
+
|
|
23
|
+
build:
|
|
24
|
+
desc: Build source and wheel distributions.
|
|
25
|
+
cmds:
|
|
26
|
+
- rm -rf dist
|
|
27
|
+
- UV_CACHE_DIR="${TMPDIR:-/tmp}/warmpath-uv-cache" uv build --no-sources
|
|
28
|
+
|
|
29
|
+
outdated:
|
|
30
|
+
desc: Print outdated project dependencies.
|
|
31
|
+
cmd: uv tree --outdated
|
|
32
|
+
|
|
33
|
+
release:
|
|
34
|
+
desc: Check, build, publish, smoke-test, and tag a committed version.
|
|
35
|
+
deps:
|
|
36
|
+
- check
|
|
37
|
+
cmd: scripts/release.sh
|
|
@@ -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
|
|
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 the saved session's expiry locally.
|
|
22
22
|
|
|
23
23
|
## Defaults
|
|
24
24
|
|
|
@@ -1,21 +1,19 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "warmpath"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Local LinkedIn visible-connections scraper."
|
|
5
5
|
requires-python = ">=3.10"
|
|
6
6
|
dependencies = [
|
|
7
|
+
"browser-cookie3>=0.20.1",
|
|
7
8
|
"open-linkedin-api>=2.3.1",
|
|
9
|
+
"platformdirs>=4.11.8",
|
|
8
10
|
]
|
|
9
11
|
|
|
10
12
|
[dependency-groups]
|
|
11
|
-
dev = [
|
|
12
|
-
"pytest>=8.4.1",
|
|
13
|
-
"pytest-xdist>=3.8.0",
|
|
14
|
-
"pyright>=1.1.402",
|
|
15
|
-
]
|
|
13
|
+
dev = ["pytest>=8.4.1", "pytest-xdist>=3.8.0", "pyright>=1.1.411"]
|
|
16
14
|
|
|
17
15
|
[tool.pytest.ini_options]
|
|
18
|
-
addopts = "-n auto"
|
|
16
|
+
addopts = "-n auto --dist loadgroup"
|
|
19
17
|
testpaths = ["tests"]
|
|
20
18
|
|
|
21
19
|
[project.scripts]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
PACKAGE="warmpath"
|
|
5
|
+
TOKEN_FILE="${PYPI_TOKEN_FILE:-.pypi-token}"
|
|
6
|
+
export UV_CACHE_DIR="${RELEASE_UV_CACHE_DIR:-${TMPDIR:-/tmp}/warmpath-uv-cache}"
|
|
7
|
+
|
|
8
|
+
if test "$#" -ne 0; then
|
|
9
|
+
echo "usage: $0" >&2
|
|
10
|
+
exit 2
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
version="$(uv version --short)"
|
|
14
|
+
tag="v${version}"
|
|
15
|
+
|
|
16
|
+
if ! test -f "$TOKEN_FILE"; then
|
|
17
|
+
echo "$TOKEN_FILE is required for publishing." >&2
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
token="$(tr -d '\r\n\t ' < "$TOKEN_FILE")"
|
|
22
|
+
test -n "$token"
|
|
23
|
+
case "$token" in
|
|
24
|
+
pypi-*) ;;
|
|
25
|
+
*) echo "$TOKEN_FILE must contain a PyPI token." >&2; exit 1 ;;
|
|
26
|
+
esac
|
|
27
|
+
|
|
28
|
+
rm -rf dist
|
|
29
|
+
if test -n "$(git status --porcelain --untracked-files=all)"; then
|
|
30
|
+
echo "Working tree is not clean; commit before release." >&2
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
if git show-ref --verify --quiet "refs/tags/$tag"; then
|
|
35
|
+
echo "Tag $tag already exists locally." >&2
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
remote_tag="$(git ls-remote --tags origin "refs/tags/$tag")"
|
|
40
|
+
if test -n "$remote_tag"; then
|
|
41
|
+
echo "Tag $tag already exists on origin." >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
echo "Building artifacts"
|
|
46
|
+
uv build --no-sources
|
|
47
|
+
|
|
48
|
+
sdist="dist/${PACKAGE}-${version}.tar.gz"
|
|
49
|
+
wheel="dist/${PACKAGE}-${version}-py3-none-any.whl"
|
|
50
|
+
|
|
51
|
+
echo "Verifying artifacts"
|
|
52
|
+
test -f "$sdist"
|
|
53
|
+
test -f "$wheel"
|
|
54
|
+
|
|
55
|
+
tar tf "$sdist" | grep -q "^${PACKAGE}-${version}/${PACKAGE}/cli.py$"
|
|
56
|
+
uv run python -m zipfile -l "$wheel" | grep -q "${PACKAGE}/cli.py"
|
|
57
|
+
|
|
58
|
+
if tar tf "$sdist" | grep -q '\.pypi-token'; then
|
|
59
|
+
echo ".pypi-token leaked into sdist." >&2
|
|
60
|
+
exit 1
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
if tar tf "$sdist" | grep -q '\.uv-cache'; then
|
|
64
|
+
echo ".uv-cache leaked into sdist." >&2
|
|
65
|
+
exit 1
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
if uv run python -m zipfile -l "$wheel" | grep -q '\.pypi-token'; then
|
|
69
|
+
echo ".pypi-token leaked into wheel." >&2
|
|
70
|
+
exit 1
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
if uv run python -m zipfile -l "$wheel" | grep -q '\.uv-cache'; then
|
|
74
|
+
echo ".uv-cache leaked into wheel." >&2
|
|
75
|
+
exit 1
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
echo "Publishing ${PACKAGE} ${version}"
|
|
79
|
+
UV_PUBLISH_TOKEN="$token" uv publish
|
|
80
|
+
|
|
81
|
+
echo "Smoke-testing uvx ${PACKAGE} ${version}"
|
|
82
|
+
uvx --refresh-package "$PACKAGE" --from "${PACKAGE}==${version}" "$PACKAGE" --help
|
|
83
|
+
|
|
84
|
+
echo "Tagging ${PACKAGE} ${version}"
|
|
85
|
+
git tag -a "$tag" -m "${PACKAGE} ${version}"
|
|
86
|
+
git push origin "$tag"
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import Mock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from requests import Request
|
|
8
|
+
from requests.cookies import RequestsCookieJar
|
|
9
|
+
|
|
10
|
+
from warmpath import auth, cli
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def auth_path(tmp_path, monkeypatch):
|
|
15
|
+
path = tmp_path / "warmpath" / "auth.json"
|
|
16
|
+
monkeypatch.setattr(auth, "auth_store_path", lambda: path)
|
|
17
|
+
return path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def browser_cookies():
|
|
22
|
+
jar = RequestsCookieJar()
|
|
23
|
+
jar.set(
|
|
24
|
+
"li_at", "private-linkedin-token", domain=".linkedin.com", path="/",
|
|
25
|
+
secure=True, expires=4102444800,
|
|
26
|
+
)
|
|
27
|
+
jar.set(
|
|
28
|
+
"JSESSIONID", '"ajax:private-csrf-token"', domain="www.linkedin.com",
|
|
29
|
+
path="/", secure=True,
|
|
30
|
+
)
|
|
31
|
+
next(cookie for cookie in jar if cookie.name == "JSESSIONID").domain_specified = False
|
|
32
|
+
return jar
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def reader(browser_cookies, monkeypatch):
|
|
37
|
+
reader = Mock(return_value=browser_cookies)
|
|
38
|
+
monkeypatch.setattr(auth.browser_cookie3, "chrome", reader)
|
|
39
|
+
return reader
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize("browser", auth.BROWSERS)
|
|
43
|
+
def test_import_reads_only_the_selected_browser(browser, browser_cookies, monkeypatch):
|
|
44
|
+
reader = Mock(return_value=browser_cookies)
|
|
45
|
+
monkeypatch.setattr(auth.browser_cookie3, browser.replace("-", "_"), reader)
|
|
46
|
+
|
|
47
|
+
cli.main(["auth", "import", "--browser", browser])
|
|
48
|
+
|
|
49
|
+
reader.assert_called_once_with(domain_name="linkedin.com")
|
|
50
|
+
assert auth.load_auth().browser == browser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_import_filters_unrelated_expired_and_out_of_scope_cookies(reader, browser_cookies, auth_path):
|
|
54
|
+
for name, domain, path, expires, value in [
|
|
55
|
+
("tracking", ".linkedin.com", "/", None, "unrelated-tracking-token"),
|
|
56
|
+
("li_at", ".notlinkedin.com", "/", None, "unrelated-domain-token"),
|
|
57
|
+
("li_at", ".linkedin.com.example.org", "/", None, "unrelated-suffix-token"),
|
|
58
|
+
("li_at", "jobs.linkedin.com", "/", None, "unrelated-host-token"),
|
|
59
|
+
("li_at", ".linkedin.com", "/jobs", None, "unrelated-path-token"),
|
|
60
|
+
("li_at", "www.linkedin.com", "/", 1, "expired-token"),
|
|
61
|
+
("JSESSIONID", ".linkedin.com", "/", None, "invalid;token"),
|
|
62
|
+
]:
|
|
63
|
+
browser_cookies.set(name, value, domain=domain, path=path, expires=expires)
|
|
64
|
+
|
|
65
|
+
auth.import_browser("chrome")
|
|
66
|
+
|
|
67
|
+
saved = auth.load_cookies()
|
|
68
|
+
assert saved.get_dict() == {
|
|
69
|
+
"li_at": "private-linkedin-token",
|
|
70
|
+
"JSESSIONID": '"ajax:private-csrf-token"',
|
|
71
|
+
}
|
|
72
|
+
assert len(saved) == 2
|
|
73
|
+
assert "unrelated" not in auth_path.read_text()
|
|
74
|
+
assert "expired-token" not in auth_path.read_text()
|
|
75
|
+
assert "invalid;token" not in auth_path.read_text()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_imported_session_reaches_linkedin_with_csrf_and_cookie_attributes(reader, browser_cookies):
|
|
79
|
+
# A broader JSESSIONID must not conflict with the www cookie in the API's
|
|
80
|
+
# lookup by name, or replace the token associated with that scope.
|
|
81
|
+
browser_cookies.set("JSESSIONID", '"ajax:broader-token"', domain=".linkedin.com")
|
|
82
|
+
auth.import_browser("chrome")
|
|
83
|
+
|
|
84
|
+
api = cli.build_api()
|
|
85
|
+
session = api.client.session
|
|
86
|
+
request = session.prepare_request(Request("GET", "https://www.linkedin.com/voyager/api/me"))
|
|
87
|
+
|
|
88
|
+
assert request.headers["csrf-token"] == "ajax:private-csrf-token"
|
|
89
|
+
assert "li_at=private-linkedin-token" in request.headers["Cookie"]
|
|
90
|
+
assert 'JSESSIONID="ajax:private-csrf-token"' in request.headers["Cookie"]
|
|
91
|
+
assert "broader-token" not in request.headers["Cookie"]
|
|
92
|
+
cookies = {cookie.name: cookie for cookie in session.cookies}
|
|
93
|
+
assert cookies["li_at"].expires == 4102444800
|
|
94
|
+
assert cookies["li_at"].domain == ".linkedin.com"
|
|
95
|
+
assert cookies["JSESSIONID"].expires is None
|
|
96
|
+
assert cookies["JSESSIONID"].domain_specified is False
|
|
97
|
+
assert all(cookie.secure for cookie in session.cookies)
|
|
98
|
+
assert "Cookie" not in session.prepare_request(Request("GET", "https://example.org/")).headers
|
|
99
|
+
assert "Cookie" not in session.prepare_request(Request("GET", "http://www.linkedin.com/")).headers
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_import_and_status_report_metadata_without_cookie_values(reader, capsys, auth_path):
|
|
103
|
+
cli.main(["auth", "import", "--browser", "Chrome"])
|
|
104
|
+
before = auth_path.read_bytes()
|
|
105
|
+
reader.reset_mock()
|
|
106
|
+
cli.main(["auth", "status"])
|
|
107
|
+
|
|
108
|
+
output = capsys.readouterr()
|
|
109
|
+
assert output.err == ""
|
|
110
|
+
assert output.out.count("Status: ready (local expiry check)") == 2
|
|
111
|
+
assert "Browser: chrome" in output.out
|
|
112
|
+
assert "Imported:" in output.out
|
|
113
|
+
assert str(auth_path) in output.out
|
|
114
|
+
assert "Cookie: JSESSIONID; expires: session" in output.out
|
|
115
|
+
assert "Cookie: li_at; expires: 2100-01-01T00:00:00+00:00" in output.out
|
|
116
|
+
assert "private-linkedin-token" not in output.out
|
|
117
|
+
assert "private-csrf-token" not in output.out
|
|
118
|
+
assert auth_path.read_bytes() == before
|
|
119
|
+
reader.assert_not_called()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.skipif(os.name != "posix", reason="Unix file permissions")
|
|
123
|
+
def test_auth_store_is_private_even_when_replacing_an_existing_file(reader, auth_path):
|
|
124
|
+
auth_path.parent.mkdir()
|
|
125
|
+
auth_path.write_text("previous session")
|
|
126
|
+
auth_path.chmod(0o644)
|
|
127
|
+
|
|
128
|
+
auth.import_browser("chrome")
|
|
129
|
+
|
|
130
|
+
assert auth_path.stat().st_mode & 0o777 == 0o600
|
|
131
|
+
assert list(auth_path.parent.iterdir()) == [auth_path]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.parametrize("condition", ["empty", "missing", "expired", "empty_value"])
|
|
135
|
+
def test_unusable_import_preserves_previous_session(condition, reader, browser_cookies, auth_path):
|
|
136
|
+
auth.import_browser("chrome")
|
|
137
|
+
before = auth_path.read_bytes()
|
|
138
|
+
if condition == "empty":
|
|
139
|
+
browser_cookies.clear()
|
|
140
|
+
elif condition == "missing":
|
|
141
|
+
browser_cookies.clear(".linkedin.com", "/", "li_at")
|
|
142
|
+
else:
|
|
143
|
+
cookie = next(cookie for cookie in browser_cookies if cookie.name == "li_at")
|
|
144
|
+
if condition == "expired":
|
|
145
|
+
cookie.expires = 1
|
|
146
|
+
else:
|
|
147
|
+
cookie.value = ""
|
|
148
|
+
|
|
149
|
+
with pytest.raises(auth.AuthError, match="No usable LinkedIn session"):
|
|
150
|
+
auth.import_browser("chrome")
|
|
151
|
+
|
|
152
|
+
assert auth_path.read_bytes() == before
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_browser_error_preserves_session_and_does_not_echo_secrets(reader, auth_path, capsys):
|
|
156
|
+
auth.import_browser("chrome")
|
|
157
|
+
before = auth_path.read_bytes()
|
|
158
|
+
reader.side_effect = RuntimeError("private-linkedin-token")
|
|
159
|
+
|
|
160
|
+
with pytest.raises(SystemExit) as error:
|
|
161
|
+
cli.main(["auth", "import", "--browser", "chrome"])
|
|
162
|
+
|
|
163
|
+
assert error.value.code == 2
|
|
164
|
+
output = capsys.readouterr()
|
|
165
|
+
assert "Could not read chrome cookies" in output.err
|
|
166
|
+
assert "private-linkedin-token" not in output.out + output.err
|
|
167
|
+
assert auth_path.read_bytes() == before
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_failed_save_preserves_previous_session_and_cleans_up(reader, auth_path, monkeypatch):
|
|
171
|
+
auth.import_browser("chrome")
|
|
172
|
+
before = auth_path.read_bytes()
|
|
173
|
+
monkeypatch.setattr(Path, "replace", Mock(side_effect=PermissionError("cannot replace")))
|
|
174
|
+
|
|
175
|
+
with pytest.raises(auth.AuthError, match="Could not save"):
|
|
176
|
+
auth.import_browser("chrome")
|
|
177
|
+
|
|
178
|
+
assert auth_path.read_bytes() == before
|
|
179
|
+
assert list(auth_path.parent.iterdir()) == [auth_path]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@pytest.mark.parametrize("command", [
|
|
183
|
+
["auth", "status"], ["company", "Acme"], ["skill", "Python"],
|
|
184
|
+
["human", "https://www.linkedin.com/in/example/"],
|
|
185
|
+
])
|
|
186
|
+
def test_missing_auth_has_import_guidance_without_browser_or_api_access(command, reader, monkeypatch, capsys, auth_path):
|
|
187
|
+
linkedin = Mock()
|
|
188
|
+
monkeypatch.setattr(cli, "Linkedin", linkedin)
|
|
189
|
+
|
|
190
|
+
with pytest.raises(SystemExit) as error:
|
|
191
|
+
cli.main(command)
|
|
192
|
+
|
|
193
|
+
assert error.value.code == 2
|
|
194
|
+
assert "warmpath auth import --browser" in capsys.readouterr().err
|
|
195
|
+
assert not auth_path.exists()
|
|
196
|
+
reader.assert_not_called()
|
|
197
|
+
linkedin.assert_not_called()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@pytest.mark.parametrize("content", [b"", b"[]", b"{}", b"private-token", b"\xff"])
|
|
201
|
+
def test_corrupt_store_has_reimport_guidance(content, auth_path, capsys):
|
|
202
|
+
auth_path.parent.mkdir()
|
|
203
|
+
auth_path.write_bytes(content)
|
|
204
|
+
|
|
205
|
+
with pytest.raises(SystemExit) as error:
|
|
206
|
+
cli.main(["auth", "status"])
|
|
207
|
+
|
|
208
|
+
assert error.value.code == 2
|
|
209
|
+
output = capsys.readouterr()
|
|
210
|
+
assert "saved LinkedIn session is invalid" in output.err
|
|
211
|
+
assert "warmpath auth import --browser" in output.err
|
|
212
|
+
assert "private-token" not in output.out + output.err
|
|
213
|
+
assert auth_path.read_bytes() == content
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@pytest.mark.parametrize("field,value", [
|
|
217
|
+
("domain", "example.org"), ("path", "/jobs"), ("secure", "true"),
|
|
218
|
+
("expires", "tomorrow"), ("expires", 10**20),
|
|
219
|
+
("domain_specified", False), ("value", "token\r\nInjected: header"),
|
|
220
|
+
])
|
|
221
|
+
def test_invalid_stored_cookie_is_rejected(field, value, reader, auth_path):
|
|
222
|
+
auth.import_browser("chrome")
|
|
223
|
+
payload = json.loads(auth_path.read_text())
|
|
224
|
+
payload["cookies"][0][field] = value
|
|
225
|
+
auth_path.write_text(json.dumps(payload))
|
|
226
|
+
|
|
227
|
+
with pytest.raises(auth.AuthError, match="saved LinkedIn session is invalid"):
|
|
228
|
+
auth.load_cookies()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_expired_session_is_reported_and_rejected_without_reimport(reader, capsys):
|
|
232
|
+
session = auth.import_browser("chrome")
|
|
233
|
+
next(cookie for cookie in session.cookies if cookie.name == "li_at").expires = 1
|
|
234
|
+
auth.save_auth(session)
|
|
235
|
+
reader.reset_mock()
|
|
236
|
+
|
|
237
|
+
with pytest.raises(SystemExit) as status_error:
|
|
238
|
+
cli.main(["auth", "status"])
|
|
239
|
+
|
|
240
|
+
assert status_error.value.code == 1
|
|
241
|
+
output = capsys.readouterr()
|
|
242
|
+
assert "Status: expired or incomplete" in output.out
|
|
243
|
+
assert "Missing or expired: li_at" in output.out
|
|
244
|
+
assert "warmpath auth import --browser chrome" in output.out
|
|
245
|
+
|
|
246
|
+
with pytest.raises(SystemExit) as api_error:
|
|
247
|
+
cli.build_api()
|
|
248
|
+
|
|
249
|
+
assert api_error.value.code == 2
|
|
250
|
+
assert "warmpath auth import --browser chrome" in capsys.readouterr().err
|
|
251
|
+
reader.assert_not_called()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@pytest.mark.parametrize("arguments", [[], ["import"], ["import", "--browser", "unknown"], ["login"]])
|
|
255
|
+
def test_auth_rejects_missing_and_unknown_arguments(arguments, reader):
|
|
256
|
+
with pytest.raises(SystemExit) as error:
|
|
257
|
+
cli.main(["auth", *arguments])
|
|
258
|
+
|
|
259
|
+
assert error.value.code == 2
|
|
260
|
+
reader.assert_not_called()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@pytest.mark.parametrize("command,target", [("company", "Acme"), ("skill", "Python"), ("human", "example")])
|
|
264
|
+
def test_manual_cookie_option_is_removed(command, target, capsys):
|
|
265
|
+
with pytest.raises(SystemExit) as error:
|
|
266
|
+
cli.main([command, target, "--cookie-file", "unused"])
|
|
267
|
+
|
|
268
|
+
assert error.value.code == 2
|
|
269
|
+
assert "unrecognized arguments" in capsys.readouterr().err
|