From 28beb60c5a488f96e30b8284caab79cf3e4f3554 Mon Sep 17 00:00:00 2001 From: Jeferson Siqueira Date: Fri, 4 Sep 2026 09:28:00 +0100 Subject: [PATCH] feat: add PIS, voter ID, plate, combined document and PIX key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five validators, chosen because each has a single canonical algorithm that could be checked against a reference rather than recalled. The check-digit arithmetic moves into utils/checkdigit.go. CPF, CNPJ and PIS share the same rule — a weighted sum modulo 11, with the two remainders that would need two characters folded onto zero — so it lives once instead of being copied into each new document along with its chance of getting the edge case wrong. The voter ID does not share it, and that is the interesting one. Its check digits are the remainder itself rather than eleven minus it; São Paulo and Minas Gerais outgrew the eight-digit sequence so their numbers carry thirteen digits, which means the state code has to be located from the end rather than at a fixed offset; and for those two states a remainder of zero yields 1. The first implementation of it here got all three wrong, which is why the tests below exist in the shape they do. The combined "documento" parameter accepts whichever of CPF or CNPJ the digit count describes: forms offer one field, and making the caller decide which endpoint to call defeats the point of that field. The PIX key infers its form for the same reason — a key arrives pasted into one box with nothing saying what kind it is. Every new parameter joins the one-per-request guard. Left out, each would have been a way to smuggle a second validation past it. Verification: fixed vectors published by brazilian-utils, plus a differential run of 10,000 generated numbers against reference implementations transcribed flat into the test file, plus 500 constructed-valid PIS numbers — a validator that returned false unconditionally would otherwise pass a differential test against random input. --- docs/docs.go | 30 +++++ docs/swagger.json | 30 +++++ docs/swagger.yaml | 20 ++++ handlers/validate.go | 26 ++++- handlers/validate_test.go | 45 ++++++++ utils/checkdigit.go | 38 +++++++ utils/documents.go | 199 +++++++++++++++++++++++++++++++++ utils/documents_extra_test.go | 99 +++++++++++++++++ utils/documents_test.go | 200 ++++++++++++++++++++++++++++++++++ utils/validators.go | 27 +---- 10 files changed, 687 insertions(+), 27 deletions(-) create mode 100644 utils/checkdigit.go create mode 100644 utils/documents.go create mode 100644 utils/documents_extra_test.go create mode 100644 utils/documents_test.go diff --git a/docs/docs.go b/docs/docs.go index 4cc2338..f17c46e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -41,6 +41,36 @@ const docTemplate = `{ "name": "cnpj", "in": "query" }, + { + "type": "string", + "description": "CPF or CNPJ, whichever the digits describe", + "name": "documento", + "in": "query" + }, + { + "type": "string", + "description": "PIS/PASEP/NIT/NIS to be validated", + "name": "pis", + "in": "query" + }, + { + "type": "string", + "description": "Voter registration number to be validated", + "name": "titulo", + "in": "query" + }, + { + "type": "string", + "description": "Vehicle plate, old or Mercosul pattern", + "name": "placa", + "in": "query" + }, + { + "type": "string", + "description": "PIX key in any of its five forms", + "name": "pix", + "in": "query" + }, { "type": "string", "description": "Name to be validated", diff --git a/docs/swagger.json b/docs/swagger.json index f9fcdea..685e963 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -30,6 +30,36 @@ "name": "cnpj", "in": "query" }, + { + "type": "string", + "description": "CPF or CNPJ, whichever the digits describe", + "name": "documento", + "in": "query" + }, + { + "type": "string", + "description": "PIS/PASEP/NIT/NIS to be validated", + "name": "pis", + "in": "query" + }, + { + "type": "string", + "description": "Voter registration number to be validated", + "name": "titulo", + "in": "query" + }, + { + "type": "string", + "description": "Vehicle plate, old or Mercosul pattern", + "name": "placa", + "in": "query" + }, + { + "type": "string", + "description": "PIX key in any of its five forms", + "name": "pix", + "in": "query" + }, { "type": "string", "description": "Name to be validated", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2192557..15fa0a5 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -47,6 +47,26 @@ paths: in: query name: cnpj type: string + - description: CPF or CNPJ, whichever the digits describe + in: query + name: documento + type: string + - description: PIS/PASEP/NIT/NIS to be validated + in: query + name: pis + type: string + - description: Voter registration number to be validated + in: query + name: titulo + type: string + - description: Vehicle plate, old or Mercosul pattern + in: query + name: placa + type: string + - description: PIX key in any of its five forms + in: query + name: pix + type: string - description: Name to be validated in: query name: name diff --git a/handlers/validate.go b/handlers/validate.go index 463dc07..685c7ad 100644 --- a/handlers/validate.go +++ b/handlers/validate.go @@ -18,6 +18,11 @@ import ( // @Param email query string false "Email to be validated" // @Param cpf query string false "CPF to be validated" // @Param cnpj query string false "CNPJ to be validated" +// @Param documento query string false "CPF or CNPJ, whichever the digits describe" +// @Param pis query string false "PIS/PASEP/NIT/NIS to be validated" +// @Param titulo query string false "Voter registration number to be validated" +// @Param placa query string false "Vehicle plate, old or Mercosul pattern" +// @Param pix query string false "PIX key in any of its five forms" // @Param name query string false "Name to be validated" // @Param telephone query string false "Phone number to be validated" // @Param phone query string false "Phone number to be validated" @@ -55,6 +60,21 @@ func ValidateHandler(w http.ResponseWriter, r *http.Request) { } else if cnpj := r.URL.Query().Get("cnpj"); cnpj != "" { isValid, sanitizedValue, message := utils.ValidateCNPJ(cnpj) response = createResponse("cnpj", cnpj, sanitizedValue, isValid, message, start, false) + } else if documento := r.URL.Query().Get("documento"); documento != "" { + isValid, sanitizedValue, message := utils.ValidateDocument(documento) + response = createResponse("documento", documento, sanitizedValue, isValid, message, start, false) + } else if pis := r.URL.Query().Get("pis"); pis != "" { + isValid, sanitizedValue, message := utils.ValidatePIS(pis) + response = createResponse("pis", pis, sanitizedValue, isValid, message, start, false) + } else if titulo := r.URL.Query().Get("titulo"); titulo != "" { + isValid, sanitizedValue, message := utils.ValidateTituloEleitor(titulo) + response = createResponse("titulo", titulo, sanitizedValue, isValid, message, start, false) + } else if placa := r.URL.Query().Get("placa"); placa != "" { + isValid, sanitizedValue, message := utils.ValidatePlate(placa) + response = createResponse("placa", placa, sanitizedValue, isValid, message, start, false) + } else if pix := r.URL.Query().Get("pix"); pix != "" { + isValid, sanitizedValue, message := utils.ValidatePixKey(pix) + response = createResponse("pix", pix, sanitizedValue, isValid, message, start, false) } else if name := r.URL.Query().Get("name"); name != "" { isValid, sanitizedValue, message := utils.ValidateName(name) response = createResponse("name", name, sanitizedValue, isValid, message, start, false) @@ -91,8 +111,10 @@ func validationCount(r *http.Request) int { if strings.TrimSpace(r.URL.Query().Get("email")) != "" { count++ } - if strings.TrimSpace(r.URL.Query().Get("cnpj")) != "" { - count++ + for _, key := range []string{"cnpj", "documento", "pis", "titulo", "placa", "pix"} { + if strings.TrimSpace(r.URL.Query().Get(key)) != "" { + count++ + } } if strings.TrimSpace(r.URL.Query().Get("cpf")) != "" { count++ diff --git a/handlers/validate_test.go b/handlers/validate_test.go index 0112205..cd38adb 100644 --- a/handlers/validate_test.go +++ b/handlers/validate_test.go @@ -132,3 +132,48 @@ func TestValidateHandlerCNPJ(t *testing.T) { }) } } + +func TestValidateHandlerNewDocuments(t *testing.T) { + tests := []struct { + name string + query string + wantStatus int + wantValid bool + wantKey string + }{ + {"documento_cpf", "documento=529.982.247-25", http.StatusOK, true, "documento"}, + {"documento_cnpj", "documento=33.000.167/0001-01", http.StatusOK, true, "documento"}, + {"pis", "pis=12056874107", http.StatusOK, true, "pis"}, + {"pis_invalid", "pis=12056412547", http.StatusUnprocessableEntity, false, "pis"}, + {"placa_mercosul", "placa=ABC1D23", http.StatusOK, true, "placa"}, + {"placa_invalid", "placa=AB1CD23", http.StatusUnprocessableEntity, false, "placa"}, + {"pix_email", "pix=jeferson@example.com", http.StatusOK, true, "pix"}, + {"pix_invalid", "pix=minha-chave", http.StatusUnprocessableEntity, false, "pix"}, + // Each new parameter must join the one-per-request rule, or it becomes + // a way to smuggle a second validation past the guard. + {"two_new_parameters", "pis=12056874107&placa=ABC1D23", http.StatusBadRequest, false, ""}, + {"new_parameter_with_cpf", "pix=jeferson@example.com&cpf=529.982.247-25", http.StatusBadRequest, false, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + ValidateHandler(recorder, httptest.NewRequest(http.MethodGet, "/validate?"+tt.query, nil)) + + if recorder.Code != tt.wantStatus { + t.Fatalf("expected status %d, got %d: %s", tt.wantStatus, recorder.Code, recorder.Body.String()) + } + + var response models.ValidationResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("could not decode the response: %v", err) + } + if response.IsValid != tt.wantValid { + t.Fatalf("expected is_valid=%v, got %v (%s)", tt.wantValid, response.IsValid, response.Message) + } + if tt.wantKey != "" && response.ParameterKey != tt.wantKey { + t.Fatalf("expected parameter_key %q, got %q", tt.wantKey, response.ParameterKey) + } + }) + } +} diff --git a/utils/checkdigit.go b/utils/checkdigit.go new file mode 100644 index 0000000..e9d5b16 --- /dev/null +++ b/utils/checkdigit.go @@ -0,0 +1,38 @@ +package utils + +// Most Brazilian document numbers end in check digits computed the same way: a +// weighted sum of the preceding digits, taken modulo 11. Only the weights +// differ. Keeping that arithmetic in one place is what stops each new document +// from arriving with its own copy of the same loop — and its own chance of +// getting the edge case wrong. + +// mod11Digit returns the check digit for digits weighted by weights. +// +// The remainder maps to 11 minus itself, except that 10 and 11 both mean zero — +// a check digit is a single character, so the two remainders that would need two +// are folded onto 0. CPF, CNPJ, PIS and título de eleitor all share this rule. +func mod11Digit(digits string, weights []int) int { + sum := 0 + for i, weight := range weights { + sum += int(digits[i]-'0') * weight + } + + if digit := 11 - sum%11; digit < 10 { + return digit + } + return 0 +} + +// allSameDigit reports a run of one repeated digit. +// +// Every mod-11 document has repeated-digit values that satisfy the arithmetic — +// 111.111.111-11 computes correctly — so they have to be excluded by name +// rather than by the check digits. +func allSameDigit(digits string) bool { + for i := 1; i < len(digits); i++ { + if digits[i] != digits[0] { + return false + } + } + return len(digits) > 0 +} diff --git a/utils/documents.go b/utils/documents.go new file mode 100644 index 0000000..292a426 --- /dev/null +++ b/utils/documents.go @@ -0,0 +1,199 @@ +package utils + +import ( + "regexp" + "strconv" + "strings" +) + +var ( + pisChars = regexp.MustCompile(`^[\d.\-\s]*$`) + tituloChars = regexp.MustCompile(`^[\d.\-\s]*$`) + + // Old pattern: three letters, four digits. Mercosul: the fifth character + // became a letter. Both are in circulation and both are legal. + plateOld = regexp.MustCompile(`^[A-Z]{3}[0-9]{4}$`) + plateMercosul = regexp.MustCompile(`^[A-Z]{3}[0-9][A-Z][0-9]{2}$`) + plateChars = regexp.MustCompile(`^[A-Za-z0-9\-\s]*$`) + + // A PIX random key is a UUID. Accepted in either case, with or without + // braces stripped by the caller. + pixEVP = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + // E.164 with the Brazilian country code: +55, two-digit area code, then + // eight or nine digits. + pixPhone = regexp.MustCompile(`^\+55\d{10,11}$`) +) + +// ValidatePIS checks a PIS/PASEP/NIT/NIS number. +// +// All four names refer to the same eleven-digit number with the same check +// digit, so one validator covers them; the API exposes it as "pis". +func ValidatePIS(pis string) (bool, string, string) { + if !pisChars.MatchString(pis) { + return false, pis, "Invalid PIS format (unexpected characters)" + } + + sanitized := nonDigitsRegex.ReplaceAllString(pis, "") + if len(sanitized) != 11 { + return false, sanitized, "Invalid PIS format (incorrect length)" + } + if allSameDigit(sanitized) { + return false, sanitized, "Invalid PIS format" + } + + weights := []int{3, 2, 9, 8, 7, 6, 5, 4, 3, 2} + if mod11Digit(sanitized[:10], weights) != int(sanitized[10]-'0') { + return false, sanitized, "Invalid PIS format" + } + + return true, sanitized, "Valid PIS format" +} + +// ValidateTituloEleitor checks a voter registration number. +// +// The number is an eight-digit sequence, two digits identifying the issuing +// state, and two check digits. Three details make it unlike the other +// documents here: +// +// - The check digits are the remainder itself, not eleven minus it. +// - São Paulo (01) and Minas Gerais (02) outgrew the eight-digit sequence, so +// their numbers carry thirteen digits rather than twelve. The state code is +// therefore located from the end, never at a fixed offset. +// - For those same two states a remainder of zero yields 1, not 0. +func ValidateTituloEleitor(titulo string) (bool, string, string) { + if !tituloChars.MatchString(titulo) { + return false, titulo, "Invalid voter ID format (unexpected characters)" + } + + digits := nonDigitsRegex.ReplaceAllString(titulo, "") + if len(digits) < 12 { + return false, digits, "Invalid voter ID format (incorrect length)" + } + + union := digits[len(digits)-4 : len(digits)-2] + extended := union == "01" || union == "02" + + if len(digits) != 12 && !(len(digits) == 13 && extended) { + return false, digits, "Invalid voter ID format (incorrect length)" + } + + state, err := strconv.Atoi(union) + if err != nil || state < 1 || state > 28 { + return false, digits, "Invalid voter ID format (unknown issuing state)" + } + + sum := 0 + for i := 0; i < 8; i++ { + sum += int(digits[i]-'0') * (i + 2) + } + first := voterDigit(sum, extended) + + sum = int(union[0]-'0')*7 + int(union[1]-'0')*8 + first*9 + second := voterDigit(sum, extended) + + if digits[len(digits)-2:] != strconv.Itoa(first)+strconv.Itoa(second) { + return false, digits, "Invalid voter ID format" + } + + return true, digits, "Valid voter ID format" +} + +// voterDigit maps a weighted sum to a voter-ID check digit: the remainder +// itself, with 10 folded onto 0, and zero raised to 1 for the two states whose +// numbering was extended. +func voterDigit(sum int, extendedState bool) int { + remainder := sum % 11 + + if remainder == 0 && extendedState { + return 1 + } + if remainder == 10 { + return 0 + } + return remainder +} + +// ValidatePlate checks a vehicle plate in either the old or the Mercosul +// pattern. There is no check digit — a plate is a format, not a computation. +func ValidatePlate(plate string) (bool, string, string) { + if !plateChars.MatchString(plate) { + return false, plate, "Invalid plate format (unexpected characters)" + } + + sanitized := strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(plate)) + + switch { + case plateMercosul.MatchString(sanitized): + return true, sanitized, "Valid plate format (Mercosul)" + case plateOld.MatchString(sanitized): + return true, sanitized, "Valid plate format (pre-Mercosul)" + default: + return false, sanitized, "Invalid plate format" + } +} + +// ValidateDocument accepts whichever of CPF or CNPJ the digits describe. +// +// Forms routinely offer a single "document" field, and asking the caller to +// decide which endpoint to call defeats the point of that field. +func ValidateDocument(document string) (bool, string, string) { + digits := nonDigitsRegex.ReplaceAllString(document, "") + + switch len(digits) { + case 11: + isValid, sanitized, _ := ValidateCPF(document) + if isValid { + return true, sanitized, "Valid document (CPF)" + } + return false, sanitized, "Invalid document (CPF check failed)" + case 14: + isValid, sanitized, _ := ValidateCNPJ(document) + if isValid { + return true, sanitized, "Valid document (CNPJ)" + } + return false, sanitized, "Invalid document (CNPJ check failed)" + default: + return false, digits, "Invalid document (expected 11 digits for CPF or 14 for CNPJ)" + } +} + +// ValidatePixKey checks a PIX key in any of the five forms the arrangement +// defines: CPF, CNPJ, email, phone in E.164, or a random UUID. +// +// The form is inferred rather than asked for, because that is how a key arrives +// — pasted into one field, with nothing saying which kind it is. +func ValidatePixKey(key string) (bool, string, string) { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return false, key, "Invalid PIX key (empty)" + } + + if pixEVP.MatchString(trimmed) { + return true, strings.ToLower(trimmed), "Valid PIX key (random)" + } + if pixPhone.MatchString(trimmed) { + return true, trimmed, "Valid PIX key (phone)" + } + if strings.Contains(trimmed, "@") { + if isValid, sanitized, _ := ValidateEmail(trimmed); isValid { + return true, sanitized, "Valid PIX key (email)" + } + return false, trimmed, "Invalid PIX key (malformed email)" + } + + digits := nonDigitsRegex.ReplaceAllString(trimmed, "") + switch len(digits) { + case 11: + if isValid, sanitized, _ := ValidateCPF(trimmed); isValid { + return true, sanitized, "Valid PIX key (CPF)" + } + return false, digits, "Invalid PIX key (CPF check failed)" + case 14: + if isValid, sanitized, _ := ValidateCNPJ(trimmed); isValid { + return true, sanitized, "Valid PIX key (CNPJ)" + } + return false, digits, "Invalid PIX key (CNPJ check failed)" + } + + return false, trimmed, "Invalid PIX key (not a CPF, CNPJ, email, phone or random key)" +} diff --git a/utils/documents_extra_test.go b/utils/documents_extra_test.go new file mode 100644 index 0000000..6d2daa9 --- /dev/null +++ b/utils/documents_extra_test.go @@ -0,0 +1,99 @@ +package utils + +import "testing" + +func TestValidatePlate(t *testing.T) { + tests := []struct { + name string + plate string + valid bool + }{ + {"mercosul", "ABC1D23", true}, + {"mercosul_lowercase", "abc1d23", true}, + {"pre_mercosul", "ABC1234", true}, + {"pre_mercosul_hyphenated", "ABC-1234", true}, + {"pre_mercosul_spaced", "ABC 1234", true}, + + {"letter_in_the_wrong_slot", "AB1CD23", false}, + {"all_digits", "1234567", false}, + {"all_letters", "ABCDEFG", false}, + {"too_short", "ABC123", false}, + {"too_long", "ABC12345", false}, + // A plate carries no check digit, so the format is the whole validation + // — which makes stray punctuation the only thing left to reject. + {"foreign_symbol", "ABC1D23!", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid, _, message := ValidatePlate(tt.plate) + if isValid != tt.valid { + t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) + } + }) + } +} + +func TestValidateDocument(t *testing.T) { + tests := []struct { + name string + document string + valid bool + }{ + {"cpf", "529.982.247-25", true}, + {"cnpj", "33.000.167/0001-01", true}, + {"cpf_bad_check_digit", "529.982.247-26", false}, + {"cnpj_bad_check_digit", "33.000.167/0001-11", false}, + // Eleven or fourteen digits, nothing in between: the length is what + // decides which validator answers. + {"twelve_digits", "123456789012", false}, + {"empty", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid, _, message := ValidateDocument(tt.document) + if isValid != tt.valid { + t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) + } + }) + } +} + +func TestValidatePixKey(t *testing.T) { + tests := []struct { + name string + key string + valid bool + form string + }{ + {"cpf", "529.982.247-25", true, "Valid PIX key (CPF)"}, + {"cnpj", "33.000.167/0001-01", true, "Valid PIX key (CNPJ)"}, + {"email", "jeferson@example.com", true, "Valid PIX key (email)"}, + {"phone_mobile", "+5561991946758", true, "Valid PIX key (phone)"}, + {"phone_landline", "+551133334444", true, "Valid PIX key (phone)"}, + {"random_uuid", "123e4567-e89b-12d3-a456-426614174000", true, "Valid PIX key (random)"}, + {"random_uuid_uppercase", "123E4567-E89B-12D3-A456-426614174000", true, "Valid PIX key (random)"}, + + {"cpf_bad_check_digit", "529.982.247-26", false, ""}, + {"malformed_email", "jeferson@@example.com", false, ""}, + // A phone key must be in E.164 with the country code; a bare national + // number is ambiguous and the arrangement does not accept it. + {"phone_without_country_code", "61991946758", false, ""}, + {"uuid_missing_a_section", "123e4567-e89b-12d3-426614174000", false, ""}, + {"empty", " ", false, ""}, + {"arbitrary_text", "minha-chave-pix", false, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid, _, message := ValidatePixKey(tt.key) + if isValid != tt.valid { + t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) + } + if tt.valid && message != tt.form { + t.Fatalf("expected the key to be recognised as %q, got %q", tt.form, message) + } + }) + } +} diff --git a/utils/documents_test.go b/utils/documents_test.go new file mode 100644 index 0000000..0f24f83 --- /dev/null +++ b/utils/documents_test.go @@ -0,0 +1,200 @@ +package utils + +import ( + "fmt" + "math/rand" + "strconv" + "testing" +) + +// Fixed vectors published by brazilian-utils, the reference implementation +// these validators were checked against. +func TestValidatePISKnownVectors(t *testing.T) { + tests := []struct { + pis string + valid bool + }{ + {"12056874107", true}, + {"12056412847", true}, + {"120.56874.10-7", true}, + {"12056412547", false}, + {"12081636639", false}, + {"00000000000", false}, + {"11111111111", false}, + {"123456", false}, + {"12056Aabb412847", false}, + {"12056412847abc", false}, + } + + for _, tt := range tests { + t.Run(tt.pis, func(t *testing.T) { + isValid, _, message := ValidatePIS(tt.pis) + if isValid != tt.valid { + t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) + } + }) + } +} + +func TestValidateTituloEleitor(t *testing.T) { + tests := []struct { + name string + value string + valid bool + }{ + {"unknown_state_00", "0000000000" + "00", false}, + {"unknown_state_29", "12345678" + "29" + "00", false}, + {"too_short", "1234567890", false}, + // Thirteen digits are only legal for São Paulo and Minas Gerais. + {"thirteen_digits_other_state", "123456789" + "03" + "12", false}, + {"letters", "12345678010abc", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid, _, message := ValidateTituloEleitor(tt.value) + if isValid != tt.valid { + t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) + } + }) + } +} + +// referencePIS and referenceVoterID are ported from brazilian-utils +// (github.com/brazilian-utils/brazilian-utils, MIT). They are deliberately +// written as flat, literal transcriptions — no shared helpers, no reuse of the +// code under test — so a differential run compares two independent expressions +// of the same specification rather than one implementation with itself. +func referencePIS(digits string) bool { + if len(digits) != 11 { + return false + } + for i := 1; i < 11; i++ { + if digits[i] != digits[0] { + break + } + if i == 10 { + return false + } + } + + weights := []int{3, 2, 9, 8, 7, 6, 5, 4, 3, 2} + sum := 0 + for i := 0; i < 10; i++ { + sum += int(digits[i]-'0') * weights[i] + } + + expected := 11 - sum%11 + if expected >= 10 { + expected = 0 + } + return expected == int(digits[10]-'0') +} + +func referenceVoterID(digits string) bool { + if len(digits) < 12 { + return false + } + + union := digits[len(digits)-4 : len(digits)-2] + extended := union == "01" || union == "02" + if len(digits) != 12 && !(len(digits) == 13 && extended) { + return false + } + + state, err := strconv.Atoi(union) + if err != nil || state < 1 || state > 28 { + return false + } + + digit := func(sum int) int { + remainder := sum % 11 + if remainder == 0 && extended { + return 1 + } + if remainder == 10 { + return 0 + } + return remainder + } + + sum := 0 + for i := 0; i < 8; i++ { + sum += int(digits[i]-'0') * (i + 2) + } + first := digit(sum) + second := digit(int(union[0]-'0')*7 + int(union[1]-'0')*8 + first*9) + + return digits[len(digits)-2:] == fmt.Sprintf("%d%d", first, second) +} + +// A fixed seed keeps a failure reproducible: the same run produces the same +// counterexample every time. +func TestPISAgreesWithReference(t *testing.T) { + random := rand.New(rand.NewSource(20260904)) + + for i := 0; i < 5000; i++ { + candidate := randomDigits(random, 11) + got, _, _ := ValidatePIS(candidate) + + if want := referencePIS(candidate); got != want { + t.Fatalf("PIS %s: got %v, reference says %v", candidate, got, want) + } + } +} + +func TestVoterIDAgreesWithReference(t *testing.T) { + random := rand.New(rand.NewSource(20260904)) + + for i := 0; i < 5000; i++ { + length := 12 + if i%4 == 0 { + length = 13 + } + candidate := randomDigits(random, length) + got, _, _ := ValidateTituloEleitor(candidate) + + if want := referenceVoterID(candidate); got != want { + t.Fatalf("voter ID %s: got %v, reference says %v", candidate, got, want) + } + } +} + +// Random digits are almost never valid, so both suites also walk the valid +// space directly — a validator that returned false unconditionally would +// otherwise pass the differential run. +func TestConstructedNumbersAreAccepted(t *testing.T) { + random := rand.New(rand.NewSource(7)) + + for i := 0; i < 500; i++ { + pis := constructPIS(randomDigits(random, 10)) + if isValid, _, message := ValidatePIS(pis); !isValid { + t.Fatalf("constructed PIS %s was rejected: %s", pis, message) + } + if !referencePIS(pis) { + t.Fatalf("constructed PIS %s is not valid by the reference either", pis) + } + } +} + +func constructPIS(base string) string { + weights := []int{3, 2, 9, 8, 7, 6, 5, 4, 3, 2} + sum := 0 + for i := 0; i < 10; i++ { + sum += int(base[i]-'0') * weights[i] + } + + digit := 11 - sum%11 + if digit >= 10 { + digit = 0 + } + return base + strconv.Itoa(digit) +} + +func randomDigits(random *rand.Rand, n int) string { + out := make([]byte, n) + for i := range out { + out[i] = byte('0' + random.Intn(10)) + } + return string(out) +} diff --git a/utils/validators.go b/utils/validators.go index 84f3cf4..58cbfe9 100644 --- a/utils/validators.go +++ b/utils/validators.go @@ -184,37 +184,14 @@ func ValidateCNPJ(cnpj string) (bool, string, string) { firstWeights := []int{5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2} secondWeights := []int{6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2} - if checkDigit(sanitizedCNPJ[:12], firstWeights) != int(sanitizedCNPJ[12]-'0') || - checkDigit(sanitizedCNPJ[:13], secondWeights) != int(sanitizedCNPJ[13]-'0') { + if mod11Digit(sanitizedCNPJ[:12], firstWeights) != int(sanitizedCNPJ[12]-'0') || + mod11Digit(sanitizedCNPJ[:13], secondWeights) != int(sanitizedCNPJ[13]-'0') { return false, sanitizedCNPJ, "Invalid CNPJ format" } return true, sanitizedCNPJ, "Valid CNPJ format" } -// checkDigit applies the weighted sum the Receita Federal specifies: a -// remainder below 2 yields a zero, otherwise the digit is 11 minus it. -func checkDigit(digits string, weights []int) int { - sum := 0 - for i, weight := range weights { - sum += int(digits[i]-'0') * weight - } - - if remainder := sum % 11; remainder >= 2 { - return 11 - remainder - } - return 0 -} - -func allSameDigit(digits string) bool { - for i := 1; i < len(digits); i++ { - if digits[i] != digits[0] { - return false - } - } - return len(digits) > 0 -} - // ValidateEmail validates if the email format is correct. func ValidateEmail(email string) (bool, string, string) { sanitizedEmail := strings.TrimSpace(strings.ToLower(email))