1package provider
2
3import (
4 "bytes"
5 "net/http"
6 "strings"
7 "testing"
8 "time"
9)
10
11// TestSigV4Known checks our signer against AWS's published test vector
12// from the SigV4 documentation. The expected Authorization header below
13// is taken from "get-vanilla" in the suite (adapted: we use POST with
14// an empty body since Bedrock is always POST).
15//
16// We don't run the official suite (it lives in S3 as a tarball and the
17// signing rules vary slightly between examples); instead this test
18// pins a fixed input/output so future refactors can't silently break
19// the signer.
20func TestSigV4Deterministic(t *testing.T) {
21 creds := &bedrockSigV4Creds{
22 accessKeyID: "AKIDEXAMPLE",
23 secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
24 }
25 body := []byte(`{"hello":"world"}`)
26 req, err := http.NewRequest("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/model/foo/converse-stream", bytes.NewReader(body))
27 if err != nil {
28 t.Fatal(err)
29 }
30 req.Header.Set("content-type", "application/json")
31 when := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
32 if err := signSigV4(req, body, "bedrock", "us-east-1", creds, when); err != nil {
33 t.Fatal(err)
34 }
35 auth := req.Header.Get("Authorization")
36 if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 ") {
37 t.Fatalf("missing AWS4 prefix: %q", auth)
38 }
39 if !strings.Contains(auth, "Credential=AKIDEXAMPLE/20240115/us-east-1/bedrock/aws4_request") {
40 t.Errorf("wrong credential scope: %q", auth)
41 }
42 if !strings.Contains(auth, "SignedHeaders=") {
43 t.Errorf("missing SignedHeaders: %q", auth)
44 }
45 if !strings.Contains(auth, "Signature=") {
46 t.Errorf("missing Signature: %q", auth)
47 }
48 // Sign again with identical inputs -> identical signature (determinism).
49 req2, _ := http.NewRequest("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/model/foo/converse-stream", bytes.NewReader(body))
50 req2.Header.Set("content-type", "application/json")
51 if err := signSigV4(req2, body, "bedrock", "us-east-1", creds, when); err != nil {
52 t.Fatal(err)
53 }
54 if req2.Header.Get("Authorization") != auth {
55 t.Errorf("signature not deterministic:\n a=%q\n b=%q", auth, req2.Header.Get("Authorization"))
56 }
57}
58
59func TestSigV4PathEncoding(t *testing.T) {
60 // Bedrock model IDs include colons and dots. The colon must encode
61 // to %3A in the canonical path (per SigV4 rules).
62 cases := map[string]string{
63 "/model/anthropic.claude-sonnet-4-5-20250929-v1:0/converse-stream": "/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/converse-stream",
64 "/": "/",
65 "": "/",
66 "/a": "/a",
67 }
68 for in, want := range cases {
69 got := canonicalSigV4Path(in)
70 if got != want {
71 t.Errorf("canonicalSigV4Path(%q) = %q, want %q", in, got, want)
72 }
73 }
74}
75
76func TestReadAWSCredentialsFile(t *testing.T) {
77 // Smoke test: parses an INI-ish file when present. We don't require
78 // the file to exist on the test host.
79 if _, err := readAWSCredentialsFile("default"); err != nil {
80 // missing file or missing profile is fine — both are non-panics
81 t.Logf("no aws creds available (expected on CI): %v", err)
82 }
83}
84
85func TestBedrockEventPayloadHelpers(t *testing.T) {
86 wrapped := []byte(`{"contentBlockDelta":{"contentBlockIndex":0,"delta":{"text":"Hello"}}}`)
87 if got := bedrockEventTypeFromPayload(wrapped); got != "contentBlockDelta" {
88 t.Fatalf("event type = %q, want contentBlockDelta", got)
89 }
90 var delta struct {
91 ContentBlockIndex int `json:"contentBlockIndex"`
92 Delta struct {
93 Text string `json:"text"`
94 } `json:"delta"`
95 }
96 if err := unmarshalBedrockEventPayload(wrapped, "contentBlockDelta", &delta); err != nil {
97 t.Fatal(err)
98 }
99 if delta.ContentBlockIndex != 0 || delta.Delta.Text != "Hello" {
100 t.Fatalf("unexpected wrapped delta: %+v", delta)
101 }
102
103 direct := []byte(`{"contentBlockIndex":1,"delta":{"text":"world"}}`)
104 if err := unmarshalBedrockEventPayload(direct, "contentBlockDelta", &delta); err != nil {
105 t.Fatal(err)
106 }
107 if delta.ContentBlockIndex != 1 || delta.Delta.Text != "world" {
108 t.Fatalf("unexpected direct delta: %+v", delta)
109 }
110}
111
112func TestResolveBedrockInferenceProfileID(t *testing.T) {
113 cases := []struct {
114 model string
115 region string
116 want string
117 }{
118 // Bare Anthropic foundation IDs get the region-matched prefix.
119 {"anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"},
120 {"anthropic.claude-sonnet-4-5-20250929-v1:0", "eu-central-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
121 {"anthropic.claude-opus-4-6-v1", "ap-southeast-2", "apac.anthropic.claude-opus-4-6-v1"},
122 {"anthropic.claude-opus-4-6-v1", "us-gov-west-1", "us-gov.anthropic.claude-opus-4-6-v1"},
123 {"deepseek.r1-v1:0", "eu-west-1", "eu.deepseek.r1-v1:0"},
124 // Empty region defaults to us.
125 {"anthropic.claude-opus-4-6-v1", "", "us.anthropic.claude-opus-4-6-v1"},
126 // Already-prefixed IDs are left untouched.
127 {"eu.anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
128 {"global.anthropic.claude-opus-4-6-v1", "us-east-1", "global.anthropic.claude-opus-4-6-v1"},
129 {"us.anthropic.claude-opus-4-6-v1", "eu-central-1", "us.anthropic.claude-opus-4-6-v1"},
130 // ARNs are passed through verbatim.
131 {"arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-opus-4-6-v1", "eu-west-1", "arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-opus-4-6-v1"},
132 // Families that don't need a profile are untouched.
133 {"amazon.nova-pro-v1:0", "us-east-1", "amazon.nova-pro-v1:0"},
134 {"", "us-east-1", ""},
135 }
136 for _, c := range cases {
137 if got := resolveBedrockInferenceProfileID(c.model, c.region); got != c.want {
138 t.Errorf("resolveBedrockInferenceProfileID(%q, %q) = %q; want %q", c.model, c.region, got, c.want)
139 }
140 }
141}