package provider import ( "context" "reflect" "testing" "time" "git.toki-labs.com/toki/gito/services/core/internal/core" ) func TestConfigNormalizedTrimsIdentityFields(t *testing.T) { config := Config{ ID: ProviderID(" GitHub "), Endpoint: " https://api.github.com ", CredentialRef: " secret://gito/providers/github ", Capabilities: CapabilityChangeRequest | CapabilityWebhook, } got := config.Normalized() if got.ID != ProviderGitHub { t.Fatalf("ID: got %q want %q", got.ID, ProviderGitHub) } if got.Endpoint != "https://api.github.com" { t.Fatalf("Endpoint: got %q", got.Endpoint) } if got.CredentialRef != "secret://gito/providers/github" { t.Fatalf("CredentialRef: got %q", got.CredentialRef) } } func TestCapabilityFlags(t *testing.T) { capabilities := CapabilityChangeRequest | CapabilityWebhook | CapabilityComment if !capabilities.Has(CapabilityChangeRequest) { t.Fatal("expected change request capability") } if !capabilities.Has(CapabilityWebhook) { t.Fatal("expected webhook capability") } if !capabilities.Has(CapabilityComment) { t.Fatal("expected comment capability") } if capabilities.Has(CapabilityChecks) { t.Fatal("did not expect checks capability") } if capabilities.Has(0) { t.Fatal("zero capability should not be supported") } } func TestConfigSupportsCapability(t *testing.T) { config := Config{ ID: ProviderForgejo, Capabilities: CapabilityWebhook | CapabilityChecks, } if !config.Supports(CapabilityWebhook) { t.Fatal("expected webhook support") } if !config.Supports(CapabilityChecks) { t.Fatal("expected checks support") } if config.Supports(CapabilityChangeRequest) { t.Fatal("did not expect change request support") } } func TestChangeRequestMappingCandidatesCoverProviderDifferences(t *testing.T) { mappings := ChangeRequestMappingCandidates() if len(mappings) != 3 { t.Fatalf("mapping count: got %d want 3", len(mappings)) } seen := make(map[ProviderID]ChangeRequestMappingCandidate, len(mappings)) for _, mapping := range mappings { seen[mapping.Provider] = mapping assertChangeRequestMappingTargets(t, mapping) assertChangeRequestMappingStates(t, mapping) assertChangeRequestMappingActions(t, mapping) if len(mapping.ExtensionFields) == 0 { t.Fatalf("%s mapping should define provider extension fields", mapping.Provider) } } for _, provider := range []ProviderID{ProviderGitHub, ProviderGitLab, ProviderGitea} { if _, ok := seen[provider]; !ok { t.Fatalf("missing %s change request mapping", provider) } } if seen[ProviderGitHub].ProviderObject != "pull_request" { t.Fatalf("github provider object: %q", seen[ProviderGitHub].ProviderObject) } if seen[ProviderGitLab].ProviderObject != "merge_request" { t.Fatalf("gitlab provider object: %q", seen[ProviderGitLab].ProviderObject) } if seen[ProviderGitea].ProviderObject != "pull_request" { t.Fatalf("gitea provider object: %q", seen[ProviderGitea].ProviderObject) } } func TestChangeRequestProviderExtensionsStayOutOfCoreDTO(t *testing.T) { crType := reflect.TypeOf(core.ChangeRequest{}) for _, field := range []string{"Extensions", "ExtensionFields", "ProviderFields", "Raw"} { if _, ok := crType.FieldByName(field); ok { t.Fatalf("provider-specific field %q must not live on core.ChangeRequest", field) } } } func assertChangeRequestMappingTargets(t *testing.T, mapping ChangeRequestMappingCandidate) { t.Helper() targets := make(map[string]bool, len(mapping.Fields)) for _, field := range mapping.Fields { targets[field.Target] = true if field.Source == "" { t.Fatalf("%s field %q has empty source", mapping.Provider, field.Target) } } for _, target := range []string{ "provider", "external_id", "repo_id", "source_branch", "target_branch", "title", "body", "draft", "state", } { if !targets[target] { t.Fatalf("%s mapping missing target field %q", mapping.Provider, target) } } } func assertChangeRequestMappingStates(t *testing.T, mapping ChangeRequestMappingCandidate) { t.Helper() states := make(map[core.ChangeRequestState]bool, len(mapping.States)) for _, state := range mapping.States { states[state.State] = true if state.Source == "" { t.Fatalf("%s state %q has empty source", mapping.Provider, state.State) } } for _, state := range []core.ChangeRequestState{ core.ChangeRequestOpen, core.ChangeRequestMerged, core.ChangeRequestClosed, } { if !states[state] { t.Fatalf("%s mapping missing state %q", mapping.Provider, state) } } } func assertChangeRequestMappingActions(t *testing.T, mapping ChangeRequestMappingCandidate) { t.Helper() actions := make(map[core.ChangeRequestAction]bool, len(mapping.Actions)) for _, action := range mapping.Actions { actions[action] = true } for _, action := range []core.ChangeRequestAction{ core.ChangeRequestActionCreate, core.ChangeRequestActionUpdate, core.ChangeRequestActionComment, core.ChangeRequestActionRequestReview, core.ChangeRequestActionMerge, core.ChangeRequestActionClose, } { if !actions[action] { t.Fatalf("%s mapping missing action %q", mapping.Provider, action) } } } type fakeChangeRequestAdapter struct { created core.ChangeRequestCreate updatedID string updated core.ChangeRequestUpdate commentedID string commented core.ChangeRequestComment reviewedID string reviewed core.ChangeRequestReviewRequest mergedID string merged core.ChangeRequestMerge closedID string closed core.ChangeRequestClose changeRequest core.ChangeRequest } func (f *fakeChangeRequestAdapter) CreateChangeRequest(ctx context.Context, input core.ChangeRequestCreate) (core.ChangeRequest, error) { f.created = input return f.changeRequest, nil } func (f *fakeChangeRequestAdapter) UpdateChangeRequest(ctx context.Context, id string, input core.ChangeRequestUpdate) (core.ChangeRequest, error) { f.updatedID = id f.updated = input return f.changeRequest, nil } func (f *fakeChangeRequestAdapter) CommentChangeRequest(ctx context.Context, id string, input core.ChangeRequestComment) error { f.commentedID = id f.commented = input return nil } func (f *fakeChangeRequestAdapter) RequestChangeRequestReview(ctx context.Context, id string, input core.ChangeRequestReviewRequest) error { f.reviewedID = id f.reviewed = input return nil } func (f *fakeChangeRequestAdapter) MergeChangeRequest(ctx context.Context, id string, input core.ChangeRequestMerge) (core.ChangeRequest, error) { f.mergedID = id f.merged = input return f.changeRequest, nil } func (f *fakeChangeRequestAdapter) CloseChangeRequest(ctx context.Context, id string, input core.ChangeRequestClose) (core.ChangeRequest, error) { f.closedID = id f.closed = input return f.changeRequest, nil } var _ ChangeRequestAdapter = (*fakeChangeRequestAdapter)(nil) func TestChangeRequestAdapterContractActions(t *testing.T) { title := "Updated title" draft := true fake := &fakeChangeRequestAdapter{ changeRequest: core.ChangeRequest{ Provider: string(ProviderGitHub), ExternalID: "42", RepoID: "repo-1", SourceBranch: "feature/a", TargetBranch: "main", Title: "Add CR model", Body: "body", Draft: false, State: core.ChangeRequestOpen, }, } created, err := fake.CreateChangeRequest(context.Background(), core.ChangeRequestCreate{ RepoID: "repo-1", SourceBranch: "feature/a", TargetBranch: "main", Title: "Add CR model", Draft: false, }) if err != nil { t.Fatalf("CreateChangeRequest: %v", err) } if created.ExternalID != "42" || fake.created.Title != "Add CR model" { t.Fatalf("create action: created=%+v input=%+v", created, fake.created) } updated, err := fake.UpdateChangeRequest(context.Background(), "42", core.ChangeRequestUpdate{ Title: &title, Draft: &draft, }) if err != nil { t.Fatalf("UpdateChangeRequest: %v", err) } if updated.ExternalID != "42" || fake.updatedID != "42" || fake.updated.Title == nil || *fake.updated.Title != title { t.Fatalf("update action: updated=%+v id=%q input=%+v", updated, fake.updatedID, fake.updated) } if fake.updated.Draft == nil || *fake.updated.Draft != draft { t.Fatalf("update draft: %+v", fake.updated) } if err := fake.CommentChangeRequest(context.Background(), "42", core.ChangeRequestComment{Body: "looks good"}); err != nil { t.Fatalf("CommentChangeRequest: %v", err) } if fake.commentedID != "42" || fake.commented.Body != "looks good" { t.Fatalf("comment action: id=%q input=%+v", fake.commentedID, fake.commented) } if err := fake.RequestChangeRequestReview(context.Background(), "42", core.ChangeRequestReviewRequest{Reviewers: []string{"alice"}}); err != nil { t.Fatalf("RequestChangeRequestReview: %v", err) } if fake.reviewedID != "42" || len(fake.reviewed.Reviewers) != 1 || fake.reviewed.Reviewers[0] != "alice" { t.Fatalf("request review action: id=%q input=%+v", fake.reviewedID, fake.reviewed) } merged, err := fake.MergeChangeRequest(context.Background(), "42", core.ChangeRequestMerge{ CommitTitle: "Merge CR", CommitMessage: "Merge provider-neutral CR", }) if err != nil { t.Fatalf("MergeChangeRequest: %v", err) } if merged.ExternalID != "42" || fake.mergedID != "42" || fake.merged.CommitTitle != "Merge CR" { t.Fatalf("merge action: merged=%+v id=%q input=%+v", merged, fake.mergedID, fake.merged) } closed, err := fake.CloseChangeRequest(context.Background(), "42", core.ChangeRequestClose{Reason: "superseded"}) if err != nil { t.Fatalf("CloseChangeRequest: %v", err) } if closed.ExternalID != "42" || fake.closedID != "42" || fake.closed.Reason != "superseded" { t.Fatalf("close action: closed=%+v id=%q input=%+v", closed, fake.closedID, fake.closed) } } // --- Fake adapter for testing --- // fakeWebhookAdapter is a test implementation of WebhookAdapter. type fakeWebhookAdapter struct { verified bool verifyErr error candidates []WebhookCandidate normalizeErr error // Recorded requests for verifier and normalizer. verifyReq WebhookRequest normalizeReq WebhookRequest } func (f *fakeWebhookAdapter) VerifyWebhook(ctx context.Context, req WebhookRequest) error { if f.verifyErr != nil { return f.verifyErr } f.verified = true f.verifyReq = req return nil } func (f *fakeWebhookAdapter) NormalizeWebhook(ctx context.Context, req WebhookRequest) ([]WebhookCandidate, error) { if f.normalizeErr != nil { return nil, f.normalizeErr } f.normalizeReq = req return f.candidates, nil } // verifyWebhookAdapter ensures WebhookAdapter compile-time assertion. var _ WebhookAdapter = (*fakeWebhookAdapter)(nil) func TestWebhookAdapterContractCompileTime(t *testing.T) { // fakeWebhookAdapter is already assigned to var _ WebhookAdapter. // This test passes if the file compiles. } func TestWebhookAdapterContractRequestDelivery(t *testing.T) { revision := &core.RevisionEvent{ RepoID: "test-repo", Branch: "main", Before: "abc123", After: "def456", ObservedAt: time.Now().UTC(), } now := time.Now().UTC() req := WebhookRequest{ Provider: ProviderForgejo, EventType: "push", ExternalID: "delivery-123", Payload: []byte(`{"ref":"refs/heads/main"}`), Headers: map[string][]string{ "X-Forgejo-Signature": {"sha256=abc"}, }, Query: map[string][]string{ "repo_id": {"test-repo"}, }, ReceivedAt: now, } fake := &fakeWebhookAdapter{ candidates: []WebhookCandidate{ { Type: "branch.updated", Provider: ProviderForgejo, ExternalID: "delivery-123", Revision: revision, }, }, } ctx := context.Background() // Verify delivery if err := fake.VerifyWebhook(ctx, req); err != nil { t.Fatalf("VerifyWebhook: %v", err) } if !fake.verified { t.Fatal("expected verified to be true") } // Normalize delivery candidates, err := fake.NormalizeWebhook(ctx, req) if err != nil { t.Fatalf("NormalizeWebhook: %v", err) } if len(candidates) != 1 { t.Fatalf("expected 1 candidate, got %d", len(candidates)) } c := candidates[0] if c.Type != "branch.updated" { t.Fatalf("Type: got %q want %q", c.Type, "branch.updated") } if c.Provider != ProviderForgejo { t.Fatalf("Provider: got %q want %q", c.Provider, ProviderForgejo) } if c.ExternalID != "delivery-123" { t.Fatalf("ExternalID: got %q want %q", c.ExternalID, "delivery-123") } if c.Revision == nil { t.Fatal("expected Revision to be set") } if c.Revision.RepoID != "test-repo" { t.Fatalf("Revision.RepoID: got %q want %q", c.Revision.RepoID, "test-repo") } if c.Revision.Branch != "main" { t.Fatalf("Revision.Branch: got %q want %q", c.Revision.Branch, "main") } // Verify that the request was actually delivered to the verifier. if fake.verifyReq.Provider == "" { t.Fatal("expected verifyReq.Provider to be set") } if fake.verifyReq.Provider != ProviderForgejo { t.Fatalf("verifyReq.Provider: got %q want %q", fake.verifyReq.Provider, ProviderForgejo) } if fake.verifyReq.EventType != "push" { t.Fatalf("verifyReq.EventType: got %q want %q", fake.verifyReq.EventType, "push") } if fake.verifyReq.ExternalID != "delivery-123" { t.Fatalf("verifyReq.ExternalID: got %q want %q", fake.verifyReq.ExternalID, "delivery-123") } if string(fake.verifyReq.Payload) != `{"ref":"refs/heads/main"}` { t.Fatalf("verifyReq.Payload: got %q", fake.verifyReq.Payload) } if len(fake.verifyReq.Headers) != 1 { t.Fatalf("verifyReq.Headers: got %d entries, want 1", len(fake.verifyReq.Headers)) } if sigs, ok := fake.verifyReq.Headers["X-Forgejo-Signature"]; !ok { t.Fatal("verifyReq.Headers['X-Forgejo-Signature'] not found") } else if len(sigs) != 1 || sigs[0] != "sha256=abc" { t.Fatalf("verifyReq.Headers['X-Forgejo-Signature']: got %v, want [sha256=abc]", sigs) } if len(fake.verifyReq.Query) != 1 { t.Fatalf("verifyReq.Query: got %d entries, want 1", len(fake.verifyReq.Query)) } if repos, ok := fake.verifyReq.Query["repo_id"]; !ok { t.Fatal("verifyReq.Query['repo_id'] not found") } else if len(repos) != 1 || repos[0] != "test-repo" { t.Fatalf("verifyReq.Query['repo_id']: got %v, want [test-repo]", repos) } if !fake.verifyReq.ReceivedAt.Equal(now) { t.Fatalf("verifyReq.ReceivedAt: got %v, want %v", fake.verifyReq.ReceivedAt, now) } // Verify that the request was actually delivered to the normalizer. if fake.normalizeReq.Provider != ProviderForgejo { t.Fatalf("normalizeReq.Provider: got %q want %q", fake.normalizeReq.Provider, ProviderForgejo) } if fake.normalizeReq.EventType != "push" { t.Fatalf("normalizeReq.EventType: got %q want %q", fake.normalizeReq.EventType, "push") } if fake.normalizeReq.ExternalID != "delivery-123" { t.Fatalf("normalizeReq.ExternalID: got %q want %q", fake.normalizeReq.ExternalID, "delivery-123") } if string(fake.normalizeReq.Payload) != `{"ref":"refs/heads/main"}` { t.Fatalf("normalizeReq.Payload: got %q", fake.normalizeReq.Payload) } if len(fake.normalizeReq.Headers) != 1 { t.Fatalf("normalizeReq.Headers: got %d entries, want 1", len(fake.normalizeReq.Headers)) } if sigs, ok := fake.normalizeReq.Headers["X-Forgejo-Signature"]; !ok { t.Fatal("normalizeReq.Headers['X-Forgejo-Signature'] not found") } else if len(sigs) != 1 || sigs[0] != "sha256=abc" { t.Fatalf("normalizeReq.Headers['X-Forgejo-Signature']: got %v, want [sha256=abc]", sigs) } if len(fake.normalizeReq.Query) != 1 { t.Fatalf("normalizeReq.Query: got %d entries, want 1", len(fake.normalizeReq.Query)) } if repos, ok := fake.normalizeReq.Query["repo_id"]; !ok { t.Fatal("normalizeReq.Query['repo_id'] not found") } else if len(repos) != 1 || repos[0] != "test-repo" { t.Fatalf("normalizeReq.Query['repo_id']: got %v, want [test-repo]", repos) } if !fake.normalizeReq.ReceivedAt.Equal(now) { t.Fatalf("normalizeReq.ReceivedAt: got %v, want %v", fake.normalizeReq.ReceivedAt, now) } } func TestWebhookRequestEmptyPayloadPreservation(t *testing.T) { req := WebhookRequest{ Provider: ProviderGitHub, EventType: "pull_request", ExternalID: "", Payload: []byte{}, Headers: nil, Query: nil, ReceivedAt: time.Time{}, } if len(req.Payload) != 0 { t.Fatalf("expected empty payload, got %d bytes", len(req.Payload)) } if req.ExternalID != "" { t.Fatalf("expected empty ExternalID, got %q", req.ExternalID) } if !req.ReceivedAt.IsZero() { t.Fatalf("expected zero ReceivedAt") } } func TestWebhookCandidateNoSecretRequirement(t *testing.T) { // WebhookCandidate must not expose any secret-related fields. // This is verified by the struct definition itself: // - Type string // - Provider ProviderID // - ExternalID string // - Revision *core.RevisionEvent // No secret, signature, or token fields. c := WebhookCandidate{ Type: "branch.updated", Provider: ProviderForgejo, ExternalID: "delivery-1", } // Verify the struct has no secret-related fields by construction. // If any secret field were added, compilation would still pass, // but the test ensures the current minimal metadata contract. if c.Type == "" { t.Fatal("Type should be settable") } if c.Provider == "" { t.Fatal("Provider should be settable") } }