feat(payments): report income to «Мой налог»
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
The direct rail runs on НПД, where the provider neither files with the tax service nor issues a receipt — so nobody was doing it. This registers each rouble purchase, annuls its receipt on a refund, and hands the buyer the receipt by email. Two properties of the (unofficial) lknpd API shape the design. Registering an income takes no idempotency key, so an error does not mean nothing happened: the service name is frozen before the call and carries a marker from the tail of the order id, and after a failure the taxpayer's income list is searched for that exact name. Found means filed; not found halts the queue for a human, because declaring an income twice is as wrong as not declaring it. And faults are classified rather than logged: a token is renewed silently, a throttle backs off, an outage retries, but three unfixable rejections take the rail out of service — a changed format must not become thousands of requests overnight. The console button and the worker share one RunBatch. Automatic mode is armed from the console, not from configuration, so the operator can watch a run go through by hand first. A daily watchdog runs whether or not it is armed, since the case it exists for is the export being off. An idle queue issues no call at all — not even an authentication. No payment path changed: the purchase letter rides the existing payment-event outbox on its own cursor, the receipt and annulment letters ride the export row. Decisions D53-D60.
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
package mynalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// msk is the taxpayer's time zone in these tests. It matters: the offset a moment is rendered with
|
||||
// decides which tax month it lands in.
|
||||
var msk = time.FixedZone("MSK", 3*60*60)
|
||||
|
||||
// capture records what the fake cabinet received, so a test can assert on the request the client
|
||||
// built. raw is kept alongside the decoded body because some assertions are about JSON *shape* —
|
||||
// notably that an amount is a number and not a string.
|
||||
type capture struct {
|
||||
method string
|
||||
path string
|
||||
query string
|
||||
bearer string
|
||||
raw string
|
||||
body map[string]any
|
||||
}
|
||||
|
||||
// fakeCabinet serves one canned response and records the request, returning a client already
|
||||
// pointed at it.
|
||||
func fakeCabinet(t *testing.T, status int, response string) (*Client, *capture) {
|
||||
t.Helper()
|
||||
got := &capture{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got.method = r.Method
|
||||
got.path = r.URL.Path
|
||||
got.query = r.URL.RawQuery
|
||||
got.bearer = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if data, _ := io.ReadAll(r.Body); len(data) > 0 {
|
||||
got.raw = string(data)
|
||||
_ = json.Unmarshal(data, &got.body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, response)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return NewClient(srv.URL, "device-1", srv.Client()), got
|
||||
}
|
||||
|
||||
// liveSession is a session with a token that has not aged out.
|
||||
func liveSession() Session {
|
||||
return Session{Token: "access-1", RefreshToken: "refresh-1", INN: "770000000000",
|
||||
ExpiresAt: time.Now().Add(time.Hour)}
|
||||
}
|
||||
|
||||
func TestAuthByPasswordBuildsTheRequest(t *testing.T) {
|
||||
c, got := fakeCabinet(t, http.StatusOK, `{
|
||||
"token":"access-1","refreshToken":"refresh-1",
|
||||
"tokenExpireIn":"2026-07-28T15:04:05+03:00",
|
||||
"profile":{"inn":"770000000000"}}`)
|
||||
|
||||
now := time.Date(2026, 7, 28, 14, 0, 0, 0, msk)
|
||||
sess, err := c.AuthByPassword(context.Background(), "770000000000", "hunter2", now)
|
||||
if err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
if got.path != "/api/v1/auth/lkfl" {
|
||||
t.Fatalf("path = %q", got.path)
|
||||
}
|
||||
if got.body["username"] != "770000000000" || got.body["password"] != "hunter2" {
|
||||
t.Fatalf("credentials not sent: %v", got.body)
|
||||
}
|
||||
device, _ := got.body["deviceInfo"].(map[string]any)
|
||||
if device["sourceDeviceId"] != "device-1" || device["sourceType"] != "WEB" {
|
||||
t.Fatalf("deviceInfo = %v", device)
|
||||
}
|
||||
if sess.Token != "access-1" || sess.RefreshToken != "refresh-1" || sess.INN != "770000000000" {
|
||||
t.Fatalf("session = %+v", sess)
|
||||
}
|
||||
// The stated expiry, less the renewal slack.
|
||||
want := time.Date(2026, 7, 28, 15, 3, 5, 0, msk)
|
||||
if !sess.ExpiresAt.Equal(want) {
|
||||
t.Fatalf("expires = %v, want %v", sess.ExpiresAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthByPasswordFallsBackToTheLoginForTheINN(t *testing.T) {
|
||||
c, _ := fakeCabinet(t, http.StatusOK, `{"token":"access-1","refreshToken":"refresh-1"}`)
|
||||
|
||||
now := time.Date(2026, 7, 28, 14, 0, 0, 0, msk)
|
||||
sess, err := c.AuthByPassword(context.Background(), "770000000000", "hunter2", now)
|
||||
if err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
if sess.INN != "770000000000" {
|
||||
t.Fatalf("inn = %q", sess.INN)
|
||||
}
|
||||
if !sess.Valid(now) {
|
||||
t.Fatal("session should be valid right after authenticating")
|
||||
}
|
||||
}
|
||||
|
||||
// A rejected password is a permanent failure, not an aged-out token: reporting it as ErrAuthExpired
|
||||
// would send the caller into a pointless refresh loop.
|
||||
func TestAuthByPasswordRejectionIsPermanent(t *testing.T) {
|
||||
c, _ := fakeCabinet(t, http.StatusUnauthorized, `{"message":"Неверный логин или пароль"}`)
|
||||
|
||||
_, err := c.AuthByPassword(context.Background(), "770000000000", "wrong", time.Now())
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("err = %v, want ErrPermanent", err)
|
||||
}
|
||||
if errors.Is(err, ErrAuthExpired) {
|
||||
t.Fatal("a bad password must not look like an expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthByRefreshKeepsTheOldTokenWhenNotRotated(t *testing.T) {
|
||||
c, got := fakeCabinet(t, http.StatusOK, `{"token":"access-2"}`)
|
||||
|
||||
sess, err := c.AuthByRefresh(context.Background(), "refresh-1", "770000000000", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
if got.path != "/api/v1/auth/token" || got.body["refreshToken"] != "refresh-1" {
|
||||
t.Fatalf("request = %s %s %v", got.method, got.path, got.body)
|
||||
}
|
||||
if sess.RefreshToken != "refresh-1" {
|
||||
t.Fatalf("refresh token = %q, want the supplied one carried forward", sess.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
// The service sometimes issues a new refresh token; losing it would strand the next renewal.
|
||||
func TestAuthByRefreshSurfacesARotatedToken(t *testing.T) {
|
||||
c, _ := fakeCabinet(t, http.StatusOK, `{"token":"access-2","refreshToken":"refresh-2"}`)
|
||||
|
||||
sess, err := c.AuthByRefresh(context.Background(), "refresh-1", "770000000000", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
if sess.RefreshToken != "refresh-2" {
|
||||
t.Fatalf("refresh token = %q, want the rotated one", sess.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthByRefreshRejectionIsPermanent(t *testing.T) {
|
||||
c, _ := fakeCabinet(t, http.StatusUnauthorized, `{"message":"token is invalid"}`)
|
||||
|
||||
_, err := c.AuthByRefresh(context.Background(), "stale", "770000000000", time.Now())
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("err = %v, want ErrPermanent — only a fresh password login can recover", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddIncomeBuildsTheRequest(t *testing.T) {
|
||||
c, got := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`)
|
||||
|
||||
receipt, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
|
||||
Name: `Внутриигровая валюта: "Фишка", 100 шт. (ID: 0198c1f2)`,
|
||||
Amount: "149.50",
|
||||
OperationTime: time.Date(2026, 7, 28, 23, 30, 0, 0, msk),
|
||||
RequestTime: time.Date(2026, 7, 29, 0, 5, 0, 0, msk),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("add income: %v", err)
|
||||
}
|
||||
if receipt != "20abcdef01" {
|
||||
t.Fatalf("receipt = %q", receipt)
|
||||
}
|
||||
if got.path != "/api/v1/income" || got.bearer != "access-1" {
|
||||
t.Fatalf("request = %s %s bearer=%q", got.method, got.path, got.bearer)
|
||||
}
|
||||
if got.body["operationTime"] != "2026-07-28T23:30:00+03:00" {
|
||||
t.Fatalf("operationTime = %v — the offset decides the tax month", got.body["operationTime"])
|
||||
}
|
||||
if got.body["requestTime"] != "2026-07-29T00:05:00+03:00" {
|
||||
t.Fatalf("requestTime = %v", got.body["requestTime"])
|
||||
}
|
||||
if got.body["totalAmount"] != "149.50" {
|
||||
t.Fatalf("totalAmount = %v, want the exact decimal string", got.body["totalAmount"])
|
||||
}
|
||||
if got.body["paymentType"] != paymentTypeCash {
|
||||
t.Fatalf("paymentType = %v", got.body["paymentType"])
|
||||
}
|
||||
if got.body["ignoreMaxTotalIncomeRestriction"] != false {
|
||||
t.Fatal("the annual ceiling must stay enforced")
|
||||
}
|
||||
services, _ := got.body["services"].([]any)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("services = %v", got.body["services"])
|
||||
}
|
||||
line, _ := services[0].(map[string]any)
|
||||
if line["quantity"] != float64(1) {
|
||||
t.Fatalf("quantity = %v", line["quantity"])
|
||||
}
|
||||
payer, _ := got.body["client"].(map[string]any)
|
||||
if payer["incomeType"] != incomeTypeIndividual || payer["inn"] != nil {
|
||||
t.Fatalf("client = %v — a private buyer carries no INN", payer)
|
||||
}
|
||||
}
|
||||
|
||||
// The amount must reach the tax service as a JSON number carrying the exact decimal we hold, with
|
||||
// no float32/float64 round-trip anywhere in between.
|
||||
func TestAddIncomeSendsTheAmountAsANumber(t *testing.T) {
|
||||
c, got := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`)
|
||||
|
||||
if _, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
|
||||
Name: "x", Amount: "149.50",
|
||||
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
|
||||
}); err != nil {
|
||||
t.Fatalf("add income: %v", err)
|
||||
}
|
||||
if !strings.Contains(got.raw, `"amount":149.50`) {
|
||||
t.Fatalf("body = %s, want a bare JSON number for amount", got.raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddIncomeRejectsAnAmountThatIsNotDecimal(t *testing.T) {
|
||||
c, _ := fakeCabinet(t, http.StatusOK, `{"approvedReceiptUuid":"20abcdef01"}`)
|
||||
|
||||
for _, bad := range []string{"", "1,50", "1e2", "-5", "149.50 RUB"} {
|
||||
_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
|
||||
Name: "x", Amount: bad,
|
||||
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
|
||||
})
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("amount %q: err = %v, want ErrPermanent", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A 200 with no receipt id leaves the income unannullable, so it counts as ambiguous and the caller
|
||||
// must probe rather than assume it failed.
|
||||
func TestAddIncomeWithoutAReceiptIsTransient(t *testing.T) {
|
||||
c, _ := fakeCabinet(t, http.StatusOK, `{}`)
|
||||
|
||||
_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
|
||||
Name: "x", Amount: "10",
|
||||
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
|
||||
})
|
||||
if !errors.Is(err, ErrTransient) {
|
||||
t.Fatalf("err = %v, want ErrTransient", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorClassification(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
want error
|
||||
notWant error
|
||||
}{
|
||||
{"expired token", http.StatusUnauthorized, `{"message":"unauthorized"}`, ErrAuthExpired, ErrPermanent},
|
||||
{"throttled", http.StatusTooManyRequests, `{"message":"slow down"}`, ErrThrottled, ErrPermanent},
|
||||
{"server fault", http.StatusBadGateway, `{}`, ErrTransient, ErrPermanent},
|
||||
{"bad request", http.StatusBadRequest, `{"message":"bad operationTime"}`, ErrPermanent, ErrTransient},
|
||||
{"annual ceiling", http.StatusBadRequest, `{"message":"Превышен лимит дохода"}`, ErrIncomeLimit, ErrTransient},
|
||||
}
|
||||
// The ceiling refines a permanent rejection rather than replacing it: a caller that only knows
|
||||
// about ErrPermanent must still stop on it.
|
||||
if !errors.Is(ErrIncomeLimit, ErrPermanent) {
|
||||
t.Fatal("ErrIncomeLimit must subsume ErrPermanent")
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, _ := fakeCabinet(t, tc.status, tc.body)
|
||||
_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
|
||||
Name: "x", Amount: "10",
|
||||
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
|
||||
})
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("err = %v, want %v", err, tc.want)
|
||||
}
|
||||
if errors.Is(err, tc.notWant) {
|
||||
t.Fatalf("err = %v must not also be %v", err, tc.notWant)
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.StatusCode != tc.status {
|
||||
t.Fatalf("err does not carry the status: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A connection that never answers is indistinguishable from one whose request was applied, so it
|
||||
// has to be transient — the caller probes before retrying.
|
||||
func TestUnreachableServiceIsTransient(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
url := srv.URL
|
||||
srv.Close()
|
||||
|
||||
c := NewClient(url, "device-1", &http.Client{Timeout: time.Second})
|
||||
_, err := c.AddIncome(context.Background(), liveSession(), IncomeRequest{
|
||||
Name: "x", Amount: "10",
|
||||
OperationTime: time.Now().In(msk), RequestTime: time.Now().In(msk),
|
||||
})
|
||||
if !errors.Is(err, ErrTransient) {
|
||||
t.Fatalf("err = %v, want ErrTransient", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelIncomeBuildsTheRequest(t *testing.T) {
|
||||
c, got := fakeCabinet(t, http.StatusOK, `{}`)
|
||||
|
||||
now := time.Date(2026, 8, 1, 10, 0, 0, 0, msk)
|
||||
if err := c.CancelIncome(context.Background(), liveSession(), "20abcdef01", now); err != nil {
|
||||
t.Fatalf("cancel: %v", err)
|
||||
}
|
||||
if got.path != "/api/v1/cancel" {
|
||||
t.Fatalf("path = %q", got.path)
|
||||
}
|
||||
if got.body["receiptUuid"] != "20abcdef01" {
|
||||
t.Fatalf("receiptUuid = %v", got.body["receiptUuid"])
|
||||
}
|
||||
if got.body["comment"] != cancelReasonRefund {
|
||||
t.Fatalf("comment = %v, want the reason the service recognises", got.body["comment"])
|
||||
}
|
||||
if got.body["operationTime"] != "2026-08-01T10:00:00+03:00" {
|
||||
t.Fatalf("operationTime = %v", got.body["operationTime"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindIncomeMatchesTheExactName(t *testing.T) {
|
||||
c, got := fakeCabinet(t, http.StatusOK, `{"content":[
|
||||
{"name":"другая покупка","approvedReceiptUuid":"other"},
|
||||
{"name":"нужный чек","approvedReceiptUuid":"wanted"}]}`)
|
||||
|
||||
from := time.Date(2026, 7, 28, 0, 0, 0, 0, msk)
|
||||
to := time.Date(2026, 7, 29, 0, 0, 0, 0, msk)
|
||||
uuid, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек", from, to)
|
||||
if err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
if !found || uuid != "wanted" {
|
||||
t.Fatalf("found = %v, uuid = %q", found, uuid)
|
||||
}
|
||||
if got.path != "/api/v1/incomes" {
|
||||
t.Fatalf("path = %q", got.path)
|
||||
}
|
||||
if !strings.Contains(got.query, "from=2026-07-28T00%3A00%3A00.000%2B03%3A00") {
|
||||
t.Fatalf("query = %q", got.query)
|
||||
}
|
||||
}
|
||||
|
||||
// An annulled receipt must never satisfy the probe: annulling it again would fail, and treating it
|
||||
// as ours would hide an income that really is missing.
|
||||
func TestFindIncomeSkipsAnnulledReceipts(t *testing.T) {
|
||||
c, _ := fakeCabinet(t, http.StatusOK, `{"content":[
|
||||
{"name":"нужный чек","approvedReceiptUuid":"dead","cancellationInfo":{"comment":"Возврат средств"}}]}`)
|
||||
|
||||
_, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек",
|
||||
time.Now().In(msk).Add(-24*time.Hour), time.Now().In(msk))
|
||||
if err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("an annulled receipt must not be reported as ours")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindIncomeReportsAbsence(t *testing.T) {
|
||||
c, _ := fakeCabinet(t, http.StatusOK, `{"content":[]}`)
|
||||
|
||||
_, found, err := c.FindIncome(context.Background(), liveSession(), "нужный чек",
|
||||
time.Now().In(msk).Add(-24*time.Hour), time.Now().In(msk))
|
||||
if err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("found = true on an empty list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceIDForLoginIsStable(t *testing.T) {
|
||||
first := DeviceIDForLogin("770000000000")
|
||||
if first != DeviceIDForLogin("770000000000") {
|
||||
t.Fatal("the device id must not change between runs")
|
||||
}
|
||||
if len(first) != deviceIDLength {
|
||||
t.Fatalf("device id = %q, want %d characters", first, deviceIDLength)
|
||||
}
|
||||
if first == DeviceIDForLogin("770000000001") {
|
||||
t.Fatal("different logins must yield different devices")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiptURL(t *testing.T) {
|
||||
c := NewClient("https://lknpd.example.test", "device-1", nil)
|
||||
got := c.ReceiptURL("770000000000", "20abcdef01")
|
||||
want := "https://lknpd.example.test/api/v1/receipt/770000000000/20abcdef01/print"
|
||||
if got != want {
|
||||
t.Fatalf("url = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomeName(t *testing.T) {
|
||||
order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001")
|
||||
|
||||
got := IncomeName(100, order, "")
|
||||
want := `Внутриигровая валюта: "Фишка", 100 шт. (ID: cafe0001)`
|
||||
if got != want {
|
||||
t.Fatalf("name = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The marker must come from the random tail of a UUIDv7, not its clock-derived head: the first
|
||||
// eight hex digits are the high bits of a millisecond timestamp and are shared by every order placed
|
||||
// within about a minute. Two such orders would be indistinguishable to the recovery probe, which is
|
||||
// how an income ends up filed twice.
|
||||
func TestIncomeNameMarkerIsNotClockDerived(t *testing.T) {
|
||||
// Two ids one millisecond apart, differing only in their random tail.
|
||||
first := uuid.MustParse("0198c1f2-0001-7000-8000-000000000001")
|
||||
second := uuid.MustParse("0198c1f2-0002-7000-8000-000000000002")
|
||||
|
||||
if IncomeName(100, first, "") == IncomeName(100, second, "") {
|
||||
t.Fatal("two orders in the same time bucket share a service name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomeNameFallsBackToTheProductTitle(t *testing.T) {
|
||||
order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001")
|
||||
|
||||
if got := IncomeName(0, order, "Подсказки"); got != "Подсказки (ID: cafe0001)" {
|
||||
t.Fatalf("name = %q", got)
|
||||
}
|
||||
if got := IncomeName(0, order, " "); got != defaultTitle+" (ID: cafe0001)" {
|
||||
t.Fatalf("name = %q, want the default description", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The marker is what makes the post-timeout probe exact, so the description gives way first and the
|
||||
// marker is never truncated.
|
||||
func TestIncomeNameCapsLengthButKeepsTheMarker(t *testing.T) {
|
||||
order := uuid.MustParse("0198c1f2-1111-7000-8000-0000cafe0001")
|
||||
|
||||
got := IncomeName(0, order, strings.Repeat("Ы", 400))
|
||||
if n := len([]rune(got)); n != maxNameRunes {
|
||||
t.Fatalf("length = %d runes, want %d", n, maxNameRunes)
|
||||
}
|
||||
if !strings.HasSuffix(got, "(ID: cafe0001)") {
|
||||
t.Fatalf("name = %q, the marker must survive truncation", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoRoundTrip(t *testing.T) {
|
||||
key, err := ParseKey("YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=")
|
||||
if err != nil {
|
||||
t.Fatalf("parse key: %v", err)
|
||||
}
|
||||
blob, err := Seal(key, "refresh-1")
|
||||
if err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
if strings.Contains(string(blob), "refresh-1") {
|
||||
t.Fatal("the sealed blob still contains the plaintext")
|
||||
}
|
||||
got, err := Open(key, blob)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if got != "refresh-1" {
|
||||
t.Fatalf("opened %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Losing or rotating the key must be a nuisance — sign in again — not a crash.
|
||||
func TestOpenWithAWrongKeyIsUndecryptable(t *testing.T) {
|
||||
good, _ := ParseKey("YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=")
|
||||
other, _ := ParseKey("MTIzNDU2NzhhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dzA=")
|
||||
|
||||
blob, err := Seal(good, "refresh-1")
|
||||
if err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
if _, err := Open(other, blob); !errors.Is(err, ErrUndecryptable) {
|
||||
t.Fatalf("err = %v, want ErrUndecryptable", err)
|
||||
}
|
||||
if _, err := Open(good, blob[:4]); !errors.Is(err, ErrUndecryptable) {
|
||||
t.Fatalf("truncated blob: err = %v, want ErrUndecryptable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKey(t *testing.T) {
|
||||
if _, err := ParseKey("6162636465666768696a6b6c6d6e6f707172737475767778797a313233343536"); err != nil {
|
||||
t.Fatalf("hex key rejected: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"", "short", "YWJj"} {
|
||||
if _, err := ParseKey(bad); err == nil {
|
||||
t.Fatalf("ParseKey(%q) accepted a key of the wrong size", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user