initial commit
This commit is contained in:
22
tests/integration/Dockerfile
Normal file
22
tests/integration/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM golang:1.25.5
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates coreutils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN ls -la tests/integration/fixtures/ || echo "No fixtures found"
|
||||
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
# Pre-compile tests to fail early on build errors and speed up runtime
|
||||
RUN go test -c -o integration.test -v ./tests/integration
|
||||
|
||||
CMD ["sh", "-c", "echo 'Starting integration tests...' && stdbuf -oL -eL ./integration.test -test.v -test.count=1 -test.timeout=0"]
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccountDeleteAutoCancelOnLogin(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
resp, err := client.postJSONWithAuth("/users/@me/delete", map[string]string{
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete account: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, 204)
|
||||
|
||||
loginResp := loginTestUser(t, client, account.Email, account.Password)
|
||||
if loginResp.Token == "" {
|
||||
t.Fatal("expected to be able to login")
|
||||
}
|
||||
|
||||
dataResp, err := client.get("/test/users/" + loginResp.UserID + "/data-exists")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check user data: %v", err)
|
||||
}
|
||||
defer dataResp.Body.Close()
|
||||
|
||||
var dataExists userDataExistsResponse
|
||||
decodeJSONResponse(t, dataResp, &dataExists)
|
||||
|
||||
if dataExists.HasSelfDeletedFlag {
|
||||
t.Error("expected SELF_DELETED flag to be removed after login")
|
||||
}
|
||||
|
||||
if dataExists.PendingDeletionAt != nil {
|
||||
t.Error("expected pending_deletion_at to be cleared after login")
|
||||
}
|
||||
|
||||
t.Log("Auto-cancel deletion on login test passed")
|
||||
}
|
||||
86
tests/integration/account_delete_grace_period_test.go
Normal file
86
tests/integration/account_delete_grace_period_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccountDeleteGracePeriod(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
resp, err := client.postJSONWithAuth("/users/@me/delete", map[string]string{
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete account: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, 204)
|
||||
|
||||
resp, err = client.getWithAuth("/users/@me", account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get user: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Errorf("expected old token to be invalid (401), got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
dataResp, err := client.get("/test/users/" + account.UserID + "/data-exists")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check user data before login: %v", err)
|
||||
}
|
||||
defer dataResp.Body.Close()
|
||||
|
||||
var dataExistsBeforeLogin userDataExistsResponse
|
||||
decodeJSONResponse(t, dataResp, &dataExistsBeforeLogin)
|
||||
|
||||
if !dataExistsBeforeLogin.HasSelfDeletedFlag {
|
||||
t.Error("expected SELF_DELETED flag to be set before login")
|
||||
}
|
||||
|
||||
if dataExistsBeforeLogin.PendingDeletionAt == nil {
|
||||
t.Error("expected pending_deletion_at to be set before login")
|
||||
}
|
||||
|
||||
loginResp := loginTestUser(t, client, account.Email, account.Password)
|
||||
if loginResp.Token == "" {
|
||||
t.Fatal("expected to be able to login during grace period")
|
||||
}
|
||||
|
||||
dataRespAfterLogin, err := client.get("/test/users/" + loginResp.UserID + "/data-exists")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check user data after login: %v", err)
|
||||
}
|
||||
defer dataRespAfterLogin.Body.Close()
|
||||
|
||||
var dataExistsAfterLogin userDataExistsResponse
|
||||
decodeJSONResponse(t, dataRespAfterLogin, &dataExistsAfterLogin)
|
||||
|
||||
if dataExistsAfterLogin.HasSelfDeletedFlag {
|
||||
t.Error("expected SELF_DELETED flag to be cleared after login")
|
||||
}
|
||||
|
||||
if dataExistsAfterLogin.PendingDeletionAt != nil {
|
||||
t.Error("expected pending_deletion_at to be cleared after login")
|
||||
}
|
||||
|
||||
t.Log("Account deletion grace period test passed")
|
||||
}
|
||||
106
tests/integration/account_delete_message_pagination_test.go
Normal file
106
tests/integration/account_delete_message_pagination_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAccountDeleteAnonymizesMessagesBeyondChunkSize(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
guild := createGuild(t, client, account.Token, "Message Pagination Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
guildID := parseSnowflake(t, guild.ID)
|
||||
|
||||
const (
|
||||
chunkSize = 100
|
||||
extraMessages = 5
|
||||
)
|
||||
totalMessages := chunkSize + extraMessages
|
||||
|
||||
for i := 0; i < totalMessages; i++ {
|
||||
sendChannelMessage(t, client, account.Token, channelID, fmt.Sprintf("Message %d", i+1))
|
||||
}
|
||||
|
||||
newOwner := createTestAccount(t, client)
|
||||
invite := createChannelInvite(t, client, account.Token, channelID)
|
||||
joinGuild(t, client, newOwner.Token, invite.Code)
|
||||
|
||||
resp, err := client.postJSONWithAuth(
|
||||
fmt.Sprintf("/guilds/%d/transfer-ownership", guildID),
|
||||
map[string]string{
|
||||
"new_owner_id": newOwner.UserID,
|
||||
"password": account.Password,
|
||||
},
|
||||
account.Token,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to transfer guild ownership: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
guildResp, err := client.getWithAuth(fmt.Sprintf("/guilds/%d", guildID), account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get guild after transfer: %v", err)
|
||||
}
|
||||
assertStatus(t, guildResp, http.StatusOK)
|
||||
var guildRespBody struct {
|
||||
OwnerID string `json:"owner_id"`
|
||||
}
|
||||
decodeJSONResponse(t, guildResp, &guildRespBody)
|
||||
if guildRespBody.OwnerID != newOwner.UserID {
|
||||
t.Fatalf("expected guild owner to be %s, got %s", newOwner.UserID, guildRespBody.OwnerID)
|
||||
}
|
||||
guildResp.Body.Close()
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/delete", map[string]string{
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete account: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
setPendingDeletionAt(t, client, account.UserID, time.Now().Add(-time.Minute))
|
||||
|
||||
triggerDeletionWorker(t, client)
|
||||
waitForDeletionCompletion(t, client, account.UserID, 60*time.Second)
|
||||
|
||||
countResp, err := client.get(fmt.Sprintf("/test/users/%s/messages/count", account.UserID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch message count: %v", err)
|
||||
}
|
||||
assertStatus(t, countResp, http.StatusOK)
|
||||
var response struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
decodeJSONResponse(t, countResp, &response)
|
||||
|
||||
if response.Count != 0 {
|
||||
t.Fatalf("expected 0 remaining messages after anonymization, got %d", response.Count)
|
||||
}
|
||||
}
|
||||
51
tests/integration/account_delete_permanent_test.go
Normal file
51
tests/integration/account_delete_permanent_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAccountDeletePermanent(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
friend := createTestAccount(t, client)
|
||||
createFriendship(t, client, account, friend)
|
||||
|
||||
resp, err := client.postJSONWithAuth("/users/@me/delete", map[string]string{
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete account: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, 204)
|
||||
|
||||
setPendingDeletionAt(t, client, account.UserID, time.Now().Add(-time.Minute))
|
||||
|
||||
triggerDeletionWorker(t, client)
|
||||
|
||||
waitForDeletionCompletion(t, client, account.UserID, 60*time.Second)
|
||||
|
||||
verifyUserDataDeleted(t, client, account.UserID)
|
||||
|
||||
t.Log("Permanent account deletion test passed")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccountDisableAutoCancelOnLogin(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
resp, err := client.postJSONWithAuth("/users/@me/disable", map[string]string{
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to disable account: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
loginResp := loginTestUser(t, client, account.Email, account.Password)
|
||||
if loginResp.Token == "" {
|
||||
t.Fatal("expected to be able to login")
|
||||
}
|
||||
|
||||
resp, err = client.getWithAuth("/users/@me", loginResp.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get user: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
_ = parseSnowflake(t, loginResp.UserID)
|
||||
otherUser := createTestAccount(t, client)
|
||||
otherUserID := parseSnowflake(t, otherUser.UserID)
|
||||
|
||||
guild := createGuild(t, client, loginResp.Token, "Test Guild")
|
||||
invite := createChannelInvite(t, client, loginResp.Token, parseSnowflake(t, guild.SystemChannel))
|
||||
joinGuild(t, client, otherUser.Token, invite.Code)
|
||||
|
||||
dmChannel := createDmChannel(t, client, loginResp.Token, otherUserID)
|
||||
dmChannelID := parseSnowflake(t, dmChannel.ID)
|
||||
message := sendChannelMessage(t, client, loginResp.Token, dmChannelID, "test message")
|
||||
|
||||
if message.ID == "" {
|
||||
t.Error("expected to be able to send messages after auto-undisable")
|
||||
}
|
||||
|
||||
t.Log("Auto-undisable on login test passed")
|
||||
}
|
||||
80
tests/integration/account_disable_test.go
Normal file
80
tests/integration/account_disable_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccountDisable(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
friend := createTestAccount(t, client)
|
||||
createFriendship(t, client, account, friend)
|
||||
|
||||
resp, err := client.postJSONWithAuth("/users/@me/disable", map[string]string{
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to disable account: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.getWithAuth("/users/@me", account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get user: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected old token to be invalid (401), got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
loginResp := loginTestUser(t, client, account.Email, account.Password)
|
||||
if loginResp.Token == "" {
|
||||
t.Fatal("expected to be able to login after disable")
|
||||
}
|
||||
|
||||
dataResp, err := client.get(fmt.Sprintf("/test/users/%s/data-exists", loginResp.UserID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check user data: %v", err)
|
||||
}
|
||||
defer dataResp.Body.Close()
|
||||
|
||||
var dataExists userDataExistsResponse
|
||||
decodeJSONResponse(t, dataResp, &dataExists)
|
||||
|
||||
if dataExists.RelationshipsCount == 0 {
|
||||
t.Error("expected relationships to be preserved after disable")
|
||||
}
|
||||
|
||||
if dataExists.EmailCleared {
|
||||
t.Error("expected email to be preserved after disable")
|
||||
}
|
||||
|
||||
if dataExists.PasswordCleared {
|
||||
t.Error("expected password to be preserved after disable")
|
||||
}
|
||||
|
||||
t.Log("Account disable test passed")
|
||||
}
|
||||
50
tests/integration/add_recipient_permissions_test.go
Normal file
50
tests/integration/add_recipient_permissions_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAddRecipientPermissions(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user1 := createTestAccount(t, client)
|
||||
user2 := createTestAccount(t, client)
|
||||
user3 := createTestAccount(t, client)
|
||||
user4 := createTestAccount(t, client)
|
||||
|
||||
createFriendship(t, client, user1, user2)
|
||||
createFriendship(t, client, user1, user3)
|
||||
|
||||
groupDmChannel := createGroupDmChannel(t, client, user1.Token, user2.UserID, user3.UserID)
|
||||
|
||||
t.Run("cannot add non-friend", func(t *testing.T) {
|
||||
resp, err := client.putJSONWithAuth(fmt.Sprintf("/channels/%d/recipients/%s", parseSnowflake(t, groupDmChannel.ID), user4.UserID), nil, user1.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send add recipient request: %v", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNoContent {
|
||||
t.Fatalf("expected adding non-friend to fail")
|
||||
}
|
||||
resp.Body.Close()
|
||||
})
|
||||
}
|
||||
76
tests/integration/add_recipient_to_group_dm_test.go
Normal file
76
tests/integration/add_recipient_to_group_dm_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAddRecipientToGroupDM(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user1 := createTestAccount(t, client)
|
||||
user2 := createTestAccount(t, client)
|
||||
user3 := createTestAccount(t, client)
|
||||
|
||||
user1Socket := newGatewayClient(t, client, user1.Token)
|
||||
t.Cleanup(user1Socket.Close)
|
||||
user2Socket := newGatewayClient(t, client, user2.Token)
|
||||
t.Cleanup(user2Socket.Close)
|
||||
user3Socket := newGatewayClient(t, client, user3.Token)
|
||||
t.Cleanup(user3Socket.Close)
|
||||
|
||||
createFriendship(t, client, user1, user2)
|
||||
createFriendship(t, client, user1, user3)
|
||||
|
||||
drainRelationshipEvents(t, user1Socket)
|
||||
drainRelationshipEvents(t, user2Socket)
|
||||
drainRelationshipEvents(t, user3Socket)
|
||||
|
||||
dmChannel := createDmChannel(t, client, user1.Token, parseSnowflake(t, user2.UserID))
|
||||
waitForChannelEvent(t, user1Socket, "CHANNEL_CREATE", dmChannel.ID)
|
||||
|
||||
sendChannelMessage(t, client, user1.Token, parseSnowflake(t, dmChannel.ID), "test message")
|
||||
waitForChannelEvent(t, user2Socket, "CHANNEL_CREATE", dmChannel.ID)
|
||||
|
||||
groupDmChannel := createGroupDmChannel(t, client, user1.Token, user2.UserID, user3.UserID)
|
||||
waitForChannelEvent(t, user1Socket, "CHANNEL_CREATE", groupDmChannel.ID)
|
||||
waitForChannelEvent(t, user2Socket, "CHANNEL_CREATE", groupDmChannel.ID)
|
||||
waitForChannelEvent(t, user3Socket, "CHANNEL_CREATE", groupDmChannel.ID)
|
||||
|
||||
resp, err := client.getWithAuth(fmt.Sprintf("/channels/%d", parseSnowflake(t, groupDmChannel.ID)), user3.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get channel as user3: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var channel struct {
|
||||
ID string `json:"id"`
|
||||
Type int `json:"type"`
|
||||
Recipients []any `json:"recipients"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &channel)
|
||||
if channel.Type != 3 {
|
||||
t.Fatalf("expected channel type to be 3 (GROUP_DM), got %d", channel.Type)
|
||||
}
|
||||
if len(channel.Recipients) != 2 {
|
||||
t.Fatalf("expected 2 recipients in group DM (excluding current user), got %d", len(channel.Recipients))
|
||||
}
|
||||
}
|
||||
56
tests/integration/admin_endpoints_authz_helper.go
Normal file
56
tests/integration/admin_endpoints_authz_helper.go
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func doAdminRequest(t testing.TB, client *testClient, method, path, token string) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
var body *strings.Reader
|
||||
if method == http.MethodGet {
|
||||
body = strings.NewReader("")
|
||||
} else {
|
||||
body = strings.NewReader("{}")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, client.baseURL+path, body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build request: %v", err)
|
||||
}
|
||||
|
||||
if method != http.MethodGet {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
client.applyCommonHeaders(req)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
149
tests/integration/admin_endpoints_require_auth_and_acls_test.go
Normal file
149
tests/integration/admin_endpoints_require_auth_and_acls_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdminEndpointsRequireAuthAndAcls(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
admin := createTestAccount(t, client)
|
||||
|
||||
setUserACLs(t, client, admin.UserID, []string{"admin:authenticate"})
|
||||
|
||||
redirectURI := "https://example.com/callback"
|
||||
appID, _, _, _ := createOAuth2Application(
|
||||
t,
|
||||
client,
|
||||
admin,
|
||||
fmt.Sprintf("Admin AuthZ %d", time.Now().UnixNano()),
|
||||
[]string{redirectURI},
|
||||
nil,
|
||||
)
|
||||
code, _ := authorizeOAuth2(t, client, admin.Token, appID, redirectURI, []string{"identify"}, "", "", "")
|
||||
token := exchangeOAuth2AuthorizationCode(t, client, appID, "", code, redirectURI, "").AccessToken
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/admin/reports/list"},
|
||||
{http.MethodGet, "/admin/reports/1"},
|
||||
{http.MethodPost, "/admin/reports/resolve"},
|
||||
{http.MethodPost, "/admin/reports/search"},
|
||||
{http.MethodPost, "/admin/bulk/update-user-flags"},
|
||||
{http.MethodPost, "/admin/bulk/update-guild-features"},
|
||||
{http.MethodPost, "/admin/bulk/add-guild-members"},
|
||||
{http.MethodPost, "/admin/bulk/schedule-user-deletion"},
|
||||
{http.MethodPost, "/admin/guilds/search"},
|
||||
{http.MethodPost, "/admin/users/search"},
|
||||
{http.MethodPost, "/admin/search/refresh-index"},
|
||||
{http.MethodPost, "/admin/search/refresh-status"},
|
||||
{http.MethodPost, "/admin/voice/regions/list"},
|
||||
{http.MethodPost, "/admin/voice/regions/get"},
|
||||
{http.MethodPost, "/admin/voice/regions/create"},
|
||||
{http.MethodPost, "/admin/voice/regions/update"},
|
||||
{http.MethodPost, "/admin/voice/regions/delete"},
|
||||
{http.MethodPost, "/admin/voice/servers/list"},
|
||||
{http.MethodPost, "/admin/voice/servers/get"},
|
||||
{http.MethodPost, "/admin/voice/servers/create"},
|
||||
{http.MethodPost, "/admin/voice/servers/update"},
|
||||
{http.MethodPost, "/admin/voice/servers/delete"},
|
||||
{http.MethodPost, "/admin/pending-verifications/list"},
|
||||
{http.MethodPost, "/admin/pending-verifications/approve"},
|
||||
{http.MethodPost, "/admin/pending-verifications/reject"},
|
||||
{http.MethodPost, "/admin/messages/lookup"},
|
||||
{http.MethodPost, "/admin/messages/lookup-by-attachment"},
|
||||
{http.MethodPost, "/admin/messages/delete"},
|
||||
{http.MethodPost, "/admin/gateway/memory-stats"},
|
||||
{http.MethodPost, "/admin/gateway/reload-all"},
|
||||
{http.MethodGet, "/admin/gateway/stats"},
|
||||
{http.MethodPost, "/admin/audit-logs"},
|
||||
{http.MethodPost, "/admin/audit-logs/search"},
|
||||
{http.MethodPost, "/admin/guilds/lookup"},
|
||||
{http.MethodPost, "/admin/guilds/list-members"},
|
||||
{http.MethodPost, "/admin/guilds/clear-fields"},
|
||||
{http.MethodPost, "/admin/guilds/update-features"},
|
||||
{http.MethodPost, "/admin/guilds/update-name"},
|
||||
{http.MethodPost, "/admin/guilds/update-settings"},
|
||||
{http.MethodPost, "/admin/guilds/transfer-ownership"},
|
||||
{http.MethodPost, "/admin/guilds/update-vanity"},
|
||||
{http.MethodPost, "/admin/guilds/force-add-user"},
|
||||
{http.MethodPost, "/admin/guilds/reload"},
|
||||
{http.MethodPost, "/admin/guilds/shutdown"},
|
||||
{http.MethodPost, "/admin/users/lookup"},
|
||||
{http.MethodPost, "/admin/users/list-guilds"},
|
||||
{http.MethodPost, "/admin/users/disable-mfa"},
|
||||
{http.MethodPost, "/admin/users/clear-fields"},
|
||||
{http.MethodPost, "/admin/users/set-bot-status"},
|
||||
{http.MethodPost, "/admin/users/set-system-status"},
|
||||
{http.MethodPost, "/admin/users/verify-email"},
|
||||
{http.MethodPost, "/admin/users/send-password-reset"},
|
||||
{http.MethodPost, "/admin/users/change-username"},
|
||||
{http.MethodPost, "/admin/users/change-email"},
|
||||
{http.MethodPost, "/admin/users/terminate-sessions"},
|
||||
{http.MethodPost, "/admin/users/temp-ban"},
|
||||
{http.MethodPost, "/admin/users/unban"},
|
||||
{http.MethodPost, "/admin/users/schedule-deletion"},
|
||||
{http.MethodPost, "/admin/users/cancel-deletion"},
|
||||
{http.MethodPost, "/admin/users/set-acls"},
|
||||
{http.MethodPost, "/admin/users/update-flags"},
|
||||
{http.MethodPost, "/admin/users/unlink-phone"},
|
||||
{http.MethodPost, "/admin/users/change-dob"},
|
||||
{http.MethodPost, "/admin/users/update-suspicious-activity-flags"},
|
||||
{http.MethodPost, "/admin/users/disable-suspicious"},
|
||||
{http.MethodPost, "/admin/users/list-sessions"},
|
||||
{http.MethodPost, "/admin/bans/ip/add"},
|
||||
{http.MethodPost, "/admin/bans/ip/remove"},
|
||||
{http.MethodPost, "/admin/bans/ip/check"},
|
||||
{http.MethodPost, "/admin/bans/email/add"},
|
||||
{http.MethodPost, "/admin/bans/email/remove"},
|
||||
{http.MethodPost, "/admin/bans/email/check"},
|
||||
{http.MethodPost, "/admin/bans/phone/add"},
|
||||
{http.MethodPost, "/admin/bans/phone/remove"},
|
||||
{http.MethodPost, "/admin/bans/phone/check"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
ep := ep
|
||||
|
||||
t.Run(ep.method+" "+ep.path+" unauthorized", func(t *testing.T) {
|
||||
resp := doAdminRequest(t, client, ep.method, ep.path, "")
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for %s %s without auth, got %d: %s", ep.method, ep.path, resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(ep.method+" "+ep.path+" missing-acl", func(t *testing.T) {
|
||||
resp := doAdminRequest(t, client, ep.method, ep.path, token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for %s %s with admin:authenticate only, got %d: %s", ep.method, ep.path, resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
129
tests/integration/admin_user_deletion_schedule_minimums_test.go
Normal file
129
tests/integration/admin_user_deletion_schedule_minimums_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
standardMinimumDays = 60
|
||||
userRequestedDays = 14
|
||||
)
|
||||
|
||||
func TestAdminUserDeletionScheduleMinimums(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
admin := createTestAccount(t, client)
|
||||
setUserACLs(t, client, admin.UserID, []string{"admin:authenticate", "user:delete"})
|
||||
|
||||
t.Run("NonUserRequestedRequiresStandardMinimum", func(t *testing.T) {
|
||||
target := createTestAccount(t, client)
|
||||
scheduleAdminDeletion(t, client, admin.Token, target.UserID, 2, 10)
|
||||
|
||||
requirePendingDurationBetween(
|
||||
t,
|
||||
client,
|
||||
target.UserID,
|
||||
(standardMinimumDays-1)*24*time.Hour,
|
||||
(standardMinimumDays+2)*24*time.Hour,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("UserRequestedUsesUserMinimum", func(t *testing.T) {
|
||||
target := createTestAccount(t, client)
|
||||
scheduleAdminDeletion(t, client, admin.Token, target.UserID, 1, 5)
|
||||
|
||||
requirePendingDurationBetween(
|
||||
t,
|
||||
client,
|
||||
target.UserID,
|
||||
(userRequestedDays-1)*24*time.Hour,
|
||||
(userRequestedDays+2)*24*time.Hour,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func scheduleAdminDeletion(t testing.TB, client *testClient, token, userID string, reasonCode, days int) {
|
||||
t.Helper()
|
||||
|
||||
payload := map[string]any{
|
||||
"user_id": userID,
|
||||
"reason_code": reasonCode,
|
||||
"days_until_deletion": days,
|
||||
}
|
||||
|
||||
resp, err := client.postJSONWithAuth("/admin/users/schedule-deletion", payload, token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to request admin deletion: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 when scheduling deletion, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
}
|
||||
|
||||
func requirePendingDurationBetween(t testing.TB, client *testClient, userID string, min, max time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
diff := pendingDeletionDuration(t, client, userID)
|
||||
|
||||
if diff < min {
|
||||
t.Fatalf("expected pending deletion at least %s from now, got %s", min, diff)
|
||||
}
|
||||
if diff > max {
|
||||
t.Fatalf("expected pending deletion at most %s from now, got %s", max, diff)
|
||||
}
|
||||
}
|
||||
|
||||
func pendingDeletionDuration(t testing.TB, client *testClient, userID string) time.Duration {
|
||||
t.Helper()
|
||||
|
||||
resp, err := client.get("/test/users/" + userID + "/data-exists")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch user data: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 when fetching user data, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
|
||||
var data userDataExistsResponse
|
||||
decodeJSONResponse(t, resp, &data)
|
||||
|
||||
if data.PendingDeletionAt == nil {
|
||||
t.Fatalf("expected pending_deletion_at to be set, but it was nil")
|
||||
}
|
||||
|
||||
pendingAt, err := time.Parse(time.RFC3339, *data.PendingDeletionAt)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid pending_deletion_at: %v", err)
|
||||
}
|
||||
|
||||
diff := time.Until(pendingAt)
|
||||
if diff < 0 {
|
||||
t.Fatalf("expected pending deletion to be in the future, got %s", diff)
|
||||
}
|
||||
|
||||
return diff
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminUsersSetAclsRequiresAclSetUser(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
admin := createTestAccount(t, client)
|
||||
setUserACLs(t, client, admin.UserID, []string{"admin:authenticate", "user:update:flags"})
|
||||
|
||||
target := createTestAccount(t, client)
|
||||
payload := map[string]any{
|
||||
"user_id": target.UserID,
|
||||
"acls": []string{"user:update:flags"},
|
||||
}
|
||||
|
||||
unauthorizedResp, err := client.postJSONWithAuth("/admin/users/set-acls", payload, admin.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call set-acls without acl:set:user: %v", err)
|
||||
}
|
||||
if unauthorizedResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf(
|
||||
"expected 403 when missing acl:set:user, got %d: %s",
|
||||
unauthorizedResp.StatusCode,
|
||||
readResponseBody(unauthorizedResp),
|
||||
)
|
||||
}
|
||||
unauthorizedResp.Body.Close()
|
||||
|
||||
setUserACLs(t, client, admin.UserID, []string{"admin:authenticate", "acl:set:user"})
|
||||
|
||||
authorizedResp, err := client.postJSONWithAuth("/admin/users/set-acls", payload, admin.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call set-acls with acl:set:user: %v", err)
|
||||
}
|
||||
if authorizedResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200 when acl:set:user is present, got %d: %s",
|
||||
authorizedResp.StatusCode,
|
||||
readResponseBody(authorizedResp),
|
||||
)
|
||||
}
|
||||
authorizedResp.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdministratorRoleGrantsChannelAccess(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
owner := createTestAccount(t, client)
|
||||
member := createTestAccount(t, client)
|
||||
|
||||
guild := createGuild(t, client, owner.Token, fmt.Sprintf("Admin Perms Guild %d", time.Now().UnixNano()))
|
||||
guildID := parseSnowflake(t, guild.ID)
|
||||
|
||||
administratorPermission := 1 << 3
|
||||
resp, err := client.patchJSONWithAuth(fmt.Sprintf("/guilds/%d/roles/%d", guildID, guildID), map[string]any{
|
||||
"permissions": fmt.Sprintf("%d", administratorPermission),
|
||||
}, owner.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update @everyone role: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
invite := createChannelInvite(t, client, owner.Token, parseSnowflake(t, guild.SystemChannel))
|
||||
resp, err = client.postJSONWithAuth(fmt.Sprintf("/invites/%s", invite.Code), nil, member.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to accept invite: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
memberGateway := newGatewayClient(t, client, member.Token)
|
||||
t.Cleanup(memberGateway.Close)
|
||||
|
||||
guildCreate := memberGateway.WaitForEvent(t, "GUILD_CREATE", 60*time.Second, func(raw json.RawMessage) bool {
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return false
|
||||
}
|
||||
return payload.ID == guild.ID
|
||||
})
|
||||
|
||||
var guildPayload struct {
|
||||
ID string `json:"id"`
|
||||
Channels []json.RawMessage `json:"channels"`
|
||||
}
|
||||
if err := json.Unmarshal(guildCreate.Data, &guildPayload); err != nil {
|
||||
t.Fatalf("failed to decode GUILD_CREATE payload: %v", err)
|
||||
}
|
||||
|
||||
if len(guildPayload.Channels) == 0 {
|
||||
t.Fatalf("expected member with ADMINISTRATOR permission on @everyone role to see channels, but got empty channels array")
|
||||
}
|
||||
|
||||
systemChannelFound := false
|
||||
for _, channelRaw := range guildPayload.Channels {
|
||||
var channel struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(channelRaw, &channel); err != nil {
|
||||
continue
|
||||
}
|
||||
if channel.ID == guild.SystemChannel {
|
||||
systemChannelFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !systemChannelFound {
|
||||
t.Fatalf("expected system channel %s to be visible to member with ADMINISTRATOR permission", guild.SystemChannel)
|
||||
}
|
||||
}
|
||||
29
tests/integration/asset_upload_types.go
Normal file
29
tests/integration/asset_upload_types.go
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
// assetVerifyResponse is the response from /test/verify-asset/* endpoints
|
||||
type assetVerifyResponse struct {
|
||||
Hash *string `json:"hash"`
|
||||
S3Key string `json:"s3_key,omitempty"`
|
||||
ExistsInS3 *bool `json:"existsInS3"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
40
tests/integration/asset_upload_verification_guild_banner.go
Normal file
40
tests/integration/asset_upload_verification_guild_banner.go
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// verifyGuildBannerInS3 checks if the guild's banner exists in S3
|
||||
func verifyGuildBannerInS3(t *testing.T, client *testClient, guildID string) assetVerifyResponse {
|
||||
t.Helper()
|
||||
resp, err := client.get(fmt.Sprintf("/test/verify-asset/guild/%s/banner", guildID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify guild banner: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var result assetVerifyResponse
|
||||
decodeJSONResponse(t, resp, &result)
|
||||
return result
|
||||
}
|
||||
40
tests/integration/asset_upload_verification_guild_icon.go
Normal file
40
tests/integration/asset_upload_verification_guild_icon.go
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// verifyGuildIconInS3 checks if the guild's icon exists in S3
|
||||
func verifyGuildIconInS3(t *testing.T, client *testClient, guildID string) assetVerifyResponse {
|
||||
t.Helper()
|
||||
resp, err := client.get(fmt.Sprintf("/test/verify-asset/guild/%s/icon", guildID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify guild icon: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var result assetVerifyResponse
|
||||
decodeJSONResponse(t, resp, &result)
|
||||
return result
|
||||
}
|
||||
40
tests/integration/asset_upload_verification_guild_splash.go
Normal file
40
tests/integration/asset_upload_verification_guild_splash.go
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// verifyGuildSplashInS3 checks if the guild's splash exists in S3
|
||||
func verifyGuildSplashInS3(t *testing.T, client *testClient, guildID string) assetVerifyResponse {
|
||||
t.Helper()
|
||||
resp, err := client.get(fmt.Sprintf("/test/verify-asset/guild/%s/splash", guildID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify guild splash: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var result assetVerifyResponse
|
||||
decodeJSONResponse(t, resp, &result)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFavoriteMemeAttachmentsNeverHitDecay(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
channelID := parseSnowflake(t, user.UserID)
|
||||
|
||||
resp, err := client.postJSONWithAuth(
|
||||
"/users/@me/memes",
|
||||
map[string]string{"url": favoriteMemeTestImageURL},
|
||||
user.Token,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create favorite meme: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
|
||||
var meme favoriteMemeResponse
|
||||
decodeJSONResponse(t, resp, &meme)
|
||||
|
||||
resp, err = client.postJSONWithAuth(
|
||||
fmt.Sprintf("/channels/%d/messages", channelID),
|
||||
map[string]any{"favorite_meme_id": meme.ID},
|
||||
user.Token,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send favorite meme message: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
Attachments []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &created)
|
||||
|
||||
if len(created.Attachments) == 0 {
|
||||
t.Fatalf("expected favorite meme message to include attachments")
|
||||
}
|
||||
|
||||
attachmentID := created.Attachments[0].ID
|
||||
|
||||
assertAttachmentDecayRowMissing(t, client, attachmentID, user.Token)
|
||||
|
||||
resp, err = client.getWithAuth(fmt.Sprintf("/channels/%d/messages/%s", channelID, created.ID), user.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch favorite meme message: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
assertAttachmentDecayRowMissing(t, client, attachmentID, user.Token)
|
||||
}
|
||||
|
||||
func assertAttachmentDecayRowMissing(t testing.TB, client *testClient, attachmentID, token string) {
|
||||
t.Helper()
|
||||
|
||||
resp, err := client.getWithAuth(fmt.Sprintf("/test/attachment-decay/%s", attachmentID), token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query attachment decay row: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var payload struct {
|
||||
Row *struct {
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
} `json:"row"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &payload)
|
||||
if payload.Row != nil {
|
||||
t.Fatalf("expected no attachment decay entry for %s", attachmentID)
|
||||
}
|
||||
}
|
||||
128
tests/integration/attachment_decay_referenced_message_test.go
Normal file
128
tests/integration/attachment_decay_referenced_message_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAttachmentDecayPopulatesReferencedMessage(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
author := createTestAccount(t, client)
|
||||
recipient := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, author.Token)
|
||||
ensureSessionStarted(t, client, recipient.Token)
|
||||
|
||||
createFriendship(t, client, author, recipient)
|
||||
|
||||
channel := createDmChannel(t, client, author.Token, parseSnowflake(t, recipient.UserID))
|
||||
|
||||
originalMessage, originalAttachmentID := sendChannelMessageWithAttachment(
|
||||
t,
|
||||
client,
|
||||
author.Token,
|
||||
parseSnowflake(t, channel.ID),
|
||||
"Original message for reference",
|
||||
"document.pdf",
|
||||
)
|
||||
|
||||
replyPayload := map[string]any{
|
||||
"content": "Reply referencing expired attachment",
|
||||
"message_reference": map[string]any{
|
||||
"message_id": originalMessage.ID,
|
||||
"channel_id": channel.ID,
|
||||
"type": 0,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.postJSONWithAuth(
|
||||
fmt.Sprintf("/channels/%d/messages", parseSnowflake(t, channel.ID)),
|
||||
replyPayload,
|
||||
author.Token,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to post reply: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var reply messageResponse
|
||||
decodeJSONResponse(t, resp, &reply)
|
||||
|
||||
rows := []map[string]any{
|
||||
{
|
||||
"attachment_id": fmt.Sprintf("%d", originalAttachmentID),
|
||||
"channel_id": channel.ID,
|
||||
"message_id": originalMessage.ID,
|
||||
"expires_at": time.Now().Add(-1 * time.Hour).Format(time.RFC3339Nano),
|
||||
"uploaded_at": time.Now().Add(-2 * time.Hour).Format(time.RFC3339Nano),
|
||||
"last_accessed_at": time.Now().Add(-1 * time.Hour).Format(time.RFC3339Nano),
|
||||
"filename": "expired-reference.bin",
|
||||
"size_bytes": 2048,
|
||||
"cost": 2,
|
||||
"lifetime_days": 1,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err = client.postJSONWithAuth("/test/attachment-decay/rows", map[string]any{"rows": rows}, author.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed attachment decay rows: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
resp, err = client.getWithAuth(
|
||||
fmt.Sprintf("/test/messages/%d/%d/with-reference", parseSnowflake(t, channel.ID), parseSnowflake(t, reply.ID)),
|
||||
author.Token,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch message with reference: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var fetched struct {
|
||||
ID string `json:"id"`
|
||||
ReferencedMessage struct {
|
||||
Attachments []struct {
|
||||
ID string `json:"id"`
|
||||
URL *string `json:"url"`
|
||||
Expired *bool `json:"expired"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
} `json:"attachments"`
|
||||
} `json:"referenced_message"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &fetched)
|
||||
|
||||
if len(fetched.ReferencedMessage.Attachments) == 0 {
|
||||
t.Fatalf("expected referenced message to include attachments")
|
||||
}
|
||||
|
||||
for _, attachment := range fetched.ReferencedMessage.Attachments {
|
||||
if attachment.URL != nil {
|
||||
t.Fatalf("expected referenced message attachment to hide url")
|
||||
}
|
||||
if attachment.Expired == nil || !*attachment.Expired {
|
||||
t.Fatalf("expected referenced message attachment to be marked expired")
|
||||
}
|
||||
if attachment.ExpiresAt == nil {
|
||||
t.Fatalf("expected referenced message attachment to include expires_at")
|
||||
}
|
||||
}
|
||||
}
|
||||
152
tests/integration/attachment_decay_snapshot_test.go
Normal file
152
tests/integration/attachment_decay_snapshot_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAttachmentDecayAppliesToSnapshotClones(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user1 := createTestAccount(t, client)
|
||||
user2 := createTestAccount(t, client)
|
||||
user3 := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user1.Token)
|
||||
ensureSessionStarted(t, client, user2.Token)
|
||||
ensureSessionStarted(t, client, user3.Token)
|
||||
|
||||
createFriendship(t, client, user1, user2)
|
||||
createFriendship(t, client, user1, user3)
|
||||
|
||||
channel1 := createDmChannel(t, client, user1.Token, parseSnowflake(t, user2.UserID))
|
||||
channel2 := createDmChannel(t, client, user1.Token, parseSnowflake(t, user3.UserID))
|
||||
|
||||
originalMessage, _ := sendChannelMessageWithAttachment(
|
||||
t,
|
||||
client,
|
||||
user1.Token,
|
||||
parseSnowflake(t, channel1.ID),
|
||||
"Original with attachment",
|
||||
"yeah.png",
|
||||
)
|
||||
|
||||
forwardPayload := map[string]any{
|
||||
"message_reference": map[string]any{
|
||||
"message_id": originalMessage.ID,
|
||||
"channel_id": channel1.ID,
|
||||
"type": 1,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.postJSONWithAuth(
|
||||
fmt.Sprintf("/channels/%d/messages", parseSnowflake(t, channel2.ID)),
|
||||
forwardPayload,
|
||||
user1.Token,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to forward message: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var forwarded messageResponse
|
||||
decodeJSONResponse(t, resp, &forwarded)
|
||||
|
||||
fetchedSnapshots := fetchMessageSnapshots(t, client, user1.Token, parseSnowflake(t, channel2.ID), parseSnowflake(t, forwarded.ID))
|
||||
if len(fetchedSnapshots) == 0 {
|
||||
t.Fatalf("expected forwarded message to contain snapshots")
|
||||
}
|
||||
|
||||
if len(fetchedSnapshots[0].Attachments) == 0 {
|
||||
t.Fatalf("expected snapshot to include attachments")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
rows := []map[string]any{}
|
||||
for _, attachment := range fetchedSnapshots[0].Attachments {
|
||||
rows = append(rows, map[string]any{
|
||||
"attachment_id": attachment.ID,
|
||||
"channel_id": channel2.ID,
|
||||
"message_id": forwarded.ID,
|
||||
"expires_at": now.Add(-1 * time.Hour).Format(time.RFC3339Nano),
|
||||
"uploaded_at": now.Add(-2 * time.Hour).Format(time.RFC3339Nano),
|
||||
"last_accessed_at": now.Add(-1 * time.Hour).Format(time.RFC3339Nano),
|
||||
"filename": "expired-snapshot.bin",
|
||||
"size_bytes": 1024,
|
||||
"cost": 1,
|
||||
"lifetime_days": 1,
|
||||
})
|
||||
}
|
||||
|
||||
resp, err = client.postJSONWithAuth("/test/attachment-decay/rows", map[string]any{"rows": rows}, user1.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed attachment decay rows: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
fetchedSnapshots = fetchMessageSnapshots(t, client, user1.Token, parseSnowflake(t, channel2.ID), parseSnowflake(t, forwarded.ID))
|
||||
if len(fetchedSnapshots[0].Attachments) == 0 {
|
||||
t.Fatalf("expected snapshot to include attachments after update")
|
||||
}
|
||||
|
||||
for _, attachment := range fetchedSnapshots[0].Attachments {
|
||||
if attachment.URL != nil {
|
||||
t.Fatalf("expected expired snapshot attachment to hide url")
|
||||
}
|
||||
if attachment.Expired == nil || !*attachment.Expired {
|
||||
t.Fatalf("expected snapshot attachment to be marked expired")
|
||||
}
|
||||
if attachment.ExpiresAt == nil {
|
||||
t.Fatalf("expected snapshot attachment to include expires_at")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type snapshotAttachment struct {
|
||||
ID string `json:"id"`
|
||||
URL *string `json:"url"`
|
||||
Expired *bool `json:"expired"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type messageSnapshotResponse struct {
|
||||
Attachments []snapshotAttachment `json:"attachments"`
|
||||
}
|
||||
|
||||
func fetchMessageSnapshots(t testing.TB, client *testClient, token string, channelID, messageID int64) []messageSnapshotResponse {
|
||||
t.Helper()
|
||||
|
||||
resp, err := client.getWithAuth(
|
||||
fmt.Sprintf("/channels/%d/messages/%d", channelID, messageID),
|
||||
token,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch message: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var fetched struct {
|
||||
MessageSnapshots []messageSnapshotResponse `json:"message_snapshots"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &fetched)
|
||||
return fetched.MessageSnapshots
|
||||
}
|
||||
91
tests/integration/attachment_upload_empty_filename_test.go
Normal file
91
tests/integration/attachment_upload_empty_filename_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_EmptyFilename tests that empty filenames are rejected
|
||||
func TestAttachmentUpload_EmptyFilename(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Empty Filename Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData := []byte("test content")
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Empty filename test",
|
||||
"attachments": []map[string]any{
|
||||
{"id": 0, "filename": ""},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", "actualname.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_ExtremelyLongFilename tests that excessively long filenames are rejected
|
||||
func TestAttachmentUpload_ExtremelyLongFilename(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Long Filename Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData := []byte("test content")
|
||||
|
||||
longFilename := strings.Repeat("a", 500) + ".txt"
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Long filename test",
|
||||
"attachments": []map[string]any{
|
||||
{"id": 0, "filename": longFilename},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", longFilename)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Error("expected extremely long filename to be rejected, but got 200")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_FilenameMismatchBetweenMetadataAndFile tests strict filename matching
|
||||
func TestAttachmentUpload_FilenameMismatchBetweenMetadataAndFile(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Filename Mismatch Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData := []byte("test content")
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Filename mismatch test",
|
||||
"attachments": []map[string]any{
|
||||
{"id": 0, "filename": "expected.txt"},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", "different.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
}
|
||||
113
tests/integration/attachment_upload_flags_preserved_test.go
Normal file
113
tests/integration/attachment_upload_flags_preserved_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_FlagsPreserved tests that attachment flags are properly preserved
|
||||
func TestAttachmentUpload_FlagsPreserved(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Flags Preserved Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Flags preservation test",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"id": 0,
|
||||
"filename": "spoiler.png",
|
||||
"flags": 8,
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", "spoiler.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
Flags int `json:"flags"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
if msgResp.Attachments[0].Flags != 8 {
|
||||
t.Errorf("expected flags=8 (spoiler), got %d", msgResp.Attachments[0].Flags)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_IDMatching_MissingMetadata tests that when a file is
|
||||
// uploaded but metadata for its ID is missing, the request fails
|
||||
func TestAttachmentUpload_IDMatching_MissingMetadata(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Missing Metadata Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Missing metadata test",
|
||||
"attachments": []map[string]any{
|
||||
{"id": 0, "filename": "yeah.png", "description": "Only zero"},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[1]", "test.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create files[1] field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_IDMatching_OrderedFiles tests that files are correctly
|
||||
// matched with their metadata when sent in the natural order (files[0], files[1], etc.)
|
||||
func TestAttachmentUpload_IDMatching_OrderedFiles(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "ID Matching Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
file1Data, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
file2Data, err := fixturesFS.ReadFile("fixtures/thisisfine.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Ordered files test",
|
||||
"attachments": []map[string]any{
|
||||
{"id": 0, "filename": "yeah.png", "description": "First file", "title": "First"},
|
||||
{"id": 1, "filename": "thisisfine.gif", "description": "Second file", "title": "Second"},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
file1Writer, err := writer.CreateFormFile("files[0]", "yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create files[0] field: %v", err)
|
||||
}
|
||||
if _, err := file1Writer.Write(file1Data); err != nil {
|
||||
t.Fatalf("failed to write file1 data: %v", err)
|
||||
}
|
||||
|
||||
file2Writer, err := writer.CreateFormFile("files[1]", "thisisfine.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create files[1] field: %v", err)
|
||||
}
|
||||
if _, err := file2Writer.Write(file2Data); err != nil {
|
||||
t.Fatalf("failed to write file2 data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
Description string `json:"description"`
|
||||
Title string `json:"title"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 2 {
|
||||
t.Fatalf("expected 2 attachments, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
if msgResp.Attachments[0].Filename != "yeah.png" {
|
||||
t.Errorf("expected first filename 'yeah.png', got %q", msgResp.Attachments[0].Filename)
|
||||
}
|
||||
if msgResp.Attachments[0].Description != "First file" {
|
||||
t.Errorf("expected first description 'First file', got %q", msgResp.Attachments[0].Description)
|
||||
}
|
||||
if msgResp.Attachments[0].Title != "First" {
|
||||
t.Errorf("expected first title 'First', got %q", msgResp.Attachments[0].Title)
|
||||
}
|
||||
|
||||
if msgResp.Attachments[1].Filename != "thisisfine.gif" {
|
||||
t.Errorf("expected second filename 'thisisfine.gif', got %q", msgResp.Attachments[1].Filename)
|
||||
}
|
||||
if msgResp.Attachments[1].Description != "Second file" {
|
||||
t.Errorf("expected second description 'Second file', got %q", msgResp.Attachments[1].Description)
|
||||
}
|
||||
if msgResp.Attachments[1].Title != "Second" {
|
||||
t.Errorf("expected second title 'Second', got %q", msgResp.Attachments[1].Title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_IDMatching_ReversedMetadata tests that when metadata IDs
|
||||
// are reversed (id=1, id=0), they still match correctly with files[0] and files[1]
|
||||
func TestAttachmentUpload_IDMatching_ReversedMetadata(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Reversed Metadata Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
file1Data, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
file2Data, err := fixturesFS.ReadFile("fixtures/thisisfine.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Reversed metadata test",
|
||||
"attachments": []map[string]any{
|
||||
{"id": 1, "filename": "thisisfine.gif", "description": "Second file", "title": "GIF"},
|
||||
{"id": 0, "filename": "yeah.png", "description": "First file", "title": "PNG"},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
file1Writer, err := writer.CreateFormFile("files[0]", "yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create files[0] field: %v", err)
|
||||
}
|
||||
if _, err := file1Writer.Write(file1Data); err != nil {
|
||||
t.Fatalf("failed to write file1 data: %v", err)
|
||||
}
|
||||
|
||||
file2Writer, err := writer.CreateFormFile("files[1]", "thisisfine.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create files[1] field: %v", err)
|
||||
}
|
||||
if _, err := file2Writer.Write(file2Data); err != nil {
|
||||
t.Fatalf("failed to write file2 data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
Description string `json:"description"`
|
||||
Title string `json:"title"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 2 {
|
||||
t.Fatalf("expected 2 attachments, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
if msgResp.Attachments[0].Filename != "thisisfine.gif" {
|
||||
t.Errorf("expected first filename 'thisisfine.gif' (first in metadata array), got %q", msgResp.Attachments[0].Filename)
|
||||
}
|
||||
if msgResp.Attachments[0].Description != "Second file" {
|
||||
t.Errorf("expected first description 'Second file' (from metadata[0] with id=1), got %q", msgResp.Attachments[0].Description)
|
||||
}
|
||||
if msgResp.Attachments[0].Title != "GIF" {
|
||||
t.Errorf("expected first title 'GIF' (from metadata[0] with id=1), got %q", msgResp.Attachments[0].Title)
|
||||
}
|
||||
|
||||
if msgResp.Attachments[1].Filename != "yeah.png" {
|
||||
t.Errorf("expected second filename 'yeah.png' (second in metadata array), got %q", msgResp.Attachments[1].Filename)
|
||||
}
|
||||
if msgResp.Attachments[1].Description != "First file" {
|
||||
t.Errorf("expected second description 'First file' (from metadata[1] with id=0), got %q", msgResp.Attachments[1].Description)
|
||||
}
|
||||
if msgResp.Attachments[1].Title != "PNG" {
|
||||
t.Errorf("expected second title 'PNG' (from metadata[1] with id=0), got %q", msgResp.Attachments[1].Title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_IDMatching_SparseIDs tests using non-sequential IDs like 2, 5
|
||||
func TestAttachmentUpload_IDMatching_SparseIDs(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Sparse IDs Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
file1Data, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
file2Data, err := fixturesFS.ReadFile("fixtures/thisisfine.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Sparse IDs test",
|
||||
"attachments": []map[string]any{
|
||||
{"id": 2, "filename": "yeah.png", "description": "ID is 2", "title": "Two"},
|
||||
{"id": 5, "filename": "thisisfine.gif", "description": "ID is 5", "title": "Five"},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
file1Writer, err := writer.CreateFormFile("files[2]", "yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create files[2] field: %v", err)
|
||||
}
|
||||
if _, err := file1Writer.Write(file1Data); err != nil {
|
||||
t.Fatalf("failed to write file1 data: %v", err)
|
||||
}
|
||||
|
||||
file2Writer, err := writer.CreateFormFile("files[5]", "thisisfine.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create files[5] field: %v", err)
|
||||
}
|
||||
if _, err := file2Writer.Write(file2Data); err != nil {
|
||||
t.Fatalf("failed to write file2 data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
Description string `json:"description"`
|
||||
Title string `json:"title"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 2 {
|
||||
t.Fatalf("expected 2 attachments, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
if msgResp.Attachments[0].Description != "ID is 2" {
|
||||
t.Errorf("expected first description 'ID is 2', got %q", msgResp.Attachments[0].Description)
|
||||
}
|
||||
if msgResp.Attachments[0].Title != "Two" {
|
||||
t.Errorf("expected first title 'Two', got %q", msgResp.Attachments[0].Title)
|
||||
}
|
||||
|
||||
if msgResp.Attachments[1].Description != "ID is 5" {
|
||||
t.Errorf("expected second description 'ID is 5', got %q", msgResp.Attachments[1].Description)
|
||||
}
|
||||
if msgResp.Attachments[1].Title != "Five" {
|
||||
t.Errorf("expected second title 'Five', got %q", msgResp.Attachments[1].Title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_IDMismatchBetweenMetadataAndFile tests that mismatched IDs are caught
|
||||
func TestAttachmentUpload_IDMismatchBetweenMetadataAndFile(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "ID Mismatch Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData := []byte("test content")
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "ID mismatch test",
|
||||
"attachments": []map[string]any{
|
||||
{"id": 5, "filename": "test.txt"},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", "test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
111
tests/integration/attachment_upload_invalid_filename_test.go
Normal file
111
tests/integration/attachment_upload_invalid_filename_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_InvalidFilename tests that invalid filenames are rejected
|
||||
func TestAttachmentUpload_InvalidFilename(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Invalid Filename Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
filename string
|
||||
}{
|
||||
{"empty filename", ""},
|
||||
{"filename too long", string(make([]byte, 300))},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Invalid filename test",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"id": 0,
|
||||
"filename": tc.filename,
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", tc.filename)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Error("expected request to fail with invalid filename, but it succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_MetadataWithoutFile tests providing attachment metadata
|
||||
// but not uploading the corresponding file
|
||||
func TestAttachmentUpload_MetadataWithoutFile(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Metadata Without File Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Metadata without file",
|
||||
"attachments": []map[string]any{
|
||||
{"id": 0, "filename": "missing.png", "description": "This file doesn't exist"},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_MultipleFilesWithMixedMetadata tests complex scenario with
|
||||
// multiple files and mixed metadata quality (some with title/desc, some without)
|
||||
func TestAttachmentUpload_MultipleFilesWithMixedMetadata(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Mixed Metadata Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
file1Data, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
file2Data, err := fixturesFS.ReadFile("fixtures/thisisfine.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Mixed metadata test",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"id": 0,
|
||||
"filename": "yeah.png",
|
||||
"title": "Full Metadata",
|
||||
"description": "Complete description",
|
||||
"flags": 0,
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"filename": "thisisfine.gif",
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
file1Writer, err := writer.CreateFormFile("files[0]", "yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create files[0] field: %v", err)
|
||||
}
|
||||
if _, err := file1Writer.Write(file1Data); err != nil {
|
||||
t.Fatalf("failed to write file1 data: %v", err)
|
||||
}
|
||||
|
||||
file2Writer, err := writer.CreateFormFile("files[1]", "thisisfine.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create files[1] field: %v", err)
|
||||
}
|
||||
if _, err := file2Writer.Write(file2Data); err != nil {
|
||||
t.Fatalf("failed to write file2 data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 2 {
|
||||
t.Fatalf("expected 2 attachments, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
if msgResp.Attachments[0].Title == nil || *msgResp.Attachments[0].Title != "Full Metadata" {
|
||||
t.Error("expected first attachment to have title 'Full Metadata'")
|
||||
}
|
||||
if msgResp.Attachments[0].Description == nil || *msgResp.Attachments[0].Description != "Complete description" {
|
||||
t.Error("expected first attachment to have description 'Complete description'")
|
||||
}
|
||||
|
||||
if msgResp.Attachments[1].Title != nil {
|
||||
t.Errorf("expected second attachment title to be nil, got %q", *msgResp.Attachments[1].Title)
|
||||
}
|
||||
if msgResp.Attachments[1].Description != nil {
|
||||
t.Errorf("expected second attachment description to be nil, got %q", *msgResp.Attachments[1].Description)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_NegativeIDInMetadata tests that negative IDs are rejected
|
||||
func TestAttachmentUpload_NegativeIDInMetadata(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Negative ID Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData := []byte("test content")
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Negative ID test",
|
||||
"attachments": []map[string]any{
|
||||
{"id": -1, "filename": "test.txt"},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", "test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_NullByteInFilename tests that null bytes in filenames are sanitized
|
||||
func TestAttachmentUpload_NullByteInFilename(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Null Byte Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData := []byte("test content")
|
||||
|
||||
maliciousFilenames := []string{
|
||||
"innocent.txt\x00.exe",
|
||||
"test\x00.jpg",
|
||||
"file.pdf\x00malicious",
|
||||
"\x00hidden.txt",
|
||||
}
|
||||
|
||||
for _, filename := range maliciousFilenames {
|
||||
t.Run("filename_with_null_byte", func(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Null byte test",
|
||||
"attachments": []map[string]any{
|
||||
{"id": 0, "filename": filename},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", filename)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
sanitized := msgResp.Attachments[0].Filename
|
||||
if strings.Contains(sanitized, "\x00") {
|
||||
t.Errorf("sanitized filename still contains null bytes")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
119
tests/integration/attachment_upload_nullable_fields_test.go
Normal file
119
tests/integration/attachment_upload_nullable_fields_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_NullableFields tests that title and description can be null
|
||||
func TestAttachmentUpload_NullableFields(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Nullable Fields Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Null fields test",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"id": 0,
|
||||
"filename": "yeah.png",
|
||||
"title": nil,
|
||||
"description": nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", "yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
att := msgResp.Attachments[0]
|
||||
if att.Title != nil {
|
||||
t.Errorf("expected title to be nil, got %q", *att.Title)
|
||||
}
|
||||
if att.Description != nil {
|
||||
t.Errorf("expected description to be nil, got %q", *att.Description)
|
||||
}
|
||||
}
|
||||
118
tests/integration/attachment_upload_omitted_fields_test.go
Normal file
118
tests/integration/attachment_upload_omitted_fields_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_OmittedFields tests that when title/description are omitted
|
||||
// (not sent at all), they default to null
|
||||
func TestAttachmentUpload_OmittedFields(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Omitted Fields Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Omitted fields test",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"id": 0,
|
||||
"filename": "yeah.png",
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", "yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
att := msgResp.Attachments[0]
|
||||
if att.Title != nil {
|
||||
t.Errorf("expected omitted title to be nil, got %q", *att.Title)
|
||||
}
|
||||
if att.Description != nil {
|
||||
t.Errorf("expected omitted description to be nil, got %q", *att.Description)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_OrderingMatchesMetadataArray tests that the final attachment
|
||||
// order in the message matches the order of the attachments array, not the file indices
|
||||
func TestAttachmentUpload_OrderingMatchesMetadataArray(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Ordering Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
file1Data, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
file2Data, err := fixturesFS.ReadFile("fixtures/thisisfine.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
file3Data, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Ordering test",
|
||||
"attachments": []map[string]any{
|
||||
{"id": 2, "filename": "third.png", "description": "Should be first"},
|
||||
{"id": 0, "filename": "first.png", "description": "Should be second"},
|
||||
{"id": 1, "filename": "second.gif", "description": "Should be third"},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
file1Writer, err := writer.CreateFormFile("files[0]", "first.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create files[0] field: %v", err)
|
||||
}
|
||||
if _, err := file1Writer.Write(file1Data); err != nil {
|
||||
t.Fatalf("failed to write file1 data: %v", err)
|
||||
}
|
||||
|
||||
file2Writer, err := writer.CreateFormFile("files[1]", "second.gif")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create files[1] field: %v", err)
|
||||
}
|
||||
if _, err := file2Writer.Write(file2Data); err != nil {
|
||||
t.Fatalf("failed to write file2 data: %v", err)
|
||||
}
|
||||
|
||||
file3Writer, err := writer.CreateFormFile("files[2]", "third.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create files[2] field: %v", err)
|
||||
}
|
||||
if _, err := file3Writer.Write(file3Data); err != nil {
|
||||
t.Fatalf("failed to write file3 data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
Description string `json:"description"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 3 {
|
||||
t.Fatalf("expected 3 attachments, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
if msgResp.Attachments[0].Filename != "third.png" {
|
||||
t.Errorf("expected first attachment filename 'third.png', got %q", msgResp.Attachments[0].Filename)
|
||||
}
|
||||
if msgResp.Attachments[0].Description != "Should be first" {
|
||||
t.Errorf("expected first attachment description 'Should be first', got %q", msgResp.Attachments[0].Description)
|
||||
}
|
||||
|
||||
if msgResp.Attachments[1].Filename != "first.png" {
|
||||
t.Errorf("expected second attachment filename 'first.png', got %q", msgResp.Attachments[1].Filename)
|
||||
}
|
||||
if msgResp.Attachments[1].Description != "Should be second" {
|
||||
t.Errorf("expected second attachment description 'Should be second', got %q", msgResp.Attachments[1].Description)
|
||||
}
|
||||
|
||||
if msgResp.Attachments[2].Filename != "second.gif" {
|
||||
t.Errorf("expected third attachment filename 'second.gif', got %q", msgResp.Attachments[2].Filename)
|
||||
}
|
||||
if msgResp.Attachments[2].Description != "Should be third" {
|
||||
t.Errorf("expected third attachment description 'Should be third', got %q", msgResp.Attachments[2].Description)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_PathTraversalInFilename tests that path traversal attempts in filenames are sanitized
|
||||
func TestAttachmentUpload_PathTraversalInFilename(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Path Traversal Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData := []byte("test content")
|
||||
|
||||
testCases := []struct {
|
||||
input string
|
||||
expectedSuffix string
|
||||
}{
|
||||
{"../../../etc/passwd", "passwd"},
|
||||
{"..\\..\\..\\windows\\system32\\config\\sam", "sam"},
|
||||
{"....//....//....//etc/passwd", "passwd"},
|
||||
{"..\\..\\..", ""},
|
||||
{"../../sensitive.txt", "sensitive.txt"},
|
||||
{"./../../etc/hosts", "hosts"},
|
||||
{"foo/../../../bar.txt", "foo_bar.txt"},
|
||||
{"a/b/c/../../../d.txt", "a_b_c_d.txt"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("filename=%s", tc.input), func(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Path traversal test",
|
||||
"attachments": []map[string]any{
|
||||
{"id": 0, "filename": tc.input},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
sanitized := msgResp.Attachments[0].Filename
|
||||
if strings.Contains(sanitized, "..") || strings.Contains(sanitized, "/") || strings.Contains(sanitized, "\\") {
|
||||
t.Errorf("sanitized filename %q still contains path traversal characters", sanitized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_SpecialCharactersInFilename tests handling of special characters
|
||||
func TestAttachmentUpload_SpecialCharactersInFilename(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Special Chars Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData := []byte("test content")
|
||||
|
||||
testCases := []struct {
|
||||
filename string
|
||||
shouldBeSanitized bool
|
||||
description string
|
||||
}{
|
||||
{"file<script>.txt", true, "HTML tag in filename"},
|
||||
{"file>output.txt", true, "redirect operator"},
|
||||
{"file|pipe.txt", true, "pipe character"},
|
||||
{"file:colon.txt", true, "colon (Windows reserved)"},
|
||||
{"file*star.txt", true, "asterisk (wildcard)"},
|
||||
{"file?question.txt", true, "question mark"},
|
||||
{"file\"quote.txt", true, "double quote"},
|
||||
{"COM1.txt", true, "Windows reserved name"},
|
||||
{"LPT1.txt", true, "Windows reserved name"},
|
||||
{"file with spaces.txt", false, "spaces should be OK"},
|
||||
{"file-dash_underscore.txt", false, "dash and underscore OK"},
|
||||
{"file.multiple.dots.txt", false, "multiple dots OK"},
|
||||
{"файл.txt", false, "unicode characters OK"},
|
||||
{"文件.txt", false, "CJK characters OK"},
|
||||
{"😀.txt", false, "emoji OK"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Special char test: " + tc.description,
|
||||
"attachments": []map[string]any{
|
||||
{"id": 0, "filename": tc.filename},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
safeFilename := "test.txt"
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", safeFilename)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
sanitized := msgResp.Attachments[0].Filename
|
||||
|
||||
if tc.shouldBeSanitized {
|
||||
if strings.ContainsAny(sanitized, "<>:\"|?*") {
|
||||
t.Errorf("sanitized filename %q still contains dangerous characters", sanitized)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_StringIDCoercion tests that string IDs are coerced to numbers
|
||||
func TestAttachmentUpload_StringIDCoercion(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "String ID Coercion Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData := []byte("test content")
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payloadStr := `{"content":"test","attachments":[{"id":"0","filename":"test.txt","flags":"0"}]}`
|
||||
|
||||
if err := writer.WriteField("payload_json", payloadStr); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", "test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
Flags int `json:"flags"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
if msgResp.Attachments[0].Filename != "test.txt" {
|
||||
t.Errorf("expected filename 'test.txt', got %q", msgResp.Attachments[0].Filename)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_TitleAndDescription tests that title and description
|
||||
// fields are properly preserved through the upload flow
|
||||
func TestAttachmentUpload_TitleAndDescription(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Title Description Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Testing title and description",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"id": 0,
|
||||
"filename": "yeah.png",
|
||||
"title": "My Awesome Title",
|
||||
"description": "This is a detailed description of the attachment with special chars: émoji 🎉",
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", "yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var msgResp struct {
|
||||
Attachments []struct {
|
||||
Filename string `json:"filename"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &msgResp)
|
||||
|
||||
if len(msgResp.Attachments) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(msgResp.Attachments))
|
||||
}
|
||||
|
||||
att := msgResp.Attachments[0]
|
||||
if att.Title == nil || *att.Title != "My Awesome Title" {
|
||||
if att.Title == nil {
|
||||
t.Error("expected title to be set, got nil")
|
||||
} else {
|
||||
t.Errorf("expected title 'My Awesome Title', got %q", *att.Title)
|
||||
}
|
||||
}
|
||||
|
||||
expectedDesc := "This is a detailed description of the attachment with special chars: émoji 🎉"
|
||||
if att.Description == nil || *att.Description != expectedDesc {
|
||||
if att.Description == nil {
|
||||
t.Error("expected description to be set, got nil")
|
||||
} else {
|
||||
t.Errorf("expected description %q, got %q", expectedDesc, *att.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
100
tests/integration/attachment_upload_too_large_file_test.go
Normal file
100
tests/integration/attachment_upload_too_large_file_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_TooLargeFile tests that files exceeding size limits are rejected
|
||||
func TestAttachmentUpload_TooLargeFile(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Too Large File Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
largeFileData := make([]byte, 26*1024*1024)
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "Large file test",
|
||||
"attachments": []map[string]any{
|
||||
{
|
||||
"id": 0,
|
||||
"filename": "large.bin",
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", "large.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(largeFileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if !bytes.Contains(bodyBytes, []byte("size")) && !bytes.Contains(bodyBytes, []byte("large")) {
|
||||
t.Log("Expected error message to mention 'size' or 'large', but it didn't")
|
||||
}
|
||||
}
|
||||
91
tests/integration/attachment_upload_without_metadata_test.go
Normal file
91
tests/integration/attachment_upload_without_metadata_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAttachmentUpload_WithoutMetadata tests uploading file without any attachment metadata
|
||||
func TestAttachmentUpload_WithoutMetadata(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
user := createTestAccount(t, client)
|
||||
ensureSessionStarted(t, client, user.Token)
|
||||
|
||||
guild := createGuild(t, client, user.Token, "Without Metadata Test Guild")
|
||||
channelID := parseSnowflake(t, guild.SystemChannel)
|
||||
|
||||
fileData, err := fixturesFS.ReadFile("fixtures/yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
payload := map[string]any{
|
||||
"content": "File without metadata",
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode payload JSON: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.WriteField("payload_json", string(payloadJSON)); err != nil {
|
||||
t.Fatalf("failed to write payload_json field: %v", err)
|
||||
}
|
||||
|
||||
fileWriter, err := writer.CreateFormFile("files[0]", "yeah.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file field: %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write(fileData); err != nil {
|
||||
t.Fatalf("failed to write file data: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("%s/channels/%d/messages", client.baseURL, channelID),
|
||||
&body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client.applyCommonHeaders(req)
|
||||
req.Header.Set("Authorization", user.Token)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
83
tests/integration/auth_app_store_reviewer_bypass_test.go
Normal file
83
tests/integration/auth_app_store_reviewer_bypass_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthAppStoreReviewerIPBypass verifies that users with the APP_STORE_REVIEWER flag
|
||||
// can login from any IP address without triggering the "new login location" email verification.
|
||||
func TestAuthAppStoreReviewerIPBypass(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
initialIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("app-store-reviewer-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
reg := registerTestUser(t, client, email, password)
|
||||
|
||||
// Set the APP_STORE_REVIEWER flag on the user
|
||||
updateUserSecurityFlags(t, client, reg.UserID, userSecurityFlagsPayload{
|
||||
SetFlags: []string{"APP_STORE_REVIEWER"},
|
||||
})
|
||||
|
||||
differentIP := "10.99.88.77"
|
||||
if differentIP == initialIP {
|
||||
differentIP = "10.99.88.78"
|
||||
}
|
||||
clientWithDifferentIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: differentIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientWithDifferentIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected login to succeed for APP_STORE_REVIEWER from new IP, got status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, resp, &loginResp)
|
||||
|
||||
if loginResp.MFA {
|
||||
t.Fatalf("expected MFA to be false for APP_STORE_REVIEWER account without MFA enabled")
|
||||
}
|
||||
if loginResp.Token == "" {
|
||||
t.Fatalf("expected login response to include token")
|
||||
}
|
||||
if loginResp.UserID != reg.UserID {
|
||||
t.Fatalf("expected login user_id %s to match registration %s", loginResp.UserID, reg.UserID)
|
||||
}
|
||||
}
|
||||
77
tests/integration/auth_app_store_reviewer_multi_flag_test.go
Normal file
77
tests/integration/auth_app_store_reviewer_multi_flag_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthAppStoreReviewerWithOtherFlags verifies that the APP_STORE_REVIEWER flag works
|
||||
// correctly even when combined with other user flags.
|
||||
func TestAuthAppStoreReviewerWithOtherFlags(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
initialIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("reviewer-multi-flag-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
reg := registerTestUser(t, client, email, password)
|
||||
|
||||
// Set APP_STORE_REVIEWER along with STAFF flag
|
||||
updateUserSecurityFlags(t, client, reg.UserID, userSecurityFlagsPayload{
|
||||
SetFlags: []string{"APP_STORE_REVIEWER", "STAFF"},
|
||||
})
|
||||
|
||||
differentIP := "10.77.66.55"
|
||||
if differentIP == initialIP {
|
||||
differentIP = "10.77.66.56"
|
||||
}
|
||||
clientWithDifferentIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: differentIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientWithDifferentIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected login to succeed for APP_STORE_REVIEWER with other flags from new IP, got status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, resp, &loginResp)
|
||||
|
||||
if loginResp.Token == "" {
|
||||
t.Fatalf("expected login response to include token")
|
||||
}
|
||||
}
|
||||
68
tests/integration/auth_beta_code_redemption_flow_test.go
Normal file
68
tests/integration/auth_beta_code_redemption_flow_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthBetaCodeRedemptionFlow(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
sponsor := createTestAccount(t, client)
|
||||
pending := createTestAccount(t, client, withBetaCode("NOVERIFY"))
|
||||
|
||||
initialList := fetchBetaCodes(t, client, sponsor.Token)
|
||||
if initialList.Allowance == 0 {
|
||||
t.Fatalf("expected initial beta code allowance to be positive")
|
||||
}
|
||||
|
||||
codeForRedeem := createBetaCode(t, client, sponsor.Token)
|
||||
codeToDelete := createBetaCode(t, client, sponsor.Token)
|
||||
|
||||
deleteBetaCode(t, client, sponsor.Token, codeToDelete)
|
||||
|
||||
listAfterDelete := fetchBetaCodes(t, client, sponsor.Token)
|
||||
foundRedeem := false
|
||||
for _, entry := range listAfterDelete.BetaCodes {
|
||||
if entry.Code == codeToDelete {
|
||||
t.Fatalf("expected deleted beta code %s to be removed", codeToDelete)
|
||||
}
|
||||
if entry.Code == codeForRedeem {
|
||||
foundRedeem = true
|
||||
}
|
||||
}
|
||||
if !foundRedeem {
|
||||
t.Fatalf("expected redeem beta code %s in list", codeForRedeem)
|
||||
}
|
||||
|
||||
resp, err := client.postJSONWithAuth("/auth/redeem-beta-code", map[string]string{"beta_code": codeForRedeem}, pending.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to redeem beta code: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
login := loginTestUser(t, client, pending.Email, pending.Password)
|
||||
if login.PendingVerification != nil && *login.PendingVerification {
|
||||
t.Fatalf("expected pending verification cleared after beta redemption")
|
||||
}
|
||||
}
|
||||
36
tests/integration/auth_case_insensitive_email_helper.go
Normal file
36
tests/integration/auth_case_insensitive_email_helper.go
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func mixedCase(s string) string {
|
||||
result := []rune(s)
|
||||
for i, r := range result {
|
||||
if i%2 == 0 {
|
||||
result[i] = []rune(strings.ToUpper(string(r)))[0]
|
||||
} else {
|
||||
result[i] = []rune(strings.ToLower(string(r)))[0]
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
154
tests/integration/auth_case_insensitive_email_test.go
Normal file
154
tests/integration/auth_case_insensitive_email_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func TestAuthCaseInsensitiveEmail(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
t.Run("login with different case variations succeeds", func(t *testing.T) {
|
||||
baseEmail := fmt.Sprintf("integration-test-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
titleCaser := cases.Title(language.Und)
|
||||
|
||||
registerTestUser(t, client, baseEmail, password)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
email string
|
||||
}{
|
||||
{"lowercase", strings.ToLower(baseEmail)},
|
||||
{"uppercase", strings.ToUpper(baseEmail)},
|
||||
{"mixed case", mixedCase(baseEmail)},
|
||||
{"title case", titleCaser.String(strings.ToLower(baseEmail))},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
loginReq := loginRequest{
|
||||
Email: tc.email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected login to succeed with %s email %q, got %d: %s", tc.name, tc.email, resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, resp, &loginResp)
|
||||
|
||||
if loginResp.Token == "" {
|
||||
t.Fatalf("expected token in login response")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("registration with different case is treated as duplicate", func(t *testing.T) {
|
||||
baseEmail := fmt.Sprintf("integration-test-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
registerTestUser(t, client, baseEmail, password)
|
||||
|
||||
uppercaseEmail := strings.ToUpper(baseEmail)
|
||||
req := registerRequest{
|
||||
Email: uppercaseEmail,
|
||||
Username: fmt.Sprintf("itest%x", time.Now().UnixNano()),
|
||||
GlobalName: "Test User",
|
||||
Password: uniquePassword(),
|
||||
DateOfBirth: adultDateOfBirth(),
|
||||
Consent: true,
|
||||
}
|
||||
|
||||
resp, err := client.postJSON("/auth/register", req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call register endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected registration to fail for duplicate email (different case), got 200 OK")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("expected 400 or 409 for duplicate email, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("forgot password with different case works", func(t *testing.T) {
|
||||
baseEmail := fmt.Sprintf("integration-test-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
registerTestUser(t, client, baseEmail, password)
|
||||
|
||||
uppercaseEmail := strings.ToUpper(baseEmail)
|
||||
payload := map[string]string{
|
||||
"email": uppercaseEmail,
|
||||
}
|
||||
|
||||
resp, err := client.postJSON("/auth/forgot", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call forgot endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("expected forgot to succeed with uppercase email, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("email stored is normalized to lowercase", func(t *testing.T) {
|
||||
mixedEmail := fmt.Sprintf("Integration-Test-%d@Example.COM", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
regResp := registerTestUser(t, client, mixedEmail, password)
|
||||
|
||||
resp, err := client.getWithAuth("/users/@me", regResp.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch user: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var user userPrivateResponse
|
||||
decodeJSONResponse(t, resp, &user)
|
||||
|
||||
if user.Email != strings.ToLower(mixedEmail) {
|
||||
t.Logf("email stored as %q instead of normalized lowercase %q (may store original case)", user.Email, strings.ToLower(mixedEmail))
|
||||
}
|
||||
})
|
||||
}
|
||||
188
tests/integration/auth_concurrent_sessions_test.go
Normal file
188
tests/integration/auth_concurrent_sessions_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthConcurrentSessions(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
t.Run("same user can have multiple concurrent sessions", func(t *testing.T) {
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
session1Token := account.Token
|
||||
|
||||
loginResp := loginTestUser(t, client, account.Email, account.Password)
|
||||
session2Token := loginResp.Token
|
||||
|
||||
if session1Token == session2Token {
|
||||
t.Logf("warning: multiple logins returned the same token, may indicate single-session behavior")
|
||||
}
|
||||
|
||||
resp1, err := client.getWithAuth("/users/@me", session1Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call /users/@me with session1 token: %v", err)
|
||||
}
|
||||
if resp1.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected session1 to be valid, got %d: %s", resp1.StatusCode, readResponseBody(resp1))
|
||||
}
|
||||
resp1.Body.Close()
|
||||
|
||||
resp2, err := client.getWithAuth("/users/@me", session2Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call /users/@me with session2 token: %v", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected session2 to be valid, got %d: %s", resp2.StatusCode, readResponseBody(resp2))
|
||||
}
|
||||
resp2.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("logging out one session does not affect other sessions", func(t *testing.T) {
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
session1Token := account.Token
|
||||
|
||||
loginResp := loginTestUser(t, client, account.Email, account.Password)
|
||||
session2Token := loginResp.Token
|
||||
|
||||
loginResp = loginTestUser(t, client, account.Email, account.Password)
|
||||
session3Token := loginResp.Token
|
||||
|
||||
resp, err := client.getWithAuth("/auth/sessions", session1Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list sessions: %v", err)
|
||||
}
|
||||
var sessions []authSessionResponse
|
||||
decodeJSONResponse(t, resp, &sessions)
|
||||
resp.Body.Close()
|
||||
|
||||
if len(sessions) < 3 {
|
||||
t.Fatalf("expected at least 3 sessions, got %d", len(sessions))
|
||||
}
|
||||
|
||||
logoutResp, err := client.postJSONWithAuth("/auth/logout", nil, session2Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to logout session2: %v", err)
|
||||
}
|
||||
assertStatus(t, logoutResp, http.StatusNoContent)
|
||||
logoutResp.Body.Close()
|
||||
|
||||
resp, err = client.getWithAuth("/users/@me", session1Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call /users/@me with session1: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected session1 to still be valid after session2 logout, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.getWithAuth("/users/@me", session3Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call /users/@me with session3: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected session3 to still be valid after session2 logout, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.getWithAuth("/users/@me", session2Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call /users/@me with session2: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected session2 to be invalid after logout, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("can list all active sessions", func(t *testing.T) {
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
loginTestUser(t, client, account.Email, account.Password)
|
||||
loginTestUser(t, client, account.Email, account.Password)
|
||||
|
||||
resp, err := client.getWithAuth("/auth/sessions", account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list sessions: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var sessions []authSessionResponse
|
||||
decodeJSONResponse(t, resp, &sessions)
|
||||
|
||||
if len(sessions) == 0 {
|
||||
t.Fatalf("expected at least one session")
|
||||
}
|
||||
|
||||
for _, session := range sessions {
|
||||
if session.ID == "" {
|
||||
t.Errorf("session missing ID")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("can log out specific session by ID", func(t *testing.T) {
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
loginResp := loginTestUser(t, client, account.Email, account.Password)
|
||||
session2Token := loginResp.Token
|
||||
|
||||
resp, err := client.getWithAuth("/auth/sessions", account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list sessions: %v", err)
|
||||
}
|
||||
var sessions []authSessionResponse
|
||||
decodeJSONResponse(t, resp, &sessions)
|
||||
resp.Body.Close()
|
||||
|
||||
if len(sessions) < 2 {
|
||||
t.Fatalf("expected at least 2 sessions, got %d", len(sessions))
|
||||
}
|
||||
|
||||
targetSessionID := sessions[1].ID
|
||||
|
||||
payload := map[string]any{
|
||||
"session_id_hashes": []string{targetSessionID},
|
||||
"password": account.Password,
|
||||
}
|
||||
|
||||
logoutResp, err := client.postJSONWithAuth("/auth/sessions/logout", payload, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to logout specific session: %v", err)
|
||||
}
|
||||
assertStatus(t, logoutResp, http.StatusNoContent)
|
||||
logoutResp.Body.Close()
|
||||
|
||||
resp, err = client.getWithAuth("/users/@me", session2Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify session2 is logged out: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected session2 to be logged out, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAuthDesktopHandoffCodeNormalization tests that codes work with or without dashes
|
||||
// and are case-insensitive
|
||||
func TestAuthDesktopHandoffCodeNormalization(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
login := loginTestUser(t, client, account.Email, account.Password)
|
||||
|
||||
resp, err := client.postJSON("/auth/handoff/initiate", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initiate desktop handoff: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var initResp handoffInitiateResponse
|
||||
decodeJSONResponse(t, resp, &initResp)
|
||||
|
||||
if !validateHandoffCodeFormat(initResp.Code) {
|
||||
t.Fatalf("expected code in XXXX-XXXX format, got %s", initResp.Code)
|
||||
}
|
||||
|
||||
codeWithoutDash := strings.ReplaceAll(initResp.Code, "-", "")
|
||||
statusURL := fmt.Sprintf("/auth/handoff/%s/status", codeWithoutDash)
|
||||
resp, err = client.get(statusURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to poll status with code without dash: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var status handoffStatusResponse
|
||||
decodeJSONResponse(t, resp, &status)
|
||||
if status.Status != "pending" {
|
||||
t.Fatalf("expected pending status, got %s", status.Status)
|
||||
}
|
||||
|
||||
lowercaseCode := strings.ToLower(initResp.Code)
|
||||
resp, err = client.postJSON("/auth/handoff/complete", map[string]string{
|
||||
"code": lowercaseCode,
|
||||
"token": login.Token,
|
||||
"user_id": login.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to complete handoff with lowercase code: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.get(fmt.Sprintf("/auth/handoff/%s/status", initResp.Code))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to poll completed handoff status: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
decodeJSONResponse(t, resp, &status)
|
||||
if status.Status != "completed" {
|
||||
t.Fatalf("expected completed status, got %s", status.Status)
|
||||
}
|
||||
if status.Token == "" {
|
||||
t.Fatalf("expected token in completed status")
|
||||
}
|
||||
if status.Token == login.Token {
|
||||
t.Fatalf("expected handoff token to be a new session token")
|
||||
}
|
||||
}
|
||||
152
tests/integration/auth_desktop_handoff_flow_test.go
Normal file
152
tests/integration/auth_desktop_handoff_flow_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthDesktopHandoffFlow(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
login := loginTestUser(t, client, account.Email, account.Password)
|
||||
|
||||
resp, err := client.postJSON("/auth/handoff/initiate", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initiate desktop handoff: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var initResp handoffInitiateResponse
|
||||
decodeJSONResponse(t, resp, &initResp)
|
||||
if initResp.Code == "" {
|
||||
t.Fatalf("expected handoff code")
|
||||
}
|
||||
|
||||
if !validateHandoffCodeFormat(initResp.Code) {
|
||||
t.Fatalf("expected code in XXXX-XXXX format, got %s", initResp.Code)
|
||||
}
|
||||
|
||||
statusURL := fmt.Sprintf("/auth/handoff/%s/status", initResp.Code)
|
||||
resp, err = client.get(statusURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to poll pending handoff status: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var status handoffStatusResponse
|
||||
decodeJSONResponse(t, resp, &status)
|
||||
if status.Status != "pending" {
|
||||
t.Fatalf("expected pending status, got %s", status.Status)
|
||||
}
|
||||
if status.Token != "" || status.UserID != "" {
|
||||
t.Fatalf("expected no token while pending")
|
||||
}
|
||||
|
||||
resp, err = client.postJSON("/auth/handoff/complete", map[string]string{
|
||||
"code": initResp.Code,
|
||||
"token": login.Token,
|
||||
"user_id": login.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to complete handoff: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.get(statusURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to poll completed handoff status: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
decodeJSONResponse(t, resp, &status)
|
||||
if status.Status != "completed" {
|
||||
t.Fatalf("expected completed status, got %s", status.Status)
|
||||
}
|
||||
if status.Token == "" {
|
||||
t.Fatalf("expected handoff token to be returned")
|
||||
}
|
||||
if status.Token == login.Token {
|
||||
t.Fatalf("expected handoff token to be a new session token")
|
||||
}
|
||||
if status.UserID != login.UserID {
|
||||
t.Fatalf("expected user id %s, got %s", login.UserID, status.UserID)
|
||||
}
|
||||
|
||||
resp, err = client.getWithAuth("/users/@me", login.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch /users/@me with original session: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var originalSession userPrivateResponse
|
||||
decodeJSONResponse(t, resp, &originalSession)
|
||||
if originalSession.ID != login.UserID {
|
||||
t.Fatalf("expected original session to remain valid for user %s, got %s", login.UserID, originalSession.ID)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.getWithAuth("/users/@me", status.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch /users/@me with handoff session: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var handoffSession userPrivateResponse
|
||||
decodeJSONResponse(t, resp, &handoffSession)
|
||||
if handoffSession.ID != login.UserID {
|
||||
t.Fatalf("expected handoff session to resolve to user %s, got %s", login.UserID, handoffSession.ID)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.get(statusURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to poll status after token retrieval: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
decodeJSONResponse(t, resp, &status)
|
||||
if status.Status != "expired" {
|
||||
t.Fatalf("expected expired status after token retrieval, got %s", status.Status)
|
||||
}
|
||||
|
||||
resp, err = client.postJSON("/auth/handoff/initiate", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initiate second handoff: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var second handoffInitiateResponse
|
||||
decodeJSONResponse(t, resp, &second)
|
||||
|
||||
cancelURL := fmt.Sprintf("/auth/handoff/%s", second.Code)
|
||||
resp, err = client.delete(cancelURL, "")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to cancel handoff: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.get(fmt.Sprintf("/auth/handoff/%s/status", second.Code))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to poll cancelled handoff status: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
decodeJSONResponse(t, resp, &status)
|
||||
if status.Status != "expired" {
|
||||
t.Fatalf("expected expired status after cancelling, got %s", status.Status)
|
||||
}
|
||||
}
|
||||
42
tests/integration/auth_desktop_handoff_helpers.go
Normal file
42
tests/integration/auth_desktop_handoff_helpers.go
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
type handoffInitiateResponse struct {
|
||||
Code string `json:"code"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type handoffStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
Token string `json:"token,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// validateHandoffCodeFormat checks that the code matches XXXX-XXXX format
|
||||
// with uppercase letters and digits (excluding ambiguous characters 0/O, 1/I/L)
|
||||
func validateHandoffCodeFormat(code string) bool {
|
||||
pattern := regexp.MustCompile(`^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$`)
|
||||
return pattern.MatchString(code)
|
||||
}
|
||||
61
tests/integration/auth_desktop_handoff_negative_test.go
Normal file
61
tests/integration/auth_desktop_handoff_negative_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Covers error paths for desktop handoff: unknown code and mismatched token.
|
||||
func TestAuthDesktopHandoffNegativePaths(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
resp, err := client.get("/auth/handoff/unknown-code/status")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call handoff status: %v", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected unknown handoff code to fail")
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSON("/auth/handoff/complete", map[string]string{
|
||||
"code": "bad-code",
|
||||
"token": "bad-token",
|
||||
"user_id": "123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call handoff complete: %v", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected handoff complete with bad code/token to fail, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.delete("/auth/handoff/unknown-code", "")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call handoff cancel: %v", err)
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
t.Fatalf("expected cancel unknown code not to 5xx, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
83
tests/integration/auth_desktop_handoff_usage_test.go
Normal file
83
tests/integration/auth_desktop_handoff_usage_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthDesktopHandoffCompleteSingleUse(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
login := loginTestUser(t, client, account.Email, account.Password)
|
||||
|
||||
resp, err := client.postJSON("/auth/handoff/initiate", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initiate desktop handoff: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var initResp handoffInitiateResponse
|
||||
decodeJSONResponse(t, resp, &initResp)
|
||||
|
||||
resp, err = client.postJSON("/auth/handoff/complete", map[string]string{
|
||||
"code": initResp.Code,
|
||||
"token": login.Token,
|
||||
"user_id": login.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to complete desktop handoff: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSON("/auth/handoff/complete", map[string]string{
|
||||
"code": initResp.Code,
|
||||
"token": login.Token,
|
||||
"user_id": login.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call desktop handoff complete the second time: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected second desktop handoff complete to fail, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.get(fmt.Sprintf("/auth/handoff/%s/status", initResp.Code))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to poll desktop handoff status: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var status handoffStatusResponse
|
||||
decodeJSONResponse(t, resp, &status)
|
||||
if status.Status != "completed" {
|
||||
t.Fatalf("expected completed status after successful handoff, got %s", status.Status)
|
||||
}
|
||||
if status.Token == "" {
|
||||
t.Fatalf("expected a session token to be returned")
|
||||
}
|
||||
if status.Token == login.Token {
|
||||
t.Fatalf("expected handoff token to be distinct from the original token")
|
||||
}
|
||||
|
||||
resp.Body.Close()
|
||||
}
|
||||
43
tests/integration/auth_email_change_flow_request_new.go
Normal file
43
tests/integration/auth_email_change_flow_request_new.go
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func requestNewEmailChange(t testing.TB, client *testClient, account testAccount, ticket, newEmail, originalProof, password string) emailChangeRequestNewResponse {
|
||||
t.Helper()
|
||||
resp, err := client.postJSONWithAuth("/users/@me/email-change/request-new", map[string]any{
|
||||
"ticket": ticket,
|
||||
"new_email": newEmail,
|
||||
"original_proof": originalProof,
|
||||
"password": password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to request new email: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
defer resp.Body.Close()
|
||||
var parsed emailChangeRequestNewResponse
|
||||
decodeJSONResponse(t, resp, &parsed)
|
||||
return parsed
|
||||
}
|
||||
40
tests/integration/auth_email_change_flow_start.go
Normal file
40
tests/integration/auth_email_change_flow_start.go
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func startEmailChange(t testing.TB, client *testClient, account testAccount, password string) emailChangeStartResponse {
|
||||
t.Helper()
|
||||
resp, err := client.postJSONWithAuth("/users/@me/email-change/start", map[string]any{
|
||||
"password": password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start email change: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
defer resp.Body.Close()
|
||||
var start emailChangeStartResponse
|
||||
decodeJSONResponse(t, resp, &start)
|
||||
return start
|
||||
}
|
||||
211
tests/integration/auth_email_change_flow_test.go
Normal file
211
tests/integration/auth_email_change_flow_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthEmailChangeFlow(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
t.Run("email change uses ticketed dual-code flow with sudo and proof token", func(t *testing.T) {
|
||||
account := createTestAccount(t, client)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
startResp := startEmailChange(t, client, account, account.Password)
|
||||
|
||||
var originalProof string
|
||||
if startResp.RequireOriginal {
|
||||
originalEmail := waitForEmail(t, client, "email_change_original", account.Email)
|
||||
originalCode := originalEmail.Metadata["code"]
|
||||
if originalCode == "" {
|
||||
t.Fatalf("expected original verification code in email metadata")
|
||||
}
|
||||
originalProof = verifyOriginalEmailChange(t, client, account, startResp.Ticket, originalCode, account.Password)
|
||||
} else {
|
||||
if startResp.OriginalProof == nil || *startResp.OriginalProof == "" {
|
||||
t.Fatalf("expected original_proof in start response for unverified account")
|
||||
}
|
||||
originalProof = *startResp.OriginalProof
|
||||
}
|
||||
|
||||
newEmail := fmt.Sprintf("integration-new-%d@example.com", time.Now().UnixNano())
|
||||
newReq := requestNewEmailChange(t, client, account, startResp.Ticket, newEmail, originalProof, account.Password)
|
||||
if newReq.NewEmail != newEmail {
|
||||
t.Fatalf("expected new email %s, got %s", newEmail, newReq.NewEmail)
|
||||
}
|
||||
|
||||
newEmailData := waitForEmail(t, client, "email_change_new", newEmail)
|
||||
newCode := newEmailData.Metadata["code"]
|
||||
if newCode == "" {
|
||||
t.Fatalf("expected new email verification code in metadata")
|
||||
}
|
||||
|
||||
token := verifyNewEmailChange(t, client, account, startResp.Ticket, newCode, originalProof, account.Password)
|
||||
|
||||
resp, err := client.patchJSONWithAuth("/users/@me", map[string]any{
|
||||
"email_token": token,
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to finalize email change: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var updated userPrivateResponse
|
||||
decodeJSONResponse(t, resp, &updated)
|
||||
resp.Body.Close()
|
||||
|
||||
if updated.Email != newEmail {
|
||||
t.Fatalf("expected email to be updated to %s, got %s", newEmail, updated.Email)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct email field is rejected", func(t *testing.T) {
|
||||
account := createTestAccount(t, client)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
newEmail := fmt.Sprintf("integration-direct-%d@example.com", time.Now().UnixNano())
|
||||
resp, err := client.patchJSONWithAuth("/users/@me", map[string]any{
|
||||
"email": newEmail,
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt direct email change: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected direct email change to be rejected, got 200 OK")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("request-new fails without original_proof", func(t *testing.T) {
|
||||
account := createTestAccount(t, client)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
startResp := startEmailChange(t, client, account, account.Password)
|
||||
|
||||
newEmail := fmt.Sprintf("integration-no-proof-%d@example.com", time.Now().UnixNano())
|
||||
resp, err := client.postJSONWithAuth("/users/@me/email-change/request-new", map[string]any{
|
||||
"ticket": startResp.Ticket,
|
||||
"new_email": newEmail,
|
||||
"original_proof": "invalid-proof-token",
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected request-new to fail with invalid original_proof")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify-new fails without original_proof", func(t *testing.T) {
|
||||
account := createTestAccount(t, client)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
startResp := startEmailChange(t, client, account, account.Password)
|
||||
|
||||
var originalProof string
|
||||
if startResp.RequireOriginal {
|
||||
originalEmail := waitForEmail(t, client, "email_change_original", account.Email)
|
||||
originalCode := originalEmail.Metadata["code"]
|
||||
originalProof = verifyOriginalEmailChange(t, client, account, startResp.Ticket, originalCode, account.Password)
|
||||
} else {
|
||||
originalProof = *startResp.OriginalProof
|
||||
}
|
||||
|
||||
newEmail := fmt.Sprintf("integration-verify-no-proof-%d@example.com", time.Now().UnixNano())
|
||||
requestNewEmailChange(t, client, account, startResp.Ticket, newEmail, originalProof, account.Password)
|
||||
|
||||
newEmailData := waitForEmail(t, client, "email_change_new", newEmail)
|
||||
newCode := newEmailData.Metadata["code"]
|
||||
|
||||
resp, err := client.postJSONWithAuth("/users/@me/email-change/verify-new", map[string]any{
|
||||
"ticket": startResp.Ticket,
|
||||
"code": newCode,
|
||||
"original_proof": "invalid-proof-token",
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected verify-new to fail with invalid original_proof")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("original_proof returned from start when require_original is false", func(t *testing.T) {
|
||||
account := createTestAccount(t, client)
|
||||
clearTestEmails(t, client)
|
||||
unclaimAccount(t, client, account.UserID)
|
||||
|
||||
resp, err := client.postJSONWithAuth("/users/@me/email-change/start", map[string]any{}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start email change: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var startResp emailChangeStartResponse
|
||||
decodeJSONResponse(t, resp, &startResp)
|
||||
resp.Body.Close()
|
||||
|
||||
if startResp.RequireOriginal {
|
||||
t.Fatalf("expected require_original=false for unclaimed/unverified account")
|
||||
}
|
||||
|
||||
if startResp.OriginalProof == nil || *startResp.OriginalProof == "" {
|
||||
t.Fatalf("expected original_proof in start response when require_original=false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify-original returns original_proof for verified email accounts", func(t *testing.T) {
|
||||
account := createTestAccount(t, client)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
startResp := startEmailChange(t, client, account, account.Password)
|
||||
|
||||
if !startResp.RequireOriginal {
|
||||
t.Logf("account did not require original verification; treating as already verified path")
|
||||
if startResp.OriginalProof == nil || *startResp.OriginalProof == "" {
|
||||
t.Fatalf("expected original_proof when original verification is not required")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
originalEmail := waitForEmail(t, client, "email_change_original", account.Email)
|
||||
originalCode := originalEmail.Metadata["code"]
|
||||
if originalCode == "" {
|
||||
t.Fatalf("expected original verification code in email metadata")
|
||||
}
|
||||
|
||||
originalProof := verifyOriginalEmailChange(t, client, account, startResp.Ticket, originalCode, account.Password)
|
||||
if originalProof == "" {
|
||||
t.Fatalf("expected original_proof in verify-original response")
|
||||
}
|
||||
})
|
||||
}
|
||||
43
tests/integration/auth_email_change_flow_types.go
Normal file
43
tests/integration/auth_email_change_flow_types.go
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
type emailChangeStartResponse struct {
|
||||
Ticket string `json:"ticket"`
|
||||
RequireOriginal bool `json:"require_original"`
|
||||
OriginalProof *string `json:"original_proof,omitempty"`
|
||||
OriginalCodeExpiry *string `json:"original_code_expires_at,omitempty"`
|
||||
ResendAvailableAt *string `json:"resend_available_at,omitempty"`
|
||||
}
|
||||
|
||||
type emailChangeVerifyOriginalResponse struct {
|
||||
OriginalProof string `json:"original_proof"`
|
||||
}
|
||||
|
||||
type emailChangeRequestNewResponse struct {
|
||||
Ticket string `json:"ticket"`
|
||||
NewEmail string `json:"new_email"`
|
||||
NewCodeExpiresAt string `json:"new_code_expires_at"`
|
||||
ResendAvailableAt *string `json:"resend_available_at,omitempty"`
|
||||
}
|
||||
|
||||
type emailChangeVerifyNewResponse struct {
|
||||
EmailToken string `json:"email_token"`
|
||||
}
|
||||
46
tests/integration/auth_email_change_flow_verify_new.go
Normal file
46
tests/integration/auth_email_change_flow_verify_new.go
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func verifyNewEmailChange(t testing.TB, client *testClient, account testAccount, ticket, code, originalProof, password string) string {
|
||||
t.Helper()
|
||||
resp, err := client.postJSONWithAuth("/users/@me/email-change/verify-new", map[string]any{
|
||||
"ticket": ticket,
|
||||
"code": code,
|
||||
"original_proof": originalProof,
|
||||
"password": password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify new email: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
defer resp.Body.Close()
|
||||
var parsed emailChangeVerifyNewResponse
|
||||
decodeJSONResponse(t, resp, &parsed)
|
||||
if parsed.EmailToken == "" {
|
||||
t.Fatalf("expected email_token in response")
|
||||
}
|
||||
return parsed.EmailToken
|
||||
}
|
||||
45
tests/integration/auth_email_change_flow_verify_original.go
Normal file
45
tests/integration/auth_email_change_flow_verify_original.go
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func verifyOriginalEmailChange(t testing.TB, client *testClient, account testAccount, ticket, code, password string) string {
|
||||
t.Helper()
|
||||
resp, err := client.postJSONWithAuth("/users/@me/email-change/verify-original", map[string]any{
|
||||
"ticket": ticket,
|
||||
"code": code,
|
||||
"password": password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify original email: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
defer resp.Body.Close()
|
||||
var parsed emailChangeVerifyOriginalResponse
|
||||
decodeJSONResponse(t, resp, &parsed)
|
||||
if parsed.OriginalProof == "" {
|
||||
t.Fatalf("expected original_proof in response")
|
||||
}
|
||||
return parsed.OriginalProof
|
||||
}
|
||||
92
tests/integration/auth_email_change_resend_cooldown_test.go
Normal file
92
tests/integration/auth_email_change_resend_cooldown_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthEmailChangeResendCooldown(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
startResp, err := client.postJSONWithAuth("/users/@me/email-change/start", map[string]any{
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start email change: %v", err)
|
||||
}
|
||||
assertStatus(t, startResp, http.StatusOK)
|
||||
var start emailChangeStartResponse
|
||||
decodeJSONResponse(t, startResp, &start)
|
||||
startResp.Body.Close()
|
||||
|
||||
// Get original proof - either from start response or by verifying original email
|
||||
var originalProof string
|
||||
if start.RequireOriginal {
|
||||
originalEmail := waitForEmail(t, client, "email_change_original", account.Email)
|
||||
originalCode := originalEmail.Metadata["code"]
|
||||
originalProof = verifyOriginalEmailChange(t, client, account, start.Ticket, originalCode, account.Password)
|
||||
} else {
|
||||
if start.OriginalProof == nil || *start.OriginalProof == "" {
|
||||
t.Fatalf("expected original_proof in start response")
|
||||
}
|
||||
originalProof = *start.OriginalProof
|
||||
}
|
||||
|
||||
newEmail := fmt.Sprintf("cooldown-%d@example.com", time.Now().UnixNano())
|
||||
reqNewResp, err := client.postJSONWithAuth("/users/@me/email-change/request-new", map[string]any{
|
||||
"ticket": start.Ticket,
|
||||
"new_email": newEmail,
|
||||
"original_proof": originalProof,
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to request new email: %v", err)
|
||||
}
|
||||
assertStatus(t, reqNewResp, http.StatusOK)
|
||||
reqNewResp.Body.Close()
|
||||
|
||||
resendResp, err := client.postJSONWithAuth("/users/@me/email-change/resend-new", map[string]any{
|
||||
"ticket": start.Ticket,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt immediate resend: %v", err)
|
||||
}
|
||||
if resendResp.StatusCode != http.StatusTooManyRequests && resendResp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected resend to be rate limited, got %d: %s", resendResp.StatusCode, readResponseBody(resendResp))
|
||||
}
|
||||
resendResp.Body.Close()
|
||||
|
||||
time.Sleep(31 * time.Second)
|
||||
|
||||
resendResp, err = client.postJSONWithAuth("/users/@me/email-change/resend-new", map[string]any{
|
||||
"ticket": start.Ticket,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resend after cooldown: %v", err)
|
||||
}
|
||||
assertStatus(t, resendResp, http.StatusNoContent)
|
||||
resendResp.Body.Close()
|
||||
}
|
||||
135
tests/integration/auth_email_revert_flow_test.go
Normal file
135
tests/integration/auth_email_revert_flow_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthEmailRevertFlow(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
t.Run("revert restores original email and clears mfa", func(t *testing.T) {
|
||||
account := createTestAccount(t, client)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
startResp := startEmailChange(t, client, account, account.Password)
|
||||
|
||||
var originalProof string
|
||||
if startResp.RequireOriginal {
|
||||
originalEmail := waitForEmail(t, client, "email_change_original", account.Email)
|
||||
originalCode := originalEmail.Metadata["code"]
|
||||
originalProof = verifyOriginalEmailChange(t, client, account, startResp.Ticket, originalCode, account.Password)
|
||||
} else {
|
||||
originalProof = *startResp.OriginalProof
|
||||
}
|
||||
|
||||
newEmail := fmt.Sprintf("integration-revert-%d@example.com", time.Now().UnixNano())
|
||||
requestNewEmailChange(t, client, account, startResp.Ticket, newEmail, originalProof, account.Password)
|
||||
|
||||
newEmailData := waitForEmail(t, client, "email_change_new", newEmail)
|
||||
newCode := newEmailData.Metadata["code"]
|
||||
if newCode == "" {
|
||||
t.Fatalf("expected new email code in metadata")
|
||||
}
|
||||
|
||||
emailToken := verifyNewEmailChange(t, client, account, startResp.Ticket, newCode, originalProof, account.Password)
|
||||
|
||||
resp, err := client.patchJSONWithAuth("/users/@me", map[string]any{
|
||||
"email_token": emailToken,
|
||||
"password": account.Password,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to finalize email change: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
revertEmail := waitForEmail(t, client, "email_change_revert", account.Email)
|
||||
revertToken := revertEmail.Metadata["token"]
|
||||
if revertToken == "" {
|
||||
t.Fatalf("expected revert token in email metadata")
|
||||
}
|
||||
|
||||
newPassword := uniquePassword()
|
||||
resp, err = client.postJSON("/auth/email-revert", map[string]any{
|
||||
"token": revertToken,
|
||||
"password": newPassword,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call email revert endpoint: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var revertResp loginResponse
|
||||
decodeJSONResponse(t, resp, &revertResp)
|
||||
resp.Body.Close()
|
||||
|
||||
if revertResp.Token == "" {
|
||||
t.Fatalf("expected revert to return a new token")
|
||||
}
|
||||
|
||||
if respOld, err := client.getWithAuth("/users/@me", account.Token); err == nil {
|
||||
if respOld.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected old session token to be invalidated after revert")
|
||||
}
|
||||
respOld.Body.Close()
|
||||
}
|
||||
|
||||
resp, err = client.getWithAuth("/users/@me", revertResp.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch user with reverted token: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var user userPrivateResponse
|
||||
decodeJSONResponse(t, resp, &user)
|
||||
resp.Body.Close()
|
||||
|
||||
if user.Email != account.Email {
|
||||
t.Fatalf("expected email to revert to %s, got %s", account.Email, user.Email)
|
||||
}
|
||||
if user.MfaEnabled || len(user.AuthenticatorTypes) > 0 {
|
||||
t.Fatalf("expected MFA to be disabled after revert")
|
||||
}
|
||||
if user.Phone != nil {
|
||||
t.Fatalf("expected phone number to be removed after revert, got %v", *user.Phone)
|
||||
}
|
||||
if user.PasswordLastChangedAt == nil {
|
||||
t.Fatalf("expected password_last_changed_at to be set after revert")
|
||||
}
|
||||
|
||||
login := loginTestUser(t, client, account.Email, newPassword)
|
||||
if login.Token == "" {
|
||||
t.Fatalf("expected login with new password to succeed")
|
||||
}
|
||||
|
||||
resp, err = client.postJSON("/auth/login", loginRequest{Email: account.Email, Password: account.Password})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login with old password: %v", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected old password login to fail after revert")
|
||||
}
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
resp.Body.Close()
|
||||
})
|
||||
}
|
||||
56
tests/integration/auth_email_verification_flow_test.go
Normal file
56
tests/integration/auth_email_verification_flow_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthEmailVerificationFlow(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
resp, err := client.postJSONWithAuth("/auth/verify/resend", nil, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call resend verification: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
email := waitForEmail(t, client, "email_verification", account.Email)
|
||||
token, ok := email.Metadata["token"]
|
||||
if !ok || token == "" {
|
||||
t.Fatalf("expected verification token in email metadata")
|
||||
}
|
||||
|
||||
resp, err = client.postJSON("/auth/verify", map[string]string{"token": token})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call verify: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
login := loginTestUser(t, client, account.Email, account.Password)
|
||||
if login.PendingVerification != nil && *login.PendingVerification {
|
||||
t.Fatalf("expected pending verification to be cleared after verification")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
requireVerifiedEmail = 1 << 0
|
||||
requireVerifiedPhone = 1 << 2
|
||||
)
|
||||
|
||||
type suspiciousActivityErrorResponse struct {
|
||||
errorResponse
|
||||
Data struct {
|
||||
SuspiciousActivityFlags int `json:"suspicious_activity_flags"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func TestAuthEmailVerificationOnlyClearsEmailRelatedSuspiciousFlags(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
updateUserSecurityFlags(t, client, account.UserID, userSecurityFlagsPayload{
|
||||
SuspiciousActivityFlagNames: []string{"REQUIRE_VERIFIED_EMAIL", "REQUIRE_VERIFIED_PHONE"},
|
||||
})
|
||||
|
||||
checkSuspiciousFlags := func(expected int) {
|
||||
resp, err := client.getWithAuth("/users/@me", account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch /users/@me: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusForbidden)
|
||||
var errBody suspiciousActivityErrorResponse
|
||||
decodeJSONResponse(t, resp, &errBody)
|
||||
if errBody.Data.SuspiciousActivityFlags != expected {
|
||||
t.Fatalf("expected suspicious flags %d, got %d", expected, errBody.Data.SuspiciousActivityFlags)
|
||||
}
|
||||
}
|
||||
|
||||
checkSuspiciousFlags(requireVerifiedEmail | requireVerifiedPhone)
|
||||
|
||||
resp, err := client.postJSONWithAuth("/auth/verify/resend", nil, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to request verification email: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
email := waitForEmail(t, client, "email_verification", account.Email)
|
||||
token, ok := email.Metadata["token"]
|
||||
if !ok || token == "" {
|
||||
t.Fatalf("expected verification token in email metadata")
|
||||
}
|
||||
|
||||
resp, err = client.postJSON("/auth/verify", map[string]string{"token": token})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify email: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
checkSuspiciousFlags(requireVerifiedPhone)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthForgotAndResetPasswordFlow(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
resp, err := client.postJSON("/auth/forgot", map[string]string{"email": account.Email})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call forgot password: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
email := waitForEmail(t, client, "password_reset", account.Email)
|
||||
token, ok := email.Metadata["token"]
|
||||
if !ok || token == "" {
|
||||
t.Fatalf("expected password reset token in email metadata")
|
||||
}
|
||||
newPassword := uniquePassword()
|
||||
|
||||
resp, err = client.postJSON("/auth/reset", map[string]string{"token": token, "password": newPassword})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call reset password: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var resetResp loginResponse
|
||||
decodeJSONResponse(t, resp, &resetResp)
|
||||
if resetResp.Token == "" {
|
||||
t.Fatalf("expected reset to return a new token")
|
||||
}
|
||||
|
||||
login := loginTestUser(t, client, account.Email, newPassword)
|
||||
if login.Token == "" {
|
||||
t.Fatalf("expected login with new password to succeed")
|
||||
}
|
||||
|
||||
resp, err = client.postJSON("/auth/login", loginRequest{Email: account.Email, Password: account.Password})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login with old password: %v", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected old password login to fail")
|
||||
}
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationBypassFlagAddedLater validates that when a bypass flag
|
||||
// is added to an existing user, they can immediately login from new IPs without
|
||||
// requiring authorization.
|
||||
func TestAuthIPAuthorizationBypassFlagAddedLater(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("bypass-added-later-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
reg := registerTestUser(t, client, email, password)
|
||||
|
||||
newIP := "10.80.90.100"
|
||||
if newIP == originalIP {
|
||||
newIP = "10.80.90.101"
|
||||
}
|
||||
clientFromNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: newIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt login from new IP: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected first login attempt to require IP auth, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Add the APP_STORE_REVIEWER flag
|
||||
updateUserSecurityFlags(t, client, reg.UserID, userSecurityFlagsPayload{
|
||||
SetFlags: []string{"APP_STORE_REVIEWER"},
|
||||
})
|
||||
|
||||
resp, err = clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login after adding bypass flag: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected login to succeed after adding bypass flag, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, resp, &loginResp)
|
||||
|
||||
if loginResp.Token == "" {
|
||||
t.Fatalf("expected login response to include token")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationBypassFlagRemoved validates that when a bypass flag
|
||||
// is removed from a user, they once again require IP authorization for new IPs.
|
||||
func TestAuthIPAuthorizationBypassFlagRemoved(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("bypass-removed-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
reg := registerTestUser(t, client, email, password)
|
||||
|
||||
// Set the APP_STORE_REVIEWER flag initially
|
||||
updateUserSecurityFlags(t, client, reg.UserID, userSecurityFlagsPayload{
|
||||
SetFlags: []string{"APP_STORE_REVIEWER"},
|
||||
})
|
||||
|
||||
newIP := "10.100.110.120"
|
||||
if newIP == originalIP {
|
||||
newIP = "10.100.110.121"
|
||||
}
|
||||
clientFromNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: newIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login with bypass flag: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected login with bypass flag to succeed, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Remove the APP_STORE_REVIEWER flag
|
||||
updateUserSecurityFlags(t, client, reg.UserID, userSecurityFlagsPayload{
|
||||
ClearFlags: []string{"APP_STORE_REVIEWER"},
|
||||
})
|
||||
|
||||
anotherNewIP := "10.130.140.150"
|
||||
if anotherNewIP == originalIP || anotherNewIP == newIP {
|
||||
anotherNewIP = "10.130.140.151"
|
||||
}
|
||||
clientFromAnotherIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: anotherNewIP,
|
||||
}
|
||||
|
||||
resp, err = clientFromAnotherIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt login after removing bypass flag: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected login after removing bypass flag to require IP auth, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var ipAuthResp struct {
|
||||
IPAuthorizationRequired bool `json:"ip_authorization_required"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &ipAuthResp)
|
||||
|
||||
if !ipAuthResp.IPAuthorizationRequired {
|
||||
t.Fatalf("expected ip_authorization_required to be true after flag removal")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationBypassWithMultipleFlags validates that users with
|
||||
// bypass flags can login from any IP without authorization, even when combined
|
||||
// with other security flags.
|
||||
func TestAuthIPAuthorizationBypassWithMultipleFlags(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
flags []string
|
||||
}{
|
||||
{
|
||||
name: "APP_STORE_REVIEWER only",
|
||||
flags: []string{"APP_STORE_REVIEWER"},
|
||||
},
|
||||
{
|
||||
name: "APP_STORE_REVIEWER with STAFF",
|
||||
flags: []string{"APP_STORE_REVIEWER", "STAFF"},
|
||||
},
|
||||
{
|
||||
name: "APP_STORE_REVIEWER with CTP_MEMBER",
|
||||
flags: []string{"APP_STORE_REVIEWER", "CTP_MEMBER"},
|
||||
},
|
||||
{
|
||||
name: "APP_STORE_REVIEWER with BUG_HUNTER",
|
||||
flags: []string{"APP_STORE_REVIEWER", "BUG_HUNTER"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
email := fmt.Sprintf("bypass-flags-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
reg := registerTestUser(t, client, email, password)
|
||||
|
||||
updateUserSecurityFlags(t, client, reg.UserID, userSecurityFlagsPayload{
|
||||
SetFlags: tc.flags,
|
||||
})
|
||||
|
||||
newIP := "10.50.60.70"
|
||||
if newIP == originalIP {
|
||||
newIP = "10.50.60.71"
|
||||
}
|
||||
clientFromNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: newIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login from new IP: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected login to succeed for user with bypass flags from new IP, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, resp, &loginResp)
|
||||
|
||||
if loginResp.Token == "" {
|
||||
t.Fatalf("expected login response to include token")
|
||||
}
|
||||
if loginResp.UserID != reg.UserID {
|
||||
t.Fatalf("expected user_id %s to match registration %s", loginResp.UserID, reg.UserID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
129
tests/integration/auth_ip_authorization_flow_test.go
Normal file
129
tests/integration/auth_ip_authorization_flow_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationFlow validates the complete IP authorization flow:
|
||||
// 1. User registers from IP A
|
||||
// 2. User attempts to login from IP B (new IP)
|
||||
// 3. Login is blocked with 403 and returns IP authorization ticket
|
||||
// 4. User retrieves IP authorization token from email
|
||||
// 5. User authorizes the new IP with the token
|
||||
// 6. User can now login from IP B successfully
|
||||
func TestAuthIPAuthorizationFlow(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("ip-auth-flow-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
reg := registerTestUser(t, client, email, password)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
newIP := "10.20.30.40"
|
||||
if newIP == originalIP {
|
||||
newIP = "10.20.30.41"
|
||||
}
|
||||
clientFromNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: newIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login endpoint from new IP: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected login from new IP to return 403, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var ipAuthResp struct {
|
||||
Code string `json:"code"`
|
||||
Ticket string `json:"ticket"`
|
||||
IPAuthorizationRequired bool `json:"ip_authorization_required"`
|
||||
Email string `json:"email"`
|
||||
ResendAvailableIn int `json:"resend_available_in"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &ipAuthResp)
|
||||
|
||||
if !ipAuthResp.IPAuthorizationRequired {
|
||||
t.Fatalf("expected ip_authorization_required to be true")
|
||||
}
|
||||
if ipAuthResp.Ticket == "" {
|
||||
t.Fatalf("expected ticket to be present in response")
|
||||
}
|
||||
if ipAuthResp.Email != email {
|
||||
t.Fatalf("expected email to match registered email, got %s", ipAuthResp.Email)
|
||||
}
|
||||
|
||||
emailData := waitForEmail(t, client, "ip_authorization", email)
|
||||
authToken, ok := emailData.Metadata["token"]
|
||||
if !ok || authToken == "" {
|
||||
t.Fatalf("expected ip authorization token in email metadata")
|
||||
}
|
||||
|
||||
resp, err = clientFromNewIP.postJSON("/auth/authorize-ip", map[string]string{"token": authToken})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to authorize IP: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected IP authorization to succeed with 204, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, err = clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login after IP authorization: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected login to succeed after IP authorization, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, resp, &loginResp)
|
||||
|
||||
if loginResp.Token == "" {
|
||||
t.Fatalf("expected login response to include token")
|
||||
}
|
||||
if loginResp.UserID != reg.UserID {
|
||||
t.Fatalf("expected login user_id %s to match registration %s", loginResp.UserID, reg.UserID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationInvalidToken validates that attempting to authorize
|
||||
// an IP with an invalid token fails with appropriate error.
|
||||
func TestAuthIPAuthorizationInvalidToken(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
resp, err := client.postJSON("/auth/authorize-ip", map[string]string{"token": "invalid-token-12345"})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call authorize-ip endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
t.Fatalf("expected invalid token to be rejected, got 204 success")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusBadRequest {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected invalid token to return 401 or 400, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationKnownIPNoAuth validates that login from a known IP
|
||||
// does not trigger IP authorization.
|
||||
func TestAuthIPAuthorizationKnownIPNoAuth(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
email := fmt.Sprintf("ip-known-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
reg := registerTestUser(t, client, email, password)
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected login from known IP to succeed with 200, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, resp, &loginResp)
|
||||
|
||||
if loginResp.Token == "" {
|
||||
t.Fatalf("expected login response to include token")
|
||||
}
|
||||
if loginResp.UserID != reg.UserID {
|
||||
t.Fatalf("expected login user_id %s to match registration %s", loginResp.UserID, reg.UserID)
|
||||
}
|
||||
}
|
||||
119
tests/integration/auth_ip_authorization_multiple_ips_test.go
Normal file
119
tests/integration/auth_ip_authorization_multiple_ips_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationMultipleIPs validates that a user can authorize
|
||||
// multiple different IPs independently.
|
||||
func TestAuthIPAuthorizationMultipleIPs(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("ip-multi-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
registerTestUser(t, client, email, password)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
firstNewIP := "10.11.12.13"
|
||||
if firstNewIP == originalIP {
|
||||
firstNewIP = "10.11.12.14"
|
||||
}
|
||||
clientFromFirstIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: firstNewIP,
|
||||
}
|
||||
|
||||
resp, err := clientFromFirstIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login from first new IP: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected first new IP to trigger authorization, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
email1 := waitForEmail(t, client, "ip_authorization", email)
|
||||
token1 := email1.Metadata["token"]
|
||||
resp, err = clientFromFirstIP.postJSON("/auth/authorize-ip", map[string]string{"token": token1})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to authorize first IP: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
clearTestEmails(t, client)
|
||||
|
||||
secondNewIP := "10.22.33.44"
|
||||
if secondNewIP == originalIP || secondNewIP == firstNewIP {
|
||||
secondNewIP = "10.22.33.45"
|
||||
}
|
||||
clientFromSecondIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: secondNewIP,
|
||||
}
|
||||
|
||||
resp, err = clientFromSecondIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login from second new IP: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected second new IP to trigger authorization, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
email2 := waitForEmail(t, client, "ip_authorization", email)
|
||||
token2 := email2.Metadata["token"]
|
||||
resp, err = clientFromSecondIP.postJSON("/auth/authorize-ip", map[string]string{"token": token2})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to authorize second IP: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = clientFromFirstIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login from first IP after auth: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = clientFromSecondIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login from second IP after auth: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationMultipleResendAttempts validates that multiple
|
||||
// resend attempts in quick succession are all rate limited.
|
||||
func TestAuthIPAuthorizationMultipleResendAttempts(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("ip-multi-resend-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
registerTestUser(t, client, email, password)
|
||||
|
||||
newIP := "10.30.31.32"
|
||||
if newIP == originalIP {
|
||||
newIP = "10.30.31.33"
|
||||
}
|
||||
clientFromNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: newIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to trigger IP authorization: %v", err)
|
||||
}
|
||||
|
||||
var ipAuthResp struct {
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &ipAuthResp)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
resp, err = clientFromNewIP.postJSON("/auth/ip-authorization/resend", map[string]string{
|
||||
"ticket": ipAuthResp.Ticket,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed resend attempt %d: %v", i+1, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected resend attempt %d to be rate limited, got %d: %s", i+1, resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationRegularUserStillRequiresAuth validates that users
|
||||
// without bypass flags still require IP authorization for new IPs, even with
|
||||
// other non-bypass flags set.
|
||||
func TestAuthIPAuthorizationRegularUserStillRequiresAuth(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("regular-with-flags-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
reg := registerTestUser(t, client, email, password)
|
||||
|
||||
updateUserSecurityFlags(t, client, reg.UserID, userSecurityFlagsPayload{
|
||||
SetFlags: []string{"CTP_MEMBER", "BUG_HUNTER"},
|
||||
})
|
||||
|
||||
newIP := "10.160.170.180"
|
||||
if newIP == originalIP {
|
||||
newIP = "10.160.170.181"
|
||||
}
|
||||
clientFromNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: newIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt login from new IP: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected regular user with non-bypass flags to require IP auth, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var ipAuthResp struct {
|
||||
IPAuthorizationRequired bool `json:"ip_authorization_required"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &ipAuthResp)
|
||||
|
||||
if !ipAuthResp.IPAuthorizationRequired {
|
||||
t.Fatalf("expected ip_authorization_required to be true for regular user")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationResendAfterAuthorization validates that attempting
|
||||
// to resend after the IP has already been authorized fails appropriately.
|
||||
func TestAuthIPAuthorizationResendAfterAuthorization(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("ip-resend-after-auth-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
registerTestUser(t, client, email, password)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
newIP := "10.26.27.28"
|
||||
if newIP == originalIP {
|
||||
newIP = "10.26.27.29"
|
||||
}
|
||||
clientFromNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: newIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to trigger IP authorization: %v", err)
|
||||
}
|
||||
|
||||
var ipAuthResp3 struct {
|
||||
Ticket string `json:"ticket"`
|
||||
IPAuthorizationRequired bool `json:"ip_authorization_required"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &ipAuthResp3)
|
||||
|
||||
emailData := waitForEmail(t, client, "ip_authorization", email)
|
||||
authToken := emailData.Metadata["token"]
|
||||
|
||||
resp, err = clientFromNewIP.postJSON("/auth/authorize-ip", map[string]string{"token": authToken})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to authorize IP: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp.Body.Close()
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
resp, err = clientFromNewIP.postJSON("/auth/ip-authorization/resend", map[string]string{
|
||||
"ticket": ipAuthResp3.Ticket,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt resend after authorization: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNoContent {
|
||||
t.Fatalf("expected resend after authorization to fail, got success status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationResendWithEmptyTicket validates that attempting to
|
||||
// resend with an empty ticket is rejected.
|
||||
func TestAuthIPAuthorizationResendWithEmptyTicket(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
resp, err := client.postJSON("/auth/ip-authorization/resend", map[string]string{
|
||||
"ticket": "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call resend endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNoContent {
|
||||
t.Fatalf("expected empty ticket to be rejected, got success status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected empty ticket to return 400 or 401, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationResendInvalidTicket validates that attempting to
|
||||
// resend with an invalid ticket fails appropriately.
|
||||
func TestAuthIPAuthorizationResendInvalidTicket(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
resp, err := client.postJSON("/auth/ip-authorization/resend", map[string]string{
|
||||
"ticket": "invalid-ticket-xyz-12345",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call resend endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNoContent {
|
||||
t.Fatalf("expected invalid ticket to be rejected, got success status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusNotFound {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected invalid ticket to return 401, 400, or 404, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationResendRateLimit validates that the resend endpoint
|
||||
// properly rate limits repeated resend attempts.
|
||||
func TestAuthIPAuthorizationResendRateLimit(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("ip-resend-limit-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
registerTestUser(t, client, email, password)
|
||||
|
||||
newIP := "10.200.210.220"
|
||||
if newIP == originalIP {
|
||||
newIP = "10.200.210.221"
|
||||
}
|
||||
clientFromNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: newIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to trigger IP authorization: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected IP authorization to be required, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var ipAuthResp struct {
|
||||
Ticket string `json:"ticket"`
|
||||
IPAuthorizationRequired bool `json:"ip_authorization_required"`
|
||||
ResendAvailableIn int `json:"resend_available_in"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &ipAuthResp)
|
||||
|
||||
if !ipAuthResp.IPAuthorizationRequired || ipAuthResp.Ticket == "" {
|
||||
t.Fatalf("expected valid IP authorization response")
|
||||
}
|
||||
|
||||
resp, err = clientFromNewIP.postJSON("/auth/ip-authorization/resend", map[string]string{
|
||||
"ticket": ipAuthResp.Ticket,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt resend: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected immediate resend to be rate limited with 429, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationResendResponseFormat validates that the resend
|
||||
// endpoint returns appropriate response data when rate limited.
|
||||
func TestAuthIPAuthorizationResendResponseFormat(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("ip-resend-format-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
registerTestUser(t, client, email, password)
|
||||
|
||||
newIP := "10.230.240.250"
|
||||
if newIP == originalIP {
|
||||
newIP = "10.230.240.251"
|
||||
}
|
||||
clientFromNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: newIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to trigger IP authorization: %v", err)
|
||||
}
|
||||
|
||||
var ipAuthResp2 struct {
|
||||
Ticket string `json:"ticket"`
|
||||
IPAuthorizationRequired bool `json:"ip_authorization_required"`
|
||||
ResendAvailableIn int `json:"resend_available_in"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &ipAuthResp2)
|
||||
|
||||
resp, err = clientFromNewIP.postJSON("/auth/ip-authorization/resend", map[string]string{
|
||||
"ticket": ipAuthResp2.Ticket,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt resend: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected rate limit, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var rateLimitResp struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
ResendAvailableIn *int `json:"resend_available_in,omitempty"`
|
||||
RetryAfter *int `json:"retry_after,omitempty"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &rateLimitResp)
|
||||
|
||||
if rateLimitResp.Code == "" {
|
||||
t.Fatalf("expected error code in rate limit response")
|
||||
}
|
||||
if rateLimitResp.Message == "" {
|
||||
t.Fatalf("expected error message in rate limit response")
|
||||
}
|
||||
}
|
||||
59
tests/integration/auth_ip_authorization_resend_used_test.go
Normal file
59
tests/integration/auth_ip_authorization_resend_used_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ensures resend fails when resendUsed flag is already set on the ticket.
|
||||
func TestAuthIPAuthorizationResendAlreadyUsed(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
ticket := fmt.Sprintf("ticket-used-%d", time.Now().UnixNano())
|
||||
token := fmt.Sprintf("token-used-%d", time.Now().UnixNano())
|
||||
|
||||
seedIPAuthorizationTicket(t, client, ipAuthSeedPayload{
|
||||
Ticket: ticket,
|
||||
Token: token,
|
||||
UserID: account.UserID,
|
||||
Email: account.Email,
|
||||
Username: "resend-used-user",
|
||||
ClientIP: "203.0.113.10",
|
||||
UserAgent: "IntegrationTest/1.0",
|
||||
ClientLocation: "Testland",
|
||||
ResendUsed: true,
|
||||
CreatedAt: time.Now().Add(-2 * time.Minute),
|
||||
TTLSeconds: 900,
|
||||
})
|
||||
|
||||
resp, err := client.postJSON("/auth/ip-authorization/resend", map[string]string{"ticket": ticket})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call ip-authorization resend: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected resend to fail when already used, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthIPAuthorizationStreamInvalidTicket(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
resp, err := client.get("/auth/ip-authorization/stream?ticket=does-not-exist")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call stream: %v", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected invalid ticket to fail, got 200")
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ensures stream delivers one event then closes when publish happens.
|
||||
func TestAuthIPAuthorizationStreamReceivesAndCloses(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
ticket := fmt.Sprintf("stream-%d", time.Now().UnixNano())
|
||||
token := fmt.Sprintf("token-%d", time.Now().UnixNano())
|
||||
|
||||
seedIPAuthorizationTicket(t, client, ipAuthSeedPayload{
|
||||
Ticket: ticket,
|
||||
Token: token,
|
||||
UserID: account.UserID,
|
||||
Email: account.Email,
|
||||
Username: "stream-user",
|
||||
ClientIP: "192.0.2.10",
|
||||
UserAgent: "IntegrationTest/1.0",
|
||||
ClientLocation: "Testland",
|
||||
CreatedAt: time.Now().Add(-1 * time.Minute),
|
||||
TTLSeconds: 900,
|
||||
})
|
||||
|
||||
resp, err := client.get(fmt.Sprintf("/auth/ip-authorization/stream?ticket=%s", ticket))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open stream: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 from stream, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
_, err = client.postJSON("/test/auth/ip-authorization/publish", map[string]string{
|
||||
"ticket": ticket,
|
||||
"token": token,
|
||||
"user_id": account.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to publish ip auth event: %v", err)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
foundData := false
|
||||
|
||||
for {
|
||||
line, readErr := reader.ReadString('\n')
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
if !strings.Contains(line, token) || !strings.Contains(line, account.UserID) {
|
||||
t.Fatalf("data line missing expected payload: %s", line)
|
||||
}
|
||||
foundData = true
|
||||
}
|
||||
if line == "\n" && foundData {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundData {
|
||||
t.Fatalf("did not receive data event from stream")
|
||||
}
|
||||
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationTicketExpiration validates that the ticket returned
|
||||
// in the login response expires and cannot be used for resending after expiration.
|
||||
func TestAuthIPAuthorizationTicketExpiration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping ticket expiration test in short mode")
|
||||
}
|
||||
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("ip-ticket-expire-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
registerTestUser(t, client, email, password)
|
||||
|
||||
newIP := "10.70.80.90"
|
||||
if newIP == originalIP {
|
||||
newIP = "10.70.80.91"
|
||||
}
|
||||
clientFromNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: newIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to trigger IP authorization: %v", err)
|
||||
}
|
||||
|
||||
var ipAuthResp struct {
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &ipAuthResp)
|
||||
|
||||
if ipAuthResp.Ticket == "" {
|
||||
t.Fatalf("expected ticket in response")
|
||||
}
|
||||
|
||||
expireIPAuthorization(t, client, ipAuthResp.Ticket, "")
|
||||
|
||||
resp, err = clientFromNewIP.postJSON("/auth/ip-authorization/resend", map[string]string{
|
||||
"ticket": ipAuthResp.Ticket,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt resend with expired ticket: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNoContent {
|
||||
t.Fatalf("expected expired ticket to be rejected, got success status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
acceptableStatuses := []int{
|
||||
http.StatusUnauthorized,
|
||||
http.StatusBadRequest,
|
||||
http.StatusGone,
|
||||
http.StatusNotFound,
|
||||
}
|
||||
|
||||
statusAcceptable := false
|
||||
for _, status := range acceptableStatuses {
|
||||
if resp.StatusCode == status {
|
||||
statusAcceptable = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !statusAcceptable {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected expired ticket to return 401/400/410/404, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
90
tests/integration/auth_ip_authorization_ticket_test.go
Normal file
90
tests/integration/auth_ip_authorization_ticket_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIpAuthorizationTicketFlow validates the new IP authorization ticket error response
|
||||
// and the resend rate limit behavior.
|
||||
func TestAuthIpAuthorizationTicketFlow(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
primaryIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("ip-ticket-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
_ = registerTestUser(t, client, email, password)
|
||||
|
||||
otherIP := "10.55.44.33"
|
||||
if otherIP == primaryIP {
|
||||
otherIP = "10.55.44.34"
|
||||
}
|
||||
|
||||
clientWithNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: otherIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientWithNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected login to return 403 for IP authorization flow, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
Ticket string `json:"ticket"`
|
||||
IPAuthorizationRequired bool `json:"ip_authorization_required"`
|
||||
Email string `json:"email"`
|
||||
ResendAvailableIn int `json:"resend_available_in"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &body)
|
||||
|
||||
if !body.IPAuthorizationRequired || body.Ticket == "" || body.Email == "" {
|
||||
t.Fatalf("expected ip authorization payload, got: %+v", body)
|
||||
}
|
||||
|
||||
resendResp, err := clientWithNewIP.postJSON("/auth/ip-authorization/resend", map[string]string{"ticket": body.Ticket})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call resend endpoint: %v", err)
|
||||
}
|
||||
defer resendResp.Body.Close()
|
||||
|
||||
if resendResp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected resend to be rate limited with 429, got %d: %s", resendResp.StatusCode, readResponseBody(resendResp))
|
||||
}
|
||||
}
|
||||
107
tests/integration/auth_ip_authorization_token_expiration_test.go
Normal file
107
tests/integration/auth_ip_authorization_token_expiration_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationTokenExpiration validates that IP authorization
|
||||
// tokens expire after a reasonable time period and cannot be used after expiration.
|
||||
//
|
||||
// Note: This test assumes a relatively short expiration time (e.g., 15-30 minutes).
|
||||
// If the actual expiration is longer, this test may need adjustment or be marked
|
||||
// as a longer-running integration test.
|
||||
func TestAuthIPAuthorizationTokenExpiration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping expiration test in short mode")
|
||||
}
|
||||
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("ip-expire-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
registerTestUser(t, client, email, password)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
newIP := "10.40.50.60"
|
||||
if newIP == originalIP {
|
||||
newIP = "10.40.50.61"
|
||||
}
|
||||
clientFromNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: newIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to trigger IP authorization: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected IP authorization required, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
emailData := waitForEmail(t, client, "ip_authorization", email)
|
||||
authToken := emailData.Metadata["token"]
|
||||
if authToken == "" {
|
||||
t.Fatalf("expected authorization token in email")
|
||||
}
|
||||
|
||||
expireIPAuthorization(t, client, "", authToken)
|
||||
|
||||
resp, err = clientFromNewIP.postJSON("/auth/authorize-ip", map[string]string{"token": authToken})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt authorization with expired token: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected expired token to be rejected, got success status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusGone {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected expired token to return 401, 400, or 410, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, err = clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify login still requires auth: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected login to still require IP authorization after expired token, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationTokenSingleUse validates that an IP authorization
|
||||
// token can only be used once and becomes invalid after successful use.
|
||||
func TestAuthIPAuthorizationTokenSingleUse(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("ip-single-use-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
registerTestUser(t, client, email, password)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
newIP := "10.100.101.102"
|
||||
if newIP == originalIP {
|
||||
newIP = "10.100.101.103"
|
||||
}
|
||||
clientFromNewIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: newIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromNewIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to trigger IP authorization: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
emailData := waitForEmail(t, client, "ip_authorization", email)
|
||||
authToken := emailData.Metadata["token"]
|
||||
|
||||
resp, err = clientFromNewIP.postJSON("/auth/authorize-ip", map[string]string{"token": authToken})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to authorize IP first time: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected first authorization to succeed, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = clientFromNewIP.postJSON("/auth/authorize-ip", map[string]string{"token": authToken})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt second authorization: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected token reuse to be rejected, got success status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusGone {
|
||||
body := readResponseBody(resp)
|
||||
t.Fatalf("expected token reuse to return 401, 400, or 410, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthIPAuthorizationTokenForWrongIP validates that a token generated
|
||||
// for one IP cannot be used to authorize a different IP.
|
||||
func TestAuthIPAuthorizationTokenForWrongIP(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
originalIP := client.clientIP
|
||||
|
||||
email := fmt.Sprintf("ip-wrong-ip-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
registerTestUser(t, client, email, password)
|
||||
clearTestEmails(t, client)
|
||||
|
||||
firstNewIP := "10.110.120.130"
|
||||
if firstNewIP == originalIP {
|
||||
firstNewIP = "10.110.120.131"
|
||||
}
|
||||
clientFromFirstIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: firstNewIP,
|
||||
}
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := clientFromFirstIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to trigger IP authorization from first IP: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
emailData := waitForEmail(t, client, "ip_authorization", email)
|
||||
authToken := emailData.Metadata["token"]
|
||||
|
||||
secondNewIP := "10.140.150.160"
|
||||
if secondNewIP == originalIP || secondNewIP == firstNewIP {
|
||||
secondNewIP = "10.140.150.161"
|
||||
}
|
||||
clientFromSecondIP := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: secondNewIP,
|
||||
}
|
||||
|
||||
resp, err = clientFromSecondIP.postJSON("/auth/authorize-ip", map[string]string{"token": authToken})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt authorization from wrong IP: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
resp2, err := clientFromSecondIP.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify second IP still requires auth: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusForbidden {
|
||||
body := readResponseBody(resp2)
|
||||
t.Fatalf("expected second IP to still require authorization, got %d: %s", resp2.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
65
tests/integration/auth_login_after_registration_test.go
Normal file
65
tests/integration/auth_login_after_registration_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthLoginAfterRegistration(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
email := fmt.Sprintf("integration-login-%d@example.com", time.Now().UnixNano())
|
||||
password := uniquePassword()
|
||||
|
||||
reg := registerTestUser(t, client, email, password)
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login endpoint: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, resp, &loginResp)
|
||||
|
||||
if loginResp.MFA {
|
||||
t.Fatalf("expected MFA to be false for fresh account")
|
||||
}
|
||||
if loginResp.Token == "" {
|
||||
t.Fatalf("expected login response to include token")
|
||||
}
|
||||
if loginResp.UserID != reg.UserID {
|
||||
t.Fatalf("expected login user_id %s to match registration %s", loginResp.UserID, reg.UserID)
|
||||
}
|
||||
if loginResp.PendingVerification != nil && *loginResp.PendingVerification {
|
||||
t.Fatalf("did not expect pending verification during login for dev environment")
|
||||
}
|
||||
}
|
||||
68
tests/integration/auth_login_disabled_flag_recovery_test.go
Normal file
68
tests/integration/auth_login_disabled_flag_recovery_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Covers auto-clearing of DISABLED flag on login (when not temp-banned).
|
||||
func TestAuthLoginDisabledFlagRecovery(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
updateUserSecurityFlags(t, client, account.UserID, userSecurityFlagsPayload{
|
||||
SetFlags: []string{"DISABLED"},
|
||||
})
|
||||
|
||||
resp, err := client.postJSON("/auth/login", loginRequest{
|
||||
Email: account.Email,
|
||||
Password: account.Password,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login with disabled flag: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
statusResp, err := client.getWithAuth("/test/users/"+account.UserID+"/data-exists", account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch data-exists: %v", err)
|
||||
}
|
||||
assertStatus(t, statusResp, http.StatusOK)
|
||||
var payload struct {
|
||||
HasSelfDeletedFlag bool `json:"has_self_deleted_flag"`
|
||||
HasDeletedFlag bool `json:"has_deleted_flag"`
|
||||
Flags string `json:"flags"`
|
||||
}
|
||||
decodeJSONResponse(t, statusResp, &payload)
|
||||
if payload.HasDeletedFlag || payload.HasSelfDeletedFlag {
|
||||
t.Fatalf("expected DELETED/SELF_DELETED to be false")
|
||||
}
|
||||
if payload.Flags == "" {
|
||||
statusResp.Body.Close()
|
||||
return
|
||||
}
|
||||
if payload.Flags == "2" {
|
||||
t.Fatalf("expected DISABLED flag to be cleared, got flags=%s", payload.Flags)
|
||||
}
|
||||
statusResp.Body.Close()
|
||||
}
|
||||
116
tests/integration/auth_login_invalid_credentials_test.go
Normal file
116
tests/integration/auth_login_invalid_credentials_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthLoginInvalidCredentials(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
t.Run("wrong password returns bad request with field errors", func(t *testing.T) {
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: account.Email,
|
||||
Password: "WrongPassword123!",
|
||||
}
|
||||
|
||||
resp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 Bad Request for wrong password, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-existent email returns bad request with field errors", func(t *testing.T) {
|
||||
loginReq := loginRequest{
|
||||
Email: "nonexistent@example.com",
|
||||
Password: "SomePassword123!",
|
||||
}
|
||||
|
||||
resp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 Bad Request for non-existent email, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid email format returns bad request", func(t *testing.T) {
|
||||
loginReq := loginRequest{
|
||||
Email: "not-an-email",
|
||||
Password: "SomePassword123!",
|
||||
}
|
||||
|
||||
resp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 400 Bad Request or 401 Unauthorized for invalid email format, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty password returns bad request or unauthorized", func(t *testing.T) {
|
||||
loginReq := loginRequest{
|
||||
Email: "test@example.com",
|
||||
Password: "",
|
||||
}
|
||||
|
||||
resp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 400 Bad Request or 401 Unauthorized for empty password, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty email returns bad request or unauthorized", func(t *testing.T) {
|
||||
loginReq := loginRequest{
|
||||
Email: "",
|
||||
Password: "SomePassword123!",
|
||||
}
|
||||
|
||||
resp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login endpoint: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 400 Bad Request or 401 Unauthorized for empty email, got %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
})
|
||||
}
|
||||
83
tests/integration/auth_login_invite_auto_join_test.go
Normal file
83
tests/integration/auth_login_invite_auto_join_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Covers auto-joining a guild when logging in with invite_code.
|
||||
func TestAuthLoginInviteAutoJoin(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
owner := createTestAccount(t, client)
|
||||
guild := createGuild(t, client, owner.Token, fmt.Sprintf("InviteGuild-%d", time.Now().UnixNano()))
|
||||
systemChannelID, err := strconv.ParseInt(guild.SystemChannel, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse system channel id: %v", err)
|
||||
}
|
||||
invite := createChannelInvite(t, client, owner.Token, systemChannelID)
|
||||
|
||||
member := createTestAccount(t, client)
|
||||
|
||||
loginReq := loginRequest{
|
||||
Email: member.Email,
|
||||
Password: member.Password,
|
||||
InviteCode: &invite.Code,
|
||||
}
|
||||
|
||||
resp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login with invite: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, resp, &loginResp)
|
||||
if loginResp.Token == "" {
|
||||
t.Fatalf("expected login to return token")
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
guildsResp, err := client.getWithAuth("/users/@me/guilds", loginResp.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch guilds: %v", err)
|
||||
}
|
||||
assertStatus(t, guildsResp, http.StatusOK)
|
||||
var guilds []struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
decodeJSONResponse(t, guildsResp, &guilds)
|
||||
guildsResp.Body.Close()
|
||||
|
||||
found := false
|
||||
for _, g := range guilds {
|
||||
if g.ID == guild.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected guild %s to be joined after login with invite", guild.ID)
|
||||
}
|
||||
}
|
||||
65
tests/integration/auth_login_invite_invalid_code_test.go
Normal file
65
tests/integration/auth_login_invite_invalid_code_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Login with an invalid invite_code should still succeed and not add guilds.
|
||||
func TestAuthLoginInviteInvalidCode(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
member := createTestAccount(t, client)
|
||||
|
||||
badCode := "invalidcode123"
|
||||
loginReq := loginRequest{
|
||||
Email: member.Email,
|
||||
Password: member.Password,
|
||||
InviteCode: &badCode,
|
||||
}
|
||||
|
||||
resp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login with invalid invite: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, resp, &loginResp)
|
||||
if loginResp.Token == "" {
|
||||
t.Fatalf("expected login to return token")
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
guildsResp, err := client.getWithAuth("/users/@me/guilds", loginResp.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch guilds: %v", err)
|
||||
}
|
||||
assertStatus(t, guildsResp, http.StatusOK)
|
||||
var guilds []struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
decodeJSONResponse(t, guildsResp, &guilds)
|
||||
guildsResp.Body.Close()
|
||||
|
||||
if len(guilds) != 0 {
|
||||
t.Fatalf("expected no guilds joined when invite code is invalid, got %d", len(guilds))
|
||||
}
|
||||
}
|
||||
247
tests/integration/auth_login_mfa_totp_flag_test.go
Normal file
247
tests/integration/auth_login_mfa_totp_flag_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuthLoginMfaTotpFlagMatchesAuthenticatorTypes ensures MFA login responses
|
||||
// only advertise TOTP when the authenticator types actually include it.
|
||||
func TestAuthLoginMfaTotpFlagMatchesAuthenticatorTypes(t *testing.T) {
|
||||
t.Run("WebAuthnOnlyReportsTotpFalse", func(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
secret := newTotpSecret(t)
|
||||
|
||||
resp, err := client.postJSONWithAuth("/users/@me/mfa/totp/enable", map[string]string{
|
||||
"secret": secret,
|
||||
"code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable totp: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("enable totp returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
var enableResp backupCodesResponse
|
||||
decodeJSONResponse(t, resp, &enableResp)
|
||||
if len(enableResp.BackupCodes) == 0 {
|
||||
t.Fatalf("expected backup codes after enabling totp")
|
||||
}
|
||||
|
||||
loginWithTotp := loginTestUserWithTotp(t, client, account.Email, account.Password, secret)
|
||||
account.Token = loginWithTotp.Token
|
||||
|
||||
device := newWebAuthnDevice(t)
|
||||
var registrationOptions webAuthnRegistrationOptions
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/webauthn/credentials/registration-options", map[string]any{
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to request webauthn registration options: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("registration options returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
decodeJSONResponse(t, resp, ®istrationOptions)
|
||||
if registrationOptions.RP.ID != "" {
|
||||
device.rpID = registrationOptions.RP.ID
|
||||
}
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/webauthn/credentials", map[string]any{
|
||||
"response": device.registerResponse(t, registrationOptions),
|
||||
"challenge": registrationOptions.Challenge,
|
||||
"name": "integration passkey",
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to register webauthn credential: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("register credential returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/totp/disable", map[string]any{
|
||||
"code": enableResp.BackupCodes[0].Code,
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to disable totp: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("disable totp returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
loginResp, err := client.postJSON("/auth/login", loginRequest{
|
||||
Email: account.Email,
|
||||
Password: account.Password,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login: %v", err)
|
||||
}
|
||||
if loginResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login returned %d: %s", loginResp.StatusCode, readResponseBody(loginResp))
|
||||
}
|
||||
var login loginResponse
|
||||
decodeJSONResponse(t, loginResp, &login)
|
||||
|
||||
if !login.MFA {
|
||||
t.Fatalf("expected MFA to be required after totp disable")
|
||||
}
|
||||
if login.Ticket == "" {
|
||||
t.Fatalf("expected MFA ticket")
|
||||
}
|
||||
if !login.WebAuthn {
|
||||
t.Fatalf("expected WebAuthn to remain available")
|
||||
}
|
||||
if login.TOTP {
|
||||
t.Fatalf("expected TOTP flag to be false when totp is disabled")
|
||||
}
|
||||
if login.SMS {
|
||||
t.Fatalf("expected SMS flag to stay false for this user")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SmsOnlyReportsTotpFalse", func(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
secret := newTotpSecret(t)
|
||||
|
||||
resp, err := client.postJSONWithAuth("/users/@me/mfa/totp/enable", map[string]string{
|
||||
"secret": secret,
|
||||
"code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable totp: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("enable totp returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
var enableResp backupCodesResponse
|
||||
decodeJSONResponse(t, resp, &enableResp)
|
||||
if len(enableResp.BackupCodes) == 0 {
|
||||
t.Fatalf("expected backup codes after enabling totp")
|
||||
}
|
||||
|
||||
loginWithTotp := loginTestUserWithTotp(t, client, account.Email, account.Password, secret)
|
||||
account.Token = loginWithTotp.Token
|
||||
|
||||
phone := fmt.Sprintf("+1555%07d", time.Now().UnixNano()%1_000_0000)
|
||||
resp, err = client.postJSONWithAuth("/users/@me/phone/send-verification", map[string]string{
|
||||
"phone": phone,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send phone verification: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("send verification returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/phone/verify", map[string]string{
|
||||
"phone": phone,
|
||||
"code": "123456",
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify phone: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("verify phone returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
var phoneVerify phoneVerifyResponse
|
||||
decodeJSONResponse(t, resp, &phoneVerify)
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/phone", map[string]any{
|
||||
"phone_token": phoneVerify.PhoneToken,
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attach phone: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("attach phone returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/sms/enable", map[string]any{
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable sms mfa: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("enable sms returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/totp/disable", map[string]any{
|
||||
"code": enableResp.BackupCodes[0].Code,
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to disable totp: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("disable totp returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
loginResp, err := client.postJSON("/auth/login", loginRequest{
|
||||
Email: account.Email,
|
||||
Password: account.Password,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login: %v", err)
|
||||
}
|
||||
if loginResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login returned %d: %s", loginResp.StatusCode, readResponseBody(loginResp))
|
||||
}
|
||||
var login loginResponse
|
||||
decodeJSONResponse(t, loginResp, &login)
|
||||
|
||||
if login.MFA {
|
||||
t.Fatalf("expected MFA to be disabled once totp (and sms) are removed")
|
||||
}
|
||||
if login.Token == "" {
|
||||
t.Fatalf("expected session token after login")
|
||||
}
|
||||
if login.SMS {
|
||||
t.Fatalf("expected SMS flag to be false when sms mfa is implicitly removed")
|
||||
}
|
||||
if login.TOTP {
|
||||
t.Fatalf("expected TOTP flag to be false when totp is disabled")
|
||||
}
|
||||
if login.WebAuthn {
|
||||
t.Fatalf("expected WebAuthn flag to stay false in this scenario")
|
||||
}
|
||||
})
|
||||
}
|
||||
49
tests/integration/auth_login_mfa_totp_without_secret_test.go
Normal file
49
tests/integration/auth_login_mfa_totp_without_secret_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Users without totpSecret should bypass TOTP check and still get a token.
|
||||
func TestAuthLoginMfaTotpWithoutSecretReturnsSession(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
ticket := "mfa-no-secret"
|
||||
seedMfaTicket(t, client, ticket, account.UserID, 300)
|
||||
|
||||
resp, err := client.postJSON("/auth/login/mfa/totp", map[string]string{
|
||||
"ticket": ticket,
|
||||
"code": "123456",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login mfa totp: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, resp, &loginResp)
|
||||
if loginResp.Token == "" {
|
||||
t.Fatalf("expected token for user without totpSecret")
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
68
tests/integration/auth_login_self_deleted_recovery_test.go
Normal file
68
tests/integration/auth_login_self_deleted_recovery_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Covers auto-recovery for self-deleted accounts with pending deletion.
|
||||
func TestAuthLoginSelfDeletedRecovery(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
setPendingDeletion(t, client, account.UserID, time.Now().Add(-1*time.Hour), true)
|
||||
|
||||
resp, err := client.postJSON("/auth/login", loginRequest{
|
||||
Email: account.Email,
|
||||
Password: account.Password,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to login after marking self-deleted: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
statusResp, err := client.getWithAuth("/test/users/"+account.UserID+"/data-exists", account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch data-exists: %v", err)
|
||||
}
|
||||
if statusResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("data-exists returned %d: %s", statusResp.StatusCode, readResponseBody(statusResp))
|
||||
}
|
||||
var payload struct {
|
||||
PendingDeletionAt *string `json:"pending_deletion_at"`
|
||||
HasSelfDeletedFlag bool `json:"has_self_deleted_flag"`
|
||||
HasDeletedFlag bool `json:"has_deleted_flag"`
|
||||
}
|
||||
decodeJSONResponse(t, statusResp, &payload)
|
||||
if payload.PendingDeletionAt != nil {
|
||||
t.Fatalf("expected pending_deletion_at to be cleared, got %v", *payload.PendingDeletionAt)
|
||||
}
|
||||
if payload.HasSelfDeletedFlag {
|
||||
t.Fatalf("expected SELF_DELETED flag to be cleared")
|
||||
}
|
||||
if payload.HasDeletedFlag {
|
||||
t.Fatalf("expected DELETED flag to be false")
|
||||
}
|
||||
statusResp.Body.Close()
|
||||
}
|
||||
444
tests/integration/auth_mfa_endpoints_test.go
Normal file
444
tests/integration/auth_mfa_endpoints_test.go
Normal file
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthMFAEndpoints(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
device := newWebAuthnDevice(t)
|
||||
var passkeyID string
|
||||
|
||||
secret := newTotpSecret(t)
|
||||
resp, err := client.postJSONWithAuth("/users/@me/mfa/totp/enable", map[string]string{
|
||||
"secret": secret,
|
||||
"code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable totp: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("enable totp returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
|
||||
var enableResp backupCodesResponse
|
||||
decodeJSONResponse(t, resp, &enableResp)
|
||||
if len(enableResp.BackupCodes) == 0 {
|
||||
t.Fatalf("expected backup codes after enabling totp")
|
||||
}
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/backup-codes", map[string]any{
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
"regenerate": false,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch backup codes: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("fetch backup codes returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
var fetched backupCodesResponse
|
||||
decodeJSONResponse(t, resp, &fetched)
|
||||
if len(fetched.BackupCodes) != len(enableResp.BackupCodes) {
|
||||
t.Fatalf("expected %d backup codes, got %d", len(enableResp.BackupCodes), len(fetched.BackupCodes))
|
||||
}
|
||||
|
||||
loginReq := loginRequest{Email: account.Email, Password: account.Password}
|
||||
loginHTTPResp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initiate login for totp: %v", err)
|
||||
}
|
||||
if loginHTTPResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login returned %d: %s", loginHTTPResp.StatusCode, readResponseBody(loginHTTPResp))
|
||||
}
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, loginHTTPResp, &loginResp)
|
||||
if !loginResp.MFA || loginResp.Ticket == "" || !loginResp.TOTP {
|
||||
t.Fatalf("expected login to require totp mfa")
|
||||
}
|
||||
|
||||
resp, err = client.postJSON("/auth/login/mfa/totp", map[string]string{
|
||||
"code": enableResp.BackupCodes[0].Code,
|
||||
"ticket": loginResp.Ticket,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to complete mfa totp login: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("mfa totp login returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
var totpLogin mfaLoginResponse
|
||||
decodeJSONResponse(t, resp, &totpLogin)
|
||||
if totpLogin.Token == "" {
|
||||
t.Fatalf("expected mfa login to return token")
|
||||
}
|
||||
account.Token = totpLogin.Token
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/backup-codes", map[string]any{
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
"regenerate": true,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to regenerate backup codes: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("regenerate backup codes returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
var regenerated backupCodesResponse
|
||||
decodeJSONResponse(t, resp, ®enerated)
|
||||
if len(regenerated.BackupCodes) == 0 {
|
||||
t.Fatalf("expected regenerated backup codes")
|
||||
}
|
||||
|
||||
phone := fmt.Sprintf("+1555%07d", time.Now().UnixNano()%1_000_0000)
|
||||
resp, err = client.postJSONWithAuth("/users/@me/phone/send-verification", map[string]string{"phone": phone}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send phone verification: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("send phone verification returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/phone/verify", map[string]string{
|
||||
"phone": phone,
|
||||
"code": "123456",
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify phone: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("verify phone returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
var phoneVerify phoneVerifyResponse
|
||||
decodeJSONResponse(t, resp, &phoneVerify)
|
||||
if phoneVerify.PhoneToken == "" {
|
||||
t.Fatalf("expected phone verify to return token")
|
||||
}
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/phone", map[string]any{
|
||||
"phone_token": phoneVerify.PhoneToken,
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attach phone: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("attach phone returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/sms/enable", map[string]any{
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable sms mfa: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("enable sms mfa returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
loginHTTPResp, err = client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initiate sms mfa login: %v", err)
|
||||
}
|
||||
if loginHTTPResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login returned %d: %s", loginHTTPResp.StatusCode, readResponseBody(loginHTTPResp))
|
||||
}
|
||||
decodeJSONResponse(t, loginHTTPResp, &loginResp)
|
||||
if !loginResp.MFA || !loginResp.SMS || loginResp.Ticket == "" {
|
||||
t.Fatalf("expected sms mfa requirements in login response")
|
||||
}
|
||||
|
||||
resp, err = client.postJSON("/auth/login/mfa/sms/send", map[string]string{"ticket": loginResp.Ticket})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send sms mfa code: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("sms send returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSON("/auth/login/mfa/sms", map[string]string{
|
||||
"ticket": loginResp.Ticket,
|
||||
"code": "123456",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to complete sms mfa login: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sms mfa login returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
var smsLogin mfaLoginResponse
|
||||
decodeJSONResponse(t, resp, &smsLogin)
|
||||
account.Token = smsLogin.Token
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/sms/disable", map[string]any{
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to disable sms mfa: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("disable sms mfa returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.deleteJSONWithAuth("/users/@me/phone", map[string]any{
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNext(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to remove phone: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("remove phone returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
var registrationOptions webAuthnRegistrationOptions
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/webauthn/credentials/registration-options", map[string]any{
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to request webauthn registration options: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("registration options returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
decodeJSONResponse(t, resp, ®istrationOptions)
|
||||
if registrationOptions.RP.ID != "" {
|
||||
device.rpID = registrationOptions.RP.ID
|
||||
}
|
||||
t.Logf("registration options: rp_id=%s challenge=%s", registrationOptions.RP.ID, registrationOptions.Challenge)
|
||||
|
||||
registrationResponse := device.registerResponse(t, registrationOptions)
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/webauthn/credentials", map[string]any{
|
||||
"response": registrationResponse,
|
||||
"challenge": registrationOptions.Challenge,
|
||||
"name": "Integration Passkey",
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNext(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to register webauthn credential: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("register webauthn credential returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.getWithAuth("/users/@me/mfa/webauthn/credentials", account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list webauthn credentials: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list webauthn credentials returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
var credentials []webAuthnCredentialMetadata
|
||||
decodeJSONResponse(t, resp, &credentials)
|
||||
if len(credentials) != 1 {
|
||||
t.Fatalf("expected one credential, got %d", len(credentials))
|
||||
}
|
||||
passkeyID = credentials[0].ID
|
||||
if passkeyID != encodeBase64URL(device.credentialID) {
|
||||
t.Fatalf("credential id mismatch: server=%s device=%s", passkeyID, encodeBase64URL(device.credentialID))
|
||||
}
|
||||
t.Logf("registered webauthn credential id=%s", passkeyID)
|
||||
|
||||
resp, err = client.patchJSONWithAuth(fmt.Sprintf("/users/@me/mfa/webauthn/credentials/%s", passkeyID), map[string]any{
|
||||
"name": "Renamed Integration Passkey",
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to rename webauthn credential: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("rename webauthn credential returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
loginHTTPResp, err = client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initiate webauthn mfa login: %v", err)
|
||||
}
|
||||
if loginHTTPResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login returned %d: %s", loginHTTPResp.StatusCode, readResponseBody(loginHTTPResp))
|
||||
}
|
||||
decodeJSONResponse(t, loginHTTPResp, &loginResp)
|
||||
if !loginResp.MFA || loginResp.Ticket == "" {
|
||||
t.Fatalf("expected login to require mfa before webauthn assertion")
|
||||
}
|
||||
|
||||
var mfaOptions webAuthnAuthenticationOptions
|
||||
resp, err = client.postJSON("/auth/login/mfa/webauthn/authentication-options", map[string]string{
|
||||
"ticket": loginResp.Ticket,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to request webauthn mfa options: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("mfa webauthn options returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
decodeJSONResponse(t, resp, &mfaOptions)
|
||||
if mfaOptions.RPID != "" {
|
||||
device.rpID = mfaOptions.RPID
|
||||
}
|
||||
t.Logf("mfa webauthn options: rp_id=%s challenge=%s allow=%d", mfaOptions.RPID, mfaOptions.Challenge, len(mfaOptions.AllowCredentials))
|
||||
mfaAssertion := device.authenticationResponse(t, mfaOptions)
|
||||
resp, err = client.postJSON("/auth/login/mfa/webauthn", map[string]any{
|
||||
"response": mfaAssertion,
|
||||
"challenge": mfaOptions.Challenge,
|
||||
"ticket": loginResp.Ticket,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to complete webauthn mfa login: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("mfa webauthn login returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
var webauthnMfaLogin mfaLoginResponse
|
||||
decodeJSONResponse(t, resp, &webauthnMfaLogin)
|
||||
account.Token = webauthnMfaLogin.Token
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/totp/disable", map[string]any{
|
||||
"code": regenerated.BackupCodes[0].Code,
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to disable totp: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("disable totp returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
var discoverableOptions webAuthnAuthenticationOptions
|
||||
resp, err = client.postJSON("/auth/webauthn/authentication-options", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to request discoverable webauthn options: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("discoverable webauthn options returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
decodeJSONResponse(t, resp, &discoverableOptions)
|
||||
if discoverableOptions.RPID != "" {
|
||||
device.rpID = discoverableOptions.RPID
|
||||
}
|
||||
discoverableAssertion := device.authenticationResponse(t, discoverableOptions)
|
||||
resp, err = client.postJSON("/auth/webauthn/authenticate", map[string]any{
|
||||
"response": discoverableAssertion,
|
||||
"challenge": discoverableOptions.Challenge,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to complete discoverable webauthn login: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("discoverable webauthn login returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
var passkeyLogin mfaLoginResponse
|
||||
decodeJSONResponse(t, resp, &passkeyLogin)
|
||||
account.Token = passkeyLogin.Token
|
||||
|
||||
// Get WebAuthn sudo challenge to delete the credential
|
||||
var sudoWebAuthnOptions webAuthnAuthenticationOptions
|
||||
resp, err = client.postJSONWithAuth("/users/@me/sudo/webauthn/authentication-options", map[string]any{}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get sudo webauthn options: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sudo webauthn options returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
decodeJSONResponse(t, resp, &sudoWebAuthnOptions)
|
||||
if sudoWebAuthnOptions.RPID != "" {
|
||||
device.rpID = sudoWebAuthnOptions.RPID
|
||||
}
|
||||
sudoAssertion := device.authenticationResponse(t, sudoWebAuthnOptions)
|
||||
|
||||
resp, err = client.deleteJSONWithAuth(fmt.Sprintf("/users/@me/mfa/webauthn/credentials/%s", passkeyID), map[string]any{
|
||||
"mfa_method": "webauthn",
|
||||
"webauthn_response": sudoAssertion,
|
||||
"webauthn_challenge": sudoWebAuthnOptions.Challenge,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete webauthn credential: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete webauthn credential returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
clearTestEmails(t, client)
|
||||
|
||||
// Use an explicit different IP for the attacker to ensure IP authorization is tested
|
||||
// even when FLUXER_TEST_IP environment variable is set
|
||||
attackerIP := "10.99.88.77"
|
||||
if attackerIP == client.clientIP {
|
||||
attackerIP = "10.99.88.78"
|
||||
}
|
||||
attacker := &testClient{
|
||||
baseURL: client.baseURL,
|
||||
httpClient: client.httpClient,
|
||||
clientIP: attackerIP,
|
||||
}
|
||||
attackResp, err := attacker.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attempt login from new ip: %v", err)
|
||||
}
|
||||
if attackResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected new ip login to fail with 403, got %d: %s", attackResp.StatusCode, readResponseBody(attackResp))
|
||||
}
|
||||
|
||||
email := waitForEmail(t, client, "ip_authorization", account.Email)
|
||||
token, ok := email.Metadata["token"]
|
||||
if !ok || token == "" {
|
||||
t.Fatalf("expected ip authorization email token")
|
||||
}
|
||||
|
||||
resp, err = attacker.postJSON("/auth/authorize-ip", map[string]string{"token": token})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to authorize ip: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("authorize ip returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
loginResult := loginTestUser(t, attacker, account.Email, account.Password)
|
||||
if loginResult.MFA {
|
||||
t.Fatalf("did not expect mfa after authorizing ip")
|
||||
}
|
||||
account.Token = loginResult.Token
|
||||
}
|
||||
163
tests/integration/auth_mfa_sms_enable_disable_test.go
Normal file
163
tests/integration/auth_mfa_sms_enable_disable_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthMFASMSEnableDisable(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
secret := newTotpSecret(t)
|
||||
resp, err := client.postJSONWithAuth("/users/@me/mfa/totp/enable", map[string]string{
|
||||
"secret": secret,
|
||||
"code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable totp: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("enable totp returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
phone := fmt.Sprintf("+1555%07d", time.Now().UnixNano()%10000000)
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/phone/send-verification", map[string]string{
|
||||
"phone": phone,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send phone verification: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("send phone verification returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/phone/verify", map[string]string{
|
||||
"phone": phone,
|
||||
"code": "123456",
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify phone: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("verify phone returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
|
||||
var phoneVerify phoneVerifyResponse
|
||||
decodeJSONResponse(t, resp, &phoneVerify)
|
||||
if phoneVerify.PhoneToken == "" {
|
||||
t.Fatalf("expected phone verify to return token")
|
||||
}
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/phone", map[string]any{
|
||||
"phone_token": phoneVerify.PhoneToken,
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attach phone: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("attach phone returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/sms/enable", map[string]any{
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable sms mfa: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("enable sms mfa returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
loginReq := loginRequest{Email: account.Email, Password: account.Password}
|
||||
loginHTTPResp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initiate login: %v", err)
|
||||
}
|
||||
if loginHTTPResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login returned %d: %s", loginHTTPResp.StatusCode, readResponseBody(loginHTTPResp))
|
||||
}
|
||||
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, loginHTTPResp, &loginResp)
|
||||
if !loginResp.MFA {
|
||||
t.Fatalf("expected MFA to be required after enabling SMS MFA")
|
||||
}
|
||||
if !loginResp.SMS {
|
||||
t.Fatalf("expected SMS MFA to be enabled in login response")
|
||||
}
|
||||
if loginResp.Ticket == "" {
|
||||
t.Fatalf("expected ticket in login response when MFA is required")
|
||||
}
|
||||
resp, err = client.postJSON("/auth/login/mfa/totp", map[string]string{
|
||||
"code": totpCodeNow(t, secret),
|
||||
"ticket": loginResp.Ticket,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to complete mfa login: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("mfa login returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
|
||||
var mfaLogin mfaLoginResponse
|
||||
decodeJSONResponse(t, resp, &mfaLogin)
|
||||
account.Token = mfaLogin.Token
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/sms/disable", map[string]any{
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to disable sms mfa: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("disable sms mfa returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
loginHTTPResp, err = client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initiate login after disabling SMS: %v", err)
|
||||
}
|
||||
if loginHTTPResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login after disabling SMS returned %d: %s", loginHTTPResp.StatusCode, readResponseBody(loginHTTPResp))
|
||||
}
|
||||
|
||||
decodeJSONResponse(t, loginHTTPResp, &loginResp)
|
||||
if loginResp.SMS {
|
||||
t.Fatalf("expected SMS MFA to be disabled in login response after disabling")
|
||||
}
|
||||
if !loginResp.MFA || !loginResp.TOTP {
|
||||
t.Fatalf("expected TOTP MFA to still be required after disabling SMS")
|
||||
}
|
||||
}
|
||||
198
tests/integration/auth_mfa_sms_login_flow_test.go
Normal file
198
tests/integration/auth_mfa_sms_login_flow_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthMFASMSLoginFlow(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
secret := newTotpSecret(t)
|
||||
resp, err := client.postJSONWithAuth("/users/@me/mfa/totp/enable", map[string]string{
|
||||
"secret": secret,
|
||||
"code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable totp: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("enable totp returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
phone := fmt.Sprintf("+1555%07d", time.Now().UnixNano()%10000000)
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/phone/send-verification", map[string]string{
|
||||
"phone": phone,
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send phone verification: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("send phone verification returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/phone/verify", map[string]string{
|
||||
"phone": phone,
|
||||
"code": "123456",
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify phone: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("verify phone returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
|
||||
var phoneVerify phoneVerifyResponse
|
||||
decodeJSONResponse(t, resp, &phoneVerify)
|
||||
if phoneVerify.PhoneToken == "" {
|
||||
t.Fatalf("expected phone verify to return token")
|
||||
}
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/phone", map[string]any{
|
||||
"phone_token": phoneVerify.PhoneToken,
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to attach phone: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("attach phone returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSONWithAuth("/users/@me/mfa/sms/enable", map[string]any{
|
||||
"mfa_method": "totp",
|
||||
"mfa_code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable sms mfa: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("enable sms mfa returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
loginReq := loginRequest{Email: account.Email, Password: account.Password}
|
||||
loginHTTPResp, err := client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initiate sms mfa login: %v", err)
|
||||
}
|
||||
if loginHTTPResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login returned %d: %s", loginHTTPResp.StatusCode, readResponseBody(loginHTTPResp))
|
||||
}
|
||||
|
||||
var loginResp loginResponse
|
||||
decodeJSONResponse(t, loginHTTPResp, &loginResp)
|
||||
if !loginResp.MFA {
|
||||
t.Fatalf("expected MFA to be required")
|
||||
}
|
||||
if !loginResp.SMS {
|
||||
t.Fatalf("expected SMS MFA to be available")
|
||||
}
|
||||
if loginResp.Ticket == "" {
|
||||
t.Fatalf("expected ticket in login response")
|
||||
}
|
||||
|
||||
resp, err = client.postJSON("/auth/login/mfa/sms/send", map[string]string{
|
||||
"ticket": loginResp.Ticket,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to send sms mfa code: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("sms send returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSON("/auth/login/mfa/sms", map[string]string{
|
||||
"ticket": loginResp.Ticket,
|
||||
"code": "123456",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to complete sms mfa login: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sms mfa login returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
|
||||
var smsLogin mfaLoginResponse
|
||||
decodeJSONResponse(t, resp, &smsLogin)
|
||||
if smsLogin.Token == "" {
|
||||
t.Fatalf("expected sms mfa login to return token")
|
||||
}
|
||||
|
||||
resp, err = client.getWithAuth("/users/@me", smsLogin.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch current user with sms mfa token: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("fetch current user returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
|
||||
var user struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
decodeJSONResponse(t, resp, &user)
|
||||
if user.ID != account.UserID {
|
||||
t.Fatalf("expected user id to be %s, got %s", account.UserID, user.ID)
|
||||
}
|
||||
if user.Email != account.Email {
|
||||
t.Fatalf("expected user email to be %s, got %s", account.Email, user.Email)
|
||||
}
|
||||
loginHTTPResp, err = client.postJSON("/auth/login", loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initiate second login: %v", err)
|
||||
}
|
||||
if loginHTTPResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second login returned %d: %s", loginHTTPResp.StatusCode, readResponseBody(loginHTTPResp))
|
||||
}
|
||||
|
||||
decodeJSONResponse(t, loginHTTPResp, &loginResp)
|
||||
if !loginResp.TOTP {
|
||||
t.Fatalf("expected TOTP to also be available as MFA method")
|
||||
}
|
||||
|
||||
resp, err = client.postJSON("/auth/login/mfa/totp", map[string]string{
|
||||
"code": totpCodeNow(t, secret),
|
||||
"ticket": loginResp.Ticket,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to complete totp mfa login: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp mfa login returned %d: %s", resp.StatusCode, readResponseBody(resp))
|
||||
}
|
||||
|
||||
var totpLogin mfaLoginResponse
|
||||
decodeJSONResponse(t, resp, &totpLogin)
|
||||
if totpLogin.Token == "" {
|
||||
t.Fatalf("expected totp mfa login to return token")
|
||||
}
|
||||
}
|
||||
85
tests/integration/auth_mfa_ticket_expiry_and_reuse_test.go
Normal file
85
tests/integration/auth_mfa_ticket_expiry_and_reuse_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer 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 Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Covers MFA ticket expiry and reuse rejection for TOTP.
|
||||
func TestAuthMfaTicketExpiryAndReuse(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
account := createTestAccount(t, client)
|
||||
|
||||
secret := newTotpSecret(t)
|
||||
resp, err := client.postJSONWithAuth("/users/@me/mfa/totp/enable", map[string]string{
|
||||
"secret": secret,
|
||||
"code": totpCodeNow(t, secret),
|
||||
}, account.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to enable totp: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
expiredTicket := fmt.Sprintf("expired-%d", time.Now().UnixNano())
|
||||
seedMfaTicket(t, client, expiredTicket, account.UserID, 1)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
resp, err = client.postJSON("/auth/login/mfa/totp", map[string]string{
|
||||
"ticket": expiredTicket,
|
||||
"code": totpCodeNow(t, secret),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login mfa totp with expired ticket: %v", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected expired ticket to fail, got 200")
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
validTicket := fmt.Sprintf("valid-%d", time.Now().UnixNano())
|
||||
seedMfaTicket(t, client, validTicket, account.UserID, 300)
|
||||
|
||||
resp, err = client.postJSON("/auth/login/mfa/totp", map[string]string{
|
||||
"ticket": validTicket,
|
||||
"code": totpCodeNow(t, secret),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login mfa totp: %v", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = client.postJSON("/auth/login/mfa/totp", map[string]string{
|
||||
"ticket": validTicket,
|
||||
"code": totpCodeNow(t, secret),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to call login mfa totp reuse: %v", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("expected reused ticket to fail")
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user