From c1decadb590c4d79d92bcc4df7772119a5c91244 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Sat, 22 Aug 2026 20:36:56 +0200 Subject: Initial Aegis Usenet client release --- internal/ui/app_test.go | 201 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 internal/ui/app_test.go (limited to 'internal/ui/app_test.go') diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go new file mode 100644 index 0000000..0483bdb --- /dev/null +++ b/internal/ui/app_test.go @@ -0,0 +1,201 @@ +package ui + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "reflect" + "strings" + "testing" + + "aegis/internal/cryptokit" + "aegis/internal/identity" + + "filippo.io/age" +) + +func TestNormalizeGroups(t *testing.T) { + got, err := normalizeGroups(" comp.lang.go, sci.crypt,comp.lang.go ") + if err != nil { + t.Fatal(err) + } + want := []string{"comp.lang.go", "sci.crypt"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("normalizeGroups() = %v, want %v", got, want) + } +} + +func TestNormalizeGroupsRejectsInjection(t *testing.T) { + if _, err := normalizeGroups("comp.lang.go\r\nPOST"); err == nil { + t.Fatal("normalizeGroups() accepted protocol injection") + } +} + +func TestFormatCount(t *testing.T) { + for _, test := range []struct { + value int64 + want string + }{{0, "0"}, {999, "999"}, {1000, "1 000"}, {1234567, "1 234 567"}} { + if got := formatCount(test.value); got != test.want { + t.Errorf("formatCount(%d) = %q, want %q", test.value, got, test.want) + } + } +} + +func TestBuildTextArticleUsesMIMEAndEncodesUTF8Headers(t *testing.T) { + article, err := buildTextArticle( + []string{"alt.test"}, + "Élodie ", + "Caffè e privacy", + "Corpo UTF-8: café\nseconda riga", + ) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "MIME-Version: 1.0\r\n", + "Content-Type: text/plain; charset=UTF-8\r\n", + "Content-Transfer-Encoding: 8bit\r\n", + "Face: ", + "Subject: =?UTF-8?q?Caff=C3=A8_e_privacy?=\r\n", + "From: =?UTF-8?q?=C3=89lodie?= \r\n", + "Corpo UTF-8: café\r\nseconda riga", + } { + if !strings.Contains(article, want) { + t.Fatalf("article missing %q:\n%s", want, article) + } + } + for _, line := range strings.Split(article, "\r\n") { + if strings.Contains(line, "\n") { + t.Fatalf("article contains bare LF: %q", line) + } + } +} + +func TestBuildTextArticleRejectsInvalidBody(t *testing.T) { + if _, err := buildTextArticle([]string{"alt.test"}, "reader@example.org", "Test", string([]byte{'x', 0xff})); err == nil { + t.Fatal("buildTextArticle accepted invalid UTF-8") + } +} + +func TestBuildArticleWithoutVFaceIsAllowed(t *testing.T) { + article, err := buildArticleWithIdentityHeaders( + []string{"alt.test"}, "reader@example.org", "Plain post", "body", nil, + articleCryptoOptions{Mode: "Plain"}, + ) + if err != nil { + t.Fatal(err) + } + if strings.Contains(article, "X-VFace-") || strings.Contains(article, "X-Ed25519-Pub:") { + t.Fatalf("plain article unexpectedly contains VFace headers:\n%s", article) + } +} + +func TestPrepareArticleCryptoSignsCanonicalBody(t *testing.T) { + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + privateSeed := base64.StdEncoding.EncodeToString(private.Seed()) + headers, contentType, transferEncoding, wireBody, err := prepareArticleCrypto( + "hello\r\nworld", + "Sign with Ed25519", + articleCryptoOptions{SigningKey: privateSeed, PublicKeyURL: "https://keys.example.invalid/aegis.pub"}, + ) + if err != nil { + t.Fatal(err) + } + if contentType != "text/plain; charset=UTF-8" || transferEncoding != "8bit" { + t.Fatalf("unexpected MIME metadata: %q, %q", contentType, transferEncoding) + } + joined := strings.Join(headers, "\r\n") + if !strings.Contains(joined, "X-Aegis-Public-Key-URL: https://keys.example.invalid/aegis.pub") { + t.Fatalf("missing public-key URL: %s", joined) + } + const signaturePrefix = "X-Aegis-Signature: ed25519; " + var signature string + for _, header := range headers { + if strings.HasPrefix(header, signaturePrefix) { + signature = strings.TrimPrefix(header, signaturePrefix) + } + } + if signature == "" { + t.Fatal("missing Ed25519 signature") + } + if err := cryptokit.VerifyEd25519([]byte(wireBody), signature, base64.StdEncoding.EncodeToString(public)); err != nil { + t.Fatal(err) + } +} + +func TestPrepareArticleCryptoEncryptsAndSigns(t *testing.T) { + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatal(err) + } + _, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + headers, contentType, transferEncoding, wireBody, err := prepareArticleCrypto( + "secret body", + "Encrypt with age and sign", + articleCryptoOptions{ + EncryptionKey: identity.Recipient().String(), + SigningKey: base64.StdEncoding.EncodeToString(private.Seed()), + }, + ) + if err != nil { + t.Fatal(err) + } + if contentType != "application/vnd.aegis.age" || transferEncoding != "7bit" { + t.Fatalf("unexpected encrypted MIME metadata: %q, %q", contentType, transferEncoding) + } + if !strings.Contains(strings.Join(headers, "\r\n"), "X-Aegis-Encryption: age") { + t.Fatal("missing age encryption header") + } + plaintext, err := cryptokit.DecryptAge([]byte(strings.ReplaceAll(wireBody, "\r\n", "\n")), identity.String()) + if err != nil { + t.Fatal(err) + } + if string(plaintext) != "secret body" { + t.Fatalf("decrypted body = %q", plaintext) + } +} + +func TestBuildTextArticleWithVFaceIncludesMandatoryIdentityHeaders(t *testing.T) { + if _, err := identity.FindCLI(); err != nil { + t.Skip(err) + } + public, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + publicKey := base64.StdEncoding.EncodeToString(public) + profile, err := identity.GenerateVFace("pseudonym", "reader@example.org", publicKey) + if err != nil { + t.Fatal(err) + } + article, err := buildTextArticleWithVFace( + []string{"alt.test"}, + "pseudonym ", + "VFace test", + "body", + profile, + articleCryptoOptions{Mode: "Plain", ExpectedPublicKey: profile.PublicKey}, + ) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "X-VFace-Version: 1\r\n", + "X-Ed25519-Pub: " + publicKey + "\r\n", + "Identity-Hash: " + profile.IdentityHash + "\r\n", + "X-VFace-Hash: sha256:" + profile.IdentityHash + "\r\n", + "X-VFace-PNG-SHA256: " + profile.PNGHash + "\r\n", + "X-VFace-Verify: " + identity.VFaceVerificationURL + "\r\n", + } { + if !strings.Contains(article, want) { + t.Fatalf("article missing %q:\n%s", want, article) + } + } +} -- cgit v1.2.3