summaryrefslogtreecommitdiffstats
path: root/internal/ui/app_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/ui/app_test.go')
-rw-r--r--internal/ui/app_test.go201
1 files changed, 201 insertions, 0 deletions
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 <elodie@example.org>",
+ "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?= <elodie@example.org>\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 <reader@example.org>",
+ "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)
+ }
+ }
+}