@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/api/openapi.yaml +119 -0
- package/backend/cmd/server/main.go +36 -2
- package/backend/db/migration/V005__idea.sql +49 -0
- package/backend/db/migration/V006__login_lock.sql +15 -0
- package/backend/db/migration/V007__login_attempt.sql +15 -0
- package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
- package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
- package/backend/go.mod +17 -3
- package/backend/go.sum +48 -7
- package/backend/internal/account/account_test.go +6 -6
- package/backend/internal/account/integration_test.go +45 -9
- package/backend/internal/account/internal/passwords/NOTICE +36 -0
- package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
- package/backend/internal/account/internal/passwords/passwords.go +37 -0
- package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
- package/backend/internal/account/keyboard.go +75 -0
- package/backend/internal/account/keyboard_test.go +43 -0
- package/backend/internal/account/logging_integration_test.go +1 -1
- package/backend/internal/account/password.go +131 -0
- package/backend/internal/account/sequence.go +54 -0
- package/backend/internal/account/sequence_test.go +38 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +262 -0
- package/backend/internal/auth/internal/store/login_attempt.go +53 -0
- package/backend/internal/auth/internal/store/login_lock.go +77 -0
- package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
- package/backend/internal/auth/internal/store/session.go +15 -7
- package/backend/internal/auth/login.go +16 -4
- package/backend/internal/auth/login_integration_test.go +14 -1
- package/backend/internal/auth/login_ip_integration_test.go +153 -0
- package/backend/internal/auth/login_lock_integration_test.go +205 -0
- package/backend/internal/auth/login_test.go +15 -0
- package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
- package/backend/internal/auth/purge.go +93 -0
- package/backend/internal/auth/purge_integration_test.go +220 -0
- package/backend/internal/auth/purge_test.go +51 -0
- package/backend/internal/auth/session.go +0 -6
- package/backend/internal/auth/session_integration_test.go +3 -3
- package/backend/internal/auth/throttle.go +122 -0
- package/backend/internal/deadline/gate.go +80 -0
- package/backend/internal/deadline/gate_integration_test.go +67 -0
- package/backend/internal/deadline/gate_test.go +61 -0
- package/backend/internal/idea/create.go +356 -0
- package/backend/internal/idea/create_integration_test.go +680 -0
- package/backend/internal/idea/download.go +193 -0
- package/backend/internal/idea/download_integration_test.go +311 -0
- package/backend/internal/idea/download_test.go +25 -0
- package/backend/internal/idea/handler.go +151 -0
- package/backend/internal/idea/handler_integration_test.go +107 -0
- package/backend/internal/idea/integration_test.go +149 -0
- package/backend/internal/idea/internal/store/list.go +146 -0
- package/backend/internal/idea/internal/store/list_test.go +33 -0
- package/backend/internal/idea/internal/store/store.go +166 -0
- package/backend/internal/idea/list.go +166 -0
- package/backend/internal/idea/list_integration_test.go +252 -0
- package/backend/internal/idea/list_test.go +96 -0
- package/backend/internal/idea/service.go +54 -0
- package/backend/internal/server/api.go +22 -0
- package/backend/internal/server/binding.go +4 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/server.go +20 -1
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/server/slides_test.go +82 -0
- package/backend/internal/shared/config/config.go +44 -1
- package/backend/internal/shared/config/config_test.go +21 -13
- package/backend/internal/shared/db/intx_integration_test.go +179 -0
- package/backend/internal/shared/dbtest/dbtest.go +21 -3
- package/backend/internal/shared/dbtest/faults.go +162 -0
- package/backend/internal/shared/job/job.go +54 -0
- package/backend/internal/shared/job/job_test.go +105 -0
- package/backend/internal/shared/web/multipart.go +175 -0
- package/backend/internal/shared/web/multipart_test.go +169 -0
- package/backend/internal/shared/web/problem.go +1 -0
- package/backend/internal/shared/web/request.go +42 -1
- package/backend/internal/shared/web/transport.go +4 -3
- package/backend/internal/shared/web/web_test.go +36 -0
- package/backend/internal/storage/expectedfilename.go +43 -0
- package/backend/internal/storage/expectedfilename_test.go +16 -0
- package/backend/internal/storage/internal/store/pending.go +41 -0
- package/backend/internal/storage/minio.go +139 -0
- package/backend/internal/storage/minio_integration_test.go +87 -0
- package/backend/internal/storage/minio_test.go +84 -0
- package/backend/internal/storage/pending.go +144 -0
- package/backend/internal/storage/pending_integration_test.go +324 -0
- package/backend/internal/storage/pending_test.go +23 -0
- package/backend/internal/storage/validator.go +100 -0
- package/backend/internal/storage/validator_test.go +95 -0
- package/backend/test/arch/arch_test.go +5 -1
- package/backend/test/contract/expected_filename_test.go +60 -0
- package/backend/test/contract/password_test.go +46 -0
- package/backend/test/matrix/load_test.go +12 -0
- package/backend/test/matrix/matrix_integration_test.go +92 -5
- package/backend/test/migration/pending_file_deletion_test.go +46 -0
- package/docs/API/api-endpoints.md +1 -1
- package/docs/API/error-codes.md +1 -1
- package/docs/Architecture/Architecture.md +1 -1
- package/docs/Architecture/Backend.md +2 -1
- package/frontend/Dockerfile +2 -0
- package/frontend/package-lock.json +1 -0
- package/frontend/package.json +1 -0
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
- package/frontend/src/app/pages/ideas.tsx +23 -9
- package/frontend/src/app/pages/login.test.tsx +38 -1
- package/frontend/src/app/pages/register.test.tsx +32 -0
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- package/frontend/src/features/auth/LoginForm.tsx +21 -3
- package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
- package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
- package/frontend/src/features/auth/RegisterForm.tsx +11 -2
- package/frontend/src/features/auth/common-passwords.ts +31 -0
- package/frontend/src/features/auth/password-policy.test.ts +64 -0
- package/frontend/src/features/auth/password-policy.ts +168 -0
- package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
- package/frontend/src/features/auth/useCountdown.ts +28 -0
- package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
- package/frontend/src/features/idea/create.ts +123 -0
- package/frontend/src/shared/api/activity.ts +20 -0
- package/frontend/src/shared/api/http.ts +2 -0
- package/frontend/src/shared/api/index.ts +1 -0
- package/frontend/src/shared/api/types.generated.ts +319 -1
- package/frontend/src/shared/i18n/locales/en.ts +46 -4
- package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
- package/frontend/src/shared/slides/index.ts +74 -0
- package/frontend/src/shared/slides/slides.test.ts +51 -0
- package/frontend/src/shared/ui/copy-button.tsx +52 -0
- package/frontend/src/shared/ui/native-select.tsx +27 -0
- package/frontend/src/shared/ui/textarea.tsx +19 -0
- package/frontend/src/test-setup.ts +37 -2
- package/frontend/vite.config.ts +3 -1
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +582 -0
- package/testdata/permission-matrix.json +71 -0
- package/testdata/schema/expected-filename.schema.json +32 -0
- package/testdata/schema/password.schema.json +59 -0
- package/testdata/schema/permission-matrix.schema.json +22 -1
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package idea_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"archive/zip"
|
|
7
|
+
"bytes"
|
|
8
|
+
"context"
|
|
9
|
+
"crypto/sha256"
|
|
10
|
+
"database/sql"
|
|
11
|
+
"encoding/hex"
|
|
12
|
+
"encoding/json"
|
|
13
|
+
"fmt"
|
|
14
|
+
"io"
|
|
15
|
+
"mime/multipart"
|
|
16
|
+
"net/http"
|
|
17
|
+
"net/http/httptest"
|
|
18
|
+
"strings"
|
|
19
|
+
"sync"
|
|
20
|
+
"testing"
|
|
21
|
+
|
|
22
|
+
"github.com/go-chi/chi/v5"
|
|
23
|
+
"golang.org/x/text/unicode/norm"
|
|
24
|
+
|
|
25
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
26
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
|
|
27
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
|
|
28
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/server"
|
|
29
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
30
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
31
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
32
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/storage"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
// fakeObjects is an in-memory storage.SlideStore. before runs inside Put, so a
|
|
36
|
+
// test can change the database between the pre-checks and the transaction.
|
|
37
|
+
// deleteErr makes Delete fail (MinIO down after the upload). For Get: onGet
|
|
38
|
+
// runs first, getErr fails the open (MinIO down before the first byte), and
|
|
39
|
+
// a positive failAfter makes the stream fail with streamErr after that many
|
|
40
|
+
// bytes (MinIO down mid-stream).
|
|
41
|
+
type fakeObjects struct {
|
|
42
|
+
mu sync.Mutex
|
|
43
|
+
puts map[string][]byte
|
|
44
|
+
types map[string]string
|
|
45
|
+
fail error
|
|
46
|
+
deleteErr error
|
|
47
|
+
deleted []string
|
|
48
|
+
last string // key of the last successful Put
|
|
49
|
+
before func()
|
|
50
|
+
|
|
51
|
+
onGet func()
|
|
52
|
+
getErr error
|
|
53
|
+
failAfter int64
|
|
54
|
+
streamErr error
|
|
55
|
+
gets int
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func newFakeObjects() *fakeObjects {
|
|
59
|
+
return &fakeObjects{puts: map[string][]byte{}, types: map[string]string{}}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func (f *fakeObjects) Put(_ context.Context, key string, r io.Reader, size int64, ct string) error {
|
|
63
|
+
if f.before != nil {
|
|
64
|
+
f.before()
|
|
65
|
+
}
|
|
66
|
+
if f.fail != nil {
|
|
67
|
+
return f.fail
|
|
68
|
+
}
|
|
69
|
+
b, err := io.ReadAll(r)
|
|
70
|
+
if err != nil {
|
|
71
|
+
return err
|
|
72
|
+
}
|
|
73
|
+
if int64(len(b)) != size {
|
|
74
|
+
return fmt.Errorf("size %d, read %d", size, len(b))
|
|
75
|
+
}
|
|
76
|
+
f.mu.Lock()
|
|
77
|
+
defer f.mu.Unlock()
|
|
78
|
+
f.puts[key], f.types[key] = b, ct
|
|
79
|
+
f.last = key
|
|
80
|
+
return nil
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func (f *fakeObjects) Get(_ context.Context, key string) (io.ReadCloser, int64, error) {
|
|
84
|
+
if f.onGet != nil {
|
|
85
|
+
f.onGet()
|
|
86
|
+
}
|
|
87
|
+
f.mu.Lock()
|
|
88
|
+
defer f.mu.Unlock()
|
|
89
|
+
f.gets++
|
|
90
|
+
if f.getErr != nil {
|
|
91
|
+
return nil, 0, f.getErr
|
|
92
|
+
}
|
|
93
|
+
b, ok := f.puts[key]
|
|
94
|
+
if !ok {
|
|
95
|
+
return nil, 0, storage.ErrObjectNotFound
|
|
96
|
+
}
|
|
97
|
+
var r io.Reader = bytes.NewReader(b)
|
|
98
|
+
if f.failAfter > 0 {
|
|
99
|
+
r = io.MultiReader(io.LimitReader(r, f.failAfter), failingReader{f.streamErr})
|
|
100
|
+
}
|
|
101
|
+
return io.NopCloser(r), int64(len(b)), nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type failingReader struct{ err error }
|
|
105
|
+
|
|
106
|
+
func (r failingReader) Read([]byte) (int, error) { return 0, r.err }
|
|
107
|
+
|
|
108
|
+
func (f *fakeObjects) Delete(_ context.Context, key string) error {
|
|
109
|
+
f.mu.Lock()
|
|
110
|
+
defer f.mu.Unlock()
|
|
111
|
+
if f.deleteErr != nil {
|
|
112
|
+
return f.deleteErr
|
|
113
|
+
}
|
|
114
|
+
delete(f.puts, key)
|
|
115
|
+
f.deleted = append(f.deleted, key)
|
|
116
|
+
return nil
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func (f *fakeObjects) count() int {
|
|
120
|
+
f.mu.Lock()
|
|
121
|
+
defer f.mu.Unlock()
|
|
122
|
+
return len(f.puts)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type createEnv struct {
|
|
126
|
+
db *sql.DB
|
|
127
|
+
objects *fakeObjects
|
|
128
|
+
h idea.Handler
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func newCreateEnv(t *testing.T) *createEnv {
|
|
132
|
+
t.Helper()
|
|
133
|
+
d := dbtest.New(t)
|
|
134
|
+
return newCreateEnvOn(t, d, d)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// newCreateEnvOn builds the env with the service's transactions on svcDB (for
|
|
138
|
+
// example a fault-injecting pool) and every other access on d.
|
|
139
|
+
func newCreateEnvOn(t *testing.T, d, svcDB *sql.DB) *createEnv {
|
|
140
|
+
t.Helper()
|
|
141
|
+
aud := audit.New(d, nil)
|
|
142
|
+
objs := newFakeObjects()
|
|
143
|
+
errs := server.ErrorAdapter{Audit: aud}
|
|
144
|
+
e := &createEnv{db: d, objects: objs, h: idea.Handler{
|
|
145
|
+
Service: &idea.Service{
|
|
146
|
+
DB: svcDB, Audit: aud,
|
|
147
|
+
Gate: deadline.Gate{Store: deadline.SQLStore{DB: d}},
|
|
148
|
+
Slides: server.IdeaSlideRules(),
|
|
149
|
+
Objects: objs,
|
|
150
|
+
Aborter: storage.NewPendingDeletion(d, objs, nil),
|
|
151
|
+
},
|
|
152
|
+
ExpectedFilename: storage.ExpectedFilenameBase,
|
|
153
|
+
Fail: errs.Write,
|
|
154
|
+
}}
|
|
155
|
+
e.setDeadline(t, "NOW(6) + INTERVAL 1 DAY")
|
|
156
|
+
return e
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func (e *createEnv) setDeadline(t *testing.T, expr string) {
|
|
160
|
+
t.Helper()
|
|
161
|
+
if _, err := e.db.Exec(`UPDATE contest_setting SET deadline = ` + expr + ` WHERE id = 1`); err != nil {
|
|
162
|
+
t.Fatal(err)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func (e *createEnv) count(t *testing.T, q string, args ...any) (n int) {
|
|
167
|
+
t.Helper()
|
|
168
|
+
if err := e.db.QueryRow(q, args...).Scan(&n); err != nil {
|
|
169
|
+
t.Fatal(err)
|
|
170
|
+
}
|
|
171
|
+
return n
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type form struct {
|
|
175
|
+
fields [][2]string // in order; a repeated name repeats the part
|
|
176
|
+
filename string
|
|
177
|
+
file []byte
|
|
178
|
+
noFile bool
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func pdf(n int) []byte {
|
|
182
|
+
b := append([]byte("%PDF-1.7\n"), bytes.Repeat([]byte("x"), n)...)
|
|
183
|
+
return b
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func pptx(t *testing.T) []byte {
|
|
187
|
+
t.Helper()
|
|
188
|
+
var buf bytes.Buffer
|
|
189
|
+
zw := zip.NewWriter(&buf)
|
|
190
|
+
for _, n := range []string{"[Content_Types].xml", "ppt/presentation.xml"} {
|
|
191
|
+
w, err := zw.Create(n)
|
|
192
|
+
if err != nil {
|
|
193
|
+
t.Fatal(err)
|
|
194
|
+
}
|
|
195
|
+
_, _ = w.Write([]byte("<x/>"))
|
|
196
|
+
}
|
|
197
|
+
if err := zw.Close(); err != nil {
|
|
198
|
+
t.Fatal(err)
|
|
199
|
+
}
|
|
200
|
+
return buf.Bytes()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// validForm is a valid create for leader 100001 ("User 100001").
|
|
204
|
+
func validForm(name string) form {
|
|
205
|
+
return form{
|
|
206
|
+
fields: [][2]string{
|
|
207
|
+
{"category", "COOL_IDEA"}, {"org", "BSID"}, {"direction", "TALENT_DEVELOPMENT"},
|
|
208
|
+
{"name", name}, {"summary", "line one\r\nline two"},
|
|
209
|
+
},
|
|
210
|
+
filename: "I_BSID_User 100001_100001_" + name + ".pdf",
|
|
211
|
+
file: pdf(100),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func (f form) set(name, value string) form {
|
|
216
|
+
out := f
|
|
217
|
+
out.fields = nil
|
|
218
|
+
for _, kv := range f.fields {
|
|
219
|
+
if kv[0] == name {
|
|
220
|
+
kv[1] = value
|
|
221
|
+
}
|
|
222
|
+
out.fields = append(out.fields, kv)
|
|
223
|
+
}
|
|
224
|
+
return out
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
func (f form) body(t *testing.T) (*bytes.Buffer, string) {
|
|
228
|
+
t.Helper()
|
|
229
|
+
var buf bytes.Buffer
|
|
230
|
+
mw := multipart.NewWriter(&buf)
|
|
231
|
+
for _, kv := range f.fields {
|
|
232
|
+
if err := mw.WriteField(kv[0], kv[1]); err != nil {
|
|
233
|
+
t.Fatal(err)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if !f.noFile {
|
|
237
|
+
h := make(map[string][]string)
|
|
238
|
+
h["Content-Disposition"] = []string{fmt.Sprintf(`form-data; name="slides"; filename="%s"`, f.filename)}
|
|
239
|
+
h["Content-Type"] = []string{"application/octet-stream"}
|
|
240
|
+
w, err := mw.CreatePart(h)
|
|
241
|
+
if err != nil {
|
|
242
|
+
t.Fatal(err)
|
|
243
|
+
}
|
|
244
|
+
_, _ = w.Write(f.file)
|
|
245
|
+
}
|
|
246
|
+
if err := mw.Close(); err != nil {
|
|
247
|
+
t.Fatal(err)
|
|
248
|
+
}
|
|
249
|
+
return &buf, mw.FormDataContentType()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func (e *createEnv) post(t *testing.T, p security.Principal, f form) *httptest.ResponseRecorder {
|
|
253
|
+
t.Helper()
|
|
254
|
+
body, ct := f.body(t)
|
|
255
|
+
r := chi.NewRouter()
|
|
256
|
+
r.Post("/ideas", e.h.CreateIdea)
|
|
257
|
+
req := httptest.NewRequest(http.MethodPost, "/ideas", body)
|
|
258
|
+
req.Header.Set("Content-Type", ct)
|
|
259
|
+
req = req.WithContext(security.WithPrincipal(context.Background(), p))
|
|
260
|
+
rec := httptest.NewRecorder()
|
|
261
|
+
r.ServeHTTP(rec, req)
|
|
262
|
+
return rec
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
type problem struct {
|
|
266
|
+
Code string
|
|
267
|
+
Errors []web.FieldError
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func decodeProblem(t *testing.T, rec *httptest.ResponseRecorder) problem {
|
|
271
|
+
t.Helper()
|
|
272
|
+
var p problem
|
|
273
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
|
274
|
+
t.Fatalf("problem body: %v (%s)", err, rec.Body)
|
|
275
|
+
}
|
|
276
|
+
return p
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func TestCreateIdeaSuccess(t *testing.T) {
|
|
280
|
+
e := newCreateEnv(t)
|
|
281
|
+
leader := addUser(t, e.db, "100001", "USER")
|
|
282
|
+
f := validForm("Idea A")
|
|
283
|
+
f.fields = append(f.fields, [2]string{"leaderEmpNo", "999999"}) // ignored (PRD 6.6)
|
|
284
|
+
|
|
285
|
+
rec := e.post(t, leader, f)
|
|
286
|
+
if rec.Code != http.StatusCreated {
|
|
287
|
+
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
288
|
+
}
|
|
289
|
+
var body struct {
|
|
290
|
+
ID string
|
|
291
|
+
Name, Summary string
|
|
292
|
+
Leader struct{ EmpNo, Name string }
|
|
293
|
+
PendingRequests []any
|
|
294
|
+
ExpectedFilenameBase string
|
|
295
|
+
Version int
|
|
296
|
+
Slides struct {
|
|
297
|
+
Ext, SHA256, DownloadFilename string
|
|
298
|
+
Size int64
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
302
|
+
t.Fatal(err)
|
|
303
|
+
}
|
|
304
|
+
if rec.Header().Get("Location") != "/api/v1/ideas/"+body.ID {
|
|
305
|
+
t.Errorf("Location %q, id %s", rec.Header().Get("Location"), body.ID)
|
|
306
|
+
}
|
|
307
|
+
sum := sha256.Sum256(f.file)
|
|
308
|
+
if body.Name != "Idea A" || body.Summary != "line one\nline two" || body.Leader.EmpNo != "100001" ||
|
|
309
|
+
body.Leader.Name != "User 100001" || body.PendingRequests == nil || body.Version != 0 ||
|
|
310
|
+
body.ExpectedFilenameBase != "I_BSID_User 100001_100001_Idea A" ||
|
|
311
|
+
body.Slides.Ext != "pdf" || body.Slides.Size != int64(len(f.file)) || body.Slides.SHA256 != hex.EncodeToString(sum[:]) ||
|
|
312
|
+
body.Slides.DownloadFilename != "I_BSID_User 100001_100001_Idea A.pdf" {
|
|
313
|
+
t.Errorf("body %+v", body)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
var key string
|
|
317
|
+
if err := e.db.QueryRow(`SELECT slides_object_key FROM idea WHERE id = ?`, body.ID).Scan(&key); err != nil {
|
|
318
|
+
t.Fatal(err)
|
|
319
|
+
}
|
|
320
|
+
if !strings.HasPrefix(key, "slides/"+body.ID+"/") || !strings.HasSuffix(key, ".pdf") {
|
|
321
|
+
t.Errorf("object key %q", key)
|
|
322
|
+
}
|
|
323
|
+
if !bytes.Equal(e.objects.puts[key], f.file) || e.objects.types[key] != "application/pdf" {
|
|
324
|
+
t.Errorf("stored object %q: %d bytes, type %q", key, len(e.objects.puts[key]), e.objects.types[key])
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
var after string
|
|
328
|
+
if err := e.db.QueryRow(`SELECT after_json FROM audit_log WHERE action = 'IDEA_CREATE' AND outcome = 'SUCCESS'
|
|
329
|
+
AND target_type = 'IDEA' AND target_id = ? AND status = 201 AND route = '/ideas' AND actor_emp_no = '100001'`,
|
|
330
|
+
body.ID).Scan(&after); err != nil {
|
|
331
|
+
t.Fatalf("IDEA_CREATE row: %v", err)
|
|
332
|
+
}
|
|
333
|
+
var a map[string]any
|
|
334
|
+
_ = json.Unmarshal([]byte(after), &a)
|
|
335
|
+
sl, _ := a["slides"].(map[string]any)
|
|
336
|
+
if a["name"] != "Idea A" || a["category"] != "COOL_IDEA" || sl["sha256"] != hex.EncodeToString(sum[:]) || sl["ext"] != "pdf" {
|
|
337
|
+
t.Errorf("after_json %s", after)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
func TestCreateIdeaPPTXAndNFDFilename(t *testing.T) {
|
|
342
|
+
e := newCreateEnv(t)
|
|
343
|
+
if _, err := e.db.Exec(`INSERT INTO app_user (id, emp_no, name, email, password_hash, role) VALUES (UUID(),'100001',?,?,?, 'USER')`,
|
|
344
|
+
"王小明", "100001@example.com", hash); err != nil {
|
|
345
|
+
t.Fatal(err)
|
|
346
|
+
}
|
|
347
|
+
var id string
|
|
348
|
+
if err := e.db.QueryRow(`SELECT id FROM app_user WHERE emp_no = '100001'`).Scan(&id); err != nil {
|
|
349
|
+
t.Fatal(err)
|
|
350
|
+
}
|
|
351
|
+
p := addUserPrincipal(t, id, "100001")
|
|
352
|
+
f := validForm("成立供應商永續學院").set("category", "OUTSTANDING_IMPACT")
|
|
353
|
+
f.filename = norm.NFD.String("O_BSID_王小明_100001_成立供應商永續學院.PPTX")
|
|
354
|
+
f.file = pptx(t)
|
|
355
|
+
rec := e.post(t, p, f)
|
|
356
|
+
if rec.Code != http.StatusCreated {
|
|
357
|
+
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
358
|
+
}
|
|
359
|
+
if !strings.Contains(rec.Body.String(), `"ext":"pptx"`) {
|
|
360
|
+
t.Errorf("body %s", rec.Body)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
func addUserPrincipal(t *testing.T, id, empNo string) security.Principal {
|
|
365
|
+
t.Helper()
|
|
366
|
+
p := security.Principal{EmpNo: empNo, Role: security.RoleUser}
|
|
367
|
+
if err := p.UserID.UnmarshalText([]byte(id)); err != nil {
|
|
368
|
+
t.Fatal(err)
|
|
369
|
+
}
|
|
370
|
+
return p
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
func TestCreateIdeaRefusedBeforeBody(t *testing.T) {
|
|
374
|
+
e := newCreateEnv(t)
|
|
375
|
+
user := addUser(t, e.db, "100001", "USER")
|
|
376
|
+
admin := addUser(t, e.db, "900001", "ADMIN")
|
|
377
|
+
broken := form{noFile: true} // would be 400 if the body were read
|
|
378
|
+
|
|
379
|
+
rec := e.post(t, admin, broken)
|
|
380
|
+
if rec.Code != http.StatusForbidden || decodeProblem(t, rec).Code != "FORBIDDEN" {
|
|
381
|
+
t.Fatalf("admin: %d %s", rec.Code, rec.Body)
|
|
382
|
+
}
|
|
383
|
+
e.setDeadline(t, "NULL")
|
|
384
|
+
rec = e.post(t, user, broken)
|
|
385
|
+
if rec.Code != http.StatusForbidden || decodeProblem(t, rec).Code != "SUBMISSION_NOT_OPEN" {
|
|
386
|
+
t.Fatalf("not open: %d %s", rec.Code, rec.Body)
|
|
387
|
+
}
|
|
388
|
+
e.setDeadline(t, "NOW(6) - INTERVAL 1 SECOND")
|
|
389
|
+
rec = e.post(t, user, broken)
|
|
390
|
+
if rec.Code != http.StatusForbidden || decodeProblem(t, rec).Code != "SUBMISSION_CLOSED" {
|
|
391
|
+
t.Fatalf("closed: %d %s", rec.Code, rec.Body)
|
|
392
|
+
}
|
|
393
|
+
if got := e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'IDEA_CREATE' AND outcome = 'DENIED' AND status = 403`); got != 3 {
|
|
394
|
+
t.Errorf("DENIED rows = %d, want 3", got)
|
|
395
|
+
}
|
|
396
|
+
if e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'IDEA_CREATE' AND JSON_VALUE(after_json, '$.reason') = 'ROLE'`) != 1 ||
|
|
397
|
+
e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'IDEA_CREATE' AND JSON_VALUE(after_json, '$.reason') = 'STATE'`) != 2 {
|
|
398
|
+
t.Error("DENIED reasons: want one ROLE and two STATE")
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
func TestCreateIdeaValidation(t *testing.T) {
|
|
403
|
+
e := newCreateEnv(t)
|
|
404
|
+
user := addUser(t, e.db, "100001", "USER")
|
|
405
|
+
long := strings.Repeat("長", 75) // 225 bytes: valid length, filename too long
|
|
406
|
+
|
|
407
|
+
for _, tc := range []struct {
|
|
408
|
+
name string
|
|
409
|
+
form form
|
|
410
|
+
status int
|
|
411
|
+
code string
|
|
412
|
+
fields []web.FieldError
|
|
413
|
+
}{
|
|
414
|
+
{"missing file part", form{fields: validForm("x").fields, noFile: true}, 400, "MALFORMED_REQUEST", nil},
|
|
415
|
+
{"missing text part", func() form { f := validForm("x"); f.fields = f.fields[1:]; return f }(), 400, "MALFORMED_REQUEST", nil},
|
|
416
|
+
{"repeated text part", func() form { f := validForm("x"); f.fields = append(f.fields, f.fields[0]); return f }(), 400, "MALFORMED_REQUEST", nil},
|
|
417
|
+
{
|
|
418
|
+
// Every field reports its first error in form order; the (empty)
|
|
419
|
+
// file is not validated while text is invalid.
|
|
420
|
+
"text errors collected",
|
|
421
|
+
func() form {
|
|
422
|
+
f := validForm("x").set("category", "cool_idea").set("org", " ").set("direction", "GREEN").
|
|
423
|
+
set("name", "a/b").set("summary", "\r\n")
|
|
424
|
+
f.file = nil
|
|
425
|
+
return f
|
|
426
|
+
}(),
|
|
427
|
+
422, "VALIDATION_FAILED",
|
|
428
|
+
[]web.FieldError{
|
|
429
|
+
{Field: "category", Code: "INVALID_ENUM"}, {Field: "org", Code: "REQUIRED"},
|
|
430
|
+
{Field: "direction", Code: "INVALID_ENUM"}, {Field: "name", Code: "INVALID_NAME_CHARS"},
|
|
431
|
+
{Field: "summary", Code: "REQUIRED"},
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
{"name chars first", validForm("a:b"), 422, "INVALID_NAME_CHARS", []web.FieldError{{Field: "name", Code: "INVALID_NAME_CHARS"}}},
|
|
435
|
+
{"name too long", validForm(strings.Repeat("a", 76)), 422, "VALIDATION_FAILED", []web.FieldError{{Field: "name", Code: "OUT_OF_RANGE"}}},
|
|
436
|
+
{"summary too long", validForm("x").set("summary", strings.Repeat("s", 301)), 422, "VALIDATION_FAILED", []web.FieldError{{Field: "summary", Code: "OUT_OF_RANGE"}}},
|
|
437
|
+
{"filename too long", validForm(long), 422, "FILENAME_TOO_LONG", []web.FieldError{{Field: "name", Code: "FILENAME_TOO_LONG"}}},
|
|
438
|
+
{"empty file", func() form { f := validForm("x"); f.file = nil; return f }(), 422, "EMPTY_FILE", nil},
|
|
439
|
+
{"over 5 MiB", func() form { f := validForm("x"); f.file = pdf(5 * 1024 * 1024); return f }(), 413, "FILE_TOO_LARGE", nil},
|
|
440
|
+
{"magic mismatch", func() form { f := validForm("x"); f.file = []byte("not a pdf"); return f }(), 415, "UNSUPPORTED_FILE_TYPE", nil},
|
|
441
|
+
{
|
|
442
|
+
"filename mismatch carries expected", func() form { f := validForm("x"); f.filename = "slides.pdf"; return f }(),
|
|
443
|
+
422, "FILENAME_MISMATCH",
|
|
444
|
+
[]web.FieldError{{Field: "slides", Code: "FILENAME_MISMATCH", Expected: "I_BSID_User 100001_100001_x.pdf"}},
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
"path is not stripped", func() form { f := validForm("x"); f.filename = "dir/" + f.filename; return f }(),
|
|
448
|
+
422, "FILENAME_MISMATCH",
|
|
449
|
+
[]web.FieldError{{Field: "slides", Code: "FILENAME_MISMATCH", Expected: "I_BSID_User 100001_100001_x.pdf"}},
|
|
450
|
+
},
|
|
451
|
+
} {
|
|
452
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
453
|
+
rec := e.post(t, user, tc.form)
|
|
454
|
+
p := decodeProblem(t, rec)
|
|
455
|
+
if rec.Code != tc.status || p.Code != tc.code {
|
|
456
|
+
t.Fatalf("got %d %s, want %d %s (%s)", rec.Code, p.Code, tc.status, tc.code, rec.Body)
|
|
457
|
+
}
|
|
458
|
+
if tc.fields != nil && fmt.Sprint(p.Errors) != fmt.Sprint(tc.fields) {
|
|
459
|
+
t.Errorf("errors %v, want %v", p.Errors, tc.fields)
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
if e.objects.count() != 0 || e.count(t, `SELECT COUNT(*) FROM idea`) != 0 {
|
|
464
|
+
t.Error("a rejected create must store nothing")
|
|
465
|
+
}
|
|
466
|
+
if e.count(t, `SELECT COUNT(*) FROM audit_log`) != 0 {
|
|
467
|
+
t.Error("400/413/415/422 are input errors and are not audited")
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
func TestCreateIdeaDuplicate(t *testing.T) {
|
|
472
|
+
e := newCreateEnv(t)
|
|
473
|
+
user := addUser(t, e.db, "100001", "USER")
|
|
474
|
+
other := addUser(t, e.db, "100002", "USER")
|
|
475
|
+
|
|
476
|
+
if rec := e.post(t, user, validForm("Same")); rec.Code != 201 {
|
|
477
|
+
t.Fatalf("first: %d %s", rec.Code, rec.Body)
|
|
478
|
+
}
|
|
479
|
+
// Pre-check: 409 and nothing uploaded.
|
|
480
|
+
rec := e.post(t, user, validForm("Same"))
|
|
481
|
+
if rec.Code != 409 || decodeProblem(t, rec).Code != "IDEA_DUPLICATE" || e.objects.count() != 1 {
|
|
482
|
+
t.Fatalf("pre-check: %d %s, %d objects", rec.Code, rec.Body, e.objects.count())
|
|
483
|
+
}
|
|
484
|
+
// Names are case-sensitive and unique per Leader only.
|
|
485
|
+
if rec := e.post(t, user, validForm("same")); rec.Code != 201 {
|
|
486
|
+
t.Errorf("case differs: %d %s", rec.Code, rec.Body)
|
|
487
|
+
}
|
|
488
|
+
f := validForm("Same")
|
|
489
|
+
f.filename = "I_BSID_User 100002_100002_Same.pdf"
|
|
490
|
+
if rec := e.post(t, other, f); rec.Code != 201 {
|
|
491
|
+
t.Errorf("other leader: %d %s", rec.Code, rec.Body)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Race past the pre-check: the unique key answers 409 from the transaction.
|
|
495
|
+
e.objects.before = func() {
|
|
496
|
+
e.objects.before = nil
|
|
497
|
+
if err := insert(e.db, newIdea(user.UserID, "Raced")); err != nil {
|
|
498
|
+
t.Error(err)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
rec = e.post(t, user, validForm("Raced"))
|
|
502
|
+
if rec.Code != 409 || decodeProblem(t, rec).Code != "IDEA_DUPLICATE" {
|
|
503
|
+
t.Fatalf("race: %d %s", rec.Code, rec.Body)
|
|
504
|
+
}
|
|
505
|
+
if e.count(t, `SELECT COUNT(*) FROM idea WHERE name = 'Raced'`) != 1 ||
|
|
506
|
+
e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'IDEA_CREATE' AND outcome = 'SUCCESS'`) != 3 {
|
|
507
|
+
t.Error("race: the losing create must leave no row and no audit row")
|
|
508
|
+
}
|
|
509
|
+
// Known rollback (B3-09): the losing upload is deleted at once, no row kept.
|
|
510
|
+
if len(e.objects.deleted) != 1 || e.objects.count() != 3 || e.pendingCount(t) != 0 {
|
|
511
|
+
t.Errorf("race: deleted %v, %d objects, %d pending rows", e.objects.deleted, e.objects.count(), e.pendingCount(t))
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
func TestCreateIdeaDeadlinePassesBeforeTx(t *testing.T) {
|
|
516
|
+
e := newCreateEnv(t)
|
|
517
|
+
user := addUser(t, e.db, "100001", "USER")
|
|
518
|
+
e.objects.before = func() { e.setDeadline(t, "NOW(6) - INTERVAL 1 SECOND") }
|
|
519
|
+
|
|
520
|
+
rec := e.post(t, user, validForm("Late"))
|
|
521
|
+
if rec.Code != 403 || decodeProblem(t, rec).Code != "SUBMISSION_CLOSED" {
|
|
522
|
+
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
523
|
+
}
|
|
524
|
+
if e.count(t, `SELECT COUNT(*) FROM idea`) != 0 {
|
|
525
|
+
t.Error("no row may exist")
|
|
526
|
+
}
|
|
527
|
+
if e.count(t, `SELECT COUNT(*) FROM audit_log WHERE action = 'IDEA_CREATE' AND outcome = 'DENIED'
|
|
528
|
+
AND error_code = 'SUBMISSION_CLOSED' AND JSON_VALUE(after_json, '$.reason') = 'STATE'`) != 1 {
|
|
529
|
+
t.Error("want one DENIED STATE row")
|
|
530
|
+
}
|
|
531
|
+
if len(e.objects.deleted) != 1 || e.objects.count() != 0 || e.pendingCount(t) != 0 {
|
|
532
|
+
t.Errorf("the rolled-back upload must be deleted: deleted %v, %d pending rows", e.objects.deleted, e.pendingCount(t))
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Known rollback while MinIO is down: the delete fails, so the key is
|
|
537
|
+
// recorded as UPLOAD_ABORTED, due at once; the response is the rollback's.
|
|
538
|
+
func TestCreateIdeaRollbackDeleteFails(t *testing.T) {
|
|
539
|
+
e := newCreateEnv(t)
|
|
540
|
+
user := addUser(t, e.db, "100001", "USER")
|
|
541
|
+
e.objects.deleteErr = web.NewError(web.CodeStorageUnavailable, "object storage is unavailable")
|
|
542
|
+
e.objects.before = func() { e.setDeadline(t, "NOW(6) - INTERVAL 1 SECOND") }
|
|
543
|
+
|
|
544
|
+
rec := e.post(t, user, validForm("Late"))
|
|
545
|
+
if rec.Code != 403 || decodeProblem(t, rec).Code != "SUBMISSION_CLOSED" {
|
|
546
|
+
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
547
|
+
}
|
|
548
|
+
if e.objects.count() != 1 {
|
|
549
|
+
t.Fatalf("%d objects, want the one kept upload", e.objects.count())
|
|
550
|
+
}
|
|
551
|
+
got := e.pending(t, e.objects.last)
|
|
552
|
+
if len(got) != 1 || got[0].reason != "UPLOAD_ABORTED" || got[0].dueInSec > 1 {
|
|
553
|
+
t.Errorf("pending rows %+v, want one UPLOAD_ABORTED due now", got)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Lost-commit cases (Sequence.md §2, §3; Backend.md §4.4) through the
|
|
558
|
+
// fault-injecting connector: Unknown keeps the object, records it with the
|
|
559
|
+
// grace period and answers 503; a known rollback at COMMIT deletes it.
|
|
560
|
+
func TestCreateIdeaLostCommit(t *testing.T) {
|
|
561
|
+
dsn := dbtest.Start(t)
|
|
562
|
+
d := dbtest.Open(t, dsn)
|
|
563
|
+
faulty, faults := dbtest.OpenFaulty(t, dsn)
|
|
564
|
+
e := newCreateEnvOn(t, d, faulty)
|
|
565
|
+
user := addUser(t, d, "100001", "USER")
|
|
566
|
+
|
|
567
|
+
for i, tc := range []struct {
|
|
568
|
+
name string
|
|
569
|
+
fault dbtest.CommitFault
|
|
570
|
+
applied bool // the idea row exists after the COMMIT
|
|
571
|
+
kept bool // the object stays, recorded with the grace period
|
|
572
|
+
}{
|
|
573
|
+
{"commit applied, ack lost", dbtest.LostAckCommitted, true, true},
|
|
574
|
+
{"connection lost before commit", dbtest.LostAckNotCommitted, false, true},
|
|
575
|
+
{"deadlock at commit", dbtest.DeadlockAtCommit, false, false},
|
|
576
|
+
} {
|
|
577
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
578
|
+
name := fmt.Sprintf("Lost %d", i)
|
|
579
|
+
before := len(e.objects.deleted)
|
|
580
|
+
faults.FailNextCommit(tc.fault)
|
|
581
|
+
f := validForm(name)
|
|
582
|
+
rec := e.post(t, user, f)
|
|
583
|
+
if rec.Code != 503 || decodeProblem(t, rec).Code != "SERVICE_UNAVAILABLE" {
|
|
584
|
+
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
585
|
+
}
|
|
586
|
+
key := e.objects.last
|
|
587
|
+
if n := e.count(t, `SELECT COUNT(*) FROM idea WHERE name = ?`, name); (n == 1) != tc.applied {
|
|
588
|
+
t.Errorf("idea rows = %d, applied %v", n, tc.applied)
|
|
589
|
+
}
|
|
590
|
+
_, stored := e.objects.puts[key]
|
|
591
|
+
got := e.pending(t, key)
|
|
592
|
+
if tc.kept {
|
|
593
|
+
grace := storage.UploadAbortedGrace.Seconds()
|
|
594
|
+
if !stored || len(got) != 1 || got[0].reason != "UPLOAD_ABORTED" ||
|
|
595
|
+
got[0].dueInSec < grace-30 || got[0].dueInSec > grace+1 {
|
|
596
|
+
t.Errorf("Unknown: stored %v, pending %+v; want kept and due after the grace", stored, got)
|
|
597
|
+
}
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
if stored || len(got) != 0 || len(e.objects.deleted) != before+1 {
|
|
601
|
+
t.Errorf("rollback: stored %v, pending %+v; want deleted now", stored, got)
|
|
602
|
+
}
|
|
603
|
+
})
|
|
604
|
+
}
|
|
605
|
+
if faults.Fired() != 3 {
|
|
606
|
+
t.Errorf("faults fired = %d, want 3", faults.Fired())
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
type pendingRow struct {
|
|
611
|
+
reason string
|
|
612
|
+
dueInSec float64 // next_attempt_at - NOW(6), database time
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
func (e *createEnv) pending(t *testing.T, key string) []pendingRow {
|
|
616
|
+
t.Helper()
|
|
617
|
+
rows, err := e.db.Query(`SELECT reason, TIMESTAMPDIFF(MICROSECOND, NOW(6), next_attempt_at) / 1e6
|
|
618
|
+
FROM pending_file_deletion WHERE object_key = ? ORDER BY id`, key)
|
|
619
|
+
if err != nil {
|
|
620
|
+
t.Fatal(err)
|
|
621
|
+
}
|
|
622
|
+
defer func() { _ = rows.Close() }()
|
|
623
|
+
var out []pendingRow
|
|
624
|
+
for rows.Next() {
|
|
625
|
+
var r pendingRow
|
|
626
|
+
if err := rows.Scan(&r.reason, &r.dueInSec); err != nil {
|
|
627
|
+
t.Fatal(err)
|
|
628
|
+
}
|
|
629
|
+
out = append(out, r)
|
|
630
|
+
}
|
|
631
|
+
if err := rows.Err(); err != nil {
|
|
632
|
+
t.Fatal(err)
|
|
633
|
+
}
|
|
634
|
+
return out
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
func (e *createEnv) pendingCount(t *testing.T) int {
|
|
638
|
+
t.Helper()
|
|
639
|
+
return e.count(t, `SELECT COUNT(*) FROM pending_file_deletion`)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
func TestCreateIdeaStorageDown(t *testing.T) {
|
|
643
|
+
e := newCreateEnv(t)
|
|
644
|
+
user := addUser(t, e.db, "100001", "USER")
|
|
645
|
+
e.objects.fail = web.NewError(web.CodeStorageUnavailable, "object storage is unavailable")
|
|
646
|
+
|
|
647
|
+
rec := e.post(t, user, validForm("Idea A"))
|
|
648
|
+
if rec.Code != 503 || decodeProblem(t, rec).Code != "STORAGE_UNAVAILABLE" {
|
|
649
|
+
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
650
|
+
}
|
|
651
|
+
if e.count(t, `SELECT COUNT(*) FROM idea`) != 0 || e.count(t, `SELECT COUNT(*) FROM audit_log`) != 0 {
|
|
652
|
+
t.Error("no row and no audit row may exist")
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// A declared length over the limit is answered before the body is read; the
|
|
657
|
+
// 5 MiB + 64 KiB limit leaves room for the text parts of a 5 MiB file.
|
|
658
|
+
func TestCreateIdeaDeclaredLengthTooLarge(t *testing.T) {
|
|
659
|
+
e := newCreateEnv(t)
|
|
660
|
+
user := addUser(t, e.db, "100001", "USER")
|
|
661
|
+
body, ct := validForm("x").body(t)
|
|
662
|
+
r := chi.NewRouter()
|
|
663
|
+
r.Post("/ideas", e.h.CreateIdea)
|
|
664
|
+
req := httptest.NewRequest(http.MethodPost, "/ideas", body)
|
|
665
|
+
req.Header.Set("Content-Type", ct)
|
|
666
|
+
req.ContentLength = 5*1024*1024 + 64*1024 + 1
|
|
667
|
+
req = req.WithContext(security.WithPrincipal(context.Background(), user))
|
|
668
|
+
rec := httptest.NewRecorder()
|
|
669
|
+
r.ServeHTTP(rec, req)
|
|
670
|
+
if rec.Code != 413 || decodeProblem(t, rec).Code != "FILE_TOO_LARGE" || e.objects.count() != 0 {
|
|
671
|
+
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Exactly 5 MiB of slides still fits.
|
|
675
|
+
f := validForm("Max")
|
|
676
|
+
f.file = pdf(5*1024*1024 - len("%PDF-1.7\n"))
|
|
677
|
+
if rec := e.post(t, user, f); rec.Code != 201 {
|
|
678
|
+
t.Fatalf("5 MiB file: %d %s", rec.Code, rec.Body)
|
|
679
|
+
}
|
|
680
|
+
}
|