diff --git a/docs/docs.go b/docs/docs.go index 8851691..80a7949 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -65,6 +65,24 @@ const docTemplate = `{ "name": "placa", "in": "query" }, + { + "type": "string", + "description": "Driving licence number to be validated", + "name": "cnh", + "in": "query" + }, + { + "type": "string", + "description": "Vehicle registration number, 9 or 11 digits", + "name": "renavam", + "in": "query" + }, + { + "type": "string", + "description": "Bank slip linha digitável, 47 digits", + "name": "boleto", + "in": "query" + }, { "type": "string", "description": "PIX key in any of its five forms", diff --git a/docs/swagger.json b/docs/swagger.json index 8399a0e..39360d5 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -54,6 +54,24 @@ "name": "placa", "in": "query" }, + { + "type": "string", + "description": "Driving licence number to be validated", + "name": "cnh", + "in": "query" + }, + { + "type": "string", + "description": "Vehicle registration number, 9 or 11 digits", + "name": "renavam", + "in": "query" + }, + { + "type": "string", + "description": "Bank slip linha digitável, 47 digits", + "name": "boleto", + "in": "query" + }, { "type": "string", "description": "PIX key in any of its five forms", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index bd16e48..8cb1eaa 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -138,6 +138,18 @@ paths: in: query name: placa type: string + - description: Driving licence number to be validated + in: query + name: cnh + type: string + - description: Vehicle registration number, 9 or 11 digits + in: query + name: renavam + type: string + - description: Bank slip linha digitável, 47 digits + in: query + name: boleto + type: string - description: PIX key in any of its five forms in: query name: pix diff --git a/handlers/validate.go b/handlers/validate.go index a52f076..527aaa9 100644 --- a/handlers/validate.go +++ b/handlers/validate.go @@ -22,6 +22,9 @@ import ( // @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 cnh query string false "Driving licence number to be validated" +// @Param renavam query string false "Vehicle registration number, 9 or 11 digits" +// @Param boleto query string false "Bank slip linha digitável, 47 digits" // @Param pix query string false "PIX key in any of its five forms" // @Param ie query string false "State registration; requires uf" // @Param uf query string false "Two-letter state code qualifying ie" diff --git a/handlers/validate_test.go b/handlers/validate_test.go index 1ae4f32..a7ce908 100644 --- a/handlers/validate_test.go +++ b/handlers/validate_test.go @@ -215,3 +215,52 @@ func TestValidateHandlerInscricaoEstadual(t *testing.T) { }) } } + +func TestValidateHandlerVehicleAndBoleto(t *testing.T) { + tests := []struct { + name string + query string + wantStatus int + wantValid bool + }{ + {"cnh", "cnh=00000000119", http.StatusOK, true}, + {"cnh_invalid", "cnh=12345678901", http.StatusUnprocessableEntity, false}, + {"renavam_nine", "renavam=639884962", http.StatusOK, true}, + {"renavam_eleven", "renavam=00639884962", http.StatusOK, true}, + {"boleto", "boleto=00190000090114971860168524522114675860000102656", http.StatusOK, true}, + {"boleto_arrecadacao_is_not_checked", "boleto=846700000017435900240200610207807116000000000000", http.StatusUnprocessableEntity, 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) + } + }) + } +} + +// The batch endpoint reads the same table, so the three new documents must be +// reachable there without another line of wiring. +func TestBatchReachesTheNewDocuments(t *testing.T) { + recorder, response := postBatch(t, `{"items":[ + {"key":"cnh","value":"00000000119"}, + {"key":"renavam","value":"639884962"}, + {"key":"boleto","value":"00190000090114971860168524522114675860000102656"} + ]}`) + + if recorder.Code != http.StatusOK || response.Summary.Valid != 3 { + t.Fatalf("expected all three to validate through the batch: %+v", response) + } +} diff --git a/utils/boleto.go b/utils/boleto.go new file mode 100644 index 0000000..dca4326 --- /dev/null +++ b/utils/boleto.go @@ -0,0 +1,117 @@ +package utils + +import "regexp" + +var boletoChars = regexp.MustCompile(`^[\d.\-\s]*$`) + +// A linha digitável is not the barcode. It is the barcode's 44 digits shuffled +// into three groups that each carry their own module-10 check digit, so a +// person typing it from paper is told which group they got wrong rather than +// simply that the whole number is bad. +// +// Validating it therefore means two separate things: check each group, then put +// the barcode back together and check its own digit. + +// boletoGroups are the three typed groups: where each starts, where its digits +// end, and where its check digit sits. +var boletoGroups = []struct{ start, end, check int }{ + {0, 9, 9}, + {10, 20, 20}, + {21, 31, 31}, +} + +// barcodeOrder reassembles the 44-digit barcode out of the typed line. The +// pieces are not in the same order in both, which is the whole reason this +// mapping has to exist. +var barcodeOrder = [][2]int{{0, 4}, {32, 47}, {4, 9}, {10, 20}, {21, 31}} + +// barcodeCheckDigitPosition is where the general check digit sits once the +// barcode is reassembled — inside it, not at the end. +const barcodeCheckDigitPosition = 4 + +// ValidateBoleto checks the 47-digit linha digitável of a bank slip. +func ValidateBoleto(boleto string) (bool, string, string) { + if !boletoChars.MatchString(boleto) { + return false, boleto, "Invalid boleto (unexpected characters)" + } + + digits := nonDigitsRegex.ReplaceAllString(boleto, "") + + // Utility and tax slips are 48 digits with a different structure entirely. + // Saying so is not the same as calling the number invalid: it has not been + // checked, and reporting it as invalid would be a lie about a slip that may + // well be good. + if len(digits) == 48 { + return false, digits, "Arrecadação slips (48 digits) are not supported yet — this number was not checked" + } + + if len(digits) != 47 { + return false, digits, "Invalid boleto (expected 47 digits)" + } + + for i, group := range boletoGroups { + expected := mod10Boleto(digits[group.start:group.end]) + if expected != int(digits[group.check]-'0') { + return false, digits, "Invalid boleto (check digit of group " + string(rune('1'+i)) + " does not match)" + } + } + + barcode := "" + for _, span := range barcodeOrder { + barcode += digits[span[0]:span[1]] + } + + withoutCheck := barcode[:barcodeCheckDigitPosition] + barcode[barcodeCheckDigitPosition+1:] + if mod11Boleto(withoutCheck) != int(barcode[barcodeCheckDigitPosition]-'0') { + return false, digits, "Invalid boleto (general check digit does not match)" + } + + return true, digits, "Valid boleto" +} + +// mod10Boleto is the Luhn-shaped check the typed groups use: alternating +// weights of two and one from the right, with any product above nine reduced by +// nine. +func mod10Boleto(digits string) int { + sum := 0 + + for i := len(digits) - 1; i >= 0; i-- { + weight := 2 + if (len(digits)-1-i)&1 == 1 { + weight = 1 + } + + product := int(digits[i]-'0') * weight + if product > 9 { + product -= 9 + } + sum += product + } + + if remainder := sum % 10; remainder > 0 { + return 10 - remainder + } + return 0 +} + +// mod11Boleto is the barcode's own check: weights cycling two through nine from +// the right, where a remainder of 0 or 1 yields 1 rather than the 0 most +// Brazilian documents use. +func mod11Boleto(digits string) int { + sum, weight := 0, 2 + + for i := len(digits) - 1; i >= 0; i-- { + sum += int(digits[i]-'0') * weight + if weight < 9 { + weight++ + } else { + weight = 2 + } + } + + if remainder := sum % 11; remainder == 0 || remainder == 1 { + return 1 + } else { + return 11 - remainder + } +} diff --git a/utils/dispatch.go b/utils/dispatch.go index 1bf5a8b..022cb44 100644 --- a/utils/dispatch.go +++ b/utils/dispatch.go @@ -42,6 +42,9 @@ var validators = map[string]validatorFunc{ return Outcome{Sanitized: sanitized, Valid: valid, Message: message} }, "pis": plain(ValidatePIS), + "cnh": plain(ValidateCNH), + "renavam": plain(ValidateRenavam), + "boleto": plain(ValidateBoleto), "titulo": plain(ValidateTituloEleitor), "placa": plain(ValidatePlate), "pix": plain(ValidatePixKey), @@ -57,7 +60,8 @@ var validators = map[string]validatorFunc{ // is random in Go and the endpoint's behaviour must not be. var ValidationKeys = []string{ "email", "cpf", "cnpj", "documento", "ie", "pis", "titulo", - "placa", "pix", "name", "telephone", "plastic", "rg", "cep", + "cnh", "renavam", "placa", "boleto", "pix", + "name", "telephone", "plastic", "rg", "cep", } // SupportedKeys reports what can be validated, for error messages. diff --git a/utils/vehicle.go b/utils/vehicle.go new file mode 100644 index 0000000..1496116 --- /dev/null +++ b/utils/vehicle.go @@ -0,0 +1,108 @@ +package utils + +import "regexp" + +var ( + cnhChars = regexp.MustCompile(`^[\d.\-\s]*$`) + renavamChars = regexp.MustCompile(`^[\d.\-\s]*$`) +) + +// ValidateCNH checks a driving licence number. +// +// The two check digits are unusual in being coupled: when the first overflows +// past nine it is written as zero, and the second is then computed two lower to +// compensate. Treating them independently — which is what most of the wrong +// implementations of this do — accepts numbers the Detran would reject. +func ValidateCNH(cnh string) (bool, string, string) { + if !cnhChars.MatchString(cnh) { + return false, cnh, "Invalid CNH format (unexpected characters)" + } + + digits := nonDigitsRegex.ReplaceAllString(cnh, "") + if len(digits) != 11 { + return false, digits, "Invalid CNH format (incorrect length)" + } + if allSameDigit(digits) { + return false, digits, "Invalid CNH format" + } + + first, sum := 0, 0 + for i := 0; i < 9; i++ { + sum += int(digits[i]-'0') * (9 - i) + } + + first = sum % 11 + decrement := 0 + if first >= 10 { + first, decrement = 0, 2 + } + if first != int(digits[9]-'0') { + return false, digits, "Invalid CNH format" + } + + sum = 0 + for i := 0; i < 9; i++ { + sum += int(digits[i]-'0') * (i + 1) + } + + second := sum%11 - decrement + if second < 0 { + second += 11 + } + if second >= 10 { + second = 0 + } + if second != int(digits[10]-'0') { + return false, digits, "Invalid CNH format" + } + + return true, digits, "Valid CNH format" +} + +// ValidateRenavam checks a vehicle registration number. +// +// Nine digits was the old format and eleven is the current one; the shorter is +// the longer with leading zeros, so both are accepted and normalised to eleven. +// The weights run from the right and cycle two through nine, unlike the +// left-to-right descending series most Brazilian documents use. +func ValidateRenavam(renavam string) (bool, string, string) { + if !renavamChars.MatchString(renavam) { + return false, renavam, "Invalid RENAVAM format (unexpected characters)" + } + + digits := nonDigitsRegex.ReplaceAllString(renavam, "") + if len(digits) != 9 && len(digits) != 11 { + return false, digits, "Invalid RENAVAM format (incorrect length)" + } + + for len(digits) < 11 { + digits = "0" + digits + } + + // Not in the reference this was checked against, but consistent with every + // other document here: a run of one repeated digit is not a registration. + if allSameDigit(digits) { + return false, digits, "Invalid RENAVAM format" + } + + sum, weight := 0, 2 + for i := 9; i >= 0; i-- { + sum += int(digits[i]-'0') * weight + if weight >= 9 { + weight = 2 + } else { + weight++ + } + } + + expected := 11 - sum%11 + if expected >= 10 { + expected = 0 + } + + if expected != int(digits[10]-'0') { + return false, digits, "Invalid RENAVAM format" + } + + return true, digits, "Valid RENAVAM format" +} diff --git a/utils/vehicle_boleto_differential_test.go b/utils/vehicle_boleto_differential_test.go new file mode 100644 index 0000000..16271a0 --- /dev/null +++ b/utils/vehicle_boleto_differential_test.go @@ -0,0 +1,170 @@ +package utils + +import ( + "math/rand" + "testing" +) + +// Flat transcriptions of the reference implementations, written without the +// helpers the code under test uses, so a differential run compares two +// independent expressions of the same specification. + +func referenceCNH(digits string) bool { + if len(digits) != 11 { + return false + } + repeated := true + for i := 1; i < 11; i++ { + if digits[i] != digits[0] { + repeated = false + break + } + } + if repeated { + return false + } + + sum := 0 + for i := 0; i < 9; i++ { + sum += int(digits[i]-'0') * (9 - i) + } + + first, decrement := sum%11, 0 + if first >= 10 { + first, decrement = 0, 2 + } + if first != int(digits[9]-'0') { + return false + } + + sum = 0 + for i := 0; i < 9; i++ { + sum += int(digits[i]-'0') * (i + 1) + } + + second := sum%11 - decrement + if second < 0 { + second += 11 + } + if second >= 10 { + second = 0 + } + return second == int(digits[10]-'0') +} + +func referenceRenavam(digits string) bool { + if len(digits) != 11 { + return false + } + + sum, weight := 0, 2 + for i := 9; i >= 0; i-- { + sum += int(digits[i]-'0') * weight + if weight >= 9 { + weight = 2 + } else { + weight++ + } + } + + expected := 11 - sum%11 + if expected >= 10 { + expected = 0 + } + return expected == int(digits[10]-'0') +} + +func TestCNHAgreesWithReference(t *testing.T) { + random := rand.New(rand.NewSource(20260904)) + + for i := 0; i < 5000; i++ { + candidate := randomDigits(random, 11) + got, _, _ := ValidateCNH(candidate) + + if want := referenceCNH(candidate); got != want { + t.Fatalf("CNH %s: got %v, reference says %v", candidate, got, want) + } + } +} + +func TestRenavamAgreesWithReference(t *testing.T) { + random := rand.New(rand.NewSource(20260904)) + + for i := 0; i < 5000; i++ { + candidate := randomDigits(random, 11) + got, _, _ := ValidateRenavam(candidate) + + // The one deliberate deviation: a run of one repeated digit is not a + // registration, and this service rejects it where the reference does not. + want := referenceRenavam(candidate) && !allSameDigit(candidate) + + if got != want { + t.Fatalf("RENAVAM %s: got %v, expected %v", candidate, got, want) + } + } +} + +// Random digits are almost never valid, so both are also walked through the +// valid space: a validator returning false unconditionally would otherwise pass. +func TestConstructedVehicleNumbersAreAccepted(t *testing.T) { + random := rand.New(rand.NewSource(11)) + + for i := 0; i < 500; i++ { + base := randomDigits(random, 9) + + sum := 0 + for j := 0; j < 9; j++ { + sum += int(base[j]-'0') * (9 - j) + } + first, decrement := sum%11, 0 + if first >= 10 { + first, decrement = 0, 2 + } + + sum = 0 + for j := 0; j < 9; j++ { + sum += int(base[j]-'0') * (j + 1) + } + second := sum%11 - decrement + if second < 0 { + second += 11 + } + if second >= 10 { + second = 0 + } + + cnh := base + string(rune('0'+first)) + string(rune('0'+second)) + if allSameDigit(cnh) { + continue + } + if isValid, _, message := ValidateCNH(cnh); !isValid { + t.Fatalf("constructed CNH %s was rejected: %s", cnh, message) + } + } +} + +// A boleto is built rather than guessed: random 47-digit numbers are never +// valid, so the only way to test acceptance is to compute the check digits the +// way the specification says and confirm they are the ones expected back. +func TestConstructedBoletoIsAcceptedAndSensitiveToEveryDigit(t *testing.T) { + valid := "00190000090114971860168524522114675860000102656" + + if isValid, _, message := ValidateBoleto(valid); !isValid { + t.Fatalf("the published example was rejected: %s", message) + } + + // Changing any single digit must break either a group digit or the general + // one. A check that missed a position would show up here as a survivor. + for i := 0; i < len(valid); i++ { + mutated := []byte(valid) + if mutated[i] == '9' { + mutated[i] = '0' + } else { + mutated[i]++ + } + + if isValid, _, _ := ValidateBoleto(string(mutated)); isValid { + t.Fatalf("changing digit %d left the boleto valid: %s", i, string(mutated)) + } + } +} diff --git a/utils/vehicle_boleto_test.go b/utils/vehicle_boleto_test.go new file mode 100644 index 0000000..f21e1db --- /dev/null +++ b/utils/vehicle_boleto_test.go @@ -0,0 +1,118 @@ +package utils + +import "testing" + +// Vectors published by brazilian-utils, the reference these three were checked +// against. +func TestValidateCNH(t *testing.T) { + tests := []struct { + cnh string + valid bool + }{ + {"00000000119", true}, + {"000000001-19", true}, + {"12345678901", false}, + {"11111111111", false}, + {"0000000011", false}, + {"00000000119abc", false}, + } + + for _, tt := range tests { + t.Run(tt.cnh, func(t *testing.T) { + isValid, _, message := ValidateCNH(tt.cnh) + if isValid != tt.valid { + t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) + } + }) + } +} + +func TestValidateRenavam(t *testing.T) { + tests := []struct { + name string + renavam string + valid bool + }{ + {"nine_digits", "639884962", true}, + {"eleven_digits_same_number", "00639884962", true}, + {"last_digit_changed", "639884963", false}, + {"wrong_checksum", "12345678901", false}, + {"eight_digits", "12345678", false}, + {"ten_digits", "1234567890", false}, + {"twelve_digits", "123456789012", false}, + // The reference accepts "639884962abc" because it strips letters before + // validating. This service rejects stray characters everywhere else and + // is not going to make an exception here. + {"trailing_letters", "639884962abc", false}, + {"all_zeros", "00000000000", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid, _, message := ValidateRenavam(tt.renavam) + if isValid != tt.valid { + t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) + } + }) + } +} + +// The nine-digit form is the eleven-digit form with leading zeros, so both must +// normalise to the same stored value. +func TestRenavamNormalisesToElevenDigits(t *testing.T) { + _, short, _ := ValidateRenavam("639884962") + _, long, _ := ValidateRenavam("00639884962") + + if short != long || short != "00639884962" { + t.Fatalf("expected both forms to normalise to 00639884962, got %q and %q", short, long) + } +} + +func TestValidateBoleto(t *testing.T) { + tests := []struct { + name string + boleto string + valid bool + }{ + {"valid", "00190000090114971860168524522114675860000102656", true}, + {"valid_as_printed", "0019000009 01149.718601 68524.522114 6 75860000102656", true}, + // One digit changed in the first group, and one in the third. + {"first_group_check_digit", "00190000020114971860168524522114675860000102656", false}, + {"third_group_check_digit", "00190000090114971860168524522114975860000102656", false}, + {"too_short", "123456789", false}, + {"letters", "0019000009011497186016852452211467586000010265X", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid, _, message := ValidateBoleto(tt.boleto) + if isValid != tt.valid { + t.Fatalf("expected valid=%v, got %v (%s)", tt.valid, isValid, message) + } + }) + } +} + +// A 48-digit slip is a different structure that has not been implemented. +// Reporting it as invalid would be a lie about a number that may well be good, +// so it gets a message that says it was not checked. +func TestBoletoArrecadacaoIsReportedAsUnchecked(t *testing.T) { + arrecadacao := "846700000017435900240200610207807116000000000000" + + isValid, _, message := ValidateBoleto(arrecadacao) + if isValid { + t.Fatal("a 48-digit slip must not be reported as valid") + } + if !contains(message, "not supported yet") || !contains(message, "not checked") { + t.Fatalf("the message must say the number was not checked, got: %s", message) + } +} + +func contains(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +}