feat(usecase): allow public issues
Accept issues=public, store Issue.External for approved submissions, and share a proof-of-work vector with the browser solver.
- commit
- 99db3566b9665e6cb88f2d51c3ffb53d06e081a2
- author
- brice · · 2026-10-19 07:21 UTC
- parent
- a26798b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +78 -16
M gen/ermite/storage/issue.pb.go +14 -3
| @@ -94,7 +94,10 @@ type Issue struct { | |||
| 94 | 94 | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |
| 95 | 95 | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | |
| 96 | 96 | // Last comment id handed out for this issue. | |
| 97 | - | CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"` | |
| 97 | + | CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"` | |
| 98 | + | // True for an issue approved from the public form: its text comes from | |
| 99 | + | // an anonymous visitor and is rendered as untrusted. | |
| 100 | + | External bool `protobuf:"varint,9,opt,name=external,proto3" json:"external,omitempty"` | |
| 98 | 101 | unknownFields protoimpl.UnknownFields | |
| 99 | 102 | sizeCache protoimpl.SizeCache | |
| 100 | 103 | } | |
| @@ -185,6 +188,13 @@ func (x *Issue) GetCommentSeq() int64 { | |||
| 185 | 188 | return 0 | |
| 186 | 189 | } | |
| 187 | 190 | ||
| 191 | + | func (x *Issue) GetExternal() bool { | |
| 192 | + | if x != nil { | |
| 193 | + | return x.External | |
| 194 | + | } | |
| 195 | + | return false | |
| 196 | + | } | |
| 197 | + | ||
| 188 | 198 | // IssueComment is stored under the key | |
| 189 | 199 | // "isc/<repo>/<number:010d>/<id:010d>". | |
| 190 | 200 | type IssueComment struct { | |
| @@ -430,7 +440,7 @@ var File_ermite_storage_issue_proto protoreflect.FileDescriptor | |||
| 430 | 440 | ||
| 431 | 441 | const file_ermite_storage_issue_proto_rawDesc = "" + | |
| 432 | 442 | "\n" + | |
| 433 | - | "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xaa\x02\n" + | |
| 443 | + | "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc6\x02\n" + | |
| 434 | 444 | "\x05Issue\x12\x16\n" + | |
| 435 | 445 | "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" + | |
| 436 | 446 | "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" + | |
| @@ -442,7 +452,8 @@ const file_ermite_storage_issue_proto_rawDesc = "" + | |||
| 442 | 452 | "\n" + | |
| 443 | 453 | "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" + | |
| 444 | 454 | "\vcomment_seq\x18\b \x01(\x03R\n" + | |
| 445 | - | "commentSeq\"\x85\x01\n" + | |
| 455 | + | "commentSeq\x12\x1a\n" + | |
| 456 | + | "\bexternal\x18\t \x01(\bR\bexternal\"\x85\x01\n" + | |
| 446 | 457 | "\fIssueComment\x12\x0e\n" + | |
| 447 | 458 | "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" + | |
| 448 | 459 | "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" + | |
| @@ -94,7 +94,10 @@ type Issue struct { | |||||
| 94 | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | 94 | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | ||
| 95 | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | 95 | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | ||
| 96 | // Last comment id handed out for this issue. | 96 | // Last comment id handed out for this issue. | ||
| 97 | - | CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"` | 97 | + | CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"` |
| 98 | + | // True for an issue approved from the public form: its text comes from | |||
| 99 | + | // an anonymous visitor and is rendered as untrusted. | |||
| 100 | + | External bool `protobuf:"varint,9,opt,name=external,proto3" json:"external,omitempty"` | |||
| 98 | unknownFields protoimpl.UnknownFields | 101 | unknownFields protoimpl.UnknownFields | ||
| 99 | sizeCache protoimpl.SizeCache | 102 | sizeCache protoimpl.SizeCache | ||
| 100 | } | 103 | } | ||
| @@ -185,6 +188,13 @@ func (x *Issue) GetCommentSeq() int64 { | |||||
| 185 | return 0 | 188 | return 0 | ||
| 186 | } | 189 | } | ||
| 187 | 190 | ||||
| 191 | + | func (x *Issue) GetExternal() bool { | |||
| 192 | + | if x != nil { | |||
| 193 | + | return x.External | |||
| 194 | + | } | |||
| 195 | + | return false | |||
| 196 | + | } | |||
| 197 | + | ||||
| 188 | // IssueComment is stored under the key | 198 | // IssueComment is stored under the key | ||
| 189 | // "isc/<repo>/<number:010d>/<id:010d>". | 199 | // "isc/<repo>/<number:010d>/<id:010d>". | ||
| 190 | type IssueComment struct { | 200 | type IssueComment struct { | ||
| @@ -430,7 +440,7 @@ var File_ermite_storage_issue_proto protoreflect.FileDescriptor | |||||
| 430 | 440 | ||||
| 431 | const file_ermite_storage_issue_proto_rawDesc = "" + | 441 | const file_ermite_storage_issue_proto_rawDesc = "" + | ||
| 432 | "\n" + | 442 | "\n" + | ||
| 433 | - | "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xaa\x02\n" + | 443 | + | "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc6\x02\n" + |
| 434 | "\x05Issue\x12\x16\n" + | 444 | "\x05Issue\x12\x16\n" + | ||
| 435 | "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" + | 445 | "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" + | ||
| 436 | "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" + | 446 | "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" + | ||
| @@ -442,7 +452,8 @@ const file_ermite_storage_issue_proto_rawDesc = "" + | |||||
| 442 | "\n" + | 452 | "\n" + | ||
| 443 | "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" + | 453 | "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" + | ||
| 444 | "\vcomment_seq\x18\b \x01(\x03R\n" + | 454 | "\vcomment_seq\x18\b \x01(\x03R\n" + | ||
| 445 | - | "commentSeq\"\x85\x01\n" + | 455 | + | "commentSeq\x12\x1a\n" + |
| 456 | + | "\bexternal\x18\t \x01(\bR\bexternal\"\x85\x01\n" + | |||
| 446 | "\fIssueComment\x12\x0e\n" + | 457 | "\fIssueComment\x12\x0e\n" + | ||
| 447 | "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" + | 458 | "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" + | ||
| 448 | "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" + | 459 | "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" + | ||
M internal/adapters/kvstore/issues.go +2 -0
| @@ -333,6 +333,7 @@ func encodeIssue(issue domain.Issue) ([]byte, error) { | |||
| 333 | 333 | CreatedAt: encodeTime(issue.CreatedAt), | |
| 334 | 334 | UpdatedAt: encodeTime(issue.UpdatedAt), | |
| 335 | 335 | CommentSeq: issue.CommentSeq, | |
| 336 | + | External: issue.External, | |
| 336 | 337 | }), nil | |
| 337 | 338 | } | |
| 338 | 339 | ||
| @@ -362,6 +363,7 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) { | |||
| 362 | 363 | Author: pb.GetAuthor(), | |
| 363 | 364 | CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()), | |
| 364 | 365 | CommentSeq: pb.GetCommentSeq(), | |
| 366 | + | External: pb.GetExternal(), | |
| 365 | 367 | Version: item.Version, | |
| 366 | 368 | }, nil | |
| 367 | 369 | } | |
| @@ -333,6 +333,7 @@ func encodeIssue(issue domain.Issue) ([]byte, error) { | |||||
| 333 | CreatedAt: encodeTime(issue.CreatedAt), | 333 | CreatedAt: encodeTime(issue.CreatedAt), | ||
| 334 | UpdatedAt: encodeTime(issue.UpdatedAt), | 334 | UpdatedAt: encodeTime(issue.UpdatedAt), | ||
| 335 | CommentSeq: issue.CommentSeq, | 335 | CommentSeq: issue.CommentSeq, | ||
| 336 | + | External: issue.External, | |||
| 336 | }), nil | 337 | }), nil | ||
| 337 | } | 338 | } | ||
| 338 | 339 | ||||
| @@ -362,6 +363,7 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) { | |||||
| 362 | Author: pb.GetAuthor(), | 363 | Author: pb.GetAuthor(), | ||
| 363 | CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()), | 364 | CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()), | ||
| 364 | CommentSeq: pb.GetCommentSeq(), | 365 | CommentSeq: pb.GetCommentSeq(), | ||
| 366 | + | External: pb.GetExternal(), | |||
| 365 | Version: item.Version, | 367 | Version: item.Version, | ||
| 366 | }, nil | 368 | }, nil | ||
| 367 | } | 369 | } | ||
M internal/adapters/kvstore/issues_pending.go +1 -1
| @@ -238,7 +238,7 @@ func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id do | |||
| 238 | 238 | } | |
| 239 | 239 | issue := domain.Issue{ | |
| 240 | 240 | Repo: repo, Number: n, Title: p.Title, Body: p.Body, State: domain.IssueStateOpen, | |
| 241 | - | Author: author, CreatedAt: p.CreatedAt, UpdatedAt: p.CreatedAt, | |
| 241 | + | Author: author, CreatedAt: p.CreatedAt, UpdatedAt: p.CreatedAt, External: true, | |
| 242 | 242 | } | |
| 243 | 243 | val, err := encodeIssue(issue) | |
| 244 | 244 | if err != nil { | |
| @@ -238,7 +238,7 @@ func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id do | |||||
| 238 | } | 238 | } | ||
| 239 | issue := domain.Issue{ | 239 | issue := domain.Issue{ | ||
| 240 | Repo: repo, Number: n, Title: p.Title, Body: p.Body, State: domain.IssueStateOpen, | 240 | Repo: repo, Number: n, Title: p.Title, Body: p.Body, State: domain.IssueStateOpen, | ||
| 241 | - | Author: author, CreatedAt: p.CreatedAt, UpdatedAt: p.CreatedAt, | 241 | + | Author: author, CreatedAt: p.CreatedAt, UpdatedAt: p.CreatedAt, External: true, |
| 242 | } | 242 | } | ||
| 243 | val, err := encodeIssue(issue) | 243 | val, err := encodeIssue(issue) | ||
| 244 | if err != nil { | 244 | if err != nil { | ||
M internal/adapters/kvstore/issues_pending_test.go +3 -0
| @@ -120,6 +120,7 @@ func TestPendingIssueApproveSharesNumbers(t *testing.T) { | |||
| 120 | 120 | assert.Equal(t, domain.IssueStateOpen, issue.State) | |
| 121 | 121 | assert.Equal(t, "Alex", issue.Author) | |
| 122 | 122 | assert.Equal(t, "named", issue.Title.String()) | |
| 123 | + | assert.True(t, issue.External, "an approved submission is external") | |
| 123 | 124 | ||
| 124 | 125 | _, err = ps.Get(ctx, n, named.ID) | |
| 125 | 126 | assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone") | |
| @@ -129,6 +130,7 @@ func TestPendingIssueApproveSharesNumbers(t *testing.T) { | |||
| 129 | 130 | got, err := s.Issues().Get(ctx, n, 2) | |
| 130 | 131 | require.NoError(t, err) | |
| 131 | 132 | assert.Equal(t, issue.Title, got.Title) | |
| 133 | + | assert.True(t, got.External, "the flag is stored") | |
| 132 | 134 | open, err := s.Issues().ListOpen(ctx, n) | |
| 133 | 135 | require.NoError(t, err) | |
| 134 | 136 | require.Len(t, open, 1) | |
| @@ -142,6 +144,7 @@ func TestPendingIssueApproveSharesNumbers(t *testing.T) { | |||
| 142 | 144 | next, err := s.Issues().Create(ctx, newIssue(t, n, "owner")) | |
| 143 | 145 | require.NoError(t, err) | |
| 144 | 146 | assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter") | |
| 147 | + | assert.False(t, next.External, "an issue of the owner is not external") | |
| 145 | 148 | } | |
| 146 | 149 | ||
| 147 | 150 | func TestPendingIssueApproveConcurrent(t *testing.T) { | |
| @@ -120,6 +120,7 @@ func TestPendingIssueApproveSharesNumbers(t *testing.T) { | |||||
| 120 | assert.Equal(t, domain.IssueStateOpen, issue.State) | 120 | assert.Equal(t, domain.IssueStateOpen, issue.State) | ||
| 121 | assert.Equal(t, "Alex", issue.Author) | 121 | assert.Equal(t, "Alex", issue.Author) | ||
| 122 | assert.Equal(t, "named", issue.Title.String()) | 122 | assert.Equal(t, "named", issue.Title.String()) | ||
| 123 | + | assert.True(t, issue.External, "an approved submission is external") | |||
| 123 | 124 | ||||
| 124 | _, err = ps.Get(ctx, n, named.ID) | 125 | _, err = ps.Get(ctx, n, named.ID) | ||
| 125 | assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone") | 126 | assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone") | ||
| @@ -129,6 +130,7 @@ func TestPendingIssueApproveSharesNumbers(t *testing.T) { | |||||
| 129 | got, err := s.Issues().Get(ctx, n, 2) | 130 | got, err := s.Issues().Get(ctx, n, 2) | ||
| 130 | require.NoError(t, err) | 131 | require.NoError(t, err) | ||
| 131 | assert.Equal(t, issue.Title, got.Title) | 132 | assert.Equal(t, issue.Title, got.Title) | ||
| 133 | + | assert.True(t, got.External, "the flag is stored") | |||
| 132 | open, err := s.Issues().ListOpen(ctx, n) | 134 | open, err := s.Issues().ListOpen(ctx, n) | ||
| 133 | require.NoError(t, err) | 135 | require.NoError(t, err) | ||
| 134 | require.Len(t, open, 1) | 136 | require.Len(t, open, 1) | ||
| @@ -142,6 +144,7 @@ func TestPendingIssueApproveSharesNumbers(t *testing.T) { | |||||
| 142 | next, err := s.Issues().Create(ctx, newIssue(t, n, "owner")) | 144 | next, err := s.Issues().Create(ctx, newIssue(t, n, "owner")) | ||
| 143 | require.NoError(t, err) | 145 | require.NoError(t, err) | ||
| 144 | assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter") | 146 | assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter") | ||
| 147 | + | assert.False(t, next.External, "an issue of the owner is not external") | |||
| 145 | } | 148 | } | ||
| 146 | 149 | ||||
| 147 | func TestPendingIssueApproveConcurrent(t *testing.T) { | 150 | func TestPendingIssueApproveConcurrent(t *testing.T) { | ||
M internal/app/issue_moderation_api_test.go +2 -2
| @@ -115,10 +115,10 @@ func TestIssueModerationOverTheAPI(t *testing.T) { | |||
| 115 | 115 | require.Len(t, ov.Pending, 1) | |
| 116 | 116 | assert.Nil(t, ov.Pending[0].IPHash) | |
| 117 | 117 | ||
| 118 | - | // The public mode is still refused. | |
| 118 | + | // The public mode is accepted. | |
| 119 | 119 | pub := domain.IssuesPublic | |
| 120 | 120 | _, err = r.forge.Repos.Update(ctx, domain.Root(), r.name("pub"), ports.UpdateRepoParams{Issues: &pub}) | |
| 121 | - | assert.Error(t, err) | |
| 121 | + | assert.NoError(t, err) | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | 124 | // clientlimit may not import the domain: its copies of the limits must match. | |
| @@ -115,10 +115,10 @@ func TestIssueModerationOverTheAPI(t *testing.T) { | |||||
| 115 | require.Len(t, ov.Pending, 1) | 115 | require.Len(t, ov.Pending, 1) | ||
| 116 | assert.Nil(t, ov.Pending[0].IPHash) | 116 | assert.Nil(t, ov.Pending[0].IPHash) | ||
| 117 | 117 | ||||
| 118 | - | // The public mode is still refused. | 118 | + | // The public mode is accepted. |
| 119 | pub := domain.IssuesPublic | 119 | pub := domain.IssuesPublic | ||
| 120 | _, err = r.forge.Repos.Update(ctx, domain.Root(), r.name("pub"), ports.UpdateRepoParams{Issues: &pub}) | 120 | _, err = r.forge.Repos.Update(ctx, domain.Root(), r.name("pub"), ports.UpdateRepoParams{Issues: &pub}) | ||
| 121 | - | assert.Error(t, err) | 121 | + | assert.NoError(t, err) |
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | // clientlimit may not import the domain: its copies of the limits must match. | 124 | // clientlimit may not import the domain: its copies of the limits must match. | ||
M internal/domain/issue.go +3 -0
| @@ -72,6 +72,9 @@ type Issue struct { | |||
| 72 | 72 | UpdatedAt time.Time | |
| 73 | 73 | // CommentSeq is the id of the last comment of the issue. | |
| 74 | 74 | CommentSeq int64 | |
| 75 | + | // External is true for an issue approved from the public form: its text | |
| 76 | + | // comes from an anonymous visitor and is rendered as untrusted. | |
| 77 | + | External bool | |
| 75 | 78 | // Version is set by the store on read: Update fails with ErrConflict when | |
| 76 | 79 | // the stored issue changed since. Opaque to everyone else. | |
| 77 | 80 | Version uint64 | |
| @@ -72,6 +72,9 @@ type Issue struct { | |||||
| 72 | UpdatedAt time.Time | 72 | UpdatedAt time.Time | ||
| 73 | // CommentSeq is the id of the last comment of the issue. | 73 | // CommentSeq is the id of the last comment of the issue. | ||
| 74 | CommentSeq int64 | 74 | CommentSeq int64 | ||
| 75 | + | // External is true for an issue approved from the public form: its text | |||
| 76 | + | // comes from an anonymous visitor and is rendered as untrusted. | |||
| 77 | + | External bool | |||
| 75 | // Version is set by the store on read: Update fails with ErrConflict when | 78 | // Version is set by the store on read: Update fails with ErrConflict when | ||
| 76 | // the stored issue changed since. Opaque to everyone else. | 79 | // the stored issue changed since. Opaque to everyone else. | ||
| 77 | Version uint64 | 80 | Version uint64 | ||
M internal/ports/dto.go +1 -1
| @@ -49,7 +49,7 @@ type UpdateRepoParams struct { | |||
| 49 | 49 | AllowedMergeModes *[]domain.MergeMode | |
| 50 | 50 | // FailingChecksBlock replaces the setting of that name. | |
| 51 | 51 | FailingChecksBlock *bool | |
| 52 | - | // Issues replaces the issues mode. IssuesPublic is refused. | |
| 52 | + | // Issues replaces the issues mode. | |
| 53 | 53 | Issues *domain.IssuesMode | |
| 54 | 54 | } | |
| 55 | 55 | ||
| @@ -49,7 +49,7 @@ type UpdateRepoParams struct { | |||||
| 49 | AllowedMergeModes *[]domain.MergeMode | 49 | AllowedMergeModes *[]domain.MergeMode | ||
| 50 | // FailingChecksBlock replaces the setting of that name. | 50 | // FailingChecksBlock replaces the setting of that name. | ||
| 51 | FailingChecksBlock *bool | 51 | FailingChecksBlock *bool | ||
| 52 | - | // Issues replaces the issues mode. IssuesPublic is refused. | 52 | + | // Issues replaces the issues mode. |
| 53 | Issues *domain.IssuesMode | 53 | Issues *domain.IssuesMode | ||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
A internal/usecase/issue_pow_vector_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/base64" | |
| 7 | + | "strconv" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // The proof-of-work vector shared with the browser solver: the same nonce, | |
| 15 | + | // difficulty and smallest solution are asserted in | |
| 16 | + | // internal/adapters/web/jstest/issue-form.test.mjs. If one changes, change both. | |
| 17 | + | const ( | |
| 18 | + | powVectorNonce = "AAECAwQFBgcICQoLDA0ODw" // bytes 0..15, base64url without padding | |
| 19 | + | powVectorDifficulty = 12 | |
| 20 | + | powVectorSolution = "2491" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | func TestPowVector(t *testing.T) { | |
| 24 | + | nonce, err := base64.RawURLEncoding.DecodeString(powVectorNonce) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | require.Len(t, nonce, nonceLen) | |
| 27 | + | ||
| 28 | + | first := "" | |
| 29 | + | for n := 0; n < 1<<22; n++ { | |
| 30 | + | s := strconv.Itoa(n) | |
| 31 | + | if leadingZeroBits(powHash(nonce, s)) >= powVectorDifficulty { | |
| 32 | + | first = s | |
| 33 | + | break | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | require.NotEmpty(t, first) | |
| 37 | + | assert.Equal(t, powVectorSolution, first, "the smallest solution of the shared vector") | |
| 38 | + | assert.True(t, validSolution(first)) | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/base64" | |||
| 7 | + | "strconv" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // The proof-of-work vector shared with the browser solver: the same nonce, | |||
| 15 | + | // difficulty and smallest solution are asserted in | |||
| 16 | + | // internal/adapters/web/jstest/issue-form.test.mjs. If one changes, change both. | |||
| 17 | + | const ( | |||
| 18 | + | powVectorNonce = "AAECAwQFBgcICQoLDA0ODw" // bytes 0..15, base64url without padding | |||
| 19 | + | powVectorDifficulty = 12 | |||
| 20 | + | powVectorSolution = "2491" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | func TestPowVector(t *testing.T) { | |||
| 24 | + | nonce, err := base64.RawURLEncoding.DecodeString(powVectorNonce) | |||
| 25 | + | require.NoError(t, err) | |||
| 26 | + | require.Len(t, nonce, nonceLen) | |||
| 27 | + | ||||
| 28 | + | first := "" | |||
| 29 | + | for n := 0; n < 1<<22; n++ { | |||
| 30 | + | s := strconv.Itoa(n) | |||
| 31 | + | if leadingZeroBits(powHash(nonce, s)) >= powVectorDifficulty { | |||
| 32 | + | first = s | |||
| 33 | + | break | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | require.NotEmpty(t, first) | |||
| 37 | + | assert.Equal(t, powVectorSolution, first, "the smallest solution of the shared vector") | |||
| 38 | + | assert.True(t, validSolution(first)) | |||
| 39 | + | } | |||
M internal/usecase/repos_write.go +0 -3
| @@ -77,9 +77,6 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 77 | 77 | if err != nil { | |
| 78 | 78 | return domain.Repo{}, err | |
| 79 | 79 | } | |
| 80 | - | if mode == domain.IssuesPublic { | |
| 81 | - | return domain.Repo{}, fmt.Errorf("%w: public issues are not available yet", domain.ErrInvalid) | |
| 82 | - | } | |
| 83 | 80 | params.Issues = &mode | |
| 84 | 81 | } | |
| 85 | 82 | if params.Description != nil { | |
| @@ -77,9 +77,6 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 77 | if err != nil { | 77 | if err != nil { | ||
| 78 | return domain.Repo{}, err | 78 | return domain.Repo{}, err | ||
| 79 | } | 79 | } | ||
| 80 | - | if mode == domain.IssuesPublic { | |||
| 81 | - | return domain.Repo{}, fmt.Errorf("%w: public issues are not available yet", domain.ErrInvalid) | |||
| 82 | - | } | |||
| 83 | params.Issues = &mode | 80 | params.Issues = &mode | ||
| 84 | } | 81 | } | ||
| 85 | if params.Description != nil { | 82 | if params.Description != nil { | ||
M internal/usecase/repos_write_test.go +10 -6
| @@ -247,13 +247,17 @@ func TestUpdateIssuesMode(t *testing.T) { | |||
| 247 | 247 | n := name(t, "tools") | |
| 248 | 248 | priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()} | |
| 249 | 249 | ||
| 250 | - | t.Run("public is refused before anything is written", func(t *testing.T) { | |
| 250 | + | t.Run("public is stored and published", func(t *testing.T) { | |
| 251 | 251 | t.Parallel() | |
| 252 | - | f := newFixture(t) // no Update, no Publish | |
| 253 | - | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |
| 254 | - | _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)}) | |
| 255 | - | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 256 | - | assert.Contains(t, err.Error(), "public issues are not available yet") | |
| 252 | + | f := newFixture(t) | |
| 253 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() | |
| 254 | + | f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool { | |
| 255 | + | return r.Settings.Issues == domain.IssuesPublic | |
| 256 | + | })).Return(nil) | |
| 257 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |
| 258 | + | repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)}) | |
| 259 | + | require.NoError(t, err) | |
| 260 | + | assert.Equal(t, domain.IssuesPublic, repo.Settings.Issues) | |
| 257 | 261 | }) | |
| 258 | 262 | ||
| 259 | 263 | t.Run("an unknown mode is invalid", func(t *testing.T) { | |
| @@ -247,13 +247,17 @@ func TestUpdateIssuesMode(t *testing.T) { | |||||
| 247 | n := name(t, "tools") | 247 | n := name(t, "tools") | ||
| 248 | priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()} | 248 | priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()} | ||
| 249 | 249 | ||||
| 250 | - | t.Run("public is refused before anything is written", func(t *testing.T) { | 250 | + | t.Run("public is stored and published", func(t *testing.T) { |
| 251 | t.Parallel() | 251 | t.Parallel() | ||
| 252 | - | f := newFixture(t) // no Update, no Publish | 252 | + | f := newFixture(t) |
| 253 | - | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | 253 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() |
| 254 | - | _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)}) | 254 | + | f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool { |
| 255 | - | assert.ErrorIs(t, err, domain.ErrInvalid) | 255 | + | return r.Settings.Issues == domain.IssuesPublic |
| 256 | - | assert.Contains(t, err.Error(), "public issues are not available yet") | 256 | + | })).Return(nil) |
| 257 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |||
| 258 | + | repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)}) | |||
| 259 | + | require.NoError(t, err) | |||
| 260 | + | assert.Equal(t, domain.IssuesPublic, repo.Settings.Issues) | |||
| 257 | }) | 261 | }) | ||
| 258 | 262 | ||||
| 259 | t.Run("an unknown mode is invalid", func(t *testing.T) { | 263 | t.Run("an unknown mode is invalid", func(t *testing.T) { | ||
M proto/ermite/storage/issue.proto +3 -0
| @@ -33,6 +33,9 @@ message Issue { | |||
| 33 | 33 | google.protobuf.Timestamp updated_at = 7; | |
| 34 | 34 | // Last comment id handed out for this issue. | |
| 35 | 35 | int64 comment_seq = 8; | |
| 36 | + | // True for an issue approved from the public form: its text comes from | |
| 37 | + | // an anonymous visitor and is rendered as untrusted. | |
| 38 | + | bool external = 9; | |
| 36 | 39 | } | |
| 37 | 40 | ||
| 38 | 41 | // IssueComment is stored under the key | |
| @@ -33,6 +33,9 @@ message Issue { | |||||
| 33 | google.protobuf.Timestamp updated_at = 7; | 33 | google.protobuf.Timestamp updated_at = 7; | ||
| 34 | // Last comment id handed out for this issue. | 34 | // Last comment id handed out for this issue. | ||
| 35 | int64 comment_seq = 8; | 35 | int64 comment_seq = 8; | ||
| 36 | + | // True for an issue approved from the public form: its text comes from | |||
| 37 | + | // an anonymous visitor and is rendered as untrusted. | |||
| 38 | + | bool external = 9; | |||
| 36 | } | 39 | } | ||
| 37 | 40 | ||||
| 38 | // IssueComment is stored under the key | 41 | // IssueComment is stored under the key | ||