package worker import ( "context" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/bluesky-social/indigo/atproto/atcrypto" ) const testKeyHex = "708539e1ad16e9f26329b470f51d328de0977982e8d63da6238c76f134224e68" func testClient(t *testing.T, baseURL string) *Client { t.Helper() priv, err := atcrypto.ParsePrivateBytesP256(mustDecodeHex(t, testKeyHex)) if err != nil { t.Fatalf("loading key: %v", err) } return New(baseURL, "did:web:sitesd.tangled.network", "did:web:sites.tangled.org", priv) } func mustDecodeHex(t *testing.T, s string) []byte { t.Helper() raw, err := decodeHexString(s) if err != nil { t.Fatalf("hex: %v", err) } return raw } func decodeHexString(s string) ([]byte, error) { if len(s)%2 != 0 { bytes := make([]byte, len(s)/2+1) for i := 0; i < len(s); i += 2 { hi := hexVal(s[i]) lo := byte(0) if i+1 < len(s) { lo = hexVal(s[i+1]) } bytes[i/2] = hi<<4 | lo } return bytes, nil } return hexBytes(s), nil } func hexVal(c byte) byte { switch { case c >= '0' && c <= '9': return c - '0' case c >= 'a' && c <= 'f': return c - 'a' + 10 case c >= 'A' && c <= 'F': return c - 'A' + 10 } return 0 } func hexBytes(s string) []byte { out := make([]byte, len(s)/2) for i := 0; i < len(s); i += 2 { out[i/2] = hexVal(s[i])<<4 | hexVal(s[i+1]) } return out } type jwtClaims struct { Iss string `json:"iss"` Aud string `json:"aud"` Lxm string `json:"lxm"` Exp int64 `json:"exp"` } func decodeClaims(t *testing.T, token string) jwtClaims { t.Helper() parts := strings.Split(token, ".") if len(parts) != 3 { t.Fatalf("malformed JWT segments: %d", len(parts)) } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { t.Fatalf("decoding payload: %v", err) } var claims jwtClaims if err := json.Unmarshal(payload, &claims); err != nil { t.Fatalf("unmarshal claims: %v", err) } return claims } func TestGetDeployContext(t *testing.T) { full := map[string]any{ "ownerDid": "did:plc:owner", "domain": "alice.tngl.io", "name": "blog", "rkey": "abc1", "knot": "knot.tngl.boltless.dev", "branch": "main", "dir": "/", "isIndex": false, } var sawPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sawPath = r.URL.RequestURI() if r.Method != http.MethodGet { t.Errorf("method = %s, want GET", r.Method) } claims := decodeClaims(t, strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if claims.Lxm != "org.tangled.temp.repo.getDeployContext" { t.Errorf("lxm = %q", claims.Lxm) } if claims.Aud != "did:web:sites.tangled.org" { t.Errorf("aud = %q", claims.Aud) } if claims.Iss != "did:web:sitesd.tangled.network" { t.Errorf("iss = %q", claims.Iss) } if claims.Exp <= time.Now().Unix() { t.Errorf("exp %d not in the future", claims.Exp) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(full) })) defer srv.Close() client := testClient(t, srv.URL+"/") dc, err := client.GetDeployContext(context.Background(), "did:plc:repo1") if err != nil { t.Fatalf("GetDeployContext: %v", err) } if dc == nil { t.Fatal("expected a deploy context") } if dc.RepoDid != "did:plc:repo1" || dc.OwnerDid != "did:plc:owner" || dc.Branch != "main" { t.Errorf("unexpected context: %+v", dc) } if strings.Contains(sawPath, "//xrpc") { t.Errorf("double-slash in request path: %s", sawPath) } if !strings.Contains(sawPath, "repoDid=did%3Aplc%3Arepo1") { t.Errorf("query param missing/unescaped: %s", sawPath) } } func TestGetDeployContextEmptyAndPartial(t *testing.T) { empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("{}")) })) defer empty.Close() dc, err := testClient(t, empty.URL).GetDeployContext(context.Background(), "did:plc:r") if err != nil || dc != nil { t.Fatalf("empty context: dc=%+v err=%v, want nil,nil", dc, err) } partial := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(`{"rkey":"abc1"}`)) })) defer partial.Close() if _, err := testClient(t, partial.URL).GetDeployContext(context.Background(), "did:plc:r"); err == nil { t.Fatal("partial context: expected an error") } unknown := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(`{"unexpected":true}`)) })) defer unknown.Close() if _, err := testClient(t, unknown.URL).GetDeployContext(context.Background(), "did:plc:r"); err == nil { t.Fatal("unknown-field response: expected a decode error") } } func TestRecordDeployBodyAndTypedErrors(t *testing.T) { var gotBody map[string]string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("method = %s, want POST", r.Method) } if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { t.Errorf("body: %v", err) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) w.Write([]byte(`{"error":"AuthError","message":"bad token"}`)) })) defer srv.Close() client := testClient(t, srv.URL) err := client.RecordDeploy(context.Background(), &DeployContext{ RepoDid: "did:plc:r", Branch: "main", Dir: "/", }, "abc123", "success", "push", "") if err == nil { t.Fatal("expected an error") } typed, ok := err.(*Error) if !ok { t.Fatalf("error type = %T, want *worker.Error", err) } if typed.StatusCode != http.StatusForbidden || typed.Name != "AuthError" || typed.NSID != "org.tangled.temp.repo.recordDeploy" { t.Errorf("typed error fields: %+v", typed) } if gotBody["repoDid"] != "did:plc:r" || gotBody["commitSha"] != "abc123" || gotBody["trigger"] != "push" || gotBody["status"] != "success" { t.Errorf("record body: %+v", gotBody) } }