Skip to content

Commit 427444f

Browse files
authored
[FEATURE] Custom Expiry Feature (#131)
* new feat secret expiry working, but messy * fix tests * add test * added tests for model * Apply suggestions from code review
1 parent 4851705 commit 427444f

File tree

9 files changed

+317
-52
lines changed

9 files changed

+317
-52
lines changed

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/onsi/gomega v1.31.1
1212
github.com/sirupsen/logrus v1.9.3
1313
github.com/slack-go/slack v0.13.1
14+
github.com/stretchr/testify v1.8.4
1415
go.elastic.co/apm v1.15.0
1516
go.elastic.co/apm/module/apmgin v1.15.0
1617
go.elastic.co/apm/module/apmgormv2 v1.15.0
@@ -28,6 +29,7 @@ require (
2829

2930
require (
3031
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
32+
github.com/davecgh/go-spew v1.1.1 // indirect
3133
github.com/elastic/go-licenser v0.3.1 // indirect
3234
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
3335
github.com/go-logr/logr v1.4.1 // indirect
@@ -38,6 +40,7 @@ require (
3840
github.com/jackc/puddle/v2 v2.2.1 // indirect
3941
github.com/jcchavezs/porto v0.1.0 // indirect
4042
github.com/mattn/go-sqlite3 v1.14.17 // indirect
43+
github.com/pmezard/go-difflib v1.0.0 // indirect
4144
github.com/rogpeppe/go-internal v1.11.0 // indirect
4245
go.opentelemetry.io/otel/metric v1.24.0 // indirect
4346
go.opentelemetry.io/otel/trace v1.24.0 // indirect

pkg/secretmessage/handle_interactive.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,20 @@ func (ctl *PublicController) HandleInteractive(c *gin.Context) {
2626
}
2727
tx.Context.SetLabel("userHash", hash(i.User.ID))
2828
tx.Context.SetLabel("teamHash", hash(i.Team.ID))
29-
callbackType := strings.Split(i.CallbackID, ":")[0]
30-
switch callbackType {
31-
case actions.ReadMessage:
32-
CallbackReadSecret(ctl, tx, c, i)
33-
case actions.DeleteMessage:
34-
CallbackDeleteSecret(ctl, tx, c, i)
29+
30+
switch i.Type {
31+
case slack.InteractionTypeViewSubmission:
32+
CallbackViewSubmission(ctl, tx, c, i)
3533
default:
36-
log.Error("Hit the default case. bad things happened")
37-
c.Data(http.StatusInternalServerError, gin.MIMEPlain, nil)
34+
callbackType := strings.Split(i.CallbackID, ":")[0]
35+
switch callbackType {
36+
case actions.ReadMessage:
37+
CallbackReadSecret(ctl, tx, c, i)
38+
case actions.DeleteMessage:
39+
CallbackDeleteSecret(ctl, tx, c, i)
40+
default:
41+
log.Error("Hit the default case. bad things happened")
42+
c.Data(http.StatusInternalServerError, gin.MIMEPlain, nil)
43+
}
3844
}
3945
}

pkg/secretmessage/handle_interactive_test.go

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
. "github.com/onsi/ginkgo"
1919
. "github.com/onsi/gomega"
2020
"github.com/slack-go/slack"
21+
2122
"gorm.io/driver/sqlite"
2223
"gorm.io/gorm"
2324
)
@@ -44,7 +45,6 @@ var _ = Describe("/interactive", func() {
4445
}
4546

4647
BeforeEach(func() {
47-
httpmock.Activate()
4848
gdb, err = gorm.Open(sqlite.Open("file::memory:?cache=shared&dbname=handle_interactive_get"), &gorm.Config{})
4949
if err != nil {
5050
log.Fatal(err)
@@ -61,7 +61,6 @@ var _ = Describe("/interactive", func() {
6161
serverResponse = doHttpRequest(router, strings.NewReader(requestBody.Encode()), map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, "POST", "/interactive")
6262
})
6363
AfterEach(func() {
64-
httpmock.DeactivateAndReset()
6564
db, _ := gdb.DB()
6665
db.Close()
6766
})
@@ -99,6 +98,25 @@ var _ = Describe("/interactive", func() {
9998
Expect(msg.DeleteOriginal).To(BeTrue())
10099
})
101100
})
101+
Context("on secret expired", func() {
102+
BeforeEach(func() {
103+
// Insert the secret with an expired timestamp
104+
tx := gdb.Create(&secretmessage.Secret{
105+
ID: secretIDHashed,
106+
Value: encryptedPayload,
107+
ExpiresAt: time.Now().Add(-time.Hour), // expired 1 hour ago
108+
})
109+
Expect(tx.RowsAffected).To(BeEquivalentTo(1))
110+
})
111+
It("should return error message for expired secret", func() {
112+
var msg slack.Message
113+
b, _ := ioutil.ReadAll(serverResponse.Body)
114+
json.Unmarshal(b, &msg)
115+
Expect(serverResponse.Code).To(Equal(http.StatusOK))
116+
Expect(msg.Attachments[0].Text).To(MatchRegexp(`This Secret has expired`))
117+
Expect(msg.DeleteOriginal).To(BeTrue())
118+
})
119+
})
102120
Context("on db error", func() {
103121
BeforeEach(func() {
104122
// force an error by closing DB
@@ -161,4 +179,76 @@ var _ = Describe("/interactive", func() {
161179
})
162180
})
163181
})
182+
183+
Describe("Modal Submit", func() {
184+
// setup httpmock for responseURl from privatemetadata
185+
responseURL := "https://hooks.slack.com/actions/T00000000/1234567890/abcdefghijklmnopqrstuvwxyz"
186+
187+
interactionPayload := slack.InteractionCallback{
188+
Type: slack.InteractionTypeViewSubmission,
189+
View: slack.View{
190+
PrivateMetadata: responseURL,
191+
Type: "modal",
192+
CallbackID: "test_modal_submit",
193+
State: &slack.ViewState{
194+
Values: map[string]map[string]slack.BlockAction{
195+
"secret_text_input": {
196+
"secret_text_input": slack.BlockAction{
197+
Value: "example secret text",
198+
},
199+
},
200+
"expiry_date_input": {
201+
"expiry_date_input": slack.BlockAction{
202+
SelectedDate: "2024-06-01",
203+
},
204+
},
205+
},
206+
},
207+
},
208+
}
209+
interactionBytes, err := json.Marshal(interactionPayload)
210+
if err != nil {
211+
log.Fatal(err)
212+
}
213+
requestBody := url.Values{
214+
"payload": []string{string(interactionBytes)},
215+
}
216+
217+
BeforeEach(func() {
218+
// Configuration
219+
httpmock.Activate()
220+
221+
gdb, err = gorm.Open(sqlite.Open("file::memory:?cache=shared&dbname=handle_interactive_delete"), &gorm.Config{})
222+
if err != nil {
223+
log.Fatal(err)
224+
}
225+
gdb.AutoMigrate(secretmessage.Team{})
226+
gdb.AutoMigrate(secretmessage.Secret{})
227+
ctl = secretmessage.NewController(
228+
secretmessage.Config{SkipSignatureValidation: true},
229+
gdb,
230+
)
231+
232+
})
233+
JustBeforeEach(func() {
234+
// creation of objects
235+
router = ctl.ConfigureRoutes()
236+
serverResponse = doHttpRequest(router, strings.NewReader(requestBody.Encode()), map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, "POST", "/interactive")
237+
})
238+
AfterEach(func() {
239+
httpmock.DeactivateAndReset()
240+
db, _ := gdb.DB()
241+
db.Close()
242+
})
243+
244+
Context("on happy path", func() {
245+
BeforeEach(func() {
246+
httpmock.RegisterResponder("POST", responseURL, httpmock.NewStringResponder(200, `ok`))
247+
248+
})
249+
It("should return 200", func() {
250+
Expect(serverResponse.Code).To(Equal(http.StatusOK))
251+
})
252+
})
253+
})
164254
})

pkg/secretmessage/handle_slash_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ var _ = Describe("/secret", func() {
125125
var msg slack.Message
126126
b, _ := ioutil.ReadAll(serverResponse.Body)
127127
json.Unmarshal(b, &msg)
128-
Expect(msg.Attachments[0].Text).To(MatchRegexp(`An error occurred attempting to create secret`))
128+
Expect(msg.Attachments[0].Text).To(MatchRegexp(`An error occurred`))
129129
})
130130
It("should respond with 200", func() {
131131
Expect(serverResponse.Code).To(Equal(http.StatusOK))
@@ -149,12 +149,12 @@ var _ = Describe("/secret", func() {
149149
"channel_name": []string{"fishbowl"},
150150
"trigger_id": []string{"0000000000.1111111111.222222222222aaaaaaaaaaaaaa"},
151151
}
152+
gdb.Create(&secretmessage.Team{ID: teamID, AccessToken: accessToken})
153+
154+
httpmock.RegisterResponder("POST", "https://slack.com/api/views.open", httpmock.NewStringResponder(200, `{"ok": true}`))
152155
})
153-
It("should return a useful error message", func() {
154-
var msg slack.Message
155-
b, _ := ioutil.ReadAll(serverResponse.Body)
156-
json.Unmarshal(b, &msg)
157-
Expect(msg.Attachments[0].Text).To(MatchRegexp(`It looks like you tried to send a secret but forgot to provide the secret's text`))
156+
It("should POST to views.open", func() {
157+
Expect(httpmock.GetTotalCallCount()).To(Equal(1))
158158
})
159159
It("should respond with 200", func() {
160160
Expect(serverResponse.Code).To(Equal(http.StatusOK))
@@ -168,7 +168,7 @@ var _ = Describe("/secret", func() {
168168
var msg slack.Message
169169
b, _ := ioutil.ReadAll(serverResponse.Body)
170170
json.Unmarshal(b, &msg)
171-
Expect(msg.Attachments[0].Text).To(MatchRegexp(`An error occurred attempting to create secret`))
171+
Expect(msg.Attachments[0].Text).To(MatchRegexp(`An error occurred`))
172172
})
173173
It("should respond with 200", func() {
174174
Expect(serverResponse.Code).To(Equal(http.StatusOK))

pkg/secretmessage/interactive.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package secretmessage
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"net/http"
78
"strings"
9+
"time"
810

911
"github.com/gin-gonic/gin"
1012
"github.com/neufeldtech/secretmessage-go/pkg/secretmessage/actions"
@@ -32,6 +34,14 @@ func CallbackReadSecret(ctl *PublicController, tx *apm.Transaction, c *gin.Conte
3234
var errCallback string
3335
var deleteOriginal bool
3436
switch {
37+
case !secret.ExpiresAt.IsZero() && secret.ExpiresAt.Before(time.Now()):
38+
getSecretErr = errors.New("Secret expired")
39+
tx.Context.SetLabel("errorCode", "secret_expired")
40+
errTitle = ":hourglass: Secret expired"
41+
errMsg = "This Secret has expired"
42+
errCallback = "secret_expired"
43+
deleteOriginal = true
44+
ctl.db.WithContext(hc).Unscoped().Where("id = ?", hash(secretID)).Delete(Secret{})
3545
case getSecretErr == gorm.ErrRecordNotFound:
3646
tx.Context.SetLabel("errorCode", "secret_not_found")
3747
errTitle = ":question: Secret not found"
@@ -139,3 +149,25 @@ func CallbackDeleteSecret(ctl *PublicController, tx *apm.Transaction, c *gin.Con
139149
}
140150
c.Data(http.StatusOK, gin.MIMEJSON, responseBytes)
141151
}
152+
153+
func CallbackViewSubmission(ctl *PublicController, tx *apm.Transaction, c *gin.Context, i slack.InteractionCallback) {
154+
tx.Context.SetLabel("callbackID", i.CallbackID)
155+
tx.Context.SetLabel("action", "viewSubmission")
156+
157+
secretTextVal := i.View.State.Values["secret_text_input"]["secret_text_input"].Value
158+
datePickerVal := i.View.State.Values["expiry_date_input"]["expiry_date_input"].SelectedDate
159+
160+
dateParsed, err := time.Parse("2006-01-02", datePickerVal)
161+
if err != nil {
162+
log.Errorf("error parsing date: %v, using default expiry date...", err)
163+
}
164+
165+
err = PrepareAndSendSecretEnvelope(ctl, c, tx, secretTextVal, i.Team.ID, i.User.Name, i.View.PrivateMetadata, WithExpiryDate(dateParsed))
166+
if err != nil {
167+
log.Errorf("error preparing and sending secret envelope: %v", err)
168+
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"status": "Error with the stuffs"})
169+
tx.Context.SetLabel("errorCode", "prepare_and_send_secret_error")
170+
return
171+
}
172+
c.Data(http.StatusOK, gin.MIMEPlain, nil)
173+
}

pkg/secretmessage/model.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ type Secret struct {
1414
Value string
1515
}
1616

17+
type SecretOption func(*Secret) *Secret
18+
1719
type Team struct {
1820
gorm.Model
1921
ID string
@@ -22,3 +24,36 @@ type Team struct {
2224
Name string
2325
Paid sql.NullBool `gorm:"default:false"`
2426
}
27+
28+
func WithExpiryDate(expiryDate time.Time) SecretOption {
29+
return func(s *Secret) *Secret {
30+
s.ExpiresAt = expiryDate
31+
return s
32+
}
33+
}
34+
35+
func NewSecret(id string, value string, opts ...SecretOption) *Secret {
36+
secret := &Secret{
37+
ID: id,
38+
Value: value,
39+
}
40+
41+
for _, opt := range opts {
42+
opt(secret)
43+
}
44+
45+
if secret.ExpiresAt.IsZero() {
46+
// Default to 7 days expiry if not provided
47+
secret.ExpiresAt = time.Now().AddDate(0, 0, 7)
48+
}
49+
// if expiry date is more than 30 days in the future, set it to 30 days
50+
if secret.ExpiresAt.After(time.Now().AddDate(0, 0, 30)) {
51+
secret.ExpiresAt = time.Now().AddDate(0, 0, 30)
52+
}
53+
54+
// If expiry date is in the past, set it to now
55+
if secret.ExpiresAt.Before(time.Now()) {
56+
secret.ExpiresAt = time.Now()
57+
}
58+
return secret
59+
}

pkg/secretmessage/model_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package secretmessage
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestNewSecret_DefaultExpiry(t *testing.T) {
11+
id := "abc123"
12+
value := "mysecret"
13+
secret := NewSecret(id, value)
14+
15+
assert.Equal(t, id, secret.ID)
16+
assert.Equal(t, value, secret.Value)
17+
assert.WithinDuration(t, time.Now().AddDate(0, 0, 7), secret.ExpiresAt, time.Second*2)
18+
}
19+
20+
func TestNewSecret_WithExpiryDate(t *testing.T) {
21+
id := "abc123"
22+
value := "mysecret"
23+
expiry := time.Now().AddDate(0, 0, 3)
24+
secret := NewSecret(id, value, WithExpiryDate(expiry))
25+
26+
assert.Equal(t, id, secret.ID)
27+
assert.Equal(t, value, secret.Value)
28+
assert.WithinDuration(t, expiry, secret.ExpiresAt, time.Second*2)
29+
}
30+
31+
func TestNewSecret_ExpiryMoreThan30Days(t *testing.T) {
32+
id := "abc123"
33+
value := "mysecret"
34+
expiry := time.Now().AddDate(0, 0, 40)
35+
secret := NewSecret(id, value, WithExpiryDate(expiry))
36+
37+
maxExpiry := time.Now().AddDate(0, 0, 30)
38+
assert.WithinDuration(t, maxExpiry, secret.ExpiresAt, time.Second*2)
39+
}
40+
41+
func TestNewSecret_ExpiryInPast(t *testing.T) {
42+
id := "abc123"
43+
value := "mysecret"
44+
expiry := time.Now().Add(-time.Hour * 24)
45+
secret := NewSecret(id, value, WithExpiryDate(expiry))
46+
47+
assert.WithinDuration(t, time.Now(), secret.ExpiresAt, time.Second*2)
48+
}

0 commit comments

Comments
 (0)