/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.
*/

package dkim

import (
	"context"
	"errors"
	"net"
	"testing"

	"github.com/emersion/go-msgauth/authres"
	"github.com/foxcpp/go-mockdns"
	"github.com/foxcpp/maddy/framework/buffer"
	"github.com/foxcpp/maddy/framework/config"
	"github.com/foxcpp/maddy/framework/container"
	"github.com/foxcpp/maddy/framework/exterrors"
	"github.com/foxcpp/maddy/framework/module"
	"github.com/foxcpp/maddy/internal/testutils"
)

const unsignedMailString = `From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>

Hi.

We lost the game. Are you hungry yet?

Joe.
`

const dnsPublicKey = "v=DKIM1; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ" +
	"KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt" +
	"IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v" +
	"/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi" +
	"tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB"

var testZones = map[string]mockdns.Zone{
	"brisbane._domainkey.example.com.": {
		TXT: []string{dnsPublicKey},
	},
}

const verifiedMailString = `DKIM-Signature: v=1; a=rsa-sha256; s=brisbane; d=example.com;
      c=simple/simple; q=dns/txt; i=joe@football.example.com;
      h=Received : From : To : Subject : Date : Message-ID;
      bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
      b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB
      4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut
      KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV
      4bmp/YzhwvcubU4=;
Received: from client1.football.example.com  [192.0.2.1]
      by submitserver.example.com with SUBMISSION;
      Fri, 11 Jul 2003 21:01:54 -0700 (PDT)
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>

Hi.

We lost the game. Are you hungry yet?

Joe.
`

func testCheck(t *testing.T, zones map[string]mockdns.Zone, cfg []config.Node) *Check {
	t.Helper()
	mod, err := New(container.New(), "check.dkim", "")
	if err != nil {
		t.Fatal(err)
	}
	check := mod.(*Check)
	check.resolver = &mockdns.Resolver{Zones: zones}
	check.log = testutils.Logger(t, mod.Name())

	if err := check.Configure(nil, config.NewMap(nil, config.Node{Children: cfg})); err != nil {
		t.Fatal(err)
	}

	return check
}

func TestDkimVerify_NoSig(t *testing.T) {
	check := testCheck(t, nil, nil) // No zones since this test requires no lookups.

	// Force certain reason so we can assert for it.
	check.noSigAction.Reject = true
	check.noSigAction.ReasonOverride = &exterrors.SMTPError{Code: 555}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// The usual checking flow.
	s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
		ID: "test_unsigned",
	})
	if err != nil {
		t.Fatal(err)
	}
	s.CheckConnection(ctx)
	s.CheckSender(ctx, "joe@football.example.com")
	s.CheckRcpt(ctx, "suzie@shopping.example.net")

	hdr, buf := testutils.BodyFromStr(t, unsignedMailString)
	result := s.CheckBody(ctx, hdr, buf)

	if result.Reason == nil {
		t.Fatal("No check fail reason set, auth. result:", authres.Format("", result.AuthResult))
	}
	if result.Reason.(*exterrors.SMTPError).Code != 555 {
		t.Fatal("Different fail reason:", result.Reason)
	}
}

func TestDkimVerify_InvalidSig(t *testing.T) {
	check := testCheck(t, testZones, nil)

	// Force certain reason so we can assert for it.
	check.brokenSigAction.Reject = true
	check.brokenSigAction.ReasonOverride = &exterrors.SMTPError{Code: 555}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
		ID: "test_unsigned",
	})
	if err != nil {
		t.Fatal(err)
	}

	s.CheckConnection(ctx)
	s.CheckSender(ctx, "joe@football.example.com")
	s.CheckRcpt(ctx, "suzie@shopping.example.net")

	hdr, buf := testutils.BodyFromStr(t, verifiedMailString)
	// Mess up the signature.
	hdr.Set("From", "nope")

	result := s.CheckBody(ctx, hdr, buf)

	if result.Reason == nil {
		t.Fatal("No check fail reason set, auth. result:", authres.Format("", result.AuthResult))
	}
	if result.Reason.(*exterrors.SMTPError).Code != 555 {
		t.Fatal("Different fail reason:", result.Reason)
	}
}

func TestDkimVerify_ValidSig(t *testing.T) {
	check := testCheck(t, testZones, nil)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
		ID: "test_unsigned",
	})
	if err != nil {
		t.Fatal(err)
	}

	s.CheckConnection(ctx)
	s.CheckSender(ctx, "joe@football.example.com")
	s.CheckRcpt(ctx, "suzie@shopping.example.net")

	hdr, buf := testutils.BodyFromStr(t, verifiedMailString)

	result := s.CheckBody(ctx, hdr, buf)

	if result.Reason != nil {
		t.Log(authres.Format("", result.AuthResult))
		t.Fatal("Check fail reason set, auth. result:", result.Reason, exterrors.Fields(result.Reason))
	}
}

func TestDkimVerify_RequiredFields(t *testing.T) {
	check := testCheck(t, testZones, []config.Node{
		{
			// Require field that is not covered by the signature.
			Name: "required_fields",
			Args: []string{"From", "X-Important"},
		},
	})

	// Force certain reason so we can assert for it.
	check.brokenSigAction.Reject = true
	check.brokenSigAction.ReasonOverride = &exterrors.SMTPError{Code: 555}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
		ID: "test_unsigned",
	})
	if err != nil {
		t.Fatal(err)
	}

	s.CheckConnection(ctx)
	s.CheckSender(ctx, "joe@football.example.com")
	s.CheckRcpt(ctx, "suzie@shopping.example.net")

	hdr, buf := testutils.BodyFromStr(t, verifiedMailString)

	result := s.CheckBody(ctx, hdr, buf)

	if result.Reason == nil {
		t.Fatal("No check fail reason set, auth. result:", authres.Format("", result.AuthResult))
	}
	if result.Reason.(*exterrors.SMTPError).Code != 555 {
		t.Fatal("Different fail reason:", result.Reason)
	}
}

func TestDkimVerify_BufferOpenFail(t *testing.T) {
	check := testCheck(t, testZones, nil)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
		ID: "test_unsigned",
	})
	if err != nil {
		t.Fatal(err)
	}

	s.CheckConnection(ctx)
	s.CheckSender(ctx, "joe@football.example.com")
	s.CheckRcpt(ctx, "suzie@shopping.example.net")

	var buf buffer.Buffer
	hdr, buf := testutils.BodyFromStr(t, verifiedMailString)
	buf = testutils.FailingBuffer{Blob: buf.(buffer.MemoryBuffer).Slice, OpenError: errors.New("No!")}

	result := s.CheckBody(ctx, hdr, buf)
	t.Log("auth. result:", authres.Format("", result.AuthResult))

	if result.Reason == nil {
		t.Fatal("No check fail reason set, auth. result:", authres.Format("", result.AuthResult))
	}
}

func TestDkimVerify_FailClosed(t *testing.T) {
	zones := map[string]mockdns.Zone{
		"brisbane._domainkey.example.com.": {
			Err: &net.DNSError{
				Err:         "DNS server is not having a great time",
				IsTemporary: true,
				IsTimeout:   true,
			},
		},
	}
	check := testCheck(t, zones, []config.Node{
		{
			Name: "fail_open",
			Args: []string{"false"},
		},
	})

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
		ID: "test_unsigned",
	})
	if err != nil {
		t.Fatal(err)
	}

	s.CheckConnection(ctx)
	s.CheckSender(ctx, "joe@football.example.com")
	s.CheckRcpt(ctx, "suzie@shopping.example.net")

	hdr, buf := testutils.BodyFromStr(t, verifiedMailString)

	result := s.CheckBody(ctx, hdr, buf)
	t.Log("auth. result:", authres.Format("", result.AuthResult))

	if result.Reason == nil {
		t.Fatal("No check fail reason set, auth. result:", authres.Format("", result.AuthResult))
	}
	if !result.Reject {
		t.Fatal("No reject requested")
	}
	if !exterrors.IsTemporary(result.Reason) {
		t.Fatal("Fail reason is not marked as temporary:", result.Reason)
	}
}

func TestDkimVerify_FailOpen(t *testing.T) {
	zones := map[string]mockdns.Zone{
		"brisbane._domainkey.example.com.": {
			Err: &net.DNSError{
				Err:         "DNS server is not having a great time",
				IsTemporary: true,
				IsTimeout:   true,
			},
		},
	}
	check := testCheck(t, zones, []config.Node{
		{
			Name: "fail_open",
			Args: []string{"true"},
		},
	})

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
		ID: "test_unsigned",
	})
	if err != nil {
		t.Fatal(err)
	}

	s.CheckConnection(ctx)
	s.CheckSender(ctx, "joe@football.example.com")
	s.CheckRcpt(ctx, "suzie@shopping.example.net")

	hdr, buf := testutils.BodyFromStr(t, verifiedMailString)

	result := s.CheckBody(ctx, hdr, buf)

	t.Log("auth. result:", authres.Format("", result.AuthResult))
	if result.Reason == nil {
		t.Fatal("No check fail reason set, auth. result:", authres.Format("", result.AuthResult))
	}
	if result.Reject {
		t.Fatal("Reject requested")
	}
	if exterrors.IsTemporary(result.Reason) {
		t.Fatal("Fail reason is not marked as temporary:", result.Reason)
	}

	if len(result.AuthResult) != 1 {
		t.Fatal("Wrong amount of auth. result fields:", len(result.AuthResult))
	}
	resVal := result.AuthResult[0].(*authres.DKIMResult).Value
	if resVal != authres.ResultTempError {
		t.Fatal("Result is not temp. error:", resVal)
	}
}
