current-cli 0.1.58__tar.gz → 0.1.60__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.
Files changed (22) hide show
  1. {current_cli-0.1.58 → current_cli-0.1.60}/PKG-INFO +8 -1
  2. {current_cli-0.1.58 → current_cli-0.1.60}/README.md +7 -0
  3. {current_cli-0.1.58 → current_cli-0.1.60}/current_cli/docs.py +5 -0
  4. {current_cli-0.1.58 → current_cli-0.1.60}/current_cli/main.py +83 -2
  5. {current_cli-0.1.58 → current_cli-0.1.60}/current_cli/schema.yml +358 -0
  6. {current_cli-0.1.58 → current_cli-0.1.60}/llms.txt +27 -1
  7. {current_cli-0.1.58 → current_cli-0.1.60}/pyproject.toml +1 -1
  8. current_cli-0.1.60/tests/test_auth.py +213 -0
  9. current_cli-0.1.58/tests/test_auth.py +0 -92
  10. {current_cli-0.1.58 → current_cli-0.1.60}/.gitignore +0 -0
  11. {current_cli-0.1.58 → current_cli-0.1.60}/current_cli/__init__.py +0 -0
  12. {current_cli-0.1.58 → current_cli-0.1.60}/current_cli/build.py +0 -0
  13. {current_cli-0.1.58 → current_cli-0.1.60}/current_cli/config.py +0 -0
  14. {current_cli-0.1.58 → current_cli-0.1.60}/current_cli/http.py +0 -0
  15. {current_cli-0.1.58 → current_cli-0.1.60}/current_cli/schema.py +0 -0
  16. {current_cli-0.1.58 → current_cli-0.1.60}/scripts/bump_version.py +0 -0
  17. {current_cli-0.1.58 → current_cli-0.1.60}/tests/__init__.py +0 -0
  18. {current_cli-0.1.58 → current_cli-0.1.60}/tests/conftest.py +0 -0
  19. {current_cli-0.1.58 → current_cli-0.1.60}/tests/test_bump_version.py +0 -0
  20. {current_cli-0.1.58 → current_cli-0.1.60}/tests/test_command_tree.py +0 -0
  21. {current_cli-0.1.58 → current_cli-0.1.60}/tests/test_docs.py +0 -0
  22. {current_cli-0.1.58 → current_cli-0.1.60}/tests/test_requests.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: current-cli
3
- Version: 0.1.58
3
+ Version: 0.1.60
4
4
  Summary: Command line interface for the Current API, generated from its OpenAPI schema at runtime
5
5
  Author-email: Orin <your-email@example.com>
6
6
  License-Expression: MIT
@@ -41,6 +41,13 @@ current tasks create --data '{"name": "Order beams", "workspace": "..."}'
41
41
  current workspaces members list <workspace_id>
42
42
  ```
43
43
 
44
+ `current login` emails a magic link: paste the whole link (or just its token)
45
+ when asked. Accounts that require two-factor authentication — Orin Labs
46
+ superadmins, workspace admins, and members of workspaces that require it —
47
+ are then asked for an authenticator code. An account with no authenticator
48
+ enrolled yet is shown a setup link and secret key to add to an authenticator
49
+ app, and confirms the first code to finish signing in.
50
+
44
51
  The CLI points at `https://api.current.orinlabs.ai` by default. For local
45
52
  development, run `current config set base_url http://localhost:8000`.
46
53
 
@@ -28,6 +28,13 @@ current tasks create --data '{"name": "Order beams", "workspace": "..."}'
28
28
  current workspaces members list <workspace_id>
29
29
  ```
30
30
 
31
+ `current login` emails a magic link: paste the whole link (or just its token)
32
+ when asked. Accounts that require two-factor authentication — Orin Labs
33
+ superadmins, workspace admins, and members of workspaces that require it —
34
+ are then asked for an authenticator code. An account with no authenticator
35
+ enrolled yet is shown a setup link and secret key to add to an authenticator
36
+ app, and confirms the first code to finish signing in.
37
+
31
38
  The CLI points at `https://api.current.orinlabs.ai` by default. For local
32
39
  development, run `current config set base_url http://localhost:8000`.
33
40
 
@@ -24,6 +24,11 @@ current login you@example.com # magic-link sign in, sto
24
24
  current config set default_workspace <workspace-uuid> # used when --workspace is omitted
25
25
  ```
26
26
 
27
+ `current login` asks for the sign-in link from the email (or its token). An
28
+ account that requires two-factor authentication is also asked for an
29
+ authenticator code; one with no device enrolled yet is shown a setup link
30
+ and secret key to add to an authenticator app first.
31
+
27
32
  The CLI speaks to `https://api.current.orinlabs.ai` by default. For another
28
33
  server (for example local development), set
29
34
  `current config set base_url http://localhost:8000`.
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  import sys
7
+ from urllib.parse import parse_qs, urlsplit
7
8
 
8
9
  import click
9
10
 
@@ -16,12 +17,87 @@ MAGIC_LINK_PATH = "/api/accounts/auth/magic-link/"
16
17
  VERIFY_PATH = "/api/accounts/auth/verify/"
17
18
  LOGOUT_PATH = "/api/accounts/auth/logout/"
18
19
  ME_PATH = "/api/accounts/auth/me/"
20
+ MFA_VERIFY_PATH = "/api/accounts/auth/mfa/verify/"
21
+ MFA_SETUP_PATH = "/api/accounts/auth/mfa/setup/"
22
+ MFA_CONFIRM_PATH = "/api/accounts/auth/mfa/confirm/"
23
+
24
+ # Tries at the authenticator code before login gives up. The pending-login
25
+ # ticket outlives these by far (10 minutes), so a typo costs a retry, not
26
+ # another magic link.
27
+ MFA_MAX_ATTEMPTS = 3
28
+
29
+
30
+ def _magic_token(value: str) -> str:
31
+ """The magic-link token from what the user pasted: the whole sign-in link
32
+ from the email (``.../auth/verify?token=...``) or the bare token."""
33
+ value = value.strip()
34
+ if "token=" in value:
35
+ tokens = parse_qs(urlsplit(value).query or value).get("token")
36
+ if tokens:
37
+ return tokens[0]
38
+ return value
39
+
40
+
41
+ def _detail(response) -> str:
42
+ try:
43
+ body = response.json()
44
+ except ValueError:
45
+ return response.text
46
+ if isinstance(body, dict) and isinstance(body.get("detail"), str):
47
+ return body["detail"]
48
+ return response.text
49
+
50
+
51
+ def _fail(response) -> None:
52
+ """Exit on a failed sign-in step. An expired pending-login ticket gets a
53
+ plain instruction instead of the raw API error."""
54
+ if "ticket" in _detail(response).lower():
55
+ click.echo("The sign-in expired. Run `current login` again.", err=True)
56
+ raise SystemExit(1)
57
+ http.render_response(response)
58
+ raise SystemExit(1)
59
+
60
+
61
+ def _second_factor(ticket: str, enrolled: bool) -> dict:
62
+ """Finish a sign-in that needs two-factor authentication and return the
63
+ login payload. An enrolled account verifies a code; an account without a
64
+ device enrolls one first, and confirming it completes the sign-in."""
65
+ if enrolled:
66
+ click.echo("This account requires two-factor authentication.")
67
+ path = MFA_VERIFY_PATH
68
+ else:
69
+ setup = http.request(
70
+ "post", MFA_SETUP_PATH, json_body={"ticket": ticket}, authenticated=False
71
+ )
72
+ if setup.status_code >= 400:
73
+ _fail(setup)
74
+ enrollment = setup.json()
75
+ click.echo("This account requires two-factor authentication. Add it to an")
76
+ click.echo("authenticator app with the setup link or the secret key:")
77
+ click.echo(f" Setup link: {enrollment['otpauth_url']}")
78
+ click.echo(f" Secret key: {enrollment['secret']}")
79
+ path = MFA_CONFIRM_PATH
80
+
81
+ for attempt in range(1, MFA_MAX_ATTEMPTS + 1):
82
+ code = "".join(click.prompt("Authenticator code").split())
83
+ response = http.request(
84
+ "post", path, json_body={"ticket": ticket, "code": code}, authenticated=False
85
+ )
86
+ if response.status_code < 400:
87
+ return response.json()
88
+ retryable = response.status_code == 400 and "ticket" not in _detail(response).lower()
89
+ if retryable and attempt < MFA_MAX_ATTEMPTS:
90
+ click.echo(f"{_detail(response)} Try again.", err=True)
91
+ continue
92
+ _fail(response)
93
+ raise AssertionError("unreachable")
19
94
 
20
95
 
21
96
  @click.command()
22
97
  @click.argument("email")
23
98
  def login(email: str) -> None:
24
- """Sign in with a magic link and store the auth token."""
99
+ """Sign in with a magic link (and an authenticator code when the account
100
+ requires two-factor authentication) and store the auth token."""
25
101
  response = http.request(
26
102
  "post", MAGIC_LINK_PATH, json_body={"email": email}, authenticated=False
27
103
  )
@@ -34,7 +110,7 @@ def login(email: str) -> None:
34
110
  click.echo("Using debug token from the API (DEBUG mode).")
35
111
  else:
36
112
  click.echo(f"Magic link sent to {email}.")
37
- magic_token = click.prompt("Paste the token from the sign-in link")
113
+ magic_token = _magic_token(click.prompt("Paste the sign-in link or its token"))
38
114
 
39
115
  verify = http.request(
40
116
  "post", VERIFY_PATH, json_body={"token": magic_token}, authenticated=False
@@ -44,6 +120,11 @@ def login(email: str) -> None:
44
120
  return
45
121
 
46
122
  payload = verify.json()
123
+ if payload.get("mfa_required"):
124
+ payload = _second_factor(payload["ticket"], bool(payload.get("mfa_enrolled")))
125
+
126
+ # Saved only once a real token is in hand: a sign-in abandoned part-way
127
+ # leaves the previous config as it was.
47
128
  config_store.set_value("token", payload["token"])
48
129
  config_store.set_value("email", email)
49
130
  click.echo(f"Logged in as {email}.")
@@ -1973,6 +1973,37 @@ paths:
1973
1973
  schema:
1974
1974
  $ref: '#/components/schemas/OptionUsage'
1975
1975
  description: ''
1976
+ /api/projects/field-definitions/query-metadata/:
1977
+ get:
1978
+ operationId: projects_field_definitions_query_metadata_retrieve
1979
+ description: Discover field keys, labels, types, and the workspace clock.
1980
+ parameters:
1981
+ - in: query
1982
+ name: fields
1983
+ schema:
1984
+ type: string
1985
+ description: Optional comma-separated field keys
1986
+ - in: query
1987
+ name: offset
1988
+ schema:
1989
+ type: integer
1990
+ - in: query
1991
+ name: workspace
1992
+ schema:
1993
+ type: string
1994
+ description: Filter by workspace UUID
1995
+ tags:
1996
+ - projects
1997
+ security:
1998
+ - MessagingJobTokenAuth: []
1999
+ - tokenAuth: []
2000
+ responses:
2001
+ '200':
2002
+ content:
2003
+ application/json:
2004
+ schema:
2005
+ $ref: '#/components/schemas/FieldDiscovery'
2006
+ description: ''
1976
2007
  /api/projects/projects/:
1977
2008
  get:
1978
2009
  operationId: projects_projects_list
@@ -2379,6 +2410,162 @@ paths:
2379
2410
  schema:
2380
2411
  $ref: '#/components/schemas/ProjectInventoryResponse'
2381
2412
  description: ''
2413
+ /api/projects/projects/query-fields/:
2414
+ post:
2415
+ operationId: projects_projects_query_fields_create
2416
+ description: Read canonical field snapshots. This POST does not write project
2417
+ state.
2418
+ tags:
2419
+ - projects
2420
+ requestBody:
2421
+ content:
2422
+ application/json:
2423
+ schema:
2424
+ additionalProperties: false
2425
+ properties:
2426
+ fields:
2427
+ items:
2428
+ type: string
2429
+ maxItems: 8
2430
+ minItems: 1
2431
+ title: Fields
2432
+ type: array
2433
+ project_ids:
2434
+ anyOf:
2435
+ - items:
2436
+ type: string
2437
+ maxItems: 200
2438
+ type: array
2439
+ - type: 'null'
2440
+ default: null
2441
+ title: Project Ids
2442
+ as_of:
2443
+ default: latest
2444
+ title: As Of
2445
+ type: string
2446
+ compare_to:
2447
+ anyOf:
2448
+ - type: string
2449
+ - type: 'null'
2450
+ default: null
2451
+ title: Compare To
2452
+ filters:
2453
+ items:
2454
+ additionalProperties: false
2455
+ properties:
2456
+ field:
2457
+ title: Field
2458
+ type: string
2459
+ target:
2460
+ enum:
2461
+ - as_of
2462
+ - compare_to
2463
+ - changed
2464
+ - delta_days
2465
+ title: Target
2466
+ type: string
2467
+ op:
2468
+ enum:
2469
+ - eq
2470
+ - ne
2471
+ - in
2472
+ - not_in
2473
+ - lt
2474
+ - lte
2475
+ - gt
2476
+ - gte
2477
+ - is_empty
2478
+ - is_missing
2479
+ - is_set
2480
+ title: Op
2481
+ type: string
2482
+ value:
2483
+ anyOf:
2484
+ - type: string
2485
+ - type: integer
2486
+ - type: number
2487
+ - type: boolean
2488
+ - items:
2489
+ anyOf:
2490
+ - type: string
2491
+ - type: integer
2492
+ - type: number
2493
+ - type: boolean
2494
+ type: array
2495
+ - type: 'null'
2496
+ title: Value
2497
+ required:
2498
+ - field
2499
+ - target
2500
+ - op
2501
+ - value
2502
+ title: FieldPredicate
2503
+ type: object
2504
+ maxItems: 20
2505
+ title: Filters
2506
+ type: array
2507
+ sort:
2508
+ anyOf:
2509
+ - additionalProperties: false
2510
+ properties:
2511
+ field:
2512
+ title: Field
2513
+ type: string
2514
+ target:
2515
+ enum:
2516
+ - as_of
2517
+ - compare_to
2518
+ - changed
2519
+ - delta_days
2520
+ title: Target
2521
+ type: string
2522
+ direction:
2523
+ enum:
2524
+ - asc
2525
+ - desc
2526
+ title: Direction
2527
+ type: string
2528
+ required:
2529
+ - field
2530
+ - target
2531
+ - direction
2532
+ title: FieldSort
2533
+ type: object
2534
+ - type: 'null'
2535
+ default: null
2536
+ limit:
2537
+ default: 20
2538
+ maximum: 50
2539
+ minimum: 1
2540
+ title: Limit
2541
+ type: integer
2542
+ cursor:
2543
+ anyOf:
2544
+ - maxLength: 12000
2545
+ minLength: 1
2546
+ type: string
2547
+ - type: 'null'
2548
+ default: null
2549
+ title: Cursor
2550
+ workspace:
2551
+ format: uuid
2552
+ title: Workspace
2553
+ type: string
2554
+ required:
2555
+ - fields
2556
+ - workspace
2557
+ title: FieldQueryRequest
2558
+ type: object
2559
+ security:
2560
+ - MessagingJobTokenAuth: []
2561
+ - tokenAuth: []
2562
+ responses:
2563
+ '200':
2564
+ content:
2565
+ application/json:
2566
+ schema:
2567
+ $ref: '#/components/schemas/FieldQueryResponse'
2568
+ description: ''
2382
2569
  /api/projects/projects/search/:
2383
2570
  get:
2384
2571
  operationId: projects_projects_search_retrieve
@@ -5814,6 +6001,51 @@ components:
5814
6001
  required:
5815
6002
  - items
5816
6003
  - next_cursor
6004
+ FieldComparison:
6005
+ type: object
6006
+ properties:
6007
+ as_of:
6008
+ $ref: '#/components/schemas/FieldSnapshot'
6009
+ compare_to:
6010
+ $ref: '#/components/schemas/FieldSnapshot'
6011
+ changed:
6012
+ type: boolean
6013
+ nullable: true
6014
+ delta_days:
6015
+ type: integer
6016
+ nullable: true
6017
+ required:
6018
+ - as_of
6019
+ FieldDiscovery:
6020
+ type: object
6021
+ properties:
6022
+ timezone:
6023
+ type: string
6024
+ now:
6025
+ type: string
6026
+ format: date-time
6027
+ current_month_start:
6028
+ type: string
6029
+ format: date-time
6030
+ time_basis:
6031
+ type: string
6032
+ fields:
6033
+ type: array
6034
+ items:
6035
+ $ref: '#/components/schemas/QueryFieldDescriptor'
6036
+ total:
6037
+ type: integer
6038
+ next_offset:
6039
+ type: integer
6040
+ nullable: true
6041
+ required:
6042
+ - current_month_start
6043
+ - fields
6044
+ - next_offset
6045
+ - now
6046
+ - time_basis
6047
+ - timezone
6048
+ - total
5817
6049
  FieldOption:
5818
6050
  type: object
5819
6051
  properties:
@@ -5886,6 +6118,112 @@ components:
5886
6118
  - workflow_name
5887
6119
  - written_by_email
5888
6120
  - written_by_name
6121
+ FieldQueryCounts:
6122
+ type: object
6123
+ properties:
6124
+ candidate:
6125
+ type: integer
6126
+ matched:
6127
+ type: integer
6128
+ unevaluable:
6129
+ type: integer
6130
+ unmatched:
6131
+ type: integer
6132
+ matched_with_missing_history:
6133
+ type: integer
6134
+ required:
6135
+ - candidate
6136
+ - matched
6137
+ - matched_with_missing_history
6138
+ - unevaluable
6139
+ - unmatched
6140
+ FieldQueryItem:
6141
+ type: object
6142
+ properties:
6143
+ project_id:
6144
+ type: string
6145
+ format: uuid
6146
+ fields:
6147
+ type: object
6148
+ additionalProperties:
6149
+ $ref: '#/components/schemas/FieldComparison'
6150
+ required:
6151
+ - fields
6152
+ - project_id
6153
+ FieldQueryResponse:
6154
+ type: object
6155
+ properties:
6156
+ timezone:
6157
+ type: string
6158
+ time_basis:
6159
+ type: string
6160
+ as_of:
6161
+ type: string
6162
+ format: date-time
6163
+ compare_to:
6164
+ type: string
6165
+ format: date-time
6166
+ nullable: true
6167
+ query_clock:
6168
+ type: string
6169
+ format: date-time
6170
+ fields:
6171
+ type: array
6172
+ items:
6173
+ $ref: '#/components/schemas/QueryFieldDescriptor'
6174
+ counts:
6175
+ $ref: '#/components/schemas/FieldQueryCounts'
6176
+ items:
6177
+ type: array
6178
+ items:
6179
+ $ref: '#/components/schemas/FieldQueryItem'
6180
+ next_cursor:
6181
+ type: string
6182
+ nullable: true
6183
+ required:
6184
+ - as_of
6185
+ - compare_to
6186
+ - counts
6187
+ - fields
6188
+ - items
6189
+ - next_cursor
6190
+ - query_clock
6191
+ - time_basis
6192
+ - timezone
6193
+ FieldSnapshot:
6194
+ type: object
6195
+ properties:
6196
+ state:
6197
+ $ref: '#/components/schemas/FieldSnapshotStateEnum'
6198
+ value:
6199
+ nullable: true
6200
+ display:
6201
+ nullable: true
6202
+ write_id:
6203
+ type: string
6204
+ format: uuid
6205
+ recorded_at:
6206
+ type: string
6207
+ format: date-time
6208
+ source:
6209
+ type: string
6210
+ evidence_observation_ids:
6211
+ type: array
6212
+ items:
6213
+ type: string
6214
+ required:
6215
+ - state
6216
+ - value
6217
+ FieldSnapshotStateEnum:
6218
+ enum:
6219
+ - value
6220
+ - empty
6221
+ - missing
6222
+ type: string
6223
+ description: |-
6224
+ * `value` - value
6225
+ * `empty` - empty
6226
+ * `missing` - missing
5889
6227
  FileDeleteRequestRequest:
5890
6228
  type: object
5891
6229
  properties:
@@ -7373,6 +7711,26 @@ components:
7373
7711
  pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$
7374
7712
  required:
7375
7713
  - batch_id
7714
+ QueryFieldDescriptor:
7715
+ type: object
7716
+ properties:
7717
+ id:
7718
+ type: string
7719
+ format: uuid
7720
+ key:
7721
+ type: string
7722
+ label:
7723
+ type: string
7724
+ data_type:
7725
+ type: string
7726
+ is_array:
7727
+ type: boolean
7728
+ required:
7729
+ - data_type
7730
+ - id
7731
+ - is_array
7732
+ - key
7733
+ - label
7376
7734
  QuickCapture:
7377
7735
  type: object
7378
7736
  description: |-
@@ -11,6 +11,11 @@ current login you@example.com # magic-link sign in, sto
11
11
  current config set default_workspace <workspace-uuid> # used when --workspace is omitted
12
12
  ```
13
13
 
14
+ `current login` asks for the sign-in link from the email (or its token). An
15
+ account that requires two-factor authentication is also asked for an
16
+ authenticator code; one with no device enrolled yet is shown a setup link
17
+ and secret key to add to an authenticator app first.
18
+
14
19
  The CLI speaks to `https://api.current.orinlabs.ai` by default. For another
15
20
  server (for example local development), set
16
21
  `current config set base_url http://localhost:8000`.
@@ -559,7 +564,8 @@ Options:
559
564
 
560
565
  ### current login <email>
561
566
 
562
- Sign in with a magic link and store the auth token.
567
+ Sign in with a magic link (and an authenticator code when the account
568
+ requires two-factor authentication) and store the auth token.
563
569
 
564
570
  ### current logout
565
571
 
@@ -744,6 +750,17 @@ PATCH /api/projects/field-definitions/{id}/
744
750
  Options:
745
751
  - `--data` (required) - Request body as JSON: inline, @file.json, or - for stdin.
746
752
 
753
+ ### current projects field-definitions query-metadata retrieve
754
+
755
+ Discover field keys, labels, types, and the workspace clock.
756
+
757
+ GET /api/projects/field-definitions/query-metadata/
758
+
759
+ Options:
760
+ - `--fields` - Optional comma-separated field keys
761
+ - `--offset`
762
+ - `--workspace` - Filter by workspace UUID
763
+
747
764
  ### current projects field-definitions retrieve <id>
748
765
 
749
766
  Workspace-level custom field definitions. Only admins can mutate.
@@ -805,6 +822,15 @@ PATCH /api/projects/projects/{id}/
805
822
  Options:
806
823
  - `--data` (required) - Request body as JSON: inline, @file.json, or - for stdin.
807
824
 
825
+ ### current projects query-fields create
826
+
827
+ Read canonical field snapshots. This POST does not write project state.
828
+
829
+ POST /api/projects/projects/query-fields/
830
+
831
+ Options:
832
+ - `--data` (required) - Request body as JSON: inline, @file.json, or - for stdin.
833
+
808
834
  ### current projects retrieve <id>
809
835
 
810
836
  Projects across the caller's workspaces. Any member can create a
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "current-cli"
3
- version = "0.1.58"
3
+ version = "0.1.60"
4
4
  description = "Command line interface for the Current API, generated from its OpenAPI schema at runtime"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,213 @@
1
+ """The login flow must match the API's magic-link contract."""
2
+
3
+ import json
4
+
5
+ import httpx
6
+
7
+ from current_cli import config
8
+
9
+
10
+ def make_auth_handler(debug_token: str | None = "magic-1"):
11
+ def handler(request: httpx.Request) -> httpx.Response:
12
+ if request.url.path == "/api/accounts/auth/magic-link/":
13
+ payload = {"detail": "Magic link sent."}
14
+ if debug_token:
15
+ payload["debug_token"] = debug_token
16
+ return httpx.Response(200, json=payload)
17
+ if request.url.path == "/api/accounts/auth/verify/":
18
+ body = json.loads(request.content)
19
+ if body["token"] != "magic-1":
20
+ return httpx.Response(400, json={"detail": "Invalid or expired link."})
21
+ return httpx.Response(
22
+ 200, json={"token": "drf-token", "user": {"email": "dev@example.com"}}
23
+ )
24
+ if request.url.path == "/api/accounts/auth/me/":
25
+ return httpx.Response(200, json={"email": "dev@example.com"})
26
+ if request.url.path == "/api/accounts/auth/logout/":
27
+ return httpx.Response(200, json={"detail": "Successfully logged out."})
28
+ return httpx.Response(404, json={"detail": "Not found."})
29
+
30
+ return handler
31
+
32
+
33
+ def test_login_with_debug_token(cli, runner, transport_factory):
34
+ transport = transport_factory(make_auth_handler())
35
+
36
+ result = runner.invoke(cli, ["login", "dev@example.com"])
37
+
38
+ assert result.exit_code == 0, result.output
39
+ assert config.get("token") == "drf-token"
40
+ assert config.get("email") == "dev@example.com"
41
+ magic, verify = transport.requests
42
+ assert json.loads(magic.content) == {"email": "dev@example.com"}
43
+ assert json.loads(verify.content) == {"token": "magic-1"}
44
+
45
+
46
+ def test_login_prompts_without_debug_token(cli, runner, transport_factory):
47
+ transport_factory(make_auth_handler(debug_token=None))
48
+
49
+ result = runner.invoke(cli, ["login", "dev@example.com"], input="magic-1\n")
50
+
51
+ assert result.exit_code == 0, result.output
52
+ assert config.get("token") == "drf-token"
53
+
54
+
55
+ def test_login_bad_token_fails(cli, runner, transport_factory):
56
+ transport_factory(make_auth_handler(debug_token="wrong"))
57
+
58
+ result = runner.invoke(cli, ["login", "dev@example.com"])
59
+
60
+ assert result.exit_code == 1
61
+ assert config.get("token") is None
62
+
63
+
64
+ def test_whoami_sends_token(cli, runner, transport_factory):
65
+ config.set_value("token", "drf-token")
66
+ transport = transport_factory(make_auth_handler())
67
+
68
+ result = runner.invoke(cli, ["whoami"])
69
+
70
+ assert result.exit_code == 0, result.output
71
+ assert transport.requests[0].headers["Authorization"] == "Token drf-token"
72
+ assert json.loads(result.output) == {"email": "dev@example.com"}
73
+
74
+
75
+ def test_logout_clears_token(cli, runner, transport_factory):
76
+ config.set_value("token", "drf-token")
77
+ transport = transport_factory(make_auth_handler())
78
+
79
+ result = runner.invoke(cli, ["logout"])
80
+
81
+ assert result.exit_code == 0, result.output
82
+ assert config.get("token") is None
83
+ assert transport.requests[0].url.path == "/api/accounts/auth/logout/"
84
+
85
+
86
+ def test_config_set_and_get(cli, runner):
87
+ result = runner.invoke(cli, ["config", "set", "default_workspace", "ws-1"])
88
+ assert result.exit_code == 0, result.output
89
+
90
+ result = runner.invoke(cli, ["config", "get", "default_workspace"])
91
+ assert result.exit_code == 0
92
+ assert result.output.strip() == "ws-1"
93
+
94
+
95
+ def make_mfa_handler(*, enrolled: bool, code: str = "123456", ticket_valid: bool = True):
96
+ """The API's MFA login branch: verify answers with a ticket instead of a
97
+ token, and a code (after enrollment, when not enrolled) completes it."""
98
+
99
+ def handler(request: httpx.Request) -> httpx.Response:
100
+ path = request.url.path
101
+ body = json.loads(request.content) if request.content else {}
102
+ if path == "/api/accounts/auth/magic-link/":
103
+ return httpx.Response(200, json={"detail": "Magic link sent."})
104
+ if path == "/api/accounts/auth/verify/":
105
+ if body["token"] != "magic-1":
106
+ return httpx.Response(400, json={"detail": "Invalid or expired link."})
107
+ return httpx.Response(
108
+ 200, json={"mfa_required": True, "mfa_enrolled": enrolled, "ticket": "ticket-1"}
109
+ )
110
+ if not ticket_valid or body.get("ticket") != "ticket-1":
111
+ status = 400 if path.endswith("/verify/") else 403
112
+ return httpx.Response(status, json={"detail": "Invalid or expired ticket."})
113
+ if path == "/api/accounts/auth/mfa/setup/":
114
+ return httpx.Response(
115
+ 200,
116
+ json={"secret": "SECRET", "otpauth_url": "otpauth://totp/Current?secret=SECRET"},
117
+ )
118
+ if path in ("/api/accounts/auth/mfa/verify/", "/api/accounts/auth/mfa/confirm/"):
119
+ if body["code"] != code:
120
+ return httpx.Response(400, json={"detail": "Incorrect code."})
121
+ return httpx.Response(
122
+ 200,
123
+ json={
124
+ "mfa_required": False,
125
+ "token": "drf-token",
126
+ "user": {"email": "a@orinlabs.ai"},
127
+ },
128
+ )
129
+ return httpx.Response(404, json={"detail": "Not found."})
130
+
131
+ return handler
132
+
133
+
134
+ def test_login_with_enrolled_mfa(cli, runner, transport_factory):
135
+ transport = transport_factory(make_mfa_handler(enrolled=True))
136
+
137
+ result = runner.invoke(cli, ["login", "a@orinlabs.ai"], input="magic-1\n123 456\n")
138
+
139
+ assert result.exit_code == 0, result.output
140
+ assert config.get("token") == "drf-token"
141
+ paths = [r.url.path for r in transport.requests]
142
+ assert paths[-1] == "/api/accounts/auth/mfa/verify/"
143
+ assert json.loads(transport.requests[-1].content) == {"ticket": "ticket-1", "code": "123456"}
144
+
145
+
146
+ def test_login_enrolls_mfa_when_not_enrolled(cli, runner, transport_factory):
147
+ transport = transport_factory(make_mfa_handler(enrolled=False))
148
+
149
+ result = runner.invoke(cli, ["login", "a@orinlabs.ai"], input="magic-1\n123456\n")
150
+
151
+ assert result.exit_code == 0, result.output
152
+ assert "otpauth://totp/Current?secret=SECRET" in result.output
153
+ assert "SECRET" in result.output
154
+ assert config.get("token") == "drf-token"
155
+ paths = [r.url.path for r in transport.requests]
156
+ assert paths[-2:] == ["/api/accounts/auth/mfa/setup/", "/api/accounts/auth/mfa/confirm/"]
157
+
158
+
159
+ def test_login_retries_incorrect_code(cli, runner, transport_factory):
160
+ transport_factory(make_mfa_handler(enrolled=True))
161
+
162
+ result = runner.invoke(cli, ["login", "a@orinlabs.ai"], input="magic-1\n000000\n123456\n")
163
+
164
+ assert result.exit_code == 0, result.output
165
+ assert "Incorrect code. Try again." in result.output
166
+ assert config.get("token") == "drf-token"
167
+
168
+
169
+ def test_login_gives_up_after_three_incorrect_codes(cli, runner, transport_factory):
170
+ transport = transport_factory(make_mfa_handler(enrolled=True))
171
+
172
+ result = runner.invoke(
173
+ cli, ["login", "a@orinlabs.ai"], input="magic-1\n000000\n000000\n000000\n"
174
+ )
175
+
176
+ assert result.exit_code == 1
177
+ assert config.get("token") is None
178
+ verifies = [r for r in transport.requests if r.url.path == "/api/accounts/auth/mfa/verify/"]
179
+ assert len(verifies) == 3
180
+
181
+
182
+ def test_login_expired_ticket_says_to_start_again(cli, runner, transport_factory):
183
+ transport_factory(make_mfa_handler(enrolled=True, ticket_valid=False))
184
+
185
+ result = runner.invoke(cli, ["login", "a@orinlabs.ai"], input="magic-1\n123456\n")
186
+
187
+ assert result.exit_code == 1
188
+ assert "Run `current login` again." in result.output
189
+ assert config.get("token") is None
190
+
191
+
192
+ def test_login_expired_ticket_during_enrollment(cli, runner, transport_factory):
193
+ transport_factory(make_mfa_handler(enrolled=False, ticket_valid=False))
194
+
195
+ result = runner.invoke(cli, ["login", "a@orinlabs.ai"], input="magic-1\n")
196
+
197
+ assert result.exit_code == 1
198
+ assert "Run `current login` again." in result.output
199
+ assert config.get("token") is None
200
+
201
+
202
+ def test_login_accepts_pasted_sign_in_link(cli, runner, transport_factory):
203
+ transport = transport_factory(make_auth_handler(debug_token=None))
204
+
205
+ result = runner.invoke(
206
+ cli,
207
+ ["login", "dev@example.com"],
208
+ input="https://current.orinlabs.ai/auth/verify?token=magic-1\n",
209
+ )
210
+
211
+ assert result.exit_code == 0, result.output
212
+ assert json.loads(transport.requests[1].content) == {"token": "magic-1"}
213
+ assert config.get("token") == "drf-token"
@@ -1,92 +0,0 @@
1
- """The login flow must match the API's magic-link contract."""
2
-
3
- import json
4
-
5
- import httpx
6
-
7
- from current_cli import config
8
-
9
-
10
- def make_auth_handler(debug_token: str | None = "magic-1"):
11
- def handler(request: httpx.Request) -> httpx.Response:
12
- if request.url.path == "/api/accounts/auth/magic-link/":
13
- payload = {"detail": "Magic link sent."}
14
- if debug_token:
15
- payload["debug_token"] = debug_token
16
- return httpx.Response(200, json=payload)
17
- if request.url.path == "/api/accounts/auth/verify/":
18
- body = json.loads(request.content)
19
- if body["token"] != "magic-1":
20
- return httpx.Response(400, json={"detail": "Invalid or expired link."})
21
- return httpx.Response(
22
- 200, json={"token": "drf-token", "user": {"email": "dev@example.com"}}
23
- )
24
- if request.url.path == "/api/accounts/auth/me/":
25
- return httpx.Response(200, json={"email": "dev@example.com"})
26
- if request.url.path == "/api/accounts/auth/logout/":
27
- return httpx.Response(200, json={"detail": "Successfully logged out."})
28
- return httpx.Response(404, json={"detail": "Not found."})
29
-
30
- return handler
31
-
32
-
33
- def test_login_with_debug_token(cli, runner, transport_factory):
34
- transport = transport_factory(make_auth_handler())
35
-
36
- result = runner.invoke(cli, ["login", "dev@example.com"])
37
-
38
- assert result.exit_code == 0, result.output
39
- assert config.get("token") == "drf-token"
40
- assert config.get("email") == "dev@example.com"
41
- magic, verify = transport.requests
42
- assert json.loads(magic.content) == {"email": "dev@example.com"}
43
- assert json.loads(verify.content) == {"token": "magic-1"}
44
-
45
-
46
- def test_login_prompts_without_debug_token(cli, runner, transport_factory):
47
- transport_factory(make_auth_handler(debug_token=None))
48
-
49
- result = runner.invoke(cli, ["login", "dev@example.com"], input="magic-1\n")
50
-
51
- assert result.exit_code == 0, result.output
52
- assert config.get("token") == "drf-token"
53
-
54
-
55
- def test_login_bad_token_fails(cli, runner, transport_factory):
56
- transport_factory(make_auth_handler(debug_token="wrong"))
57
-
58
- result = runner.invoke(cli, ["login", "dev@example.com"])
59
-
60
- assert result.exit_code == 1
61
- assert config.get("token") is None
62
-
63
-
64
- def test_whoami_sends_token(cli, runner, transport_factory):
65
- config.set_value("token", "drf-token")
66
- transport = transport_factory(make_auth_handler())
67
-
68
- result = runner.invoke(cli, ["whoami"])
69
-
70
- assert result.exit_code == 0, result.output
71
- assert transport.requests[0].headers["Authorization"] == "Token drf-token"
72
- assert json.loads(result.output) == {"email": "dev@example.com"}
73
-
74
-
75
- def test_logout_clears_token(cli, runner, transport_factory):
76
- config.set_value("token", "drf-token")
77
- transport = transport_factory(make_auth_handler())
78
-
79
- result = runner.invoke(cli, ["logout"])
80
-
81
- assert result.exit_code == 0, result.output
82
- assert config.get("token") is None
83
- assert transport.requests[0].url.path == "/api/accounts/auth/logout/"
84
-
85
-
86
- def test_config_set_and_get(cli, runner):
87
- result = runner.invoke(cli, ["config", "set", "default_workspace", "ws-1"])
88
- assert result.exit_code == 0, result.output
89
-
90
- result = runner.invoke(cli, ["config", "get", "default_workspace"])
91
- assert result.exit_code == 0
92
- assert result.output.strip() == "ws-1"
File without changes