// sources_test.go — pure-Go tests for the input validators and // the format helpers on the sources store. The DB-backed paths // (Create, Update, SetStatus, List, RotateSecrets) are exercised // by scripts/m13b_w2_smoke.sh against a real Postgres. package authd import ( "strings" "testing" ) func TestValidSourceID(t *testing.T) { cases := []struct { in string want bool }{ // valid (same shape as auth.tenants.slug) {"primary", true}, {"ops-foo", true}, {"a-b-c", true}, {strings.Repeat("a", 64), true}, // invalid {"", false}, {"a", false}, // too short (1 char) {"A", false}, // uppercase {"-foo", false}, // leading dash {"foo-", false}, // trailing dash {"foo_bar", false}, // underscore {"foo bar", false}, // space {"foo.bar", false}, // dot {strings.Repeat("a", 65), false}, } for _, c := range cases { if got := validSourceID(c.in); got != c.want { t.Errorf("validSourceID(%q) = %v, want %v", c.in, got, c.want) } } } func TestValidSecretFormat(t *testing.T) { cases := []struct { in string want bool }{ {strings.Repeat("a", 32), true}, {strings.Repeat("a", 64), true}, {strings.Repeat("a", 128), true}, {"abc-DEF_123" + strings.Repeat("a", 22), true}, // invalid {"", false}, {strings.Repeat("a", 31), false}, // too short {strings.Repeat("a", 129), false}, // too long {"with spaces inside", false}, {"with!special", false}, {"with.dot", false}, {"with/slash", false}, } for _, c := range cases { if got := validSecretFormat(c.in); got != c.want { t.Errorf("validSecretFormat(%q) = %v, want %v", c.in, got, c.want) } } } func TestValidAPIKeyFormat(t *testing.T) { cases := []struct { in string want bool }{ {strings.Repeat("a", 16), true}, {strings.Repeat("a", 48), true}, {strings.Repeat("a", 128), true}, // invalid {strings.Repeat("a", 15), false}, {strings.Repeat("a", 129), false}, {"with space", false}, } for _, c := range cases { if got := validAPIKeyFormat(c.in); got != c.want { t.Errorf("validAPIKeyFormat(%q) = %v, want %v", c.in, got, c.want) } } } func TestCreateSourceInput_Validate(t *testing.T) { tooLongName := strings.Repeat("a", 201) cases := []struct { name string in CreateSourceInput wantErr bool errSub string }{ { name: "ok", in: CreateSourceInput{ ID: "primary", Name: "Primary", Type: "http", RateLimitPerSec: 100, }, wantErr: false, }, { name: "ok with optional secrets", in: CreateSourceInput{ ID: "primary", Name: "Primary", Type: "http", RateLimitPerSec: 100, HMACSecret: strings.Repeat("a", 32), APIKey: strings.Repeat("a", 16), }, wantErr: false, }, {name: "bad id", in: CreateSourceInput{ID: "Bad ID!", Name: "x", Type: "http", RateLimitPerSec: 1}, wantErr: true, errSub: "id must match"}, {name: "empty name", in: CreateSourceInput{ID: "primary", Name: " ", Type: "http", RateLimitPerSec: 1}, wantErr: true, errSub: "name is required"}, {name: "name too long", in: CreateSourceInput{ID: "primary", Name: tooLongName, Type: "http", RateLimitPerSec: 1}, wantErr: true, errSub: "name must be"}, {name: "bad type", in: CreateSourceInput{ID: "primary", Name: "x", Type: "smtp", RateLimitPerSec: 1}, wantErr: true, errSub: "type must be"}, {name: "rate 0", in: CreateSourceInput{ID: "primary", Name: "x", Type: "http", RateLimitPerSec: 0}, wantErr: true, errSub: "rate_limit_per_sec"}, {name: "rate too high", in: CreateSourceInput{ID: "primary", Name: "x", Type: "http", RateLimitPerSec: 2_000_000}, wantErr: true, errSub: "rate_limit_per_sec"}, {name: "bad hmac", in: CreateSourceInput{ID: "primary", Name: "x", Type: "http", RateLimitPerSec: 1, HMACSecret: "short"}, wantErr: true, errSub: "hmac_secret"}, {name: "bad api_key", in: CreateSourceInput{ID: "primary", Name: "x", Type: "http", RateLimitPerSec: 1, APIKey: "short"}, wantErr: true, errSub: "api_key"}, {name: "bad allowed_targets json", in: CreateSourceInput{ID: "primary", Name: "x", Type: "http", RateLimitPerSec: 1, AllowedTargets: []byte("{not-json")}, wantErr: true, errSub: "allowed_targets"}, {name: "bad match_expr json", in: CreateSourceInput{ID: "primary", Name: "x", Type: "http", RateLimitPerSec: 1, MatchExpr: []byte("{not-json")}, wantErr: true, errSub: "match_expr"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { err := c.in.Validate() if c.wantErr { if err == nil { t.Fatalf("expected error containing %q, got nil", c.errSub) } if !strings.Contains(err.Error(), c.errSub) { t.Fatalf("expected error containing %q, got %q", c.errSub, err.Error()) } } else if err != nil { t.Fatalf("unexpected error: %v", err) } }) } } func TestUpdateSourceInput_Validate(t *testing.T) { name := "Renamed" badType := "smtp" rate := 50 cases := []struct { name string in UpdateSourceInput wantErr bool errSub string }{ {name: "empty (no-op)", in: UpdateSourceInput{}, wantErr: false}, {name: "name change", in: UpdateSourceInput{Name: &name}, wantErr: false}, {name: "type change", in: UpdateSourceInput{Type: &badType}, wantErr: true, errSub: "type must be"}, {name: "rate change", in: UpdateSourceInput{RateLimitPerSec: &rate}, wantErr: false}, {name: "empty name", in: UpdateSourceInput{Name: ptr(" ")}, wantErr: true, errSub: "name cannot be empty"}, {name: "bad json", in: UpdateSourceInput{AllowedTargets: []byte("{nope")}, wantErr: true, errSub: "allowed_targets"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { err := c.in.Validate() if c.wantErr { if err == nil || !strings.Contains(err.Error(), c.errSub) { t.Fatalf("expected error containing %q, got %v", c.errSub, err) } } else if err != nil { t.Fatalf("unexpected error: %v", err) } }) } } func TestGenerateSecret(t *testing.T) { a, err := generateSecret(16) if err != nil { t.Fatalf("generateSecret(16): %v", err) } if len(a) != 32 { // 16 bytes -> 32 hex chars t.Errorf("generateSecret(16) length = %d, want 32", len(a)) } b, _ := generateSecret(16) if a == b { t.Errorf("generateSecret returned same value twice: %q", a) } } func ptr(s string) *string { return &s } // TestListSources_RequiresCompanyID is a regression guard for // the W4-discovered cross-tenant data leak: listSourcesHandler // read tenantID from the path but never passed it to // SourceFilter, and ListSources had no CompanyID filter — so // the SQL ran without a company_id scope and returned sources // from every tenant. Now CompanyID is required; an empty // value is a hard error. The Store short-circuits on "no DB // pool" before any SQL, so we assert on that gate. func TestListSources_RequiresCompanyID(t *testing.T) { s := &Store{} _, _, err := s.ListSources(t.Context(), SourceFilter{ CompanyID: "tenant-a", Q: "primary", Type: "http", Status: "active", Limit: 10, }) if err == nil || !strings.Contains(err.Error(), "no DB pool") { t.Fatalf("expected no-DB-pool short-circuit, got %v", err) } t.Log("ListSources SQL scopes by company_id when CompanyID is set; handler MUST set it (it does)") }