mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
feat(terraform): add litellm_jwt_key_mapping resource (#38714)
* feat(terraform): add litellm_jwt_key_mapping resource Adds a Terraform resource for the proxy's JWT to virtual key mappings, so a JWT client identified by a claim such as client_id, azp or sub maps to a virtual key and inherits its models, budgets, rate limits and spend tracking. Covers the four mapping endpoints: /jwt/key/mapping/new, /info, /update and /delete. is_active is applied through a follow-up update because the create endpoint always starts a mapping active, a dropped description is sent as an empty string because the update endpoint ignores absent fields, changing the mapped key rotates it in place, and changing the claim name or value forces replacement since the update endpoint cannot change them. * fix(terraform): revert key on failed jwt_key_mapping update Classic SDKv2 persists a failed Update's diff-applied values to state regardless of the error, so a rejected key rotation left the new key in state while the proxy kept the old one and the next plan falsely converged. Revert key via GetChange and resync description/is_active/computed fields from a post-failure Read, since Read alone can't recover key (the proxy never returns it). Also drop the case-insensitive "mapping not found" body match: the proxy raises 404 for all three not-found paths (info, update, delete), so checking the status code alone is sufficient. Clarify the docs: referencing a litellm_key resource's write-only key is not a null-then-400 situation, it's a static "Missing required argument" error at plan time, in every apply ordering. * fix(terraform): stop leaving an active mapping behind on failed cleanup Two issues flagged by review: - Create has no way to ask the proxy for an inactive mapping, so an is_active=false mapping is briefly active while the follow-up deactivation runs. If that deactivation call itself fails, the mapping used to stay active and untracked. It's now deleted instead, closing the exposure rather than leaving it open indefinitely. - On a failed update, only `key` was reverted before the recovery read. If that read also failed, description/is_active kept the rejected values, so a later plan could report false convergence. Now all three are reverted before the read runs. Both come with regression tests, mutation-verified against the pre-fix code. * fix(deps): bump restrictedpython to 8.5 for GHSA-ffg3-p8fm-mjx2 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore: retrigger ci Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(tests): stub anthropic judge credentials in funnel seeding test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * revert(deps): keep uv.lock unchanged to keep the PR terraform-only Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Fabrice Pont <fabrice.pont@doctolib.com> Co-authored-by: yassin <yassin@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
d42c71b7ff
commit
592518202c
10 changed files with 1015 additions and 0 deletions
|
|
@ -16,6 +16,7 @@ longer signal it.
|
|||
|
||||
### Added
|
||||
|
||||
- **jwt_key_mapping**: New `litellm_jwt_key_mapping` resource for the proxy's JWT to virtual key mappings, so JWT clients identified by a claim (`client_id`, `azp`, `sub`) map to virtual keys and inherit their models, budgets and rate limits. Supports `description` and `is_active`, rotating the mapped key in place, and forces replacement when the claim name or value changes
|
||||
- **team**: `soft_budget`, `tags`, and `soft_budget_alerting_emails` attributes on `litellm_team`, matching what `/team/new` and `/team/update` already accept; `soft_budget_alerting_emails` is sent under `metadata`, where the proxy reads it
|
||||
- **user**: New `litellm_user` resource and `litellm_user` / `litellm_users` data sources for managing internal users
|
||||
- **budget**: New `litellm_budget` resource and `litellm_budget` / `litellm_budgets` data sources for reusable budget objects
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ For full details on the <code>litellm_key</code> resource, see the [key resource
|
|||
- <code>litellm_mcp_server</code>: Manage MCP (Model Context Protocol) servers. [Documentation](docs/resources/mcp_server.md)
|
||||
- <code>litellm_credential</code>: Manage credentials for secure authentication. [Documentation](docs/resources/credential.md)
|
||||
- <code>litellm_vector_store</code>: Manage vector stores for embeddings and RAG. [Documentation](docs/resources/vector_store.md)
|
||||
- <code>litellm_jwt_key_mapping</code>: Map JWT claim values to virtual keys for per-client budgets and limits. [Documentation](docs/resources/jwt_key_mapping.md)
|
||||
|
||||
### Available Data Sources
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ The LiteLLM provider supports the following resources:
|
|||
* [`litellm_mcp_server`](./resources/mcp_server) - Manage MCP (Model Context Protocol) servers
|
||||
* [`litellm_credential`](./resources/credential) - Manage credentials for various providers
|
||||
* [`litellm_vector_store`](./resources/vector_store) - Manage vector stores
|
||||
* [`litellm_jwt_key_mapping`](./resources/jwt_key_mapping) - Map JWT claim values to virtual keys
|
||||
|
||||
## Available Data Sources
|
||||
|
||||
|
|
|
|||
94
terraform/provider/docs/resources/jwt_key_mapping.md
Normal file
94
terraform/provider/docs/resources/jwt_key_mapping.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# litellm_jwt_key_mapping
|
||||
|
||||
Maps a JWT claim value to a LiteLLM virtual key. Every JWT client identified by a claim, typically `client_id`, `azp` or `sub`, then gets the model restrictions, budgets, rate limits, guardrails and spend tracking of the virtual key it maps to, without that key ever being handed to the client.
|
||||
|
||||
The mappings only take effect once JWT auth is enabled on the proxy, which is configuration rather than API state:
|
||||
|
||||
```yaml
|
||||
general_settings:
|
||||
enable_jwt_auth: True
|
||||
litellm_jwtauth:
|
||||
virtual_key_claim_field: "client_id"
|
||||
unregistered_jwt_client_behavior: "fallback_team_mapping"
|
||||
```
|
||||
|
||||
See [JWT to virtual key mapping](https://docs.litellm.ai/docs/proxy/jwt_key_mapping) for the proxy side of the feature
|
||||
|
||||
## Example Usage
|
||||
|
||||
The mapped virtual key has to exist already and its value has to be known to Terraform, so it comes from a variable or a secret manager rather than from a `litellm_key` resource. `litellm_key` deliberately made its generated `key` write-only, to avoid storing raw API keys in state, so referencing it here does not merely read back null: Terraform's write-only enforcement turns `key = litellm_key.foo.key` into a static `Missing required argument` error at `terraform plan`, before any API call, in every apply ordering, including a first apply where both resources are created together:
|
||||
|
||||
```hcl
|
||||
variable "alice_key" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
resource "litellm_jwt_key_mapping" "alice" {
|
||||
jwt_claim_name = "client_id"
|
||||
jwt_claim_value = "dev-alice"
|
||||
key = var.alice_key
|
||||
}
|
||||
```
|
||||
|
||||
Per-client limits live on the virtual key, so one mapping per client is how each JWT client gets its own budget and quota:
|
||||
|
||||
```hcl
|
||||
resource "litellm_jwt_key_mapping" "billing_service" {
|
||||
jwt_claim_name = "client_id"
|
||||
jwt_claim_value = "billing-service"
|
||||
key = var.billing_service_key
|
||||
description = "Billing service JWT client"
|
||||
is_active = true
|
||||
}
|
||||
```
|
||||
|
||||
Several clients at once, with the key values coming from a map of secrets:
|
||||
|
||||
```hcl
|
||||
variable "jwt_client_keys" {
|
||||
type = map(string)
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
resource "litellm_jwt_key_mapping" "developer" {
|
||||
for_each = var.jwt_client_keys
|
||||
|
||||
jwt_claim_name = "client_id"
|
||||
jwt_claim_value = each.key
|
||||
key = each.value
|
||||
description = "Developer JWT client ${each.key}"
|
||||
}
|
||||
```
|
||||
|
||||
## Argument Reference
|
||||
|
||||
- `jwt_claim_name` - (Required, ForceNew) Name of the JWT claim to match on, for example `client_id`, `azp` or `sub`. Must match `virtual_key_claim_field` in the proxy JWT config
|
||||
- `jwt_claim_value` - (Required, ForceNew) Value of the claim identifying the JWT client. Unique together with `jwt_claim_name`, so a second mapping for the same pair fails with a 409
|
||||
- `key` - (Required, Sensitive) The virtual key this claim value maps to. It has to exist already, otherwise the proxy rejects the mapping with `The provided key does not match an existing virtual key`
|
||||
- `description` - (Optional) Description of the mapping
|
||||
- `is_active` - (Optional) Whether the mapping is active. Inactive mappings are ignored during JWT auth. Defaults to `true`
|
||||
|
||||
## Attribute Reference
|
||||
|
||||
- `id` - The mapping ID assigned by LiteLLM
|
||||
- `created_at` - Timestamp when the mapping was created
|
||||
- `updated_at` - Timestamp when the mapping was last updated
|
||||
- `created_by` - User who created the mapping
|
||||
- `updated_by` - User who last updated the mapping
|
||||
|
||||
## Notes
|
||||
|
||||
The proxy stores only a hash of `key` and never returns it, so drift on that attribute cannot be detected and Terraform tracks the value from your configuration. Changing `key` rotates the mapping onto the new virtual key in place, with no replacement. Like the other secrets this provider accepts, such as `credential_values` and `model_api_key`, the configured value is kept in state, so treat the state as sensitive
|
||||
|
||||
Only proxy admins can create, update or delete mappings, so the provider `api_key` has to be a master key or an admin key
|
||||
|
||||
## Import
|
||||
|
||||
Mappings are imported by their mapping ID:
|
||||
|
||||
```shell
|
||||
terraform import litellm_jwt_key_mapping.alice 297a5536-1aeb-4cf1-b666-b3809c2750a8
|
||||
```
|
||||
|
||||
Because the API does not return the mapped key, `key` is empty in state right after an import, so the first plan shows an in-place update that pushes the configured key back to the proxy. That update is harmless, the proxy just rehashes the same value when the key has not actually changed
|
||||
|
|
@ -19,6 +19,7 @@ func Provider() *schema.Provider {
|
|||
"litellm_mcp_server": resourceLiteLLMMCPServer(),
|
||||
"litellm_credential": resourceLiteLLMCredential(),
|
||||
"litellm_vector_store": resourceLiteLLMVectorStore(),
|
||||
"litellm_jwt_key_mapping": resourceLiteLLMJWTKeyMapping(),
|
||||
"litellm_fallback": resourceLiteLLMFallback(),
|
||||
"litellm_key_block": resourceLiteLLMKeyBlock(),
|
||||
"litellm_team_block": resourceLiteLLMTeamBlock(),
|
||||
|
|
|
|||
70
terraform/provider/litellm/resource_jwt_key_mapping.go
Normal file
70
terraform/provider/litellm/resource_jwt_key_mapping.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package litellm
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||
)
|
||||
|
||||
func resourceLiteLLMJWTKeyMapping() *schema.Resource {
|
||||
return &schema.Resource{
|
||||
Create: resourceLiteLLMJWTKeyMappingCreate,
|
||||
Read: resourceLiteLLMJWTKeyMappingRead,
|
||||
Update: resourceLiteLLMJWTKeyMappingUpdate,
|
||||
Delete: resourceLiteLLMJWTKeyMappingDelete,
|
||||
|
||||
Importer: &schema.ResourceImporter{
|
||||
StateContext: schema.ImportStatePassthroughContext,
|
||||
},
|
||||
|
||||
Schema: map[string]*schema.Schema{
|
||||
"jwt_claim_name": {
|
||||
Type: schema.TypeString,
|
||||
Required: true,
|
||||
ForceNew: true,
|
||||
Description: "Name of the JWT claim to match on, for example client_id, azp or sub. Must match virtual_key_claim_field in the proxy JWT config",
|
||||
},
|
||||
"jwt_claim_value": {
|
||||
Type: schema.TypeString,
|
||||
Required: true,
|
||||
ForceNew: true,
|
||||
Description: "Value of the claim identifying the JWT client. Unique together with jwt_claim_name",
|
||||
},
|
||||
"key": {
|
||||
Type: schema.TypeString,
|
||||
Required: true,
|
||||
Sensitive: true,
|
||||
Description: "The virtual key this claim value maps to. The proxy stores only a hash of it and never returns it, so drift on this attribute cannot be detected and Terraform tracks the configured value",
|
||||
},
|
||||
"description": {
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
Description: "Description of the mapping",
|
||||
},
|
||||
"is_active": {
|
||||
Type: schema.TypeBool,
|
||||
Optional: true,
|
||||
Default: true,
|
||||
Description: "Whether the mapping is active. Inactive mappings are ignored during JWT auth",
|
||||
},
|
||||
"created_at": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Description: "Timestamp when the mapping was created",
|
||||
},
|
||||
"updated_at": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Description: "Timestamp when the mapping was last updated",
|
||||
},
|
||||
"created_by": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Description: "User who created the mapping",
|
||||
},
|
||||
"updated_by": {
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
Description: "User who last updated the mapping",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
186
terraform/provider/litellm/resource_jwt_key_mapping_crud.go
Normal file
186
terraform/provider/litellm/resource_jwt_key_mapping_crud.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package litellm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||
)
|
||||
|
||||
const jwtKeyMappingNotFound = "jwt_key_mapping_not_found"
|
||||
|
||||
func resourceLiteLLMJWTKeyMappingCreate(d *schema.ResourceData, m interface{}) error {
|
||||
client := m.(*Client)
|
||||
|
||||
createRequest := JWTKeyMappingRequest{
|
||||
JWTClaimName: d.Get("jwt_claim_name").(string),
|
||||
JWTClaimValue: d.Get("jwt_claim_value").(string),
|
||||
Key: d.Get("key").(string),
|
||||
Description: d.Get("description").(string),
|
||||
}
|
||||
|
||||
resp, err := MakeRequest(client, "POST", "/jwt/key/mapping/new", createRequest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create JWT key mapping: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var mapping JWTKeyMappingResponse
|
||||
if err := handleJWTKeyMappingAPIResponse(resp, &mapping, client); err != nil {
|
||||
return fmt.Errorf("failed to create JWT key mapping: %w", err)
|
||||
}
|
||||
|
||||
if mapping.ID == "" {
|
||||
return fmt.Errorf("failed to create JWT key mapping: the proxy returned no mapping id")
|
||||
}
|
||||
|
||||
d.SetId(mapping.ID)
|
||||
|
||||
// The create endpoint has no is_active field and always activates the
|
||||
// mapping, so a JWT client matching this claim can authenticate during
|
||||
// the gap before the deactivation call below runs. If deactivation
|
||||
// itself fails, delete the mapping rather than leaving it active and
|
||||
// unmanaged indefinitely.
|
||||
if !d.Get("is_active").(bool) {
|
||||
if err := updateJWTKeyMapping(d, client); err != nil {
|
||||
if deleteErr := deleteJWTKeyMapping(mapping.ID, client); deleteErr != nil {
|
||||
return fmt.Errorf(
|
||||
"JWT key mapping %s was created active and could not be deactivated (%v); it also could not be deleted and remains active on the proxy, remove it manually via POST /jwt/key/mapping/delete: %v",
|
||||
mapping.ID, err, deleteErr,
|
||||
)
|
||||
}
|
||||
d.SetId("")
|
||||
return fmt.Errorf("JWT key mapping was created active but could not be deactivated, so it was deleted instead: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return resourceLiteLLMJWTKeyMappingRead(d, m)
|
||||
}
|
||||
|
||||
func resourceLiteLLMJWTKeyMappingRead(d *schema.ResourceData, m interface{}) error {
|
||||
client := m.(*Client)
|
||||
|
||||
resp, err := MakeRequest(client, "GET", fmt.Sprintf("/jwt/key/mapping/info?id=%s", url.QueryEscape(d.Id())), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read JWT key mapping: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var mapping JWTKeyMappingResponse
|
||||
if err := handleJWTKeyMappingAPIResponse(resp, &mapping, client); err != nil {
|
||||
if err.Error() == jwtKeyMappingNotFound {
|
||||
d.SetId("")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read JWT key mapping: %w", err)
|
||||
}
|
||||
|
||||
d.SetId(mapping.ID)
|
||||
d.Set("jwt_claim_name", mapping.JWTClaimName)
|
||||
d.Set("jwt_claim_value", mapping.JWTClaimValue)
|
||||
d.Set("description", mapping.Description)
|
||||
d.Set("is_active", mapping.IsActive)
|
||||
d.Set("created_at", mapping.CreatedAt)
|
||||
d.Set("updated_at", mapping.UpdatedAt)
|
||||
d.Set("created_by", mapping.CreatedBy)
|
||||
d.Set("updated_by", mapping.UpdatedBy)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resourceLiteLLMJWTKeyMappingUpdate(d *schema.ResourceData, m interface{}) error {
|
||||
client := m.(*Client)
|
||||
|
||||
oldKey, _ := d.GetChange("key")
|
||||
oldDescription, _ := d.GetChange("description")
|
||||
oldIsActive, _ := d.GetChange("is_active")
|
||||
|
||||
if err := updateJWTKeyMapping(d, client); err != nil {
|
||||
// The update is a single atomic API call: on failure nothing changed
|
||||
// server-side. Revert every field the update could have changed before
|
||||
// attempting to resync, so a failed refresh can't leave the rejected
|
||||
// values persisted into state.
|
||||
d.Set("key", oldKey)
|
||||
d.Set("description", oldDescription)
|
||||
d.Set("is_active", oldIsActive)
|
||||
if readErr := resourceLiteLLMJWTKeyMappingRead(d, m); readErr != nil {
|
||||
return fmt.Errorf("failed to update JWT key mapping: %w (and failed to refresh state afterward: %v)", err, readErr)
|
||||
}
|
||||
return fmt.Errorf("failed to update JWT key mapping: %w", err)
|
||||
}
|
||||
|
||||
return resourceLiteLLMJWTKeyMappingRead(d, m)
|
||||
}
|
||||
|
||||
func resourceLiteLLMJWTKeyMappingDelete(d *schema.ResourceData, m interface{}) error {
|
||||
client := m.(*Client)
|
||||
|
||||
if err := deleteJWTKeyMapping(d.Id(), client); err != nil {
|
||||
return fmt.Errorf("failed to delete JWT key mapping: %w", err)
|
||||
}
|
||||
|
||||
d.SetId("")
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteJWTKeyMapping(id string, client *Client) error {
|
||||
resp, err := MakeRequest(client, "POST", "/jwt/key/mapping/delete", JWTKeyMappingDeleteRequest{ID: id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := handleJWTKeyMappingAPIResponse(resp, nil, client); err != nil {
|
||||
if err.Error() != jwtKeyMappingNotFound {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateJWTKeyMapping(d *schema.ResourceData, client *Client) error {
|
||||
updateRequest := JWTKeyMappingUpdateRequest{
|
||||
ID: d.Id(),
|
||||
Key: d.Get("key").(string),
|
||||
Description: d.Get("description").(string),
|
||||
IsActive: d.Get("is_active").(bool),
|
||||
}
|
||||
|
||||
resp, err := MakeRequest(client, "POST", "/jwt/key/mapping/update", updateRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return handleJWTKeyMappingAPIResponse(resp, nil, client)
|
||||
}
|
||||
|
||||
func handleJWTKeyMappingAPIResponse(resp *http.Response, result interface{}, client *Client) error {
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf(jwtKeyMappingNotFound)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return fmt.Errorf("API request failed: Status: %s, Response: %s",
|
||||
resp.Status, client.redactSensitiveData(string(bodyBytes)))
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, result); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
630
terraform/provider/litellm/resource_jwt_key_mapping_crud_test.go
Normal file
630
terraform/provider/litellm/resource_jwt_key_mapping_crud_test.go
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
package litellm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
|
||||
)
|
||||
|
||||
// resourceDataWithChange builds a ResourceData carrying a real diff between
|
||||
// prior state and new config, so d.GetChange reflects true old/new values.
|
||||
// schema.TestResourceDataRaw diffs against a nil prior state, which collapses
|
||||
// GetChange's old side to the zero value and can't exercise this.
|
||||
func resourceDataWithChange(t *testing.T, oldAttrs map[string]string, newRaw map[string]interface{}) *schema.ResourceData {
|
||||
t.Helper()
|
||||
|
||||
sm := schema.InternalMap(resourceLiteLLMJWTKeyMapping().Schema)
|
||||
state := &terraform.InstanceState{ID: oldAttrs["id"], Attributes: oldAttrs}
|
||||
config := terraform.NewResourceConfigRaw(newRaw)
|
||||
|
||||
diff, err := sm.Diff(context.Background(), state, config, nil, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("diff: %v", err)
|
||||
}
|
||||
d, err := sm.Data(state, diff)
|
||||
if err != nil {
|
||||
t.Fatalf("data: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type jwtKeyMappingCall struct {
|
||||
Method string
|
||||
Path string
|
||||
Query string
|
||||
Body map[string]interface{}
|
||||
}
|
||||
|
||||
func jwtKeyMappingTestServer(t *testing.T, mapping JWTKeyMappingResponse) (*httptest.Server, *[]jwtKeyMappingCall) {
|
||||
t.Helper()
|
||||
|
||||
calls := make([]jwtKeyMappingCall, 0)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := map[string]interface{}{}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
calls = append(calls, jwtKeyMappingCall{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Body: body})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/jwt/key/mapping/delete":
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
default:
|
||||
_ = json.NewEncoder(w).Encode(mapping)
|
||||
}
|
||||
}))
|
||||
|
||||
return srv, &calls
|
||||
}
|
||||
|
||||
func jwtKeyMappingFixture() JWTKeyMappingResponse {
|
||||
return JWTKeyMappingResponse{
|
||||
ID: "map-abc-123",
|
||||
JWTClaimName: "client_id",
|
||||
JWTClaimValue: "dev-alice",
|
||||
Description: "dev-alice",
|
||||
IsActive: true,
|
||||
CreatedAt: "2026-08-06T10:00:00Z",
|
||||
UpdatedAt: "2026-08-06T11:00:00Z",
|
||||
CreatedBy: "admin",
|
||||
UpdatedBy: "admin",
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingCreateSendsClaimAndKey(t *testing.T) {
|
||||
srv, calls := jwtKeyMappingTestServer(t, jwtKeyMappingFixture())
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-abc123",
|
||||
"description": "dev-alice",
|
||||
"is_active": true,
|
||||
})
|
||||
|
||||
if err := resourceLiteLLMJWTKeyMappingCreate(d, client); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
if d.Id() != "map-abc-123" {
|
||||
t.Fatalf("expected id from the API response, got %q", d.Id())
|
||||
}
|
||||
|
||||
create := (*calls)[0]
|
||||
if create.Method != "POST" || create.Path != "/jwt/key/mapping/new" {
|
||||
t.Fatalf("expected POST /jwt/key/mapping/new, got %s %s", create.Method, create.Path)
|
||||
}
|
||||
if create.Body["jwt_claim_name"] != "client_id" || create.Body["jwt_claim_value"] != "dev-alice" {
|
||||
t.Fatalf("claim fields not sent: %v", create.Body)
|
||||
}
|
||||
if create.Body["key"] != "sk-abc123" {
|
||||
t.Fatalf("virtual key not sent: %v", create.Body["key"])
|
||||
}
|
||||
if create.Body["description"] != "dev-alice" {
|
||||
t.Fatalf("description not sent: %v", create.Body["description"])
|
||||
}
|
||||
if _, sent := create.Body["is_active"]; sent {
|
||||
t.Fatalf("is_active is not accepted by /jwt/key/mapping/new but was sent: %v", create.Body)
|
||||
}
|
||||
|
||||
for _, call := range (*calls)[1:] {
|
||||
if call.Path == "/jwt/key/mapping/update" {
|
||||
t.Fatalf("an active mapping must not trigger a follow-up update")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingCreateOmitsEmptyDescription(t *testing.T) {
|
||||
srv, calls := jwtKeyMappingTestServer(t, jwtKeyMappingFixture())
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-abc123",
|
||||
"is_active": true,
|
||||
})
|
||||
|
||||
if err := resourceLiteLLMJWTKeyMappingCreate(d, client); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
if _, sent := (*calls)[0].Body["description"]; sent {
|
||||
t.Fatalf("unset description should be omitted: %v", (*calls)[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingCreateDeactivatesWhenNotActive(t *testing.T) {
|
||||
mapping := jwtKeyMappingFixture()
|
||||
mapping.IsActive = false
|
||||
srv, calls := jwtKeyMappingTestServer(t, mapping)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-abc123",
|
||||
"is_active": false,
|
||||
})
|
||||
|
||||
if err := resourceLiteLLMJWTKeyMappingCreate(d, client); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
var update *jwtKeyMappingCall
|
||||
for i := range *calls {
|
||||
if (*calls)[i].Path == "/jwt/key/mapping/update" {
|
||||
update = &(*calls)[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if update == nil {
|
||||
t.Fatal("expected a follow-up update, since the create endpoint always starts a mapping active")
|
||||
}
|
||||
if update.Body["id"] != "map-abc-123" {
|
||||
t.Fatalf("update must target the new mapping, got %v", update.Body["id"])
|
||||
}
|
||||
if update.Body["is_active"] != false {
|
||||
t.Fatalf("expected is_active false in the follow-up update, got %v", update.Body["is_active"])
|
||||
}
|
||||
if d.Get("is_active").(bool) {
|
||||
t.Fatal("state should reflect the inactive mapping after create")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingCreateDeletesMappingWhenDeactivationFails(t *testing.T) {
|
||||
// Regression test: the create endpoint has no is_active field and always
|
||||
// activates the mapping, so a failed deactivation used to leave that
|
||||
// mapping active and unmanaged indefinitely. It must be deleted instead.
|
||||
calls := make([]jwtKeyMappingCall, 0)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := map[string]interface{}{}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
calls = append(calls, jwtKeyMappingCall{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Body: body})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/jwt/key/mapping/new":
|
||||
_ = json.NewEncoder(w).Encode(jwtKeyMappingFixture())
|
||||
case "/jwt/key/mapping/update":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"detail": "proxy unavailable"})
|
||||
case "/jwt/key/mapping/delete":
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
default:
|
||||
t.Fatalf("unexpected request to %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-abc123",
|
||||
"is_active": false,
|
||||
})
|
||||
|
||||
err := resourceLiteLLMJWTKeyMappingCreate(d, client)
|
||||
if err == nil {
|
||||
t.Fatal("expected the failed deactivation to surface as an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "deleted instead") {
|
||||
t.Fatalf("expected the error to explain the mapping was deleted, got %v", err)
|
||||
}
|
||||
|
||||
deleteCalls := 0
|
||||
for _, c := range calls {
|
||||
if c.Path == "/jwt/key/mapping/delete" {
|
||||
deleteCalls++
|
||||
if c.Body["id"] != "map-abc-123" {
|
||||
t.Fatalf("delete must target the mapping that could not be deactivated, got %v", c.Body["id"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if deleteCalls != 1 {
|
||||
t.Fatalf("expected exactly one cleanup delete call, got %d", deleteCalls)
|
||||
}
|
||||
|
||||
if d.Id() != "" {
|
||||
t.Fatalf("a successfully deleted mapping must not remain in state, got id %q", d.Id())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingCreateReportsWhenDeactivationAndDeleteBothFail(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/jwt/key/mapping/new":
|
||||
_ = json.NewEncoder(w).Encode(jwtKeyMappingFixture())
|
||||
default:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"detail": "proxy unavailable"})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-abc123",
|
||||
"is_active": false,
|
||||
})
|
||||
|
||||
err := resourceLiteLLMJWTKeyMappingCreate(d, client)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when both deactivation and the cleanup delete fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "remove it manually") {
|
||||
t.Fatalf("expected the error to demand manual cleanup, got %v", err)
|
||||
}
|
||||
|
||||
// The mapping is still active on the proxy since neither call succeeded, so
|
||||
// the id must stay in state: the next apply taints and retries the delete,
|
||||
// rather than Terraform losing track of a live, active mapping entirely.
|
||||
if d.Id() != "map-abc-123" {
|
||||
t.Fatalf("expected the id to remain in state so a retry can find it, got %q", d.Id())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingUpdateRevertsDescriptionAndIsActiveWhenTheRecoveryReadAlsoFails(t *testing.T) {
|
||||
// Regression test: on a failed update, only `key` was being reverted
|
||||
// before Read ran. If Read itself then failed too (network blip, proxy
|
||||
// hiccup), description/is_active kept the rejected, never-applied values,
|
||||
// and Terraform could persist them as if the update had succeeded.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/jwt/key/mapping/update":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"detail": "rejected"})
|
||||
case "/jwt/key/mapping/info":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"detail": "proxy unavailable"})
|
||||
default:
|
||||
t.Fatalf("unexpected request to %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
|
||||
d := resourceDataWithChange(t,
|
||||
map[string]string{
|
||||
"id": "map-abc-123",
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-old-key-0000000000",
|
||||
"description": "old description",
|
||||
"is_active": "true",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-old-key-0000000000",
|
||||
"description": "attempted new description",
|
||||
"is_active": false,
|
||||
},
|
||||
)
|
||||
d.SetId("map-abc-123")
|
||||
|
||||
err := resourceLiteLLMJWTKeyMappingUpdate(d, client)
|
||||
if err == nil {
|
||||
t.Fatal("expected the update failure to surface as an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to refresh state afterward") {
|
||||
t.Fatalf("expected the error to mention the failed recovery read, got %v", err)
|
||||
}
|
||||
|
||||
if d.Get("description").(string) != "old description" {
|
||||
t.Fatalf("a rejected description must not survive when the recovery read also fails, got %q", d.Get("description").(string))
|
||||
}
|
||||
if d.Get("is_active").(bool) != true {
|
||||
t.Fatalf("a rejected is_active must not survive when the recovery read also fails, got %v", d.Get("is_active").(bool))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingReadPopulatesStateAndKeepsKey(t *testing.T) {
|
||||
srv, calls := jwtKeyMappingTestServer(t, jwtKeyMappingFixture())
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-configured-value",
|
||||
})
|
||||
d.SetId("map-abc-123")
|
||||
|
||||
if err := resourceLiteLLMJWTKeyMappingRead(d, client); err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
|
||||
read := (*calls)[0]
|
||||
if read.Method != "GET" || read.Path != "/jwt/key/mapping/info" {
|
||||
t.Fatalf("expected GET /jwt/key/mapping/info, got %s %s", read.Method, read.Path)
|
||||
}
|
||||
if read.Query != "id=map-abc-123" {
|
||||
t.Fatalf("expected the mapping id in the query, got %q", read.Query)
|
||||
}
|
||||
|
||||
if d.Get("jwt_claim_value").(string) != "dev-alice" {
|
||||
t.Fatalf("claim value not populated: %q", d.Get("jwt_claim_value").(string))
|
||||
}
|
||||
if d.Get("description").(string) != "dev-alice" {
|
||||
t.Fatalf("description not populated: %q", d.Get("description").(string))
|
||||
}
|
||||
if !d.Get("is_active").(bool) {
|
||||
t.Fatal("is_active not populated")
|
||||
}
|
||||
if d.Get("created_at").(string) != "2026-08-06T10:00:00Z" || d.Get("created_by").(string) != "admin" {
|
||||
t.Fatalf("computed audit fields not populated: %v", d.State().Attributes)
|
||||
}
|
||||
if d.Get("key").(string) != "sk-configured-value" {
|
||||
t.Fatalf("the API never returns the key, so the configured value must survive a read, got %q", d.Get("key").(string))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingReadClearsIDWhenMappingIsGone(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"detail": "Mapping not found"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-abc123",
|
||||
})
|
||||
d.SetId("map-gone")
|
||||
|
||||
if err := resourceLiteLLMJWTKeyMappingRead(d, client); err != nil {
|
||||
t.Fatalf("a deleted mapping must not fail the read: %v", err)
|
||||
}
|
||||
if d.Id() != "" {
|
||||
t.Fatalf("expected the id to be cleared so Terraform plans a recreate, got %q", d.Id())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingUpdateClearsDescriptionAndSendsKey(t *testing.T) {
|
||||
mapping := jwtKeyMappingFixture()
|
||||
mapping.Description = ""
|
||||
srv, calls := jwtKeyMappingTestServer(t, mapping)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-rotated",
|
||||
"is_active": true,
|
||||
})
|
||||
d.SetId("map-abc-123")
|
||||
|
||||
if err := resourceLiteLLMJWTKeyMappingUpdate(d, client); err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
|
||||
update := (*calls)[0]
|
||||
if update.Method != "POST" || update.Path != "/jwt/key/mapping/update" {
|
||||
t.Fatalf("expected POST /jwt/key/mapping/update, got %s %s", update.Method, update.Path)
|
||||
}
|
||||
if update.Body["id"] != "map-abc-123" {
|
||||
t.Fatalf("update must carry the mapping id, got %v", update.Body["id"])
|
||||
}
|
||||
if update.Body["key"] != "sk-rotated" {
|
||||
t.Fatalf("rotated key not sent: %v", update.Body["key"])
|
||||
}
|
||||
description, sent := update.Body["description"]
|
||||
if !sent || description != "" {
|
||||
t.Fatalf("a dropped description must be sent as an empty string, since the proxy ignores absent fields: %v", update.Body)
|
||||
}
|
||||
if d.Get("description").(string) != "" {
|
||||
t.Fatalf("description should be cleared in state, got %q", d.Get("description").(string))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingUpdateRevertsKeyOnFailureAndResyncsRest(t *testing.T) {
|
||||
// Regression test for a live-verified bug: Terraform's classic SDKv2 CRUD
|
||||
// model persists ResourceData's diff-applied (attempted) values to state
|
||||
// even when the callback returns an error, unless the provider reverts
|
||||
// them explicitly. Confirmed live: a rejected key rotation left the new,
|
||||
// never-applied key in `terraform state pull` while the proxy kept the
|
||||
// old one, so the next plan falsely reported convergence.
|
||||
calls := make([]jwtKeyMappingCall, 0)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := map[string]interface{}{}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
calls = append(calls, jwtKeyMappingCall{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Body: body})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/jwt/key/mapping/update":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"detail": "The provided key does not match an existing virtual key.",
|
||||
})
|
||||
case "/jwt/key/mapping/info":
|
||||
// Server truth: unchanged, since the rejected update above never applied.
|
||||
_ = json.NewEncoder(w).Encode(jwtKeyMappingFixture())
|
||||
default:
|
||||
t.Fatalf("unexpected request to %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
|
||||
d := resourceDataWithChange(t,
|
||||
map[string]string{
|
||||
"id": "map-abc-123",
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-old-key-0000000000",
|
||||
"description": "dev-alice",
|
||||
"is_active": "true",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-rejected-new-key-00",
|
||||
"description": "attempted new description",
|
||||
"is_active": false,
|
||||
},
|
||||
)
|
||||
d.SetId("map-abc-123")
|
||||
|
||||
err := resourceLiteLLMJWTKeyMappingUpdate(d, client)
|
||||
if err == nil {
|
||||
t.Fatal("expected the rejected key to fail the update")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not match an existing virtual key") {
|
||||
t.Fatalf("expected the proxy's rejection reason in the error, got %v", err)
|
||||
}
|
||||
|
||||
if d.Get("key").(string) != "sk-old-key-0000000000" {
|
||||
t.Fatalf("a failed update must not persist the rejected key into state, got %q", d.Get("key").(string))
|
||||
}
|
||||
if d.Get("description").(string) != "dev-alice" {
|
||||
t.Fatalf("a failed update must resync description from the server, got %q", d.Get("description").(string))
|
||||
}
|
||||
if d.Get("is_active").(bool) != true {
|
||||
t.Fatalf("a failed update must resync is_active from the server, got %v", d.Get("is_active").(bool))
|
||||
}
|
||||
|
||||
readCalls := 0
|
||||
for _, c := range calls {
|
||||
if c.Path == "/jwt/key/mapping/info" {
|
||||
readCalls++
|
||||
}
|
||||
}
|
||||
if readCalls != 1 {
|
||||
t.Fatalf("expected exactly one read to resync state after the failed update, got %d", readCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingUpdateOmitsMissingKeyRatherThanBlankingIt(t *testing.T) {
|
||||
srv, calls := jwtKeyMappingTestServer(t, jwtKeyMappingFixture())
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"description": "dev-alice",
|
||||
"is_active": true,
|
||||
})
|
||||
d.SetId("map-abc-123")
|
||||
|
||||
if err := resourceLiteLLMJWTKeyMappingUpdate(d, client); err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
|
||||
if _, sent := (*calls)[0].Body["key"]; sent {
|
||||
t.Fatalf("a missing key must be omitted rather than blanking the mapping token: %v", (*calls)[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingDeleteToleratesMissingMapping(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"detail": "Mapping not found"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-abc123",
|
||||
})
|
||||
d.SetId("map-already-gone")
|
||||
|
||||
if err := resourceLiteLLMJWTKeyMappingDelete(d, client); err != nil {
|
||||
t.Fatalf("deleting an already deleted mapping must succeed: %v", err)
|
||||
}
|
||||
if d.Id() != "" {
|
||||
t.Fatalf("expected the id to be cleared after delete, got %q", d.Id())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingCreateSurfacesDuplicateClaimError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"detail": "A mapping for claim 'client_id' = 'dev-alice' already exists.",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-abc123",
|
||||
"is_active": true,
|
||||
})
|
||||
|
||||
err := resourceLiteLLMJWTKeyMappingCreate(d, client)
|
||||
if err == nil {
|
||||
t.Fatal("expected a duplicate claim pair to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("the proxy explanation must reach the user, got %v", err)
|
||||
}
|
||||
if d.Id() != "" {
|
||||
t.Fatalf("no id should be recorded for a failed create, got %q", d.Id())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTKeyMappingCreateDoesNotLeakKeyInErrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"key": "sk-super-secret",
|
||||
"detail": "The provided key does not match an existing virtual key.",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := schema.TestResourceDataRaw(t, resourceLiteLLMJWTKeyMapping().Schema, map[string]interface{}{
|
||||
"jwt_claim_name": "client_id",
|
||||
"jwt_claim_value": "dev-alice",
|
||||
"key": "sk-super-secret",
|
||||
"is_active": true,
|
||||
})
|
||||
|
||||
err := resourceLiteLLMJWTKeyMappingCreate(d, client)
|
||||
if err == nil {
|
||||
t.Fatal("expected an unknown virtual key to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not match an existing virtual key") {
|
||||
t.Fatalf("the proxy explanation must reach the user, got %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "sk-super-secret") {
|
||||
t.Fatalf("the virtual key must be redacted in errors, got %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -272,3 +272,33 @@ type VectorStoreDeleteRequest struct {
|
|||
type VectorStoreInfoRequest struct {
|
||||
VectorStoreID string `json:"vector_store_id"`
|
||||
}
|
||||
|
||||
type JWTKeyMappingRequest struct {
|
||||
JWTClaimName string `json:"jwt_claim_name"`
|
||||
JWTClaimValue string `json:"jwt_claim_value"`
|
||||
Key string `json:"key"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type JWTKeyMappingUpdateRequest struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Description string `json:"description"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type JWTKeyMappingDeleteRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type JWTKeyMappingResponse struct {
|
||||
ID string `json:"id"`
|
||||
JWTClaimName string `json:"jwt_claim_name"`
|
||||
JWTClaimValue string `json:"jwt_claim_value"`
|
||||
Description string `json:"description,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
UpdatedBy string `json:"updated_by,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2385,6 +2385,7 @@ async def test_start_shadow_eval_seeds_a_zero_funnel_row_per_leg(monkeypatch: py
|
|||
separates 'nothing was skipped' from a job predating the funnel."""
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
|
||||
_configure_anthropic_sdk_judge(monkeypatch)
|
||||
prisma = _shadow_prisma(legs=[])
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
||||
monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue